레지스트리 없이 쿠버네티스 부트스트랩 – containerd 이미지 사전 태깅
출처: Dev.to
프라이빗 프로젝트—내부 도구, 격리된 네트워크, 사내 스택—용으로 Kubernetes를 설정하고 있다면 어느 순간 이미지가 어디서 오는가에 대한 질문에 직면하게 됩니다.
모든 튜토리얼은 docker.io, ghcr.io, quay.io 같은 공개 레지스트리에 접근할 수 있다고 가정합니다. 그런데 접근할 수 없을 때는 닭‑달걀 문제가 발생합니다. 레지스트리 이미지를 레지스트리에서 끌어올 수 없습니다. IdP가 올라오기 전에는 IdP에 인증할 수도 없습니다. 모든 기반 서비스가 같은 형태를 가집니다.
모든 기반 서비스가 같은 문제를 갖는다
다음과 같은 패턴이 어디서든 나타납니다:
- 레지스트리: kubelet이 레지스트리 이미지를 어디선가 끌어와야 하는데, 레지스트리 자체가 그 이미지를 제공해야 합니다.
- Identity Provider: OIDC를 사용하는 모든 것은 IdP가 가동 중이어야 합니다—그리고 IdP 파드도 이미지 풀 없이 시작되지 않습니다.
각각을 특수 케이스로 다루면 “첫 번째 실행 전용” 스크립트가 쌓이고, 이는 일반 배포 경로와 동기화가 맞지 않게 됩니다.
실용적인 접근법
메커니즘 자체는 매우 단순합니다:
docker build로 이미지를 만든다.docker save로 tarball 로 저장한다.- tarball 을 모든 Kubernetes 노드에 복사한다.
ctr -n k8s.io images import <파일>로 containerd에 가져온다.
Pod 스펙에 imagePullPolicy: IfNotPresent 를 설정하면, kubelet은 캐시된 이미지를 사용하고 풀을 시도하지 않습니다. 레지스트리가 가동될 필요도 없고, 외부에 접근할 필요도 없습니다.
이름이 일치해야 한다
containerd에 이미지를 가져오면, tarball에 적힌 이름 그대로 캐시에 저장됩니다. 그 이름은 단순 문자열일 뿐이며, repo/registry:latest 와 registry.example.com/repo/registry:latest 사이에 특별한 차이는 없습니다—두 형태 모두 유효하고 레지스트리 호스트명이 해석되지 않아도 캐시에 존재할 수 있습니다.
베어 네임 (Day 1)
# values.yaml
image:
repository: repo/registry
tag: latest
pullPolicy: IfNotPresent
docker build -t repo/registry:latest .
docker save repo/registry:latest > registry.tar
scp registry.tar node:/tmp/
ssh node 'sudo ctr -n k8s.io images import /tmp/registry.tar'
Day 1 에는 잘 동작합니다. 하지만 레지스트리가 가동되고 registry.example.com/repo/registry:v0.4.2 와 같은 실제 빌드를 푸시하기 시작하면, 모든 차트에서 image.repository 를 완전한 경로로 바꿔야 합니다. Day‑1 과 Day‑2 배포가 서로 다른 코드 경로가 됩니다.
Fully‑qualified name (일관된 경로)
docker build -t registry.example.com/repo/registry:v0.4.2 .
docker save registry.example.com/repo/registry:v0.4.2 > registry.tar
scp registry.tar node:/tmp/
ssh node 'sudo ctr -n k8s.io images import /tmp/registry.tar'
image:
repository: registry.example.com/repo/registry
tag: v0.4.2
pullPolicy: IfNotPresent
이제 containerd의 캐시 키, 차트의 image.repository, 그리고 캐시 미스 시 kubelet이 조회할 호스트명이 모두 같은 문자열이 됩니다. 클러스터는 이미지가 어제 사이드로드된 것인지, 오늘 아침 레지스트리에서 풀된 것인지 구분할 수 없습니다.
태그에 대한 주의점: latest 대신 v0.4.2 와 같이 불변(immutable) 태그를 사용하세요. IfNotPresent 를 쓰면 kubelet은 이미 캐시된 이미지를 그대로 유지하고 다시 풀어오지 않으므로, 사이드로드된 latest 가 레지스트리에서 새로운 latest 로 바뀌어도 그대로 고정됩니다. 불변 태그를 사용하면 부트스트랩 노드는 v0.4.2 를 영원히 보유하고, 다음 릴리즈는 v0.4.3 으로 배포되므로 레지스트리가 올라왔을 때 정상적으로 풀됩니다.
이 접근법이 제공하는 이점
- 모든 기반 서비스가 동일한 방식으로 부트스트랩된다. 레지스트리든 IdP든 같은 메커니즘, 같은 네이밍 패턴, 특수 케이스가 없습니다. 부트스트랩 스크립트는 이미지 리스트를 순회하는 루프가 됩니다.
- Helm values 를 한 번만 작성한다. 부트스트랩 모드 오버레이와 프로덕션 모드 오버레이를 구분할 필요가 없습니다.
image.repository마이그레이션을 추적할 일도 없습니다. 배포하는 values 파일이 그대로 올바른 값입니다. - Argo CD 가 클러스터를 깔끔하게 상속한다. GitOps 로 전환하면 Argo CD 역시 같은 Helm 차트를 읽습니다. 이미지 문자열이 변하지 않으므로, 사이드로드된 태그는 이미 캐시돼 있고, 새로운 릴리즈는 노드가 한 번도 보지 못한 태그이므로 레지스트리에서 정상적으로 풀됩니다. 마이그레이션도, 첫‑동기화 모드도, 부트스트랩 경로를 알아야 하는
Application도 없습니다. Day‑1 과 Day‑2 가 같은 코드 경로를 공유하게 됩니다.
결론
핵심은 메커니즘이 아니라 정렬입니다. docker save 와 ctr import 가 똑똑한 것이 아니라, containerd 안의 이름, 차트 안의 이름, 그리고 kubelet 이 호출할 이름이 모두 첫 번째 가져오기부터 동일한 문자열이어야 합니다.
이것이 맞춰지면, 닭‑달걀 문제는 더 이상 문제처럼 느껴지지 않습니다.