도커 실전 강좌 #4 CI에서 이미지 빌드 — GitHub Actions와 BuildKit 캐시

8 분 소요

여기까지 모든 빌드를 로컬에서 했습니다. 이제 그 빌드를 CI로 옮길 단계입니다. 코드 푸시 → 자동 빌드 → 레지스트리 푸시 → (다음 글에서) 배포.

도커 실전 강좌에서 이번 글의 위치:

이 글은 GitHub Actions 기준이지만, 패턴 자체는 GitLab CI / CircleCI에도 거의 그대로 옮겨 갑니다. 핵심은 BuildKit + 캐시 + 멀티 아키의 조합.

CI에서 도커 빌드의 어려움 — 캐시가 사라진다 #

로컬에서는 같은 이미지를 두 번째 빌드할 때 거의 즉시 끝납니다. 도커 데몬이 레이어 캐시를 디스크에 들고 있기 때문입니다. (중급 #2 빌드 캐시의 주제.)

CI는 다릅니다. 매 워크플로우가 깨끗한 가상머신에서 시작합니다. 캐시가 없으니 매번 베이스 이미지부터 받고, 의존성을 처음부터 다시 깔고, 빌드를 처음부터 다시 합니다. Next.js 프로젝트 하나가 5~8 분씩 걸리는 경우가 흔합니다.

이 문제를 푸는 도구가 둘입니다.

  1. docker/setup-buildx-action — BuildKit 빌더를 GHA 러너에 설치.
  2. type=gha 캐시 — BuildKit의 캐시 백엔드로 GitHub Actions의 캐시 저장소를 사용. 워크플로우 사이에 레이어 캐시가 살아남게.

이 둘이 붙으면 두 번째 빌드부터는 로컬과 비슷한 속도가 나옵니다.

가장 단순한 워크플로우 #

.github/workflows/docker.yml 한 파일.

.github/workflows/docker.yml — 첫 버전
name: Build and push image

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest

    permissions:
      contents: read
      packages: write   # GHCR 푸시에 필요

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GHCR
        if: github.event_name == 'push'
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name == 'push' }}
          tags: ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

이 파일이 하는 일:

  • on.push.branches: [main]pull_request 둘 다 트리거. PR에서는 빌드만, main 푸시 때만 push.
  • permissions.packages: write — GHCR 푸시는 기본 토큰의 packages 권한이 있어야 가능.
  • setup-buildx-action으로 BuildKit 빌더 설치.
  • login-action으로 GHCR에 로그인. PR에서는 푸시 안 하므로 로그인도 건너뜀.
  • build-push-actioncache-from/cache-to가 핵심 — GHA 캐시에 레이어를 저장/불러오기.

mode=max는 모든 중간 레이어를 캐시에 저장하는 옵션입니다. mode=min (기본값)은 최종 산출물만 저장하는데, 멀티스테이지에서는 중간 stage가 캐시되지 않아서 효율이 떨어집니다. max 권장.

이 워크플로우는 푸시할 때마다 :latest만 갱신합니다. 운영에서는 이것만으로 부족하며, 다음 절에서 태그 전략을 잡겠습니다.

태그 자동 생성 — docker/metadata-action #

:latest만 쓰면 “지금 운영 중인 이미지가 어느 커밋이지?” 를 추적할 수 없습니다. 태그를 자동으로 여러 개 다는 게 정석입니다.

metadata-action도입
- name: Extract metadata
  id: meta
  uses: docker/metadata-action@v5
  with:
    images: ghcr.io/${{ github.repository }}
    tags: |
      type=ref,event=branch        # 브랜치 이름: main
      type=ref,event=pr            # PR: pr-123
      type=sha,prefix=sha-,format=short  # 커밋: sha-a1b2c3d
      type=semver,pattern={{version}}     # 태그 푸시 시: 1.2.3
      type=semver,pattern={{major}}.{{minor}}  # 1.2
      type=raw,value=latest,enable={{is_default_branch}}

