GitHub Actions에서 ARM64 Docker 빌드가 QEMU 크래시로 멈췄던 이야기-hero-image

Tech

GitHub Actions에서 ARM64 Docker 빌드가 QEMU 크래시로 멈췄던 이야기

홈서버(라즈베리파이 5)에 배포하는 파이프라인에서 arm64 이미지 빌드가 QEMU 크래시로 멈추던 문제를 로그로 진단하고, GitHub 호스팅 네이티브 arm64 러너로 해결한 기록


팀 프로젝트의 Next.js 프론트엔드를 배포할 서버로 클라우드 대신 내 홈서버(라즈베리파이 5 + NVMe SSD)를 쓰기로 했다. PR을 머지해서 main에 push하면 GitHub Actions가 Docker 이미지를 빌드해서 GHCR에 올리고, 홈서버가 그 이미지를 pull 받아 재기동하는 구조였다. 그런데 어느 날부터 이 워크플로우가 빌드 단계에서 그냥 멈춰버리는 문제가 생겼다.

증상: 실패도 아니고 그냥 멈춤

팀원이 폴더 구조 정리 PR을 머지하면서 처음 발견했다. GitHub Actions 로그를 열어보면 상태가 “실패”가 아니라 “취소(Cancelled)“로 찍혀 있었다. 빌드가 9분 넘게 진행되다가 결국 사람이 직접 run을 취소한 것이었다.

로그를 자세히 보니 흥미로운 패턴이 있었다. 워크플로우는 docker buildx build --platform linux/arm64,linux/amd64로 두 아키텍처를 한 번에 빌드하고 있었는데,

  • linux/amd64 빌드는 deps 설치 → Next.js 빌드 → 이미지 생성까지 끝까지 문제없이 완료됐다.
  • linux/arm64 빌드는 pnpm install --frozen-lockfile 도중 이런 로그를 남기고 죽어버렸다.
#22 38.12 qemu: uncaught target signal 4 (Illegal instruction) - core dumped

더 골치 아팠던 부분은, 이 크래시가 나고도 워크플로우가 바로 실패 처리되지 않고 그 단계에서 아무 진행 없이 7분 넘게 그냥 멈춰 있었다는 점이다. GitHub Actions가 자동으로 실패로 판단해주지 않으니, 매번 사람이 로그를 보고 “이거 멈춘 것 같은데?” 하면서 수동으로 취소해야 했다.

원인: QEMU 에뮬레이션의 한계

linux/amd64는 GitHub Actions 러너(x86_64)에서 네이티브로 도는 반면, linux/arm64docker/setup-qemu-action이 설치한 QEMU(사용자 모드 에뮬레이션)로 arm64 명령어를 x86_64 러너 위에서 한 줄씩 통역해가며 실행하는 구조다. 이 워크플로우는 QEMU v10.2.3(tonistiigi/binfmt)을 쓰고 있었는데, 패키지를 대량으로 다운로드하고 압축 해제하는 고부하 작업(pnpm install) 도중 QEMU가 에뮬레이트하지 못하는 CPU 명령어를 만나면서 SIGILL(Illegal instruction)로 죽은 것으로 보인다.

크래시 시점이 @sentry/cliunrs-resolver 같은 네이티브 postinstall 스크립트가 실행되기 이전 시점이라, 특정 npm 패키지 탓이라기보다 Node/pnpm이 패키지를 풀어내는 과정 자체가 QEMU의 arm64 TCG 백엔드가 처리하지 못하는 명령어를 건드린 것에 가깝다. GitHub Community와 docker/buildx 리포지토리에도 QEMU + 크로스 아키텍처 빌드 조합에서 같은 증상을 보고한 이슈가 여러 건 있었다. amd64 빌드는 같은 Node 24로 멀쩡히 끝났으니, Node 버전 자체의 문제는 아니라는 것도 로그로 확인할 수 있었다.

대안 검토

배포 대상이 실제로 홈서버(aarch64 라즈베리파이)였기 때문에 “arm64 이미지를 아예 안 만든다”는 선택지는 없었다. 그래서 QEMU 에뮬레이션을 걷어내는 방법을 두 가지로 좁혀서 비교했다.

1) 이 홈서버를 self-hosted 러너로 등록한다. 개발용으로만 쓰는 서버라 CPU·메모리·네트워크 여유는 충분했다. 하지만 이 레포가 public 레포라는 점 때문에 접었다. GitHub 공식 문서(secure-use reference)는 “self-hosted runner는 public 레포에서 거의 쓰면 안 된다”고 명시하고 있다 — 누구나 PR을 열 수 있는 구조라, 워크플로우 트리거가 조금이라도 잘못 구성되면 러너(=내 홈서버)가 통째로 노출될 수 있기 때문이다.

2) GitHub이 호스팅하는 네이티브 arm64 러너(ubuntu-24.04-arm)를 쓴다. 2025년 8월부터 public 레포에서 무료로 정식 제공되고 있어서 비용 부담이 없었고, QEMU 에뮬레이션 없이 진짜 arm64 하드웨어에서 네이티브로 빌드하니 이번 크래시의 원인 자체가 사라진다. 결국 이 방법을 선택했다.

적용한 해결 방법

기존에는 build-and-push job 하나가 QEMU를 설치하고 --platform linux/arm64,linux/amd64로 한 번에 멀티아치 이미지를 빌드했다. 이걸 아키텍처별로 쪼개서, 각 아키텍처를 그 아키텍처에 맞는 네이티브 러너에서 따로 빌드한 뒤 매니페스트만 합치는 구조로 바꿨다.

jobs:
  build-and-push:
    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: linux/amd64
            arch: amd64
            runner: ubuntu-latest
          - platform: linux/arm64
            arch: arm64
            runner: ubuntu-24.04-arm   # GitHub 호스팅 네이티브 arm64 러너
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          platforms: ${{ matrix.platform }}
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}

  merge-manifest:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - run: |
          docker buildx imagetools create \
            -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-arm64

  deploy:
    needs: merge-manifest
    # ... 기존 SSH 배포 스텝 그대로

바뀐 흐름을 정리하면 이렇다. docker/setup-qemu-action을 완전히 제거했고, amd64/arm64를 matrix로 나눠서 각각의 네이티브 러너에서 latest-amd64, latest-arm64 태그로 따로 push한다. 그다음 merge-manifest job이 docker buildx imagetools create로 이 두 태그를 하나의 멀티아치 latest 매니페스트로 합쳐준다. 홈서버 입장에서는 docker compose pull로 여전히 latest 태그 하나만 받으면 되니 배포 스크립트는 그대로 뒀다.

결과

수정한 워크플로우를 push하자 빌드가 1분 53초 만에 성공했다. 예전 워크플로우가 실패(정확히는 무한 대기)하기까지 걸렸던 9분 30초와 비교하면, 실패하지 않게 된 것은 물론 시간까지 훨씬 짧아졌다. QEMU 에뮬레이션 오버헤드가 없어진 데다 amd64/arm64가 병렬로 돌기 때문이다.

배운 점

크로스 아키텍처 Docker 이미지를 만들 때 QEMU 에뮬레이션은 “일단 되게는 해주지만 언제 깨질지 모르는” 방식이라는 걸 체감했다. 특히 pnpm install처럼 패키지를 대량으로 풀어내는 고부하 작업에서 QEMU가 CPU 명령어 에뮬레이션에 실패해 죽는 경우가 드물지 않다는 걸 이번에 알게 됐다. 그리고 이런 크래시가 나면 GitHub Actions가 곧바로 실패 처리를 해주지 않고 그냥 멈춰버릴 수 있다는 것도 예상 밖이었다 — timeout-minutes를 워크플로우에 걸어두면 최소한 무한정 멈춰있는 상황은 막을 수 있으니, 이번 기회에 함께 추가해두면 좋을 것 같다.

무엇보다 “일단 QEMU로 되니까 계속 쓴다”보다 “애초에 에뮬레이션이 필요 없는 구조로 바꾼다”가 근본적인 해결이었다. 마침 GitHub이 이제 arm64 네이티브 러너를 public 레포에 무료로 열어준 덕분에, 별다른 트레이드오프 없이 QEMU를 완전히 걷어낼 수 있었다.

사실 이 워크플로우도, 이번에 고친 것도 전부 AI 도움을 받아서 만든 거라 지금까지는 --platform linux/arm64,linux/amd64docker buildx imagetools create 같은 걸 그냥 가져다 쓰기만 했지 왜 이렇게 되어 있는지는 잘 몰랐다. 이번에 하나씩 물어보면서 QEMU 에뮬레이션이 정확히 뭘 하는 건지, 왜 아키텍처별로 job을 나눠야 했는지, 왜 이미지를 곧장 latest로 안 올리고 latest-amd64/latest-arm64로 따로 올렸다가 나중에 하나로 합치는지, 그리고 지금 당장 안 쓰는 아키텍처를 미리 만들어두는 것도(나중에 서버를 옮길 가능성에 대비한 이식성 확보) 나름의 이유가 있다는 것까지 제대로 이해하게 됐다. AI로 뭔가를 동작하게 만드는 것과, 그게 왜 그렇게 동작하는지 이해하는 건 별개의 문제라는 걸 이번에 새삼 느꼈다.