Skip to content

필사 모드: 레지스트리를 늘렸는데 가용성은 그대로다 — zot 스케일 아웃이 실제로 파는 것

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — 인스턴스를 세 대로 늘린 날의 착각

사내 레지스트리 하나에 모든 배포가 매달려 있는 구조가 불안해서 인스턴스를 세 대로 늘렸다고 해 봅시다. 앞단에 로드밸런서를 두고, 헬스체크를 붙이고, 대시보드에 초록불 세 개가 켜집니다.

그런데 한 대를 내리는 순간 특정 리포지토리에 대한 docker pull이 전부 실패합니다. 나머지 두 대는 멀쩡한데 말입니다.

이건 버그가 아니라 설계입니다. 여러분이 켠 것은 고가용성이 아니라 샤딩이었습니다. 그리고 이 구분은 레지스트리를 고를 때 성능 수치보다 훨씬 중요한데, 대부분의 문서에서 "scale out"이라는 한 단어에 뭉뚱그려져 있습니다.

이 글은 zot의 공식 문서를 근거로 그 구분을 짚습니다. zot을 고르라거나 고르지 말라는 이야기가 아니라, 문서에서 무엇을 확인하고 넘어가야 하는지에 대한 이야기입니다.

레지스트리의 본질은 저장이 아니라 이름 붙이기다

레지스트리를 "이미지 파일 서버"로 이해하면 확장 설계를 오해하게 됩니다. 레지스트리가 실제로 관리하는 것은 두 종류의 이름입니다.

첫째는 다이제스트입니다. sha256:abc... 형태로, 블롭 내용의 해시입니다. 내용이 같으면 이름이 같고, 이름이 같으면 내용이 같습니다. 여기서는 충돌이 없고 캐시 무효화도 필요 없습니다.

둘째는 태그입니다. myapp:latest 같은 것으로, 사람이 붙이고 언제든 다른 다이제스트를 가리키도록 바뀝니다. 여기가 상태이고, 분산 시스템에서 어려운 부분은 전부 여기 있습니다.

이 구조를 이해하면 확장 설계의 제약이 자연스럽게 보입니다. 블롭은 어디에 몇 개를 복제해 두든 상관없습니다. 하지만 myapp:latest가 지금 무엇을 가리키는지에 대해서는 인스턴스들이 같은 답을 해야 합니다. 그래서 레지스트리 클러스터링은 결국 태그 갱신을 어떻게 직렬화할 것인가의 문제로 수렴합니다.

zot의 선택 — 디스크가 곧 OCI 이미지 레이아웃

zot의 설계 전제는 문서 첫 줄에 나와 있습니다. 디스크에는 OCI Image Layout Specification을 그대로 쓰고, 네트워크에는 OCI Distribution Specification을 그대로 씁니다. 문서 표현으로는 "Distribution Specification을 준수하는 어떤 클라이언트든 zot 레지스트리에서 읽고 쓸 수 있다"입니다.

실무적으로 이게 왜 중요하냐면, 디스크 디렉터리를 그대로 복사하면 그것이 유효한 이미지 저장소라는 뜻이기 때문입니다. 마이그레이션과 백업의 성격이 완전히 달라집니다. 독자적인 메타데이터 데이터베이스에 이미지 구조를 담아 두는 레지스트리라면 그 데이터베이스를 같이 옮겨야 하고, 버전이 다르면 옮기지 못합니다.

저장 백엔드는 로컬 파일시스템(NFS나 FUSE로 마운트한 원격 파일시스템 포함), AWS S3, GCS, Azure Blob Storage를 지원합니다. 문서에 명시된 예외 하나는 경로 구분자 문제로 S3가 Windows에서 지원되지 않는다는 것입니다.

스케일 아웃은 샤딩이지 HA가 아니다

이제 본론입니다. zot의 scale-out 문서를 그대로 읽으면 이렇습니다.

