Docker BuildKit 이해하기
Docker 빌드 레이어
컨테이너 이미지는 레이어 단위로 빌드됩니다. 그리고 이 레이어 구성은 약 2년 전 작성했던 컨테이너 이미지 레이어 포스트에서도 나와있다시피 Dockerfile의 명령어에 영향을 받습니다. Dockerfile의 명령어 중 RUN, COPY, ADD는 보통 파일시스템 변경 레이어를 만들고, FROM은 베이스 이미지를 기반으로 새로운 빌드 시작점을 만듭니다.
Docker 캐시 동작 방식
Docker는 빌드 속도 향상을 위해 레이어를 기반으로 캐시가 동작합니다.
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/main.js"]
예를 들어 위와 같은 Dockerfile이 있습니다. Docker는 아래와 같은 방식으로 레이어를 구성합니다.
Step 1: FROM node:20-alpine
Step 2: WORKDIR /app
Step 3: COPY package.json package-lock.json ./
Step 4: RUN npm ci
Step 5: COPY . .
Step 6: RUN npm run build
Step 7: CMD ["node", "dist/main.js"]
Docker 캐시는 단순히 명령어 하나를 캐싱하는게 아니고, 특정 시점의 파일시스템의 상태를 레이어 단위로 저장했다가 재사용하는 구조입니다. 그래서 어느 단계에서 캐시가 실패하면 그 이후 단계는 기본적으로 전부 재실행됩니다.
Dockerfile 명령어가 바뀌거나, COPY/ADD 대상 파일의 내용 또는 캐시 Checksum에 영향을 주는 메타데이터가 변경되면 캐시가 깨집니다. (참고1) 단순히 파일 수정 시간 mtime만 바뀌는 경우 캐시는 깨지지 않습니다. (참고2) RUN은 캐시 키가 같고 명령어도 같으면 캐시를 그대로 사용합니다.
앞에서 언급했다시피 이전 레이어가 바뀌면 이후 레이어도 모두 캐시가 무효화됩니다. 그렇기 때문에 Dockerfile의 순서가 캐싱 효율을 결정합니다.
(참고) 모든 명령어가 파일시스템 레이어를 만드는 것은 아닙니다.
- RUN, COPY, ADD는 파일시스템 변경 레이어를 생성합니다.
- CMD, ENTRYPOINT, ENV, LABEL, EXPOSE는 이미지 설정을 변경하는 메타데이터 명령입니다.
- WORKDIR은 이후 명령어의 작업 디렉터리를 설정합니다. 지정한 디렉터리가 없으면 생성될 수 있으므로 파일시스템 변경이 생길 수 있지만, RUN/COPY/ADD처럼 파일을 직접 추가하거나 명령 실행 결과를 저장하는 레이어 생성 명령과는 성격이 다릅니다.
BuildKit
BuildKit은 docker builder에 비해 더 현대적이고 가장 널리 쓰이고 있는 도커 빌드 엔진입니다. 기존 builder는 Dockerfile을 위에서 아래로 순차 실행하는 구조였습니다. BuildKit은 Dockerfile을 빌드 그래프로 해석해 병렬 실행, 캐시 최적화 같은 기능을 제공합니다.
병렬 빌드
의존성이 없는 빌드는 병렬 실행할 수 있습니다.
아래 멀티스테이지 빌드 예시에서 frontend-builder와 backend-builder는 서로 의존하지 않기 때문에 병렬 실행될 수 있습니다. runner는 두 builder stage의 결과물을 COPY –from으로 참조하므로, 해당 결과물이 준비된 이후에 실행됩니다.
FROM node:20-alpine AS frontend-builder
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend .
RUN npm run build
FROM node:20-alpine AS backend-builder
WORKDIR /backend
COPY backend/package*.json ./
RUN npm ci
COPY backend .
RUN npm run build
FROM nginx:alpine AS runner
COPY --from=frontend-builder /frontend/dist /usr/share/nginx/html
COPY --from=backend-builder /backend/dist /app/backend
외부 캐시 저장소 사용
GitHub-hosted Actions runner에서는 job마다 새로 세팅된 클린한 실행 환경을 제공받기 때문에, 이전 Workflow에서 만든 로컬 Docker 캐시가 제공되지 않습니다. 그래서 cache-to, cache-from을 사용해 캐시를 외부(Registry, GHA 캐시 등)에 저장하거나 불러올 수 있습니다.
Cache Mount
Cache mount를 이용하면, 다음과 같은 상황에서 캐시 miss로 npm ci가 다시 실행되더라도 이전에 다운로드했던 npm 패키지 캐시를 재사용할 수 있도록 합니다.
# syntax=docker/dockerfile:1.7
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
Secret Mount
BuildKit을 쓰면 빌드 중에만 Secret을 주입하고, 최종 이미지에는 남기지 않을 수 있습니다.
ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN
예를 들어 이런 방식은 레이어에 민감 정보가 남을 수 있습니다. 실제로 GitHub Actions에서도 위와 같이 Dockerfile을 작성하면 Warning 문구가 표시됩니다.
# syntax=docker/dockerfile:1.7
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
docker buildx build \
--secret id=npmrc,src=$HOME/.npmrc \
-t my-app .
이렇게 처리하면 RUN 명령 중에만 .npmrc가 마운트되고, 이미지 레이어에는 복사되지 않습니다.
(참고) build secret 변경은 빌드 캐시와 관련이 없어서, secret만 변경해서는 캐시 무효화가 되지 않습니다.
동시에 병렬 실행한다는데, 진짜 병렬인가? 로우 레벨 관점에서 알아보기
BuildKit이 병렬로 실행한다는 것이 로우 레벨 측면에서 어떤 의미인지 궁금했습니다. 알아보니 BuildKit이 병렬로 실행한다는 것은 내부적으로 여러 작업을 동시에 스케줄링한다는 뜻이었습니다.
BuildKit 내부에서는 Dockerfile을 LLB(Low-Level Build)라는 중간 표현으로 변환합니다. LLB는 빌드 작업을 의존성 그래프 형태로 표현하고, BuildKit은 그래프를 보고 독립적인 작업을 병렬로 실행합니다.
BuildKit의 작업은 명령어의 종류에 따라 새로운 프로세스를 수행할 수도 있고, 새로운 컨테이너를 띄워서 작업할 수도 있고, BuildKit 내부에서 처리할 수도 있습니다. 그리고 이러한 작업들을 병렬로 처리하도록 스케줄링합니다.
예를 들어 RUN npm ci 라는 Dockerfile 명령어가 있다고 하면, 이는 컨테이너 프로세스로 수행되고, 내부적으로는 아래와 같이 프로세스가 실행됩니다.
/bin/sh -C "npm ci"
node /path/npm-cli.js ci
tar 압축 해제
postinstall script
...
반면, COPY package.json . 같은 Dockerfile 명령어는 buildkitd 내부 로직이 파일을 읽고 스냅샷을 만드는 식으로 처리합니다. buildkitd는 Go로 작성되어 있고, 내부적으로 스레드나 고루틴을 사용해 처리합니다. COPY 외에도 레이어 처리, 압축, 다운로드 등도 비슷하게 처리하는 것으로 보입니다.
마치며
Dockerfile은 단순한 명령어 모음 스크립트 정도로 보이지만, 실제 빌드 과정에서는 각 명령어들이 캐시키, 의존성을 가진 작업 단위로 해석됩니다.
기존 builder에서는 순차적으로 처리되었었지만, BuildKit에서는 Dockerfile을 LLB 그래프로 변환하고, 의존성이 없는 작업을 병렬로 실행하고 필요한 캐시만 재사용할 수 있도록 최적화합니다.
Docker 빌드를 최적화하기 위해서는 “캐시를 쓴다”는 단순한 방법만으로는 부족하다는 것을 느꼈습니다. 어떤 명령어가 레이어를 생성하고, 어떤 변경점이 캐시를 무효화하며, 어떤 작업이 병렬로 실행될 수 있는 작업인지를 이해해야 올바른 최적화를 할 수 있는 것 같습니다.