arm64를 에뮬레이션하지 마라 - 네이티브 per-arch 도커 빌드

· bejoyfuuul

멀티아치 도커 이미지 빌드가 오래 걸렸다. 이유는 Docker가 느려서가 아니었다. amd64 러너 위에서 arm64 빌드를 QEMU로 흉내 내고 있었기 때문이다.

해결책은 "멀티아치를 포기한다"가 아니었다. amd64와 arm64를 각자 네이티브 러너에서 병렬로 빌드하고, 마지막에 manifest list로 합치면 된다. 결과 이미지는 사용자에게 똑같이 보인다. 빌드 파이프라인만 달라진다.

먼저 흔한 오해부터

"멀티아치 이미지로 만들면 일반 amd64 서버 사용자는 못 쓰는 것 아닌가?" 아니다.

멀티아치 이미지는 하나의 태그 아래 여러 플랫폼별 image manifest를 묶은 manifest list 또는 OCI image index다. Docker 문서는 multi-platform image를 registry에 push하면 registry가 manifest list와 개별 manifest들을 저장하고, pull할 때 Docker가 호스트 아키텍처에 맞는 variant를 자동으로 고른다고 설명한다. amd64 서버는 linux/amd64를 받고, Apple Silicon Mac이나 arm64 VPS는 linux/arm64를 받는다.

즉 멀티아치는 "arm 전용"이 아니라 "한 태그로 여러 CPU 아키텍처를 커버"하는 방식이다.

확인은 imagetools inspect로 할 수 있다.

docker buildx imagetools inspect ghcr.io/relayroom/relayroom:<tag>

정상적인 멀티아치 태그라면 linux/amd64linux/arm64 manifest가 함께 보인다. 사용자는 같은 태그를 pull하지만, 로컬 Docker가 적절한 digest를 선택한다.

느린 이유: QEMU는 편하지만 비싸다

Docker Buildx는 --platform linux/amd64,linux/arm64처럼 여러 플랫폼을 한 번에 빌드할 수 있다.

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --push \
  -t ghcr.io/relayroom/relayroom:<tag> .

단일 amd64 러너에서 이 명령을 실행하면 amd64 빌드는 네이티브로 돌지만 arm64 빌드는 에뮬레이션으로 돈다. BuildKit과 QEMU 덕분에 설정은 쉽다. 그러나 Docker 공식 문서도 QEMU 에뮬레이션은 압축, 압축 해제, 컴파일처럼 계산량이 큰 작업에서 네이티브 빌드보다 훨씬 느릴 수 있으므로 가능하면 multiple native nodes나 cross-compilation을 쓰라고 안내한다.

Next.js 빌드, pnpm install, native dependency build, 이미지 레이어 압축이 섞인 제품 이미지는 에뮬레이션 비용을 크게 느낀다. 특히 한 job에서 두 플랫폼을 순차로 처리하면 느린 arm64 구간이 전체 릴리스 시간을 잡아먹는다.

목표: 결과 manifest는 유지하고 빌드 경로만 바꾼다

우리가 원하는 결과는 그대로다.

  • ghcr.io/relayroom/relayroom:<tag> 하나를 publish한다.
  • 그 태그는 linux/amd64linux/arm64를 모두 가진다.
  • 사용자는 기존과 같은 docker compose pull을 쓴다.

바뀌는 것은 CI 내부다.

  • amd64 이미지는 amd64 러너에서 빌드한다.
  • arm64 이미지는 arm64 러너에서 빌드한다.
  • 두 결과를 digest로 push한다.
  • 마지막 job에서 digest들을 하나의 manifest list로 합친다.

패턴: per-arch build + digest + imagetools create

개념 스케치는 이렇다.

