Published on

Kaniko로 루트리스 멀티스테이지 빌드 60% 단축

Authors

CI에서 컨테이너 이미지를 빌드할 때 가장 흔한 병목은 docker build 자체가 아니라, 그 빌드를 가능하게 만들기 위한 런타임 권한과 캐시 전략입니다. Docker-in-Docker(dind)는 편하지만 보안상 특권 권한이 필요하거나, 러너 환경에 따라 네트워크/스토리지 오버헤드가 커져 빌드 시간이 들쭉날쭉해집니다.

Kaniko는 이런 상황에서 특히 강력합니다. 쿠버네티스나 일반 CI 러너에서 도커 데몬 없이 Dockerfile을 해석해 이미지를 만들고 레지스트리에 푸시합니다. 또한 캐시를 레지스트리에 저장할 수 있어, 멀티스테이지 빌드에서도 “의존성 단계”를 안정적으로 재사용할 수 있습니다.

이 글에서는 다음을 목표로 합니다.

  • 루트 권한 없이(rootless에 가까운 운영 형태) Kaniko로 이미지를 빌드/푸시하기
  • 멀티스테이지 Dockerfile을 캐시 친화적으로 재구성하기
  • 레지스트리 캐시를 활용해 CI 빌드 시간을 체감 60% 수준까지 단축하기

관련해서 CI 캐시가 예상대로 동작하지 않을 때의 디버깅 관점은 이 글도 함께 참고하면 좋습니다: GitHub Actions 캐시가 안 먹을 때 - key·dir 충돌 디버깅

왜 Kaniko가 “루트리스”에 유리한가

엄밀히 말해 Kaniko 자체가 완전한 의미의 rootless 빌더(예: BuildKit rootless 모드)와 동일하진 않습니다. 하지만 실무에서 우리가 원하는 건 대개 다음입니다.

  • CI 러너에서 --privileged 없이 빌드하기
  • dockerd를 띄우지 않기
  • 쿠버네티스에서 Pod Security 정책을 만족하기

Kaniko는 데몬이 없고, 컨테이너 내부 파일시스템에서 레이어를 구성해 푸시합니다. 즉, “도커 데몬을 위해 특권 권한을 주는 구조”를 제거하는 것만으로도 보안/운영 복잡도가 크게 줄고, 그 과정에서 빌드 시간 변동폭도 줄어듭니다.

60% 단축의 핵심: 멀티스테이지를 캐시 친화적으로 쪼개기

빌드가 느린 이유는 보통 다음 중 하나입니다.

  1. 의존성 설치 단계가 매번 다시 돈다(예: npm ci, pip install, poetry install)
  2. 소스 변경이 잦아 캐시가 쉽게 무효화된다
  3. 빌드 산출물이 큰데 불필요한 파일이 런타임 이미지에 포함된다

멀티스테이지는 3번을 해결하지만, 1번과 2번은 Dockerfile 작성 순서에 따라 오히려 더 악화될 수 있습니다. Kaniko에서 60%까지 줄이려면 “의존성 단계 캐시 적중률”을 올리는 게 핵심입니다.

예시 Dockerfile: Node.js(Next.js) 멀티스테이지

아래는 캐시를 최대한 살리는 구조입니다. 포인트는 package.json과 lockfile만 먼저 복사해서 의존성 레이어를 고정하는 것입니다.

# syntax=docker/dockerfile:1

FROM node:20-bookworm AS deps
WORKDIR /app

# 의존성 설치는 소스 전체가 아니라 lockfile 기반으로만 캐시되게
COPY package.json package-lock.json ./
RUN npm ci

FROM node:20-bookworm AS builder
WORKDIR /app

# deps 레이어를 재사용
COPY --from=deps /app/node_modules ./node_modules

# 그 다음에 소스를 복사해야 캐시가 덜 깨짐
COPY . .

# 빌드 산출물 생성
RUN npm run build

FROM node:20-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production

