🚀 요약

SUMMARY

모노레포 20여 개 모듈을 매 빌드마다 전부 빌드했고, 대부분 latest 태그로만 올라가 어떤 이미지가 어느 커밋에서 나왔는지 추적이 안 됐다. 게다가 GitOps PR이 모든 모듈의 태그를 한꺼번에 갱신해 ArgoCD 반영도 늘어졌다. 모듈 디렉토리의 마지막 커밋 해시로 불변(immutable) 태그를 붙이고, 직전 배포 커밋과의 git diff변경된 모듈만 빌드하며, 이미 있는 태그는 건너뛰고, 변경 모듈 태그만 담은 GitOps PR을 자동 생성하도록 바꿨다. executor 분리와 램디스크는 실측해봤지만 효과가 없어 보류했다.

이미지 태깅은 안 되면 당장 안 죽지만, 사고가 나면 발목을 잡는 문제다. “지금 운영에 떠 있는 이 이미지가 대체 어느 커밋에서 나온 거냐”에 답을 못 하면, 롤백도 재현도 감으로 하게 된다. 거기에 빌드는 한 모듈만 고쳐도 20여 개를 전부 다시 돌았다. 추적성과 빌드 시간, 두 문제를 같이 정리한 이야기다.

1. 현황 조사

손대기 전에 서비스 이미지가 실제로 어떻게 빌드·배포되고 있는지부터 전수 조사했다. 이미지별로 Dockerfile 위치, 태그 정책, 여러 버전이 존재하는지, 모듈 디렉토리를 공유하는지, 빌드 컨텍스트로 루트 경로를 쓰는지 따위를 표로 정리했다. 대충 이런 그림이었다(이미지명은 가렸다).

서비스이미지명latest 태그비고
code-servingmyorg/code-servingO빌드 컨텍스트가 루트
codespace-gpumyorg/codespace-gpuO모듈 디렉토리 공유
workflow-apiplatform-workflow-apiO
tensorboardplatform-tensorboardO
vllm-openaivllm/vllm-openaiX버전별 다중 태그

조사하면서 문제가 세 갈래로 정리됐다.

  • 이미지 이력 추적 불가. 대부분 latest 하나로만 올라가니, 지금 떠 있는 이미지가 어느 커밋인지 이미지만 봐서는 알 수 없었다. latest는 계속 덮어써지는 포인터라 어제의 latest와 오늘의 latest가 다른 물건이다.
  • 네이밍 통일성 없음. myorg/..., platform-..., 외부에서 그대로 가져온 이미지명이 섞여 있어 prefix 규칙이랄 게 없었다. 이건 합의가 필요한 영역이라 이번 작업의 1순위로 두진 않았다.
  • CI 파이프라인 미포함. 애초에 이 이미지들은 빌드 자동화 대상이 아니었다. 소스가 바뀌면 누군가 수작업으로 빌드해 올려야 했다. 즉 태그가 엉망인 것도 결국 수작업이라 그런 거였다.

여기에 솔루션 쪽 제약이 하나 얹혀 있었다. 이 이미지들을 소비하는 제품이 로컬 레지스트리에 대해 업로드·삭제만 지원하고, DB에 시스템 이미지와 사용자 업로드 이미지를 구분하는 플래그도 없었다. 태그 체계를 마음대로 바꾸면 사용자가 올린 이미지와 뒤섞일 위험이 있었다. 그래서 “다 갈아엎기”는 애초에 선택지가 아니었다.

2. 두 군데서 나던 지연

추적성과 별개로, 빌드 자체도 느렸다. 대상은 사내 검증망(QA) 배포 파이프라인이었고 구조는 흔한 축에 든다. Jenkins가 빌드하고, Kustomize로 매니페스트를 override하고, GitOps 리포에 PR을 올리면 ArgoCD가 클러스터에 반영한다. 애플리케이션은 모노레포에 20여 개 모듈(프론트 몇 개, API 여러 개, 모니터링 계열 등)이 함께 사는 형태다. 배포는 스프린트 중에 수시로 일어나고, “이 모듈 하나 고쳤으니 검증망에 올려달라”는 상황이 대부분이었다. 문제는 그 한 모듈을 올리는 데 파이프라인이 하는 일이 너무 많았다는 거다. 지연이 두 군데서 났다.

