728x90

"맥북 한 대를 빌드 서버로 책상 밑에 두는 것"만큼 인프라 담당자 속을 긁는 게 없다. 랙에 못 넣고, 이미지 관리가 안 되고, OS 업데이트 한 번에 파이프라인이 통째로 멎는다. 그래서 주기적으로 "Linux 러너에서 macOS 툴체인 못 돌리나?"라는 질문이 올라오고, 그 답변 후보 중 하나가 Darling이다.

1. 왜 지금 다시 Darling 이야기가 나오나

Darling은 하드웨어 에뮬레이션 없이 macOS(Darwin) 바이너리를 Linux에서 직접 실행하는 변환 계층이다. QEMU처럼 CPU를 흉내내는 게 아니라, x86_64 명령어는 그대로 호스트 CPU에서 돌리고 OS 경계에서만 번역한다. 포지션은 Wine과 같다. Wine이 Windows에 대해 하는 일을 Darling이 macOS에 대해 하는 것이고, 프로젝트 스스로도 그렇게 설명한다.

실무에서 이 주제가 반복해서 소환되는 이유는 명확하다.

  • iOS/macOS 빌드 때문에 CI 풀 전체가 이원화된다. Linux 러너는 Kubernetes로 오토스케일하는데, macOS 러너만 고정 대수 + 수동 프로비저닝.
  • 클라우드 macOS 인스턴스는 비싸고, 전용 호스트 기반이라 최소 할당 시간 같은 제약이 붙는다(정확한 조건은 각 클라우드 공식 문서 확인 필요).
  • Apple 실리콘 전환 이후 x86 맥 미니 재고로 버티던 조직들이 하드웨어 교체 압박을 받고 있다.

결론부터 말하면 Darling으로 Xcode 빌드 파이프라인을 대체하는 건 현재로선 불가능하다. 다만 "왜 불가능한가"를 이해하면 대안 설계가 훨씬 정확해진다. 그리고 CLI 도구 수준에서는 실제로 쓸 구석이 있다.

2. 동작 원리 — Wine과 결정적으로 다른 지점

바이너리 포맷부터 다르다

Linux는 ELF를 로드한다. macOS 바이너리는 Mach-O다. 커널의 binfmt 핸들러가 Mach-O를 모르니, Darling은 자체 로더(ELF로 빌드된 실행 파일)를 먼저 띄우고 그 프로세스 주소 공간에 Mach-O 이미지와 Darling판 dyld를 매핑한다. 그다음부터는 dyld가 /System/Library/Frameworks/... 경로의 dylib들을 링크한다. 여기서 dyld는 Apple 것이 아니라 Darling이 재구현/포팅한 것이다.

Wine은 API 레벨, Darling은 syscall 레벨까지 내려간다

이 차이가 핵심이다. Windows는 syscall 번호가 버전마다 바뀌는 비공개 인터페이스라, 정상적인 Windows 앱은 절대 syscall을 직접 호출하지 않고 kernel32.dll 같은 DLL을 거친다. 그래서 Wine은 DLL을 갈아끼우는 것만으로 대부분 해결된다.

macOS도 공식적으로는 "libSystem을 통해서만 호출하라"고 하지만, 실제 바이너리는 libsystem_kernel.dylib를 통해 BSD syscall과 Mach trap을 직접 때린다. Mach 포트, IPC, 태스크/스레드 추상화는 Linux에 대응물이 없다. 그래서 Darling은 다음을 다 만들어야 한다.

  • libsystem_kernel 대체 — Darwin syscall을 Linux syscall로 번역
  • Mach IPC / 포트 서브시스템
  • launchd, notifyd 같은 Darwin 데몬
  • Foundation, CoreFoundation 등 프레임워크 (상당 부분 오픈소스 Darwin 코드 기반)

Mach IPC 때문에 Darling은 오랫동안 커널 모듈을 요구했고, 이후 이 의존을 줄이려는 방향의 작업이 진행돼 왔다. 현재 릴리스에서 커널 모듈이 필수인지 여부는 버전에 따라 다르므로 공식 문서 확인 필요다. 인프라 관점에서는 이 한 줄이 도입 가능성을 좌우한다. 커널 모듈이 필요하면 관리형 Kubernetes 노드나 대부분의 SaaS CI에서는 그냥 끝이다.