# 런타임에 필요한 것만 복사
COPY --from=builder /app/package.json ./
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules

EXPOSE 3000
CMD ["npm", "run", "start"]

이 구조에서 소스 변경이 잦아도 deps 단계는 lockfile이 바뀌지 않는 한 재사용됩니다. Kaniko 캐시가 제대로 붙으면 npm ci가 통째로 스킵되는 날이 많아지고, 여기서 시간이 크게 절약됩니다.

Kaniko 실행 옵션: 캐시를 “레지스트리”에 저장하라

Kaniko는 로컬 디스크 캐시도 가능하지만, CI 러너는 대개 매번 새로 뜨므로 로컬 캐시의 효용이 제한적입니다. 대신 레지스트리 캐시를 쓰면 러너가 바뀌어도 캐시가 유지됩니다.

핵심 플래그는 다음입니다.

  • --cache=true
  • --cache-repo=... (캐시 레이어를 저장할 리포지토리)
  • --cache-copy-layers=true (복사 단계 캐시에도 도움)
  • --snapshot-mode=redo 또는 time (환경에 따라 성능/정확성 트레이드오프)

예시 커맨드:

/kaniko/executor \
  --context "$PWD" \
  --dockerfile Dockerfile \
  --destination "ghcr.io/acme/myapp:${GITHUB_SHA}" \
  --cache=true \
  --cache-repo "ghcr.io/acme/myapp-cache" \
  --cache-copy-layers=true \
  --snapshot-mode=redo

여기서 --cache-repo를 앱 이미지와 분리하면 운영이 편합니다.

  • 앱 이미지는 태그 정책이 엄격할 수 있음
  • 캐시는 태그/GC 정책이 달라도 됨

GitHub Actions 예시: 루트 권한 없이 Kaniko로 빌드/푸시

GitHub Actions에서 Kaniko를 쓰는 대표 패턴은 “Kaniko 컨테이너를 액션 런너에서 실행”하는 방식입니다.

주의할 점은 config.json(Docker auth)을 Kaniko가 읽을 수 있는 경로에 놓는 것입니다. Kaniko는 기본적으로 /kaniko/.docker/config.json을 봅니다.

name: build
on:
  push:
    branches: ["main"]

jobs:
  kaniko:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Login for Kaniko (write config.json)
        run: |
          mkdir -p kaniko-docker
          cat > kaniko-docker/config.json <<'EOF'
          {
            "auths": {
              "ghcr.io": {
                "auth": "${{ secrets.GHCR_AUTH_B64 }}"
              }
            }
          }
          EOF

      - name: Build & Push with Kaniko
        run: |
          docker run --rm \
            -v "$PWD":/workspace \
            -v "$PWD/kaniko-docker":/kaniko/.docker \
            gcr.io/kaniko-project/executor:v1.23.2 \
              --context /workspace \
              --dockerfile /workspace/Dockerfile \
              --destination "ghcr.io/${{ github.repository }}:${{ github.sha }}" \
              --cache=true \
              --cache-repo "ghcr.io/${{ github.repository }}-cache" \
              --cache-copy-layers=true \
              --snapshot-mode=redo

GHCR_AUTH_B64username:token을 base64로 인코딩한 값입니다. 예:

echo -n "USERNAME:TOKEN" | base64

캐시가 “먹는지” 확인하는 방법

Kaniko 로그에서 다음 키워드를 확인합니다.

  • Using cached layer 또는 Found cached layer
  • 특정 RUN npm ci 단계가 캐시로 대체되는지

캐시가 계속 미스난다면, 보통은 Dockerfile 레이어가 불안정한 게 원인입니다. 예를 들어 아래 패턴은 캐시를 쉽게 깨뜨립니다.

  • COPY . .가 의존성 설치보다 먼저 나옴
  • 빌드 과정에서 타임스탬프/환경에 따라 파일이 매번 달라짐
  • .dockerignore가 없어 불필요한 파일이 컨텍스트에 매번 포함됨