첫째, 빌드. 파이프라인은 커밋이 뭘 건드렸든 매번 20여 개 모듈을 전부 빌드했다. 한 모듈만 고쳐도 나머지 전부가 다시 돌았다. 캐시가 없으면 훨씬 더 걸렸고, 캐시가 살아 있어도 4분 50초 안팎에서 더는 내려가지 않았다.

둘째, 태그 갱신. GitOps PR을 만들 때 변경 여부와 무관하게 모든 모듈의 이미지 태그를 새 커밋 해시로 바꿨다. 그러면 ArgoCD 입장에선 20여 개 애플리케이션이 전부 바뀐 것처럼 보인다. 실제로는 한 개만 바뀌었는데 전체가 재동기화 대상이 되니 반영이 늘어졌다. 게다가 이 PR은 브랜치별 첫 PR 이후로는 수기로 만들고 있어서, 사람이 태그를 손으로 고치는 일까지 남아 있었다.

정리하면 매 빌드가 전부를 빌드하고, 매 PR이 전부의 태그를 흔들고 있었다. 목표는 단순했다. 바뀐 것만 건드리게 만드는 것.

3. 커밋 해시 기반 불변 태그

제약을 받아들이고 나면 방향은 명확해진다. latest는 솔루션을 건드리지 않는 한 그대로 둔다. 제품이 소비하는 로컬 레지스트리에는 계속 latest로 이미지를 올린다. 대신 빌드가 레지스트리에 밀어 넣는 이미지에는 커밋 해시 기반 태그를 따로 붙여서, 추적은 그 태그로 한다.

태그 형식은 mod-{커밋해시}로 정했다. mod-는 “이 파이프라인이 소스에서 빌드한 모듈 이미지”라는 표식이고, 뒤의 해시가 실제 식별자다. 버전이 갈리는 이미지(vLLM 같은)는 mod-{버전}-{커밋해시} 형태로 버전을 앞에 끼웠다.

핵심은 이 태그가 불변(immutable)이라는 점이다. mod-a1b2c3... 태그는 딱 그 커밋의 그 소스로 빌드한 이미지 하나만 가리킨다. 덮어쓸 일이 없으니 “이 이미지 = 이 커밋”이 항상 성립한다.

“커밋 해시”라고 하면 보통 HEAD 커밋을 떠올리는데, 모노레포에선 그러면 안 됐다. HEAD를 쓰면 모듈 A만 고쳤는데 모듈 B~Z의 태그까지 전부 바뀐다. 태그가 매번 전부 갈리면 “안 바뀐 이미지”를 골라낼 수가 없다. 그래서 모듈 디렉토리별 마지막 커밋 해시를 썼다.

# 해당 모듈 디렉토리를 마지막으로 변경한 커밋의 전체 해시 하나만 뽑는다.
# 이 경로가 안 바뀌면 해시도 그대로 → 태그도 그대로 → 재빌드 불필요.
git log -n 1 --pretty=format:%H -- container-services/code-serving

이렇게 하면 모듈 A만 고친 커밋에서 A의 해시 태그만 바뀌고 나머지는 그대로다. 태그가 “이 모듈 소스의 마지막 변경 지점”과 1:1로 묶이는 셈이다. 이 성질이 다음 단계(변경된 것만 빌드)의 전제가 된다.

4. 변경된 모듈만 빌드

파이프라인이 매번 전 모듈을 다 빌드하면 느리고 낭비다. 핵심은 “지금 이 빌드가 무엇을 바꿨는가”를 파이프라인이 알게 하는 거다. 기준점은 직전에 배포된 커밋으로 잡았다. 파이프라인은 배포할 때 GitOps PR 본문에 이번 커밋 해시를 적어두는데(뒤의 5번), 다음 빌드에서는 그 브랜치에서 마지막으로 머지된 PR 본문의 커밋 해시를 다시 읽어 기준점으로 쓴다. 기준을 직접 지정하고 싶을 때를 위해 커밋 해시를 파라미터로 넘기는 길도 뒀다.