prefix 개념

Wine에 ~/.wine이 있듯 Darling에는 ~/.darling 프리픽스가 있고, 그 안에 Darwin 파일시스템 레이아웃(/System, /usr/lib, /Users)이 구성된다. 마운트 네임스페이스와 오버레이를 써서 호스트 파일시스템 위에 Darwin 뷰를 씌우는 구조다. 컨테이너 안에서 굴릴 때 권한 문제가 터지는 이유가 바로 여기다.

3. 실무 관점 — 확인 명령, 흔한 함정, 대안

먼저 확인할 것: 내 바이너리가 애초에 후보인가

Darling 셸에 들어가기 전에 호스트 Linux에서 아키텍처와 의존성부터 본다. Apple 실리콘용 arm64 전용 바이너리라면 x86_64 Linux에서는 시작조차 못 한다.

$ file ./MyTool
./MyTool: Mach-O 64-bit x86_64 executable, flags:<NOUNDEFS|DYLDLINK|PIE>

$ file ./MyToolUniversal
./MyToolUniversal: Mach-O universal binary with 2 architectures:
  [x86_64:Mach-O 64-bit x86_64 executable]
  [arm64:Mach-O 64-bit arm64 executable]

$ darling shell
Darwin ... x86_64
bash-3.2$ otool -L /Users/me/MyTool
/Users/me/MyTool:
  /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, ...)
  /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation

(uname/버전 문자열은 Darling 빌드 시점에 따라 달라진다.) otool -L에 나온 프레임워크 목록이 사실상 성공 확률표다. Foundation/CoreFoundation 수준이면 해볼 만하고, AppKit·Metal·Security 같은 게 줄줄이 나오면 접는 게 낫다.

CI에 넣는다면 스모크 테스트를 반드시 앞단에

Darling은 "일부는 되고 일부는 안 되는" 계층이다. 파이프라인 중간에서 죽으면 원인 파악에 하루가 날아간다. job 맨 앞에 5초짜리 게이트를 둔다.

#!/usr/bin/env bash
# darling-smoke.sh — 파이프라인 최상단에서 실행
set -euo pipefail

command -v darling >/dev/null || { echo "darling 미설치"; exit 1; }

# 1) 런타임이 살아있는지
darling shell true || { echo "darling 런타임 기동 실패"; exit 1; }

# 2) 대상 바이너리 아키텍처 확인
file "$1" | grep -q 'x86_64' || { echo "x86_64 슬라이스 없음: $1"; exit 1; }

# 3) 실제 링크 가능한지 (여기서 dyld 에러가 잡힌다)
if ! darling shell otool -L "$1" >/dev/null 2>&1; then
  echo "의존성 확인 실패 — 미구현 프레임워크 가능성"; exit 1
fi
echo "smoke ok"

흔한 함정 1 — dyld image not found

가장 자주 보게 될 실패다. 해당 프레임워크가 Darling에 아직 없거나, 스텁만 있고 심볼이 비어 있는 경우다.

$ darling shell ./MyTool
dyld: Library not loaded: /System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices
  Referenced from: /Users/me/MyTool
  Reason: image not found
Trace/breakpoint trap

해결책이랄 게 별로 없다. macOS 실기에서 dylib를 복사해 넣는 방법이 떠오르겠지만, Apple 프레임워크 재배포는 라이선스 문제라 사내 CI 이미지에 굽는 순간 법무 이슈가 된다. 실무적으로는 그 기능을 쓰지 않는 빌드 옵션을 찾거나 포기하는 쪽이 빠르다.

흔한 함정 2 — 컨테이너 안에서 Bad system call

Docker 기본 seccomp 프로파일은 흔치 않은 syscall과 일부 네임스페이스 조작을 막는다. Darling은 프리픽스를 만들 때 마운트 네임스페이스를 쓰기 때문에 정면으로 충돌한다.

$ docker run --rm -it my/darling:latest darling shell uname -a
Bad system call (core dumped)