.dockerignore는 빌드 시간 단축의 숨은 1등 공신

Kaniko든 Docker든, 컨텍스트가 커지면 전송/스냅샷 비용이 증가합니다. 특히 모노레포나 프론트엔드 프로젝트에서 node_modules, .next/cache, dist 등이 컨텍스트에 섞이면 캐시 적중률이 떨어지고 빌드가 느려집니다.

# .dockerignore
node_modules
.next/cache
dist
build
coverage
.git
.github
*.log
.DS_Store

컨텍스트 최적화는 “캐시가 잘 붙는데도 느린” 케이스를 많이 줄여줍니다.

멀티스테이지에서 자주 하는 실수: 런타임 이미지에 빌드 도구 포함

빌드 시간 단축과 별개로, 결과 이미지가 커지면 Pull 시간이 늘어 배포 시간이 길어집니다. 멀티스테이지의 목적은 런타임에 불필요한 도구를 제거하는 것입니다.

  • 빌드 스테이지: 컴파일러, dev dependencies, 테스트 도구
  • 런타임 스테이지: 실행에 필요한 바이너리/정적 파일만

이렇게 하면 CI 빌드 시간이 줄어든 만큼, 배포 리드타임도 함께 줄어드는 효과가 있습니다.

쿠버네티스에서 Kaniko를 쓸 때의 보안 포인트

쿠버네티스 Job으로 Kaniko를 돌리는 경우, 다음을 점검하세요.

  • 가능한 한 runAsNonRoot 설정
  • 쓰기 가능한 볼륨(워크스페이스) 제공
  • 레지스트리 크리덴셜은 Secret로 마운트

Pod가 자꾸 재시작하거나 상태가 불안정하면, 단순히 빌드 문제가 아니라 probe/리소스 설정 문제일 수 있습니다. 운영 관점에서 CrashLoop을 잡는 방법은 이 글이 도움이 됩니다: K8s CrashLoopBackOff - liveness probe 오탐 해결

실제로 60%를 줄이는 체크리스트

아래 항목을 순서대로 적용하면 체감 성능이 크게 좋아집니다.

  1. Dockerfile에서 의존성 설치 레이어를 lockfile 기반으로 분리했는가
  2. .dockerignore로 컨텍스트를 최소화했는가
  3. Kaniko에 --cache=true--cache-repo를 설정했는가
  4. 캐시 저장소에 대한 권한/인증이 안정적인가
  5. snapshot-mode를 환경에 맞게 선택했는가(대부분 redo부터 시도)

추가로, GitHub Actions에서 “캐시가 분명 있어야 하는데 계속 미스”라면 키/경로 충돌 같은 CI 캐시 이슈도 함께 의심해야 합니다. 이때는 GitHub Actions 캐시가 안 먹을 때 - key·dir 충돌 디버깅에서 소개한 방식처럼, 캐시가 저장되는 경로와 키가 실제로 일치하는지부터 확인하는 게 빠릅니다.

마무리

Kaniko는 단순히 docker build를 대체하는 도구가 아니라, CI에서 이미지 빌드를 “보안적으로 덜 위험하고, 캐시를 레지스트리로 표준화할 수 있는” 구조로 바꾸는 선택지입니다. 멀티스테이지 Dockerfile을 캐시 친화적으로 재구성하고, 레지스트리 캐시를 붙이면 의존성 설치 단계가 반복되는 시간을 크게 줄일 수 있습니다.

정리하면, 빌드 60% 단축의 대부분은 Kaniko 자체의 마법이 아니라 다음 2가지에서 나옵니다.

  • 레이어가 잘 쪼개진 멀티스테이지 Dockerfile
  • 러너가 바뀌어도 유지되는 레지스트리 캐시

이 두 가지를 먼저 고정하고, 그 다음에 러너 스펙/병렬화/아키텍처(예: 멀티플랫폼)로 확장하는 것이 가장 비용 대비 효과가 좋습니다.