요청이 들어오면 인스턴스는 리포지토리 경로를 해시해서 해시 테이블을 조회하고, 그 리포지토리를 담당하는 인스턴스를 알아냅니다. 해시 함수는 충돌과 역상 공격 저항성을 이유로 SipHash를 씁니다. 자기가 담당이 아니면 담당 인스턴스로 요청을 전달하고, 자신은 프록시 역할로 응답을 되돌려 줍니다.

설정은 이렇게 생겼습니다.

"cluster": {
  "members": [
    "zot-server1:9000",
    "zot-server2:9000",
    "zot-server3:9000"
  ],
  "hashKey": "loremipsumdolors",
  "tls": {
    "cacert": "test/data/ca.crt"
  }
}

members가 정적 목록이라는 점을 눈여겨보세요. 합의 프로토콜도 없고, 리더 선출도 없고, 멤버십 가십도 없습니다. 모든 인스턴스가 같은 목록과 같은 hashKey를 갖고 있으므로 같은 계산을 하고 같은 답에 도달합니다. 이게 이 설계의 우아한 점입니다. 태그 갱신 직렬화 문제를 리포지토리 단위 소유권으로 치환해서 합의를 통째로 없앴습니다.

대가는 문서에 그대로 적혀 있습니다. 이 구성은 자가 치유가 되지 않으며, 인스턴스 하나가 죽으면 그 인스턴스에 매핑된 리포지토리는 클러스터를 재시작할 때까지 영향을 받습니다. 원문은 고가용성과 대비하면서 "인스턴스나 스토리지가 오프라인이 되면 서비스 가용성에 영향을 준다"고 씁니다.

즉 세 대는 처리량을 세 배로 나눈 것이지 장애를 세 배로 견디는 것이 아닙니다.

두 가지 모드가 각각 포기하는 것

zot 문서는 스케일 아웃을 두 형태로 나눕니다.

컴퓨트 전용 확장은 모든 인스턴스가 S3 호환 스토리지 하나와 분산 캐시(Redis 또는 DynamoDB)를 공유합니다. 로컬 캐시는 쓰지 않습니다. 스토리지가 공유되므로 인스턴스는 상태가 없는 계산 노드에 가까워집니다.

컴퓨트 겸 스토리지 확장은 인스턴스마다 로컬 스토리지를 갖습니다. 대신 문서는 이 형태에서 UI가 지원되지 않는다고 명시합니다.

그리고 두 형태 공통으로, 스케일 아웃 배포에서는 CVE 스캐닝이 비활성화되고 신뢰 확장(trust extension)은 공유 디렉터리를 요구하기 때문에 지원되지 않습니다.

이 목록이 왜 중요한가 하면, 대개 조직이 자체 레지스트리를 세우는 이유가 바로 "취약점 스캔 결과를 레지스트리에서 바로 보려고"이기 때문입니다. 확장을 켜는 순간 그 기능이 꺼진다면, 스캔은 CI 파이프라인 쪽으로 옮겨야 합니다. 확장을 결정하기 전에 알아야 하는 사실이고, 장애를 겪고 나서 알게 되면 곤란한 사실입니다.

boltdb가 인스턴스 수를 사실상 하나로 묶는다

중복 제거를 켜면 zot은 같은 내용의 블롭을 디스크에 한 벌만 두고 여러 매니페스트가 참조하게 합니다. 로컬 파일시스템에서는 하드 링크로 구현합니다. 시작할 때 설정된 상태를 강제해서, 켜져 있으면 기존 블롭을 중복 제거하고 꺼져 있으면 클라우드 스토리지 블롭을 원래 상태로 되돌립니다.

이 중복 제거는 "어떤 다이제스트가 어디에 실체로 있는가"를 기억하는 메타데이터를 필요로 하고, 그것을 담는 것이 캐시 드라이버입니다.

  • boltdb — 로컬 스토리지의 기본값. zot 루트 디렉터리에 파일로 embed됩니다. 문서에 쓰기 동시 접근을 제공하지 않는다고 명시되어 있고, 따라서 여러 zot 인스턴스가 공유할 수 없습니다.
  • DynamoDB — 원격 스토리지용. AWS 자격 증명과 IAM 권한이 필요합니다.
  • Redis — 원격 스토리지용 대안. 단일 인스턴스와 클러스터 구성을 지원하고 키 접두사를 설정할 수 있습니다.

