Apple Silicon Mac에서 Docker x86 빌드 속도 저하·메모리 오류 해결하기
M1 MacBook을 처음 샀을 때 Docker 빌드 속도가 Intel Mac 시절보다 몇 배는 빠를 거라고 기대했다. 실제로 네이티브 arm64 이미지를 빌드할 때는 정말 빠르다. 그런데 회사 서버가 x86_64 기반이라 --platform linux/amd64 플래그를 달고 빌드하는 순간 상황이 완전히 달라진다. 빌드 시간이 10분을 넘기도 하고, Node.js나 Python 패키지를 설치하다가 메모리 부족으로 프로세스가 죽어버리기도 한다.
이 글은 그 문제를 직접 겪고 해결하면서 정리한 내용이다.
왜 Apple Silicon에서 x86 빌드가 느린가
Apple Silicon(M1·M2·M3·M4)은 ARM 아키텍처 기반이다. Docker Desktop for Mac은 이 위에서 Linux VM을 돌리고, 그 안에서 컨테이너를 실행한다. linux/amd64 플랫폼을 지정하면 Docker는 QEMU(Quick Emulator)를 통해 x86_64 명령어를 ARM 명령어로 변환하면서 실행한다.
QEMU 에뮬레이션은 명령어 하나하나를 동적으로 번역하는 방식이라 오버헤드가 크다. 특히 컴파일이나 패키지 설치처럼 CPU를 많이 쓰는 작업에서 속도 차이가 극명하게 나타난다. 네이티브 arm64 빌드 대비 3~10배 느린 경우도 흔하다.
메모리 오류는 주로 두 가지 경로로 발생한다.
- QEMU 자체 메모리 소비: 에뮬레이션 레이어가 추가 메모리를 상당량 점유한다.
- Docker Desktop VM 메모리 제한: 기본 설정이 보수적으로 잡혀 있어서 빌드 중 OOM(Out Of Memory) killer가 프로세스를 강제 종료시킨다.
Node.js의 경우 npm install 이나 npm run build 중에 Killed 메시지만 남기고 죽는 게 대표적인 OOM 증상이다.
Docker Desktop 메모리 설정 먼저 확인하기
문제를 진단하기 전에 Docker Desktop의 리소스 설정부터 확인해야 한다.
Docker Desktop → Settings → Resources → Advanced 에서 Memory 값을 확인한다. 기본값이 2GB나 4GB로 잡혀 있는 경우가 많다. x86 에뮬레이션 빌드를 자주 한다면 최소 8GB, 여유가 있다면 12~16GB로 올리는 것을 권장한다.
CPU 수도 함께 늘려준다. 빌드는 멀티코어를 적극 활용하므로 시스템 코어의 절반 이상을 할당하면 체감 차이가 난다.
Swap 용량도 늘려두면 OOM으로 프로세스가 죽는 상황을 일부 완화할 수 있다. 1GB에서 2~4GB로 늘려두자.
Rosetta 기반 가속 활성화 (Docker Desktop 4.x 이상)
Docker Desktop 4.x 버전부터 macOS의 Rosetta 2를 이용해 x86 에뮬레이션 속도를 크게 끌어올릴 수 있다.
Docker Desktop → Settings → Features in development → Use Rosetta for x86/amd64 emulation on Apple Silicon
이 옵션을 켜면 QEMU 대신 Rosetta 2가 에뮬레이션을 담당한다. Apple이 자체 하드웨어에 최적화해서 만든 바이너리 변환 레이어라 QEMU보다 훨씬 빠르다. 체감상 빌드 시간이 절반 이하로 줄어드는 경우가 많다.
다만 Rosetta 에뮬레이션이 모든 x86 명령어 세트를 완벽히 지원하지는 않는다. AVX-512 같은 고급 명령어를 사용하는 소프트웨어에서 간헐적으로 문제가 생길 수 있다. 그런 경우라면 QEMU로 돌아가야 한다.
# 현재 사용 중인 에뮬레이터 확인
docker run --rm --platform linux/amd64 alpine uname -m
BuildKit과 멀티플랫폼 빌더 설정
Docker BuildKit은 빌드 캐시를 더 효율적으로 관리하고 병렬 빌드를 지원한다. Apple Silicon 환경에서도 BuildKit을 제대로 설정하면 불필요한 재빌드를 줄일 수 있다.
docker buildx를 사용하면 멀티플랫폼 빌드를 명시적으로 제어할 수 있다.
# 새 buildx 빌더 인스턴스 생성
docker buildx create --name mybuilder --use
# 빌더 초기화 및 상태 확인
docker buildx inspect --bootstrap
# linux/amd64 플랫폼 지정 빌드
docker buildx build --platform linux/amd64 -t myimage:latest .
docker buildx ls 명령으로 현재 빌더 목록과 지원 플랫폼을 확인할 수 있다. linux/amd64* 옆에 (emulated) 표시가 붙어 있으면 에뮬레이션을 통해 빌드하고 있다는 뜻이다.
Dockerfile 최적화로 에뮬레이션 부담 줄이기
에뮬레이션 환경에서 빌드 속도를 높이는 가장 실용적인 방법은 Dockerfile 자체를 최적화하는 것이다.
멀티 스테이지 빌드 활용
빌드 스테이지와 런타임 스테이지를 분리하면 에뮬레이션이 필요한 구간을 최소화할 수 있다. 빌드 단계는 가능하면 arm64 네이티브에서 처리하고, 최종 결과물만 amd64 이미지에 복사하는 방식이다.
# 빌드 스테이지 - 플랫폼 고정 없이 네이티브로 실행
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 런타임 스테이지 - amd64 명시
FROM --platform=linux/amd64 node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
이렇게 하면 npm ci나 컴파일 작업은 ARM 네이티브로 빠르게 처리하고, 무거운 에뮬레이션은 가벼운 파일 복사 단계에서만 일어난다.
레이어 캐시 적극 활용
COPY package*.json ./ → RUN npm ci → COPY . . 순서로 의존성 설치를 소스 코드 복사보다 앞에 두면, 소스 코드만 바뀌었을 때 패키지 재설치를 건너뛸 수 있다. 에뮬레이션 환경에서 패키지 설치가 특히 느리기 때문에 캐시 히트율을 높이는 게 중요하다.
.dockerignore 파일도 꼼꼼히 설정해야 한다. node_modules, .git, dist 같은 디렉터리가 포함되면 컨텍스트 전송 시간이 길어지고, 불필요한 레이어 무효화가 발생한다.
빌드 환경을 CI/CD로 분리하는 선택지
로컬에서 x86 에뮬레이션 빌드를 자주 해야 하는 상황이라면, 빌드 자체를 x86 리눅스 환경의 CI/CD로 넘기는 것도 현실적인 해결책이다.
GitHub Actions는 ubuntu-latest 러너가 x86_64 환경이다. 코드를 푸시하면 CI에서 네이티브로 amd64 이미지를 빌드하고, 레지스트리에 푸시하는 파이프라인을 구성하면 로컬 맥에서 무거운 에뮬레이션 빌드를 돌릴 필요가 없어진다.
# .github/workflows/docker-build.yml 예시
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push
uses: docker/build-push-action@v5
with:
platforms: linux/amd64
push: true
tags: myregistry/myimage:latest
로컬에서는 arm64 이미지로 개발하고, 배포용 amd64 이미지는 CI가 담당하는 역할 분리다. 팀 단위로 일할 때 특히 효과적이다.
Node.js 빌드 메모리 오류 대응
Node.js는 V8 엔진의 기본 힙 메모리 한도가 있어서 대형 프로젝트에서 OOM이 자주 발생한다. 에뮬레이션 환경에서는 이 문제가 더 심하게 나타난다.
package.json의 build 스크립트에 --max-old-space-size 옵션을 추가하면 V8 힙 한도를 늘릴 수 있다.
{
"scripts": {
"build": "node --max-old-space-size=4096 node_modules/.bin/webpack"
}
}
Dockerfile에서 환경 변수로 설정하는 방법도 있다.
ENV NODE_OPTIONS="--max-old-space-size=4096"
Docker Desktop의 VM 메모리를 충분히 확보한 상태에서 이 옵션을 함께 써야 효과가 있다. VM 메모리가 4GB인데 Node에 4096MB를 할당하면 의미가 없다.
실제 디버깅 흐름 정리
문제가 생겼을 때 체계적으로 접근하는 순서를 정리하면 이렇다.
docker stats명령으로 빌드 중 컨테이너 메모리 사용량을 실시간 확인한다.- 메모리가 한도에 치달리면 Docker Desktop 리소스 설정을 늘린다.
- Rosetta 에뮬레이션 옵션이 켜져 있는지 확인하고, 꺼져 있다면 활성화한다.
- Dockerfile의 레이어 순서와
.dockerignore를 점검한다. - 빌드 로그에서 어느 단계에서 시간이 오래 걸리는지 파악하고, 해당 단계를 분리하거나 캐시 전략을 바꾼다.
- 여전히 느리다면 CI/CD로 빌드를 분리하는 방안을 검토한다.
빌드 속도와 안정성은 한 가지 설정만 바꾼다고 해결되지 않는 경우가 많다. Rosetta 활성화로 속도를 올리고, VM 메모리를 늘려서 OOM을 잡고, Dockerfile 구조를 개선해서 캐시 효율을 높이는 것을 함께 적용해야 눈에 띄는 개선이 생긴다.
Apple Silicon Mac이 개발 생산성을 크게 높여준 건 사실이지만, x86 타겟 빌드에서는 아직 신경 써야 할 부분이 있다. 위에서 설명한 방법들을 상황에 맞게 조합하면 에뮬레이션 빌드 환경에서도 충분히 실용적인 수준으로 작업할 수 있다.