20180315

Docker multi-stage build 노트

Docker 17.05 이후부터 multi-stage build를 쓸 수 있고, 18.03이 최근 stable로 올라오면서 빌드 캐시 관리까지 꽤 안정됐다. 예전엔 builder 전용 이미지를 따로 만들어서 docker cp로 산출물만 빼는 Makefile 짜야 했는데 이제 Dockerfile 한 장으로 끝난다. CI 설정도 반으로 줄었다.

개념부터. FROM ... AS stage_name으로 여러 stage를 선언하고, COPY --from=stage_name으로 이전 stage 산출물만 복사. 최종 이미지는 마지막 FROM 이후 내용만 남는다. 이전 stage는 캐시에 남아 있지만 배포되는 건 마지막만.

Go 서비스 예시.

FROM golang:1.10-alpine AS deps
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download

FROM deps AS build
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux \
    go build -ldflags "-s -w -X main.version=${VERSION}" \
    -o /out/app ./cmd/app

FROM build AS test
RUN go vet ./... && go test ./...

FROM alpine:3.7
RUN apk add --no-cache ca-certificates tzdata
COPY --from=build /out/app /app
USER 65534
ENTRYPOINT ["/app"]

이미지 크기가 golang:1.10(780MB) → 최종 18MB로 줄었다. 빌드 환경(컴파일러, go modules 캐시, 테스트 도구)이 최종 이미지에 들어가지 않는 게 핵심. CGO_ENABLED=0으로 정적 바이너리 뽑아 alpine에서도 ldd 안 걸림. ldflags -s -w는 심볼 테이블/DWARF 제거로 수 MB 더 다이어트.

stage 이름 반드시 주는 이유. CI에서 docker build --target test .로 테스트 단계만 실행 가능. 예를 들어 PR 빌드는 test까지만, merge 빌드는 최종 stage까지. 한 Dockerfile로 라이프사이클 전체를 커버한다.

Python 예시 (Django/FastAPI 서비스). wheel 빌드 분리가 크다.

FROM python:3.6-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc libpq-dev libffi-dev \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --wheel-dir /wheels -r requirements.txt

FROM python:3.6-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    && rm -rf /var/lib/apt/lists/*
COPY --from=build /wheels /wheels
RUN pip install --no-index --find-links=/wheels /wheels/*.whl \
    && rm -rf /wheels
COPY . /app
WORKDIR /app
CMD ["gunicorn", "wsgi:application", "-w", "4", "-b", "0.0.0.0:8000"]

빌드용 이미지엔 gcc, libffi-dev, libpq-dev가 있고 wheel 컴파일만 수행. 런타임 이미지는 slim에 runtime lib만. psycopg2-binary 말고 psycopg2를 직접 컴파일해도 되는 식. 최종 이미지 약 120MB, 기존 빌드 툴체인 포함 400MB에서 3배 감소.

캐시 전략. requirements.txt → pip wheel을 앞단에 두면 코드만 변경된 커밋은 dependency stage 캐시 히트. 잘 나눠 놓으면 CI 반복 빌드가 2분 → 30초 수준까지 줄어든다. 다만 docker build는 기본적으로 단일 머신 로컬 캐시만 쓰기 때문에 GitLab CI runner가 분산이면 --cache-from으로 레지스트리에서 이전 이미지를 당겨와 재사용. 18.03부터 --cache-from이 multi-stage 전체 stage까지 인식한다(이전 버전은 최종 stage만).

함정 몇 개.

  • COPY --from=<stage> <src> <dst>에서 src는 stage의 WORKDIR 기준 상대경로. WORKDIR 안 박아두면 루트 기준. 헷갈려서 한번 잘못 copy하고 파일이 없다고 몇 분 삽질
  • alpine의 musl과 glibc 차이. Go는 대부분 문제 없는데, 일부 C 확장 포함 파이썬 라이브러리(grpcio, numpy)는 alpine manylinux wheel이 없어서 소스 컴파일로 빠진다. 이 경우 slim debian이 속 편함
  • 최종 stage에 USER 안 박으면 root 실행. nonroot uid(65534 nobody 등)로 내려야 K8s PodSecurityPolicy 통과
  • --squash 플래그는 experimental이라 프로덕션에선 multi-stage로 대체하는 게 정석. 레이어 머지로 줄일 필요가 없어짐

사이드 효과로 좋은 건 보안 표면 축소. gcc, apt, pip, git이 최종 이미지에 없으면 컨테이너 탈취돼도 공격자가 쓸 도구가 적다. CVE 스캔(Clair, Trivy)에서 점수도 확 좋아진다. 우리 registry에 Trivy 붙여뒀는데 HIGH 취약점 개수가 이미지당 평균 40 → 8로.

남은 숙제: BuildKit(DOCKER_BUILDKIT=1) 실험. 병렬 stage 빌드, 비밀정보 마운트(--mount=type=secret) 같은 기능이 있는데 18.06부터 기본 탑재 예정이라 조금 더 기다려 볼 생각.

댓글 없음: