Tweag의 글(“Announcing rules_img: a faster path to container images in Bazel”)을 읽고 핵심을 정리했다. 인상 깊었던 건 속도가 빨라지는 이유가 “도구 최적화”보다는 “데이터 이동 방식을 바꾸는 설계”에 있다는 점이다. 요점은 간단하다. 이미지를 메타데이터로 먼저 다루고, 바이트(레이어)는 마지막에만 움직인다.
Bazel부터 짚고 가기
Bazel은 구글이 만든 빌드 시스템이다. 의존성 그래프를 기준으로 빌드·테스트·패키징을 관리하고, 캐시와 재현성이 강점이다. 원격 캐시나 원격 실행(RBE)을 붙이면 대규모 코드베이스에서도 빌드 시간을 줄일 수 있다. 이 글에서 다루는 rules_img는 Bazel에서 컨테이너 이미지를 만드는 규칙셋이다.
문제의 출발점: 왜 Bazel 이미지 빌드가 느려지는가
Bazel은 빌드 결과물을 잘 캐싱하고 재현성을 확보한다. 그런데 컨테이너 이미지를 붙이는 순간 병목이 생긴다. 기존 rules_oci 흐름에서는 이런 일이 일어난다.
- 베이스 이미지를 풀 때 레지스트리에서 모든 레이어를 로컬로 내려받는다.
- 원격 실행(RBE)을 쓰면, 로컬이 다시 원격 캐시에 레이어를 올린다.
- 이미지 조립 액션은 수백 MB~GB 단위 레이어를 입력으로 받는다.
- 푸시할 때도 레이어를 로컬로 내려받아 다시 레지스트리에 올린다.
즉, 빌드와는 무관한 대용량 바이트 이동이 반복된다. 속도 저하는 결국 “데이터 이동량”의 문제다.
2분 요약: OCI 이미지의 구성 요소
이미지는 크게 메타데이터와 레이어 바이트로 나뉜다.
- config: 실행 커맨드, 환경변수, 사용자, diff ID 목록
- manifest: config와 레이어 블롭을 가리키는 메타데이터
- index: 멀티 아키텍처용 manifest 목록
- layers: 압축 tar 형태의 실제 파일 변경분
정리하면 이미지는 “레이어의 실제 바이트”와 “그 바이트를 가리키는 JSON”으로 구성된다. rules_img는 이 구조를 그대로 성능 최적화에 활용한다.
rules_img의 핵심 아이디어
rules_img: 메타데이터는 빌드 그래프로, 레이어 바이트는 끝에서만
rules_img: 메타데이터는 빌드 그래프로, 레이어 바이트는 끝에서만
그림 관계를 글로 읽기
- pull은 레지스트리에서 manifest와 config JSON만 가져온다. 레이어 블롭은 레지스트리에 둔다.
- 가져온 메타데이터가 빌드 그래프로 들어간다.
- image_layer 액션이 tar와 메타데이터를 함께 만들고, 레이어 바이트는 CAS에 저장된다.
- 같은 액션의 작은 메타데이터만 빌드 그래프로 흐른다.
- image_manifest 조립은 레이어의 digest와 size만 참조하는 작은 JSON 작업이다.
- 빌드 단계는 무엇을 푸시할지 명세만 만들고, 실제 바이트 이동은 bazel run 시점으로 미룬다.
- bazel run은 레지스트리에 없는 레이어만 전송한다.
1) 메타데이터만 가져오는 Pull
베이스 이미지를 받을 때 레이어를 내려받지 않는다. manifest와 config JSON만 가져와서 “무엇이 있는지”만 안다. 레이어 블롭은 레지스트리에 둔다.
2) 레이어 생성은 CAS에, 메타데이터는 그래프에
레이어는 한 번의 액션에서 tar와 메타데이터를 함께 만든다. 이때 레이어 바이트는 CAS에 저장되고, 빌드 그래프에는 작은 메타데이터만 흐른다.
3) Manifest 조립은 작은 JSON 작업으로 끝
이미지 조립 단계는 레이어의 digest와 size만 참조한다. 덕분에 원격 실행에서도 입력이 가벼워지고 캐시 효율이 높아진다.
4) Push/Load는 run 단계로 미룬다
빌드 단계에서는 “무엇을 푸시할지”에 대한 명세만 만든다. 실제 바이트 이동은 bazel run 시점에 실행되고, 레지스트리에 없는 레이어만 전송한다. 로컬 머신이 불필요한 중계 역할을 하지 않는다.
속도가 빨라지는 이유를 한 줄로 요약하면
빌드 그래프에는 메타데이터만 흐르게 하고, 바이트는 끝에서만 이동하게 만든다.
글에서 언급한 효과는 이렇다.
- 베이스 이미지 풀 시간이 분 단위에서 초 단위로 줄어든다.
- 원격 실행 환경에서 입력 스테이징 지연이 크게 줄어든다.
docker load와 같은 로컬 적재는 증분 단위로 빠르게 끝난다.- 푸시는 필요한 레이어만 전송한다.
rules_oci와 rules_img 비교 관점
| 항목 | rules_oci | rules_img |
|---|---|---|
| 베이스 이미지 풀 | 모든 레이어 다운로드 | manifest/config만 다운로드 |
| 빌드 입력 | 전체 레이어 포함 | 메타데이터 중심 |
| 원격 실행 | 입력 스테이징 부담 큼 | 부담 작음 |
| 푸시 방식 | 전체 레이어 재전송 | 누락 레이어만 전송 |
| 로컬 로드 | docker load 전체 재적재 | containerd 기반 증분 로드 |
실무에서 체크해야 할 지점
- 베이스 레이어를 빌드 시점에 실제로 마운트하지 않기 때문에, 일부 바이너리는 호환성 검증이 필요하다. 글에서는 container-structure-test 같은 검증 툴을 언급한다.
- 레이어를 반드시 빌드 시점에 가져와야 하는 환경은 옵션으로 동작을 바꿀 수 있다.
- 원격 실행 환경의 입력 스테이징 방식(지연/사전 다운로드)에 따라 성능 차이가 크게 난다.
간단한 시작 스니펫
# MODULE.bazel
bazel_dep(name = "rules_img", version = "<version>")
pull = use_repo_rule("@rules_img//img:pull.bzl", "pull")
pull(
name = "ubuntu",
registry = "index.docker.io",
repository = "library/ubuntu",
tag = "24.04",
)
# BUILD.bazel
load("@rules_img//img:layer.bzl", "image_layer")
load("@rules_img//img:image.bzl", "image_manifest")
image_layer(
name = "app_layer",
srcs = {
"/app/bin/server": "//cmd/server",
},
)
image_manifest(
name = "app_image",
base = "@ubuntu",
layers = [":app_layer"],
)
마무리
rules_img는 “이미지는 결국 메타데이터와 바이트의 조합”이라는 구조를 그대로 성능 전략으로 연결한다. 빌드 중에는 메타데이터만 다루고, 바이트 이동은 마지막에만 한다. 이 단순한 원칙이 원격 실행 환경에서 큰 체감 속도 차이를 만든다. Bazel 기반 CI에서 이미지 빌드가 병목이라면, rules_img가 왜 빠른지 이해하는 것만으로도 설계 방향이 달라진다.