IT/컨테이너

BuildKit cache mount 하나 잘못 붙였다가 CI가 두 배 느려진 이야기

gfrog 2026. 7. 14. 21:19

지난 주 화요일 아침에 백엔드 팀장이 슬랙 DM으로 물어봤다. "요즘 CI가 왜 이렇게 느려요? 예전에는 8분이면 됐는데 요새는 20분 넘게 걸리는 것 같은데." 확인해보니 정말 그랬다. Go 서비스 하나의 build job이 12분에서 19분으로 늘어나 있었다. 뭐 변경한 것도 없는데.

내가 저 CI를 마지막으로 만진 게 6월 초였다. --mount=type=cache를 붙여서 Go module 다운로드를 캐싱하도록 바꿨다. 그때는 확실히 빨라졌다. 첫 빌드가 14분, 두 번째부터는 5분대. 팀 슬랙에 자랑까지 했다. 그런데 한 달 반이 지난 지금은 20분이 나오고 있었다.

처음엔 러너 탓인 줄 알았다

첫 번째 추측은 GitHub Actions runner가 지역별로 느려졌나 하는 것이었다. 요새 self-hosted도 아니고 GitHub-hosted large runner를 쓰는데, 리전 이슈일 수도 있고. Status page 봤다. 별 이상 없음. 다른 리포지토리들도 확인해봤다. 다른 CI들은 원래대로 도는 중.

두 번째 추측은 base image 문제. golang:1.24-alpine에서 1.25로 며칠 전에 자동 dependabot이 올려놨었다. 롤백해봤다. 안 나아졌다. 도루묵.

세 번째로 buildx 캐시 자체를 의심하기 시작했다. docker buildx du를 러너에서 찍어봤는데... 아 잠깐, 그전에 우리 셋업 얘기를 좀 해야겠다. 우리는 self-hosted runner를 안 쓰고 GHA hosted runner에 registry cache backend를 쓴다. 그러니까 --cache-to=type=registry,ref=ghcr.io/company/svc:buildcache,mode=max 이렇게. 그리고 --mount=type=cache,target=/go/pkg/mod도 같이. Cache mount는 layer가 아니라 buildkit 내부 볼륨이라 이론상 registry cache와는 관계가 없어야 하는데.

진짜 원인 찾기까지

여기서 새벽 3시에 눈이 번쩍 떠졌다. 침대에 누워서 생각하다가 "혹시 registry cache가 계속 커지고 있는 건가?"라는 생각이 들었다. 다음 날 아침에 registry cache tag의 사이즈를 봤다.

crane manifest ghcr.io/company/svc:buildcache | jq '.manifests[] | .size' | awk '{s+=$1} END {print s/1024/1024 " MB"}'

7.2 GB. 원래 처음 만들었을 때는 400MB 정도였다. 대체 뭐가 이렇게 쌓였을까.

imagetools inspect로 각 blob의 라벨을 봤다.

docker buildx imagetools inspect ghcr.io/company/svc:buildcache --format '{{json .}}' | jq

거기서 힌트가 나왔다. Go build cache 관련 blob이 계속 새 layer로 추가되고 있었다. 즉 --mount=type=cache로 붙인 Go build cache가 매 빌드마다 diff로 저장되면서 registry cache가 계속 쌓이고 있었다.

원인은 mode=max였다. Registry cache backend에서 mode=max는 intermediate stage의 cache까지 다 push한다. Cache mount는 원래 registry로 안 나가지만, buildkit이 cache mount의 diff를 metadata로 함께 저장하는 케이스가 있다. 우리 Dockerfile은 build stage에서 go build를 돌리고, 그 결과물의 output이 아니라 stage 자체를 registry cache에 넣고 있었다. 그래서 Go build cache가 커질수록 registry에 push/pull 하는 시간도 같이 늘어난 것이다.

해결

일단 급한 대로 registry cache를 통째로 지웠다.

gh api -X DELETE /orgs/company/packages/container/svc%2Fbuildcache

그리고 CI를 돌려보니 첫 빌드 15분, 두 번째부터 6분. 원래대로 돌아왔다. 근데 이건 근본 해결이 아니라 시간 지나면 또 같아진다.

Dockerfile을 이렇게 바꿨다.

FROM golang:1.25-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download

COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -o /app ./cmd/svc

FROM gcr.io/distroless/static-debian12
COPY --from=builder /app /app
ENTRYPOINT ["/app"]

원래도 비슷했는데, 문제는 CI 쪽 buildx 호출이었다. mode=max 대신 mode=min으로 바꿨다.

- uses: docker/build-push-action@v6
  with:
    cache-from: type=registry,ref=ghcr.io/company/svc:buildcache
    cache-to: type=registry,ref=ghcr.io/company/svc:buildcache,mode=min,image-manifest=true,oci-mediatypes=true

mode=min은 최종 이미지의 layer만 registry에 저장한다. Intermediate stage는 캐시하지 않는다. 대신 매번 첫 stage가 다시 돈다는 단점이 있는데, 우리는 어차피 --mount=type=cache로 Go module과 build cache를 로컬에서 잡고 있으니 큰 손해는 없었다. 실제로 재빌드 시간이 오히려 미묘하게 줄었다. 5분 40초 정도.

그리고 하나 더 - registry cache에 TTL 걸기

같은 실수를 반복 안 하려고 GitHub Actions 워크플로우에 registry cache 크기 감시를 붙였다. 이건 별도 job으로.

cache-guard:
  runs-on: ubuntu-latest
  steps:
    - name: check buildcache size
      run: |
        SIZE=$(gh api /orgs/company/packages/container/svc%2Fbuildcache/versions \
          | jq '[.[].metadata.container.size_in_bytes] | add')
        SIZE_MB=$((SIZE / 1024 / 1024))
        echo "buildcache size: ${SIZE_MB} MB"
        if [ "$SIZE_MB" -gt 2000 ]; then
          echo "buildcache too large, resetting"
          gh api -X DELETE /orgs/company/packages/container/svc%2Fbuildcache
        fi
      env:
        GH_TOKEN: ${{ secrets.PACKAGES_ADMIN_TOKEN }}

주 1회 스케줄로 돌린다. 2GB 넘으면 그냥 밀어버린다. 아직 한 번도 안 밀렸는데, 안전장치 삼아 놔두고 있다.

남은 궁금증

이번에 정리하면서 아직 확실히 모르는 게 있다. mode=max가 원래 cache mount 내용을 registry에 넣는 게 정상 동작인지, 아니면 우리 buildkit 버전(0.15)의 버그인지. 이 부분은 buildkit issue tracker를 뒤져봐도 딱 맞는 케이스가 없었다. 최근 moby/buildkit#3276 같은 issue는 multi-stage에서 image size 부풀어 오르는 문제인데, 우리 케이스와 완전히 같지는 않다. 시간 나면 minimal repro 만들어서 이슈 올려보려고 한다.

혹시 비슷한 경험 있으신 분 있으면 어떻게 해결하셨는지 궁금합니다. 아직 우리 팀 셋업이 최선인지 확신이 없어서.