arm64 에뮬레이션은 느립니다: 네이티브 per-arch 도커 빌드

· bejoyfuuul

2026-07-27 업데이트. 이 글의 예시 명령은 이미지 이름을 ghcr.io/relayroom/relayroom으로 쓰고 있었습니다. 그런 이미지는 없습니다. 실제로 publish되는 것은 ghcr.io/relayroom/relayroom-serverghcr.io/relayroom/relayroom-web 두 개라서, 그대로 붙여넣으면 명령이 실패합니다. 본문의 이미지 이름을 실제 이름으로 고쳤습니다. 나머지 내용은 그대로입니다.

멀티아치 도커 이미지 빌드가 오래 걸렸습니다. 이유는 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-server:<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-server:<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-server:<tag>ghcr.io/relayroom/relayroom-web:<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-server
          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-server@sha256:$(basename "$digest_file")"
          done
          docker buildx imagetools create \
            -t ghcr.io/relayroom/relayroom-server:${{ 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 네이티브 빌드가 단순합니다. 모든 dependency가 cross target을 깔끔히 지원한다는 보장이 없고, "빌드는 됐지만 런타임에서 native module이 다르다" 같은 문제가 더 비쌉니다.

검증은 manifest와 실제 pull을 둘 다 봅니다

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

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

여기서 linux/amd64linux/arm64가 모두 보여야 합니다.

가능하면 각 플랫폼에서 실제 pull과 run도 확인합니다.

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

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

가져갈 것

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

QEMU는 좋은 bootstrap 도구이지만, 계산량이 많은 릴리스 빌드의 기본값으로 두면 비용이 커집니다. 아키텍처별 네이티브 runner에서 병렬로 빌드하고 digest를 모아 imagetools create로 합치는 패턴이 더 명확합니다.

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

참고 자료

arm64 에뮬레이션은 느립니다: 네이티브 per-arch 도커 빌드 - RelayRoom