# 기준 커밋(직전 배포 커밋)과 HEAD 사이에 바뀐 파일 경로를 뽑는다.
git diff --name-only "$BASE_COMMIT" HEAD

뽑은 경로를 모듈로 매핑하는 건 모듈 카탈로그(modules.yaml)가 맡는다. 카탈로그에는 모듈마다 소스 경로가 등록돼 있어서, 바뀐 파일 경로가 어떤 모듈의 소스 경로로 시작하면 그 모듈을 빌드 대상에 넣는다. 경로 문자열을 잘라 최상위 디렉터리로 뭉뚱그리는 대신 카탈로그에 선언된 경로로 판별하니, 모듈이 어디에 있든 정의만 맞춰두면 된다.

폴백 조건은 명시적으로 뒀다. 브랜치의 첫 PR이라 비교할 기준 커밋이 없거나, 전체 빌드 옵션을 켰으면 무조건 전체 빌드한다. 어설프게 증분으로 갔다가 안 바뀐 척 넘어간 모듈이 검증망에서 옛 이미지로 도는 게 가장 피하고 싶은 경우라, 확신이 없을 땐 전부 빌드하는 쪽으로 기울였다.

이 하나로 “한 모듈 고침 → 한 모듈만 빌드”가 됐다. 20여 개를 다 돌던 게 한두 개로 줄었으니, 앞서 말한 4분 50초의 벽은 사실상 의미가 없어졌다. 대부분의 검증망 배포가 그 모듈 하나의 빌드 시간으로 끝난다.

경로를 모듈로 매핑할 때 걸리는 지점이 몇 개 있었다.

  • 최상위가 아닌 모듈. 버전이 여럿인 워크플로우 엔진 계열이나 모니터링 계열은 한 폴더 아래에 하위 모듈이 여러 개 들어 있었다. 이건 카탈로그에 각 하위 모듈의 소스 경로를 그대로 등록해두면 풀린다. 매핑이 경로 접두사 비교라, 하위 경로까지 정확히 적어두면 상위 폴더로 뭉뚱그려지지 않는다.
  • 자기 경로 밖을 참조하는 Dockerfile. 모듈이 자기 디렉터리 밖의 공용 코드를 COPY하면, 그 공용 경로가 바뀌었을 때 자신도 다시 빌드돼야 한다. 핵심 모듈들을 확인해보니 자기 경로 밖을 참조하는 경우는 없어서 이번엔 매핑이 크게 위험하진 않았다. 다만 공용 코드를 끌어다 쓰는 모듈이 생기면 여기서 놓칠 수 있으니, 남겨둔 위험으로 적어둔다.

빌드 순서 자체는 각 모듈 Dockerfile이 builder/runtime 멀티스테이지로 자기 의존성을 안에서 해결하는 구조라, 모듈 간 위상 정렬까지 갈 필요는 없었다. 경로→모듈 매핑을 정확히 하는 게 사실상 의존성 관리의 대부분이었다.

5. 이미 있으면 건너뛰기와 빌드 캐시

변경 모듈로 잡혔더라도, mod-{해시} 태그 이미지가 레지스트리에 이미 있으면 그 모듈은 빌드하지 않는다. 해시 태그가 불변이라 가능한 최적화다 — 같은 해시 = 같은 소스 = 같은 이미지라, 다시 만들 이유가 없다.

// 대상 모듈마다 mod-{해시} 태그가 레지스트리에 있는지 확인하고,
// 있으면 스킵 목록으로, 없으면 실제 빌드 목록으로 분류한다.
targets.each { module ->
    def commitTagImage = "${registry}/${namespace}/${module.imageName}:${module.commitTag()}"
    if (imageExists(commitTagImage)) {
        skippedTargets.add(module)   // [SKIP] 이미 존재
    } else {
        actualBuildTargets.add(module) // [BUILD] 신규 해시
    }
}

