# 공유 베이스 이미지와 파생 이미지 CI - 베이스가 바뀌었는데 아무도 다시 빌드하지 않는 문제

서비스가 늘어나면 이미지도 늘어나고, 그 이미지들이 대부분 같은 것을 설치하고 있다는 사실을 알게 됩니다. 같은 파이썬 버전, 같은 시스템 패키지, 같은 무거운 런타임. 자연스러운 대응은 공통 부분을 베이스 이미지로 빼는 것입니다.

그런데 베이스를 빼고 나면 새로운 문제가 생깁니다. 베이스를 고쳤는데 파생 이미지들이 그대로 옛날 베이스 위에서 도는 상태가 됩니다. CI 로그에는 아무 실패도 없습니다. 이 글에서는 베이스와 파생 이미지 구조가 만드는 의존 관계를 CI가 어떻게 놓치는지, 캐시와 다이제스트로 무엇을 붙잡아야 하는지 정리합니다.

# 1. 베이스를 빼는 것이 실제로 무엇을 절약하는가

먼저 이득을 정확히 알아야 어디까지 복잡도를 감당할지 정할 수 있습니다. 베이스 분리가 주는 것은 세 가지입니다.

빌드 시간. 파생 이미지 다섯 개가 각자 같은 패키지를 설치하면 그 설치가 다섯 번 일어납니다. 베이스로 빼면 한 번입니다. 다만 캐시가 잘 동작하는 CI에서는 차이가 줄어듭니다. 캐시 미스가 잦은 환경일수록 이득이 큽니다.

노드의 디스크와 풀 시간. 이쪽이 더 큽니다. 컨테이너 이미지 레이어는 다이제스트 단위로 공유되므로, 파생 이미지 다섯 개가 같은 베이스 레이어를 가리키면 노드는 그 레이어를 한 번만 내려받고 한 벌만 저장합니다. 베이스가 2GB라면 이미지당 2GB씩 다섯 배로 늘던 것이 2GB + 각자의 차이분이 됩니다.

단, 조건이 있습니다. 파생 이미지들이 정확히 같은 베이스 다이제스트를 참조해야 레이어가 공유됩니다. 베이스 태그가 이동하는 상태에서 파생 이미지들이 서로 다른 시점에 빌드되면, 각자 다른 베이스 위에 올라가 레이어가 겹치지 않습니다. 절약이 통째로 사라집니다.

버전 일치. 파이썬 마이너 버전이나 시스템 라이브러리가 이미지마다 미묘하게 다른 상황을 없앱니다. "A에서는 되는데 B에서는 안 되는" 문제의 지분이 줄어듭니다.

세 이득이 모두 "같은 베이스를 정말로 공유하고 있을 때"만 성립한다는 점이 이 글의 출발점입니다.

# 2. 순진한 CI가 무너지는 지점

모노레포에서 이미지를 나눠 빌드할 때 보통 경로 필터를 씁니다.

# 바뀐 경로에 해당하는 이미지만 빌드한다
on:
  push:
    paths:
      - 'images/api/**'

이미지들이 서로 독립적일 때는 잘 동작합니다. 베이스를 도입하는 순간 깨집니다. images/base/Dockerfile을 고치면 베이스 잡만 돌고, images/api는 경로가 안 바뀌었으니 빌드되지 않습니다. 결과는 이렇습니다.

  • 레지스트리의 base:latest는 새 이미지를 가리킨다
  • api:1.4.2는 여전히 예전 베이스 위에 만들어진 이미지다
  • 두 사실을 알려 주는 신호가 아무 데도 없다

베이스에 보안 패치를 넣었다면 그 패치는 반영되지 않은 채 "반영했다"고 기록만 남습니다. 이것이 가장 위험한 형태의 실패입니다. 실패가 아니라 무동작으로 나타나기 때문입니다.

경로 필터 자체의 함정은 모노레포에서 바뀐 이미지만 빌드하기 (opens new window)에 따로 정리했습니다. 이 글에서 더하는 것은 파일 경로에 안 나타나는 의존성입니다. FROM은 코드가 아니라 레지스트리를 가리키므로, 파일 변경만 보는 필터로는 절대 잡히지 않습니다.

# 3. 의존 관계를 어디에 적을 것인가

해결책은 하나뿐입니다. 그래프를 어딘가에 명시적으로 적고, CI가 그것을 읽게 하는 것입니다. 선택지가 셋 있습니다.

