
무색 홈페이지 CI/CD 파이프라인을 구축한 뒤로 빌드가 좀 느리다는 느낌이 계속 있었다. push 한 번 하면 빌드부터 배포까지 체감상 꽤 걸렸고, 워크플로우 로그를 뜯어보니 느린 구간이 한두 곳이 아니었다. 한 번에 다 고치기보다 4개 커밋으로 나눠서 하나씩 잡아나갔다.
이전 글에서 Gitea Actions + DinD로 CI/CD를 처음 만들었는데, 그때는 "일단 돌아가게" 만드는 데 집중했다. 이번엔 빌드 로그를 시간 단위로 쪼개서 병목을 찾았다.
크게 세 영역에서 개선할 게 보였다.
DinD 러너 환경에는 ca-certificates, curl, git이 이미 깔려 있다. 처음 워크플로우를 만들 때는 이걸 몰라서 다 다시 설치했는데, 지금 보니 시간 낭비였다. 이미 있는 건 빼고 docker-ce-cli와 docker-buildx-plugin만 설치하도록 바꿨다.
--no-install-recommends 옵션도 추가했다. 추천 패키지까지 끌려오면 설치 시간이 꽤 늘어난다.
git clone도 --depth 1로 바꿨다. 빌드할 때 커밋 이력은 필요 없으니 최신 커밋만 가져오면 된다. 레포 크기가 크지 않아서 극적인 차이는 아니지만 안 할 이유도 없다.
빌드 시간을 가장 크게 줄인 건 캐시 전략이다. 세 가지 레이어로 캐시를 쌓았다.
buildcache라는 별도 태그로 이전 빌드 이미지를 레지스트리에 저장해두고 다음 빌드에서 --cache-from으로 참조한다. BUILDKIT_INLINE_CACHE=1을 켜면 빌드된 이미지에 캐시 메타데이터가 내장되어서 레지스트리에서 바로 캐시로 쓸 수 있다.
- name: Pull cache image
run: docker pull gitea.xssh.org/admin/museck:buildcache || true
- name: Build
env:
DOCKER_BUILDKIT: '1'
run: |
docker build \
--cache-from gitea.xssh.org/admin/museck:buildcache \
--build-arg BUILDKIT_INLINE_CACHE=1 \
-t gitea.xssh.org/admin/museck:$TAG \
-t gitea.xssh.org/admin/museck:latest \
-t gitea.xssh.org/admin/museck:buildcache .|| true가 붙어 있는 이유는 첫 빌드에서는 캐시 이미지가 없어서 pull이 실패하기 때문이다. 실패해도 빌드는 계속 진행되어야 한다.
Dockerfile에서 --mount=type=cache를 쓰면 특정 디렉토리를 빌드 간에 공유할 수 있다. pnpm store와 Next.js 빌드 캐시 두 곳에 적용했다.
# pnpm 패키지 캐시
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
corepack enable pnpm && pnpm install --frozen-lockfile
# Next.js 빌드 캐시
RUN --mount=type=cache,target=/app/.next/cache \
corepack enable pnpm && pnpm buildpnpm install은 패키지를 다운로드하는 시간이 대부분이라 store 캐시가 있으면 확 줄어든다. Next.js 빌드 캐시도 변경 없는 페이지를 다시 빌드하지 않게 해줘서 효과가 크다.
빌드 후 이미지를 레지스트리에 푸시하는데 3개 태그를 올려야 한다. SHA 태그, latest, buildcache. 순차적으로 하면 네트워크 I/O만 기다리는 시간이 3배다.
- name: Push images
run: |
docker push gitea.xssh.org/admin/museck:$TAG &
docker push gitea.xssh.org/admin/museck:latest &
docker push gitea.xssh.org/admin/museck:buildcache &
wait&로 백그라운드 실행하고 wait로 전부 끝날 때까지 기다린다. Docker 이미지 레이어는 공유되니까 실제 전송량은 1회분에 가깝고 레지스트리 API 호출만 병렬화되는 셈이다.
Docker 빌드는 컨텍스트를 먼저 데몬으로 보내는데, 불필요한 파일이 많으면 이 과정이 느려진다. 빌드에 필요 없는 디렉토리를 .dockerignore에 추가했다.
.husky
.claude
.mcp.json
scripts
tests
.giteaHusky 훅 설정, Claude MCP 설정, 스크립트, 테스트, Gitea 워크플로우 파일 등은 런타임에서 쓰지 않는다. 컨텍스트에서 빼면 전송 시간이 줄고 캐시 무효화도 방지된다.
BuildKit 마운트 캐시를 쓰려면 DOCKER_BUILDKIT=1이 필요하다. 환경변수를 설정했는데 빌드가 실패했다. --mount=type=cache 구문을 인식 못 하는 거였다.
알고 보니 DinD 환경에서는 docker-buildx-plugin을 별도로 설치해야 했다. 로컬에서는 Docker Desktop에 이미 포함되어 있어서 의식하지 못했는데, CI 러너는 최소 설치 환경이라 빠져 있었다.
# Before: buildx 없이 docker-ce-cli만 설치
apt-get install -y docker-ce-cli
# After: buildx 플러그인 추가
apt-get install -y --no-install-recommends docker-ce-cli docker-buildx-pluginCI 환경은 로컬과 다르다. 당연히 있을 거라고 생각하면 안 된다.
이 최적화를 한 커밋에 몰아넣지 않고 4개로 나눴다. apt-get 정리, git clone 최적화, Docker 캐시 도입, 병렬 push 순서대로. 각 커밋마다 빌드를 돌려보고 어디서 얼마나 줄었는지 확인할 수 있어서 좋았다.
최적화를 한꺼번에 적용하면 뭐가 효과 있었는지 알 수 없다. 한 번에 하나씩 바꾸고 측정하는 게 결국 빠르다.
Docker 빌드 최적화는 워크플로우, Dockerfile, .dockerignore 세 곳을 함께 봐야 한다. 어느 한 곳만 고치면 효과가 제한적이다.
CI/CD 파이프라인은 한 번 만들면 끝이 아니라 계속 다듬어야 한다. "느리다"는 느낌이 들 때가 개선할 타이밍이다.