또는 이런 계열도 본다.

mount: permission denied
darling: failed to set up prefix at /root/.darling

완화 방법은 --security-opt seccomp=unconfined, --cap-add SYS_ADMIN, 최악의 경우 --privileged다. 문제는 이게 "CI 러너 컨테이너에 특권을 준다"는 뜻이라는 것. 멀티테넌트 CI에서는 보안팀이 승인 안 해준다. 전용 노드풀 + taint로 격리하는 정도가 현실적 타협선인데, 그 시점이면 "그냥 mac mini 한 대 두는 것"과 운영 부담을 비교해봐야 한다. 정확히 어떤 capability가 필요한지는 버전에 따라 다르므로 공식 문서 확인 필요.

흔한 함정 3 — 결국 Xcode가 필요하다

iOS 앱 빌드는 xcodebuild, 코드 서명은 codesign과 키체인, 배포는 공증(notarization)까지 엮인다. Xcode는 Darling에서 돌지 않고, 배포도 못 한다. 즉 "Linux에서 iOS 앱 CI"라는 원래 목표에는 Darling이 답이 아니다.

bash-3.2$ xcodebuild -version
bash: xcodebuild: command not found

그럼 뭘 쓰나

  • osxcross: Linux에서 clang으로 macOS 타깃 크로스컴파일. 실행이 아니라 빌드가 목적이면 이쪽이 훨씬 실용적이다. 단 macOS SDK를 Xcode에서 추출해야 하므로 라이선스 검토는 똑같이 필요하다.
  • zig cc: 간단한 C/C++ 프로젝트라면 크로스 타깃 지정만으로 macOS 바이너리를 뽑을 수 있어 세팅이 가볍다.
  • 실제 macOS 러너 + 역할 분리: 가장 현실적. 유닛 테스트·정적 분석·의존성 캐시 같은 플랫폼 무관 단계는 Linux에서 돌리고, macOS 러너는 컴파일·서명·공증만 담당하게 줄인다. macOS 러너 점유 시간을 절반 이하로 줄이는 게 비용 관점에서 Darling 도입보다 확실한 성과다.

반대로 Darling이 실제로 값어치를 하는 구간도 있다. macOS용 CLI 유틸리티를 만드는 팀이 PR 단계 스모크 테스트(바이너리가 뜨는지, --version이 나오는지)를 Linux 러너에서 저렴하게 돌리는 용도, 그리고 Mach-O 로딩이나 dyld 동작을 뜯어보는 리버싱·학습 환경이다.

4. 정리

한 줄 요약: Darling은 "macOS용 Wine"이지만, Wine보다 훨씬 아래(Mach trap·syscall)까지 재구현해야 해서 커버리지가 좁다. CLI 도구 실행에는 쓸 만하고, Xcode 기반 iOS 파이프라인 대체용으로는 아직 아니다.

  • 써볼 만한 경우: macOS용 CLI 바이너리의 가벼운 실행 검증, Mach-O/dyld 학습과 리버싱, GUI 없는 오픈소스 도구의 크로스 플랫폼 확인.
  • 쓰면 안 되는 경우: 프로덕션 iOS/macOS 앱 빌드·서명·배포, GUI 앱 테스트, 특권 컨테이너를 허용할 수 없는 멀티테넌트 CI.
  • 도입 전 체크리스트: ① 현재 버전이 커널 모듈을 요구하는가 ② 대상 바이너리에 x86_64 슬라이스가 있는가 ③ otool -L 의존성이 Foundation 수준에서 끝나는가 ④ Apple 프레임워크/SDK 취급에 대한 법무 검토가 끝났는가.

개인적으로는 "Linux에서 macOS를 돌린다"보다 "macOS 러너가 꼭 해야 하는 일만 남기고 다 걷어낸다"가 인프라팀이 먼저 손대야 할 문제라고 본다. 그래도 Darling 같은 프로젝트가 살아있는 건 반갑다. 언젠가 Wine처럼 "대부분 그냥 된다"가 되면, 그때는 정말 판이 바뀐다.

참고 자료

728x90

+ Recent posts