(A) 단일 Dockerfile의 멀티스테이지로 합친다. 베이스를 이미지가 아니라 스테이지로 둡니다.

FROM python:3.12-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
      libgomp1 curl && rm -rf /var/lib/apt/lists/*
COPY requirements-base.txt .
RUN pip install --no-cache-dir -r requirements-base.txt

FROM base AS api
COPY api/ /app/api/
CMD ["python", "-m", "api"]

FROM base AS worker
COPY worker/ /app/worker/
CMD ["python", "-m", "worker"]

의존 관계가 파일 안에 있으므로 어긋날 수 없습니다. 가장 단순하고, 이미지 두세 개까지는 대체로 이 방법이 맞습니다. 한계는 규모입니다. 파생이 열 개를 넘어가면 한 Dockerfile이 감당하기 어렵고, 팀이 나뉘어 있으면 파일 하나를 여럿이 고치는 구조가 됩니다. 그리고 베이스만 바뀌어도 매번 전체를 빌드합니다.

(B) Bake로 타깃 간 의존을 선언한다. Buildx의 Bake는 빌드 설정을 파일로 두고 타깃끼리 연결할 수 있습니다.

To use a result of one target as a build context of another, specify the target name with target: prefix.

# docker-bake.hcl
variable "REGISTRY" { default = "registry.example.com" }
variable "VERSION"  { default = "dev" }

group "default" {
  targets = ["api", "worker"]
}

target "base" {
  dockerfile = "images/base/Dockerfile"
  context    = "."
}

target "api" {
  dockerfile = "images/api/Dockerfile"
  context    = "."
  contexts   = { base = "target:base" }     # 베이스 타깃 결과를 컨텍스트로
  tags       = ["${REGISTRY}/api:${VERSION}"]
  cache-from = ["type=registry,ref=${REGISTRY}/api:buildcache"]
  cache-to   = ["type=registry,ref=${REGISTRY}/api:buildcache,mode=max"]
}

target "worker" {
  dockerfile = "images/worker/Dockerfile"
  context    = "."
  contexts   = { base = "target:base" }
  tags       = ["${REGISTRY}/worker:${VERSION}"]
  cache-from = ["type=registry,ref=${REGISTRY}/worker:buildcache"]
  cache-to   = ["type=registry,ref=${REGISTRY}/worker:buildcache,mode=max"]
}

파생 Dockerfile은 이름 붙은 컨텍스트를 참조합니다.

FROM base
COPY api/ /app/api/
CMD ["python", "-m", "api"]

베이스는 한 번만 빌드되고 파생들이 그 결과 위에 올라갑니다. Dockerfile은 나뉘어 있으면서 의존 관계는 한 파일에 모입니다. 파생이 여럿인 모노레포에서 균형이 좋습니다.

(C) 베이스를 별도 이미지로 밀고 파생이 다이제스트로 참조한다. 베이스와 파생의 릴리스 주기를 완전히 분리하고 싶을 때 씁니다. 이 경우에만 4절의 문제가 생깁니다.

# 4. 베이스를 따로 미는 경우, 다이제스트로 묶는다

(C)를 고르면 파생 Dockerfile이 이렇게 됩니다.

ARG BASE_IMAGE=registry.example.com/base
ARG BASE_REF=sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2

FROM ${BASE_IMAGE}@${BASE_REF}
COPY api/ /app/api/
CMD ["python", "-m", "api"]

FROM base:latest가 아니라 다이제스트를 기본값으로 둡니다. 세 가지가 한꺼번에 해결됩니다.

  • 파생 이미지가 어느 베이스 위에 만들어졌는지 Git에 기록됩니다.
  • 같은 커밋을 다시 빌드하면 같은 베이스가 쓰입니다.
  • 베이스를 올리는 행위가 Git 변경이 되므로 경로 필터에 잡힙니다. 2절의 무동작이 사라집니다.

베이스 다이제스트를 갱신하는 것은 별도 파이프라인 단계가 됩니다.

# 베이스 빌드·푸시 후 다이제스트를 얻어 파생 Dockerfile들에 반영
DIGEST=$(docker buildx imagetools inspect \
  registry.example.com/base:latest --format '{{.Manifest.Digest}}')

grep -rl '^ARG BASE_REF=' images/ | while read -r f; do
  sed -i.bak "s|^ARG BASE_REF=.*|ARG BASE_REF=${DIGEST}|" "$f" && rm -f "$f.bak"
done

git diff --stat   # 변경이 있으면 PR을 연다

이 방식의 대가는 명확합니다. 베이스 갱신이 자동으로 전파되지 않습니다. PR을 열고 머지해야 반영됩니다. 대신 전파 시점이 눈에 보입니다. 자동 전파와 명시적 전파 중 무엇을 원하는지의 문제이고, 보안 패치 추적이 필요한 조직이라면 대개 명시적인 쪽이 낫습니다. 태그와 다이제스트의 관계 자체는 이미지 태그는 포인터다 (opens new window)에 정리했습니다.

# 5. 캐시를 어디에 둘 것인가

CI 러너는 매번 새로 뜨기 때문에 로컬 레이어 캐시가 남지 않습니다. 외부 캐시 백엔드를 붙여야 하고, 문서가 종류별 성격을 이렇게 설명합니다.

백엔드 문서 설명 성격
inline embeds the build cache into the image 별도 저장소 불필요, 대신 제약이 큼
registry embeds the build cache into a separate image, and pushes to a dedicated location separate from the main output 범용, CI 표준
local writes the build cache to a local directory on the filesystem 러너에 영속 디스크가 있을 때
gha uploads the build cache to GitHub Actions cache GitHub Actions 전용

여기서는 모드 차이가 중요합니다. mode=min최종 이미지에 들어간 레이어만 캐시하고, mode=max는 중간 단계까지 전부 캐시합니다. 멀티스테이지 빌드에서 이 차이가 결정적입니다.

빌드 스테이지에서 컴파일하고 런타임 스테이지에 산출물만 복사하는 구조를 생각해 봅니다. mode=min으로는 컴파일 스테이지가 최종 이미지에 없으므로 캐시되지 않습니다. 가장 오래 걸리는 단계가 매번 다시 실행됩니다. 캐시를 붙였는데 빌드 시간이 안 줄어드는 대표적인 원인입니다.

# 멀티스테이지에서 캐시 효과를 보려면 mode=max
docker buildx build --push \
  -t registry.example.com/api:1.4.2 \
  --cache-from type=registry,ref=registry.example.com/api:buildcache \
  --cache-to   type=registry,ref=registry.example.com/api:buildcache,mode=max \
  -f images/api/Dockerfile .

inline 캐시는 이미지 자체에 캐시를 심는 방식이라 mode=max를 지원하지 않습니다. 별도 저장소를 안 만들어도 되는 편의를 얻는 대신 멀티스테이지에서 힘을 못 씁니다. 멀티스테이지를 쓴다면 registry + mode=max가 사실상 기본값입니다.

캐시 위치를 정할 때 문서가 경고하는 지점이 하나 더 있습니다.

each cache writes to some location ... no location can be written to twice

여러 이미지가 같은 캐시 참조를 쓰면 마지막 빌드가 앞의 것을 덮습니다. 파생 이미지 다섯 개가 :buildcache 하나를 공유하면 캐시 적중률이 오히려 떨어집니다. 이미지마다 캐시 참조를 분리해야 합니다. 브랜치별로 나눌 때도 마찬가지입니다.

registry.example.com/api:buildcache-main
registry.example.com/api:buildcache-pr-1234

PR 빌드가 main 캐시를 읽되(cache-from) 쓰지는 않게 하면, PR이 서로의 캐시를 오염시키지 않으면서 베이스 캐시는 활용할 수 있습니다.

--cache-from type=registry,ref=.../api:buildcache-main \
--cache-from type=registry,ref=.../api:buildcache-pr-1234 \
--cache-to   type=registry,ref=.../api:buildcache-pr-1234,mode=max

cache-from은 여러 개를 나열할 수 있습니다. 다만 문서는 나열 순서에 따른 조회 우선순위를 정의하지 않으므로, 순서에 의미를 두지 않는 편이 안전합니다.

# 6. 무엇을 기준으로 고를 것인가

상황 선택 이유
이미지 2~3개, 같은 팀 멀티스테이지 단일 Dockerfile 의존 관계가 어긋날 수 없음
모노레포, 파생 다수 Bake + target: 컨텍스트 그래프가 한 파일에, Dockerfile은 분리
베이스 릴리스 주기가 다름 별도 이미지 + 다이제스트 참조 전파 시점이 명시적으로 드러남
팀·레포가 나뉨 별도 이미지 + 갱신 PR 자동 생성 소유권 경계가 레포 경계와 일치

기본값을 하나 정하라면 멀티스테이지로 시작해서, 파일이 감당 안 될 때 Bake로 나누고, 조직이 나뉠 때 이미지로 분리하는 순서가 자연스럽습니다. 처음부터 (C)로 가면 4절의 갱신 파이프라인을 만들어야 하는데, 이미지 세 개짜리 프로젝트에는 과합니다.

# 7. 직접 확인하는 방법

베이스 공유가 실제로 되고 있는지는 매니페스트의 레이어를 비교하면 바로 나옵니다.

# 두 파생 이미지의 레이어 다이제스트 목록
for img in api worker; do
  echo "== $img"
  crane manifest registry.example.com/$img:1.4.2 \
    | jq -r '.layers[].digest'
done

# 공통 레이어 개수 - 베이스를 공유하면 앞쪽 레이어가 그대로 일치한다
comm -12 \
  <(crane manifest registry.example.com/api:1.4.2    | jq -r '.layers[].digest' | sort) \
  <(crane manifest registry.example.com/worker:1.4.2 | jq -r '.layers[].digest' | sort) \
  | wc -l

공통 레이어가 기대보다 적으면 두 이미지가 서로 다른 베이스 위에 올라간 것입니다. 파생 이미지가 참조한 베이스를 직접 확인할 수도 있습니다. 빌드 시점에 라벨로 박아 두면 나중에 추적이 쉬워집니다.

ARG BASE_REF
FROM registry.example.com/base@${BASE_REF}
# FROM 앞의 ARG는 스테이지 밖이라 여기서 다시 선언해야 값이 보인다
ARG BASE_REF
LABEL org.opencontainers.image.base.digest="${BASE_REF}"
# 실행 중인 이미지가 어떤 베이스에서 왔는지
crane config registry.example.com/api:1.4.2 \
  | jq -r '.config.Labels["org.opencontainers.image.base.digest"]'

캐시가 실제로 먹었는지는 빌드 로그로 확인합니다. BuildKit은 캐시에서 가져온 단계를 CACHED로 표시합니다.

docker buildx build --progress=plain ... 2>&1 | grep -c CACHED

# 8. 트러블슈팅

증상 원인 조치
베이스를 고쳤는데 파생이 그대로 경로 필터가 FROM 의존을 모름 다이제스트를 Git에 두거나 Bake로 그래프화
노드 디스크가 예상보다 큼 파생들이 서로 다른 베이스 참조 레이어 다이제스트 비교 후 베이스 고정
캐시를 붙였는데 빌드가 안 빨라짐 mode=min이라 중간 스테이지 미캐시 mode=max + registry 백엔드
캐시 적중률이 들쭉날쭉 여러 이미지가 캐시 참조 공유 이미지·브랜치별로 참조 분리
PR 빌드가 main 캐시를 망가뜨림 PR이 공용 캐시에 씀 PR은 읽기만, 쓰기는 별도 참조로
같은 커밋인데 결과가 다름 베이스 태그가 그 사이 이동 FROM을 다이제스트로 고정
파생 빌드가 베이스보다 먼저 돔 잡 순서에 의존 관계 없음 Bake 의존 또는 잡 needs 선언

# 9. 마무리

  • 베이스 분리의 이득(빌드 시간, 디스크, 버전 일치)은 파생들이 정확히 같은 베이스 다이제스트를 참조할 때만 성립합니다.
  • FROM은 파일 경로에 나타나지 않는 의존성입니다. 경로 필터 기반 CI는 베이스 변경을 실패가 아니라 무동작으로 흘려보냅니다.
  • 그래프를 명시할 곳을 하나 고릅니다. 멀티스테이지(파일 안), Bake target:(빌드 설정), 다이제스트 ARG(Git) 순으로 결합도가 낮아지고 운영 부담이 늘어납니다.
  • 멀티스테이지 빌드에 캐시를 붙일 때는 mode=max가 필요합니다. min은 최종 이미지에 남은 레이어만 캐시하므로 정작 오래 걸리는 빌드 스테이지가 매번 다시 돕니다.
  • 캐시 위치는 한 곳에 두 번 쓸 수 없습니다. 이미지별·브랜치별로 나눠야 적중률이 유지됩니다.

# 참고