- name: Build and push
  uses: docker/build-push-action@v6
  with:
    context: .
    push: ${{ github.event_name == 'push' }}
    tags: ${{ steps.meta.outputs.tags }}
    labels: ${{ steps.meta.outputs.labels }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

metadata-action이 하는 일:

  • tags 패턴에 맞춰 자동 생성. main 푸시면 main, sha-a1b2c3d, latest가 한 번에 붙습니다.
  • labels도 자동 생성. OCI 표준 라벨(org.opencontainers.image.source 등)이 들어가서 GHCR의 패키지 페이지가 자동으로 저장소와 연결됩니다.

다음 글에서 태그 전략을 더 깊게 다루겠습니다. 여기서는 “metadata-action이 자동으로 잡아준다"는 정도로 이해하면 충분합니다.

멀티 아키텍처 — amd64 + arm64 #

Apple Silicon 개발자가 늘면서 멀티 아키 빌드가 거의 필수가 됐습니다. (고급 #2의 주제.)

멀티 아키 추가
- name: Set up QEMU
  uses: docker/setup-qemu-action@v3

- name: Set up Buildx
  uses: docker/setup-buildx-action@v3

- name: Build and push
  uses: docker/build-push-action@v6
  with:
    context: .
    platforms: linux/amd64,linux/arm64
    push: ${{ github.event_name == 'push' }}
    tags: ${{ steps.meta.outputs.tags }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

새로 들어간 요소:

  • setup-qemu-action — GHA 러너는 amd64 라서 arm64 빌드에는 QEMU에뮬레이션이 필요. 이 액션이 binfmt 등록을 자동으로 해 줍니다.
  • platforms: linux/amd64,linux/arm64 — buildx가 두 아키텍처를 한 번에 빌드하고 매니페스트로 묶어 푸시.

QEMU에뮬레이션은 느립니다. Native 빌드 대비 3~5 배. amd64만 빌드하던 게 1 분이었다면 amd64 + arm64가 4~5 분이 될 수 있습니다. 이미지 사용처가 amd64 클라우드뿐이라면 굳이 arm64를 짜지 마세요.

진짜 빠른 멀티 아키가 필요하면 runs-on: [self-hosted, arm64] 같은 ARM 러너를 띄워서 native로 빌드하는 방식이 있는데, 인프라 비용이 따라오는 선택지입니다.

빌드 시점 시크릿 — --secret #

NEXT_PUBLIC_API_URL 같은 비시크릿은 --build-arg로 충분합니다 (#3 참고). 그러나 빌드 시점에 진짜 시크릿(예: 사설 npm 레지스트리 토큰, GitHub PAT)이 필요한 경우는 --build-arg가 아닌 --secret을 써야 합니다. --build-arg는 이미지 히스토리에 평문으로 남기 때문입니다.

Dockerfile — secret 사용
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
# 빌드 시 마운트되는 시크릿 — 이미지에 안 남음
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    corepack enable && pnpm install --frozen-lockfile
워크플로우 — secret 주입
- name: Build and push
  uses: docker/build-push-action@v6
  with:
    context: .
    push: ${{ github.event_name == 'push' }}
    tags: ${{ steps.meta.outputs.tags }}
    secrets: |
      npmrc=${{ secrets.NPMRC_TOKEN }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

런타임 시크릿(DATABASE_URL 등)은 빌드 시점에 들어갈 일이 없습니다 — 그건 클라우드 환경의 환경변수/시크릿 매니저에 둡니다 (#6).

빌드 시간 줄이기 — 흔한 실수와 해결 #

빌드가 느리면 PR 사이클이 길어지고, 결국 안 쓰게 됩니다. 자주 마주치는 문제들을 짚어 둡니다.

캐시가 살아남지 않음cache-to: type=gha,mode=max가 빠졌거나, 워크플로우가 매번 다른 캐시 키를 쓰는 경우. type=gha는 자동으로 워크플로우 + 브랜치별 캐시 분리를 해 줍니다.

COPY . .가 너무 일찍 — 코드가 한 글자만 바뀌어도 그 뒤 모든 단계가 캐시 미스. 의존성 정의(package.json, pyproject.toml, go.mod)를 먼저 복사하고 의존성을 깔고, 그다음에 COPY . .. (#1, #2의 패턴.)

.dockerignore가 비어 있음node_modules, .git, coverage, .next 같은 게 빌드 컨텍스트로 통째로 전송되면 시간이 분 단위로 늘어남. 첫 단계 “Sending build context to Docker daemon” 이 길다면 신호.

여러 서비스가 한 워크플로우에 직렬로 빌드matrix로 병렬화.

matrix로 여러 이미지 병렬 빌드
jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        service: [api, web]
    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 }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}-${{ matrix.service }}
          tags: |
            type=ref,event=branch
            type=sha,prefix=sha-,format=short
      - uses: docker/build-push-action@v6
        with:
          context: ./${{ matrix.service }}
          push: ${{ github.event_name == 'push' }}
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha,scope=${{ matrix.service }}
          cache-to: type=gha,mode=max,scope=${{ matrix.service }}

scope를 다르게 두는 게 핵심 — 안 그러면 두 서비스가 같은 캐시 공간을 두고 충돌합니다.

attest와 SBOM — 공급망 보안 한 단계 #

CI에서 빌드할 때 한 번 더 얹을 수 있는 단계입니다. (고급 #4 SBOM과 서명의 주제.)

attestations + SBOM
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ${{ steps.meta.outputs.tags }}
    sbom: true                # SBOM 생성
    provenance: mode=max      # 빌드 출처 정보
    cache-from: type=gha
    cache-to: type=gha,mode=max

provenance는 “이 이미지가 어떤 워크플로우의 어떤 커밋에서 빌드됐는지"를 attestation으로 함께 푸시합니다. 나중에 supply chain 검증을 자동화할 때 이 데이터가 쓰입니다. 켜둬서 손해 볼 일 거의 없습니다.

풀 워크플로우 — 한곳에 모음 #

위 조각들을 한 파일로 모은 형태. 이대로 복붙해서 시작점으로 써도 됩니다.

.github/workflows/docker.yml — 최종
name: Build and push image

on:
  push:
    branches: [main]
    tags: ['v*.*.*']
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest

    permissions:
      contents: read
      packages: write
      id-token: write   # provenance에 필요

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GHCR
        if: github.event_name == 'push'
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=sha,prefix=sha-,format=short
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: ${{ github.event_name == 'push' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          sbom: true
          provenance: mode=max

다른 캐시 백엔드 — 언제 type=gha가 안 맞나 #

type=gha는 GHA 안에서 가장 편한 선택이지만, 두 가지 제약이 있습니다.

  • 캐시 한도 — 저장소당 GHA 캐시 총 크기 10GB. 큰 이미지에서는 LRU로 잘리며 미스가 잦아짐.
  • 워크플로우 외부에서는 못 씀 — 로컬 빌드는 type=gha 캐시를 못 끌어옴.

대안으로는 다음 두 가지가 있습니다.

  • type=registry — 캐시를 별도 이미지 태그로 레지스트리에 푸시. 모든 환경에서 공유 가능.

    cache-to: type=registry,ref=ghcr.io/.../cache,mode=max
    cache-from: type=registry,ref=ghcr.io/.../cache

    레지스트리 비용이 추가되지만 한도가 사실상 없음.

  • type=s3 / type=gcs — 클라우드 스토리지를 캐시로. 큰 조직에서 표준화하기 좋음.

소형 프로젝트라면 type=gha로 충분합니다. 캐시가 자주 미스 난다 싶으면 그때 옮기세요.

흔한 함정 #

“buildx not found” 에러setup-buildx-action 빠뜨림. 모든 도커 빌드 단계 앞에 둬야 함.

permission denied — GHCR 푸시permissions.packages: write 누락. 또는 organization의 GHCR 설정에서 write가 막혀 있을 수 있음 (Settings → Actions → General → Workflow permissions).

캐시는 잡혔는데 매번 처음부터 다시 — Dockerfile의 어떤 지점에서 timestamp 같은 비결정적 데이터가 들어가는 경우. RUN apt-get update && apt-get install -y ...의 패키지 인덱스도 시간에 따라 바뀝니다. apt 캐시는 BuildKit cache mount로 분리.

RUN --mount=type=cache,target=/var/cache/apt \
    apt-get update && apt-get install -y curl

arm64 빌드가 5 분 넘음 — QEMU에뮬레이션의 한계. 정말 자주 빌드한다면 ARM 러너 도입 검토.

PR에서 secret이 안 읽힘 — fork 된 PR은 secrets 접근이 차단됩니다. 보안상 의도된 동작. 신뢰된 컨트리뷰터 PR에 한해 별도 워크플로우(pull_request_target)를 쓸 수 있지만, 보안 함정이 많아 권장하지 않습니다.

정리 #

  • CI도커 빌드의 핵심은 **BuildKit + GHA 캐시(type=gha,mode=max)**의 조합. 두 번째 빌드부터 로컬과 비슷한 속도.
  • 태그는 docker/metadata-action으로 자동 생성. 브랜치/PR/SHA/semver/latest가 한 번에 정리합니다.
  • 멀티 아키(amd64 + arm64)는 setup-qemu + platforms 한 줄. 단, QEMU는 느림 — 필요 없으면 amd64만.
  • 빌드 시점 시크릿은 --build-arg가 아니라 --secret으로. 이미지에 안 남음.
  • 여러 서비스를 한 저장소에서 빌드한다면 matrix + 다른 cache scope로 병렬화.
  • sbom: true + provenance: mode=max는 켜두는 게 보통 이득.

다음 글(#5 레지스트리 푸시와 태그 전략)에서는 태그 전략을 본격적으로 다룹니다. semver / sha / latest의 의미와 함정, GHCR vs Docker Hub vs ECR의 갈림길, 이미지 retention 정책까지 정리합니다.

X