여기가 조용한 함정입니다. 기본값 그대로 두 대를 띄우고 같은 NFS 볼륨을 물리면, 각자 자기 boltdb를 들고 같은 디렉터리를 만지게 됩니다. 문서가 금지한 조합인데 설정 파일만 보면 티가 나지 않습니다. 공유 스토리지로 확장하려면 캐시 드라이버부터 바꿔야 한다는 것을 기억해 두세요.

미러링에서 다이제스트 고정이 깨지는 조건

zot은 상위 레지스트리를 미러링할 수 있고 모드가 두 가지입니다. 주기 폴링은 pollInterval마다 상위를 훑어 조건에 맞는 이미지를 로컬로 복사하고, 온디맨드는 요청이 들어왔을 때 받아 와 캐시합니다. 두 모드는 같이 쓸 수도 있습니다.

{
  "urls": ["https://registry1:5000"],
  "onDemand": false,
  "pollInterval": "6h",
  "content": [{ "prefix": "/repo", "destination": "/local" }]
}

문서가 짚는 제약이 실무에서 특히 아픕니다.

첫째, Docker Hub는 카탈로그 나열을 지원하지 않으므로 온디맨드만 써야 합니다. 주기 폴링은 "무엇이 있는지" 목록을 받아야 동작하는데 그게 안 됩니다.

둘째, Docker 이미지를 OCI 포맷으로 변환하면 다이제스트로 고정한 pull과 서명 검증이 깨집니다. 변환이 매니페스트 바이트를 바꾸므로 해시가 달라지기 때문입니다. 원본 다이제스트를 유지하려면 preserveDigest를 켜야 하고, 이는 http.compatdocker2s2를 넣는 것을 요구합니다.

배포 파이프라인이 태그가 아니라 다이제스트로 이미지를 고정하고 있다면(그렇게 하는 것이 맞습니다) 미러를 도입하는 순간 이 조건을 확인해야 합니다.

"가벼운 단일 바이너리"의 실제 크기

zot의 소개 문구에는 "의존성 없이 정적으로 빌드된 단일 바이너리"가 늘 따라붙습니다. 앞부분은 사실입니다. 별도 런타임이나 데이터베이스 프로세스가 필요 없고, 권한 상승 없이 동작합니다.

다만 "가볍다"는 표현은 확인하고 쓰는 편이 낫습니다. GitHub 릴리스 v2.1.20(2026년 8월 4일 공개)의 리눅스 자산 크기는 이렇습니다.

자산크기
zot-linux-amd64약 215MiB
zot-linux-amd64-minimal약 78MiB
zot-linux-arm64약 200MiB
zot-linux-arm64-minimal약 73MiB

full 빌드가 큰 이유는 취약점 스캔과 웹 UI를 포함해 모든 기능을 한 바이너리에 넣기 때문입니다. 문서도 full과 minimal 두 변형을 제공한다고 안내하면서 minimal을 "보안을 위해 기능을 덜어낸" 빌드로 설명합니다.

여기서 판단해야 할 것은 크기 자체가 아니라 무엇이 그 안에 들어 있는가입니다. 스캐너를 레지스트리 바이너리 안에 넣으면 스캐너를 갱신하려고 레지스트리를 재시작해야 합니다. 그 결합을 원하지 않으면 minimal을 쓰고 스캔을 밖으로 빼는 편이 낫습니다. 어느 쪽이든 의식적인 선택이어야 합니다.

참고 자료

현재 단락 (1/65)

사내 레지스트리 하나에 모든 배포가 매달려 있는 구조가 불안해서 인스턴스를 세 대로 늘렸다고 해 봅시다. 앞단에 로드밸런서를 두고, 헬스체크를 붙이고, 대시보드에 초록불 세 개가 ...

작성 글자: 0원문 글자: 4,467작성 단락: 0/65