실제 빌드는 docker buildx bake로 모듈별 타깃을 정의해 --push로 밀어 넣었다. 워커 여러 개가 공유 큐에서 모듈을 하나씩 집어 빌드하는 소비자 패턴으로 병렬화했고, 레지스트리·네트워크 flake로 실패한 모듈은 큐 뒤로 다시 넣어 다른 워커가 나중에 재시도하게 했다. 배치 배리어 없이 항상 N개가 도는 구조라 느린 모듈 하나가 슬롯을 오래 막지 않는다.

빌드 캐시는 별도 원격 캐시를 붙이는 대신, 빌드 노드에 상주하는 docker-container 드라이버 buildx 빌더를 하나 두고 그 빌더의 로컬 BuildKit 캐시를 재활용하는 쪽으로 갔다. BuildKit의 GC 정책으로 캐시 상한만 잡아두면 레이어 캐시가 빌드 간에 살아남는다.

# buildkitd.toml — 상주 빌더의 캐시 GC 정책.
# 캐시를 무한정 쌓지 않되(상한 500GB), 일정 용량은 확보해 레이어 재사용률을 유지한다.
[worker.oci]
  gc = true
  [[worker.oci.gcpolicy]]
    all = true
    reservedSpace = "30GB"
    maxUsedSpace = "500GB"
    minFreeSpace = "20%"

결국 “안 바뀐 모듈은 스킵, 바뀐 모듈은 로컬 캐시 태워서 빌드”라 대부분의 커밋에서 실제로 새로 빌드하는 이미지는 한두 개뿐이다.

6. GitOps PR과 태그 자동화

빌드가 정리됐으니 뒷단도 같이 고쳤다. 두 가지를 바꿨다.

변경 모듈 태그만 갱신. PR이 20여 개 태그를 다 흔들던 걸, 이번 빌드에서 실제로 바뀐 모듈의 태그만 새 해시로 올리도록 했다. 나머지는 그대로 두니 ArgoCD가 바뀐 애플리케이션만 동기화한다. 반영 지연의 원인 하나가 여기서 사라졌다.

PR 자동 생성. 첫 PR 이후 수기로 만들던 걸, 모든 파이프라인 실행이 PR을 자동으로 열도록 바꿨다. PR 본문에는 이번에 바뀐 모듈 목록과 커밋 정보를 적어, 리뷰어가 “뭐가 바뀌어서 올라온 건지”를 PR만 보고 알 수 있게 했다.

## CI 결과
**Commit:** `3ce92f1`
**Tag:** `qa-1.1.1.1-g3ce92f1`
**Message:** JSONArray 관련 오류 수정
 
### 이미지 업데이트
**Core Modules(1)**
- `example-api`
**Container Service Modules**
_변경 없음_
 
### 소스코드 변경사항
**Core Modules**
- `example-api`

수기로 태그를 고치고 PR을 만들던 단계가 통째로 없어졌다. 개발자가 브랜치에 올리면 빌드부터 PR까지 손댈 게 없다.

7. 트리거와 승격

원래 그림은 PR merge 시점 트리거였다. 그런데 이 환경은 인바운드가 막혀 있어서 GitHub 웹훅(GitHub → Jenkins)이 들어올 수가 없었다. 그래서 방향을 뒤집었다. 웹훅을 기다리는 대신, 별도 watcher 잡이 outbound로 원격 브랜치를 주기적으로 폴링한다.

// watcher가 cron(3분)마다 원격 head를 한 번에 훑어(git ls-remote),
// 브랜치별로 마지막에 본 SHA와 비교해 변한 브랜치만 빌드 잡을 트리거한다.
def lsRemote = sh(script: "git ls-remote --heads '${repoUrl}'", returnStdout: true)
// ... 브랜치별 last-seen SHA와 대조 후, 변경 시:
build job: 'Platform-Build', parameters: [string(name: 'BRANCH_NAME', value: br)], wait: false

단일 pollSCM은 브랜치 하나만 기억한다는 한계가 있어서, 브랜치별 SHA를 파일로 캐싱해 develop·main·릴리스 브랜치를 동시에 감시하게 했다. 빌드 잡 자체는 트리거를 갖지 않는 순수 실행기로 두고, 감지·폴링은 watcher로 일원화했다.