jobs:
  build:
    strategy:
      matrix:
        include:
          - platform: linux/amd64
            runner: ubuntu-latest
            arch: amd64
          - platform: linux/arm64
            runner: ubuntu-24.04-arm
            arch: arm64
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        id: build
        with:
          context: .
          platforms: ${{ matrix.platform }}
          push: true
          tags: ghcr.io/relayroom/relayroom
          outputs: type=image,push-by-digest=true,name-canonical=true,push=true
      - run: mkdir -p /tmp/digests && touch "/tmp/digests/${{ steps.build.outputs.digest }}"
      - uses: actions/upload-artifact@v4
        with:
          name: digests-${{ matrix.arch }}
          path: /tmp/digests/*
 
  merge:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          path: /tmp/digests
          pattern: digests-*
          merge-multiple: true
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - run: |
          refs=""
          for digest_file in /tmp/digests/*; do
            refs="$refs ghcr.io/relayroom/relayroom@sha256:$(basename "$digest_file")"
          done
          docker buildx imagetools create \
            -t ghcr.io/relayroom/relayroom:${{ github.ref_name }} \
            $refs

실제 workflow에서는 digest 파일명 처리, tag 목록, cache, provenance, release 조건을 더 다듬어야 한다. 핵심은 Docker의 imagetools create가 이미 registry에 존재하는 source manifest들을 기반으로 새 manifest list를 만든다는 점이다. Docker CLI 문서도 docker buildx imagetools create를 "source manifests 기반의 새 manifest list 생성" 명령으로 설명한다.

왜 digest로 넘기나

아키텍처별 build job이 같은 tag를 동시에 push하면 race가 생긴다. 마지막으로 push한 쪽이 tag를 덮거나, manifest가 의도와 다르게 변할 수 있다. 그래서 중간 산출물은 tag가 아니라 content-addressed digest로 다룬다.

digest는 이미지 내용의 해시다. amd64 build가 만든 digest와 arm64 build가 만든 digest를 merge job에 넘기면, merge job은 정확히 그 두 산출물을 참조해 최종 tag를 만든다. release tag는 마지막 한 번만 움직인다.

"셀프호스트 러너 쓰면 빠른가?"

arm64 네이티브 머신을 self-hosted runner로 붙이면 효과가 크다. 에뮬레이션을 제거하기 때문이다. 반대로 amd64 self-hosted runner만 있다면 arm64 빌드는 여전히 QEMU를 타므로 큰 구조적 개선은 없다. 디스크가 빠르거나 Docker layer cache가 오래 남아 일부 이득은 있을 수 있지만, 핵심 병목은 그대로다.

공개 저장소라면 GitHub-hosted arm64 runner를 검토할 수 있다. GitHub Actions의 runner label과 과금/가용성 정책은 시간이 지나며 바뀌므로, workflow에 넣기 전 현재 GitHub 문서를 확인해야 한다. 원칙은 변하지 않는다. arm64 산출물은 arm64 CPU에서 만들 때 가장 예측 가능하고 빠르다.

언제 cross-compilation이 더 나은가

Go나 Rust처럼 cross-compilation 지원이 좋은 단일 바이너리 앱은 BUILDPLATFORM, TARGETPLATFORM, TARGETOS, TARGETARCH를 사용해 한 러너에서 target별 바이너리를 만들 수 있다. Docker 문서도 multi-stage build에서 이 build arg들을 활용하는 cross-compilation 전략을 소개한다.

하지만 RelayRoom처럼 Node/Next.js, package manager, native dependency, runtime image 구성이 섞인 경우에는 cross-compilation보다 per-arch native build가 단순하다. 모든 dependency가 cross target을 깔끔히 지원한다는 보장이 없고, "빌드는 됐지만 런타임에서 native module이 다르다" 같은 문제가 더 비싸다.

검증은 manifest와 실제 pull 둘 다 본다

merge 후에는 두 가지를 확인한다.

docker buildx imagetools inspect ghcr.io/relayroom/relayroom:<tag>

여기서 linux/amd64, linux/arm64가 모두 보여야 한다.

가능하면 각 플랫폼에서 실제 pull/run도 확인한다.

docker run --rm --platform linux/amd64 ghcr.io/relayroom/relayroom:<tag> node -p "process.arch"
docker run --rm --platform linux/arm64 ghcr.io/relayroom/relayroom:<tag> node -p "process.arch"

로컬 머신이 해당 플랫폼을 네이티브로 실행하지 못하면 두 번째 명령은 다시 에뮬레이션을 탈 수 있다. 그래도 manifest 선택과 기본 실행 여부를 확인하는 smoke test로는 쓸 수 있다. 최종 신뢰는 각 아키텍처 네이티브 러너에서 얻는 편이 낫다.

가져갈 것

멀티아치는 사용자 호환성 문제를 푸는 도구다. 빌드 시간이 느려졌다고 멀티아치를 포기하면 Apple Silicon, arm64 VPS, Raspberry Pi 계열 사용자를 잃는다. 문제는 manifest list가 아니라 에뮬레이션 빌드다.

QEMU는 좋은 bootstrap 도구지만, compute-heavy release build의 기본값으로 두면 비용이 커진다. 아키텍처별 네이티브 runner에서 병렬로 빌드하고 digest를 모아 imagetools create로 합치는 패턴이 더 명확하다.

결과 태그는 그대로다. 사용자는 여전히 같은 이미지를 pull한다. 달라지는 것은 릴리스 파이프라인이 CPU 아키텍처를 속이지 않는다는 점이다.

참고 자료