도커 실전 강좌 #4 CI에서 이미지 빌드 — GitHub Actions와 BuildKit 캐시
여기까지 모든 빌드를 로컬에서 했습니다. 이제 그 빌드를 CI로 옮길 단계입니다. 코드 푸시 → 자동 빌드 → 레지스트리 푸시 → (다음 글에서) 배포.
도커 실전 강좌에서 이번 글의 위치:
- #1 FastAPI 컨테이너화
- #2 Django + PostgreSQL compose
- #3 React/Next.js 빌드 컨테이너
- #4 CI에서 이미지 빌드 — GitHub Actions와 BuildKit 캐시 ← 이번 글
- #5 레지스트리 푸시와 태그 전략
- #6 클라우드 배포 — Fly.io / Railway / ECS
이 글은 GitHub Actions 기준이지만, 패턴 자체는 GitLab CI / CircleCI에도 거의 그대로 옮겨 갑니다. 핵심은 BuildKit + 캐시 + 멀티 아키의 조합.
CI에서 도커 빌드의 어려움 — 캐시가 사라진다 #
로컬에서는 같은 이미지를 두 번째 빌드할 때 거의 즉시 끝납니다. 도커 데몬이 레이어 캐시를 디스크에 들고 있기 때문입니다. (중급 #2 빌드 캐시의 주제.)
CI는 다릅니다. 매 워크플로우가 깨끗한 가상머신에서 시작합니다. 캐시가 없으니 매번 베이스 이미지부터 받고, 의존성을 처음부터 다시 깔고, 빌드를 처음부터 다시 합니다. Next.js 프로젝트 하나가 5~8 분씩 걸리는 경우가 흔합니다.
이 문제를 푸는 도구가 둘입니다.
docker/setup-buildx-action— BuildKit 빌더를 GHA 러너에 설치.type=gha캐시 — BuildKit의 캐시 백엔드로 GitHub Actions의 캐시 저장소를 사용. 워크플로우 사이에 레이어 캐시가 살아남게.
이 둘이 붙으면 두 번째 빌드부터는 로컬과 비슷한 속도가 나옵니다.
가장 단순한 워크플로우 #
.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-action의cache-from/cache-to가 핵심 — GHA 캐시에 레이어를 저장/불러오기.
mode=max는 모든 중간 레이어를 캐시에 저장하는 옵션입니다. mode=min (기본값)은 최종 산출물만 저장하는데, 멀티스테이지에서는 중간 stage가 캐시되지 않아서 효율이 떨어집니다. max 권장.
이 워크플로우는 푸시할 때마다 :latest만 갱신합니다. 운영에서는 이것만으로 부족하며, 다음 절에서 태그 전략을 잡겠습니다.
태그 자동 생성 — docker/metadata-action
#
:latest만 쓰면 “지금 운영 중인 이미지가 어느 커밋이지?” 를 추적할 수 없습니다. 태그를 자동으로 여러 개 다는 게 정석입니다.
- 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=maxmetadata-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는 이미지 히스토리에 평문으로 남기 때문입니다.
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- 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로 병렬화.
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과 서명의 주제.)
- 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=maxprovenance는 “이 이미지가 어떤 워크플로우의 어떤 커밋에서 빌드됐는지"를 attestation으로 함께 푸시합니다. 나중에 supply chain 검증을 자동화할 때 이 데이터가 쓰입니다. 켜둬서 손해 볼 일 거의 없습니다.
풀 워크플로우 — 한곳에 모음 #
위 조각들을 한 파일로 모은 형태. 이대로 복붙해서 시작점으로 써도 됩니다.
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 curlarm64 빌드가 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 정책까지 정리합니다.