이렇게 개발 단계에서 빌드된 mod-{해시} 이미지는 검증·운영 배포 때 다시 빌드하지 않는다. 승격 방식은 이미지 종류에 따라 갈리는데, 서비스 이미지는 skopeo copy로 각 환경의 로컬 레지스트리에 복사하고, 코어 모듈은 docker buildx imagetools create로 원격 레지스트리에 릴리스 태그를 찍는다. 어느 쪽이든 소스 재빌드가 아니라 이미지 프로모션이라 “개발에서 검증한 그 이미지 그대로”가 운영까지 간다.

추적 메타데이터는 배포 시 파드 어노테이션으로 심었다. 각 컴포넌트 파드에 platform.example/source-image-tag: mod-{해시}를 박아두면, 운영에 떠 있는 파드만 보고도 어느 커밋에서 나온 이미지인지 역추적할 수 있다.

# 배포 매니페스트에 심는 추적용 어노테이션 (해시 태그를 그대로 노출)
podAnnotations:
  platform.example/source-image-tag: mod-a1b2c3d4e5f6a7b8

latest 하나로 뭉개져 있던 상태에서, “이 파드 → 이 이미지 → 이 커밋”이 끊기지 않고 이어지게 만든 게 이 작업의 실질적인 결과다.

8. 효과 없던 가설들

빠르게 만들 방법으로 몇 가지를 더 얹어보려다 접었다. 실측해보고 효과가 없어서다. 이걸 굳이 적는 건, 안 한 게으름이 아니라 재보고 뺀 결정이라는 걸 남겨두고 싶어서다.

  • executor 분리. Jenkins가 executor 1개로 모듈을 순차 처리하고 있었다. 모듈 수만큼(26개) executor로 쪼개 병렬로 돌리면 빨라질 거라 기대했는데, 막상 재보니 전체 빌드가 4분 50초 안팎으로 이전과 차이가 없었다. 병목이 executor 개수가 아니라 다른 데 있었다는 뜻이다. 증분 빌드로 빌드량 자체가 줄어드는 마당에 병렬화 이득은 더 옅어져서 보류했다.
  • 램디스크. 소스와 캐시를 tmpfs 램디스크에 올리면 디스크 IO를 피할 수 있다. 프론트 모듈 하나로 시험했을 땐 캐시 없이도 빌드가 2분대로 떨어져 솔깃했다. 문제는 규모였다. 빌드 캐시(/var/lib/buildkit)가 400GB를 넘겨서 램에 통째로 올릴 방법이 없었다. 재부팅 시 휘발되는 것도 상시 빌드 서버엔 부담이라 접었다.

대신 건진 게 하나 있다. 특정 서버에서만 빌드가 유독 느려 iostat·iotop으로 뒤져보니 한 파드가 디스크 IO를 거의 독점하고 있었다. 이 파드를 종료하자 IO가 정상으로 돌아왔다(로그로 원인까지 파악하진 못했다). 빠르게 만들 궁리를 하다가 엉뚱하게 느리게 만드는 범인을 잡은 셈이다.

남은 것

증분 빌드의 폴백 경로(첫 빌드 전체 빌드)는 아직 보수적으로 넓게 잡혀 있어서, 여기를 더 정교하게 좁힐 여지가 있다. 네이밍 통일 같은 숙제도 합의가 필요해서 다음으로 미뤘다. 빌드 캐시가 무한정 쌓이면 다시 디스크를 잡아먹으니 정리 주기를 걸어뒀는데, 이 주기가 적절한지는 좀 더 지켜봐야 한다. 이 캐시 GC를 전용 빌더에 물려 상·하한으로 관리한 자세한 얘기는 전용 buildx 빌더와 GC 정책으로 따로 정리했다. 근본적으로는 빌드 CI를 Jenkins 밖(예: 스팟 VM 기반 러너)으로 빼는 그림도 검토 중이지만, 그건 이 작업의 범위는 아니었다.

🔗 참고