엔지니어링 노트설치·운영과 품질

배포와 폐쇄망 적재는 태그 대신 다이제스트로 확인한다

구축형 OwlAI는 고객 호스트에서 컨테이너 이미지 아홉 종으로 뜹니다. 시험 환경에서 통과한 이미지와 고객 호스트에서 뜨는 이미지가 같은 바이트여야 시험 결과가 고객 설치에도 통합니다. backend:1.4.2 같은 태그로 이미지를 받으면 이 말을 할 수 없습니다. 태그는 다른 이미지에 다시 붙일 수 있기 때문입니다. 레지스트리가 없는 폐쇄망은 더 까다롭습니다. Docker 29에서 docker load는 blob이 빠지거나 깨진 번들을 받아도 종료 코드 0으로 끝나고 이미지 기록을 남깁니다.

그래서 이미지는 CI가 한 번만 빌드하고, 푸시할 때 받은 다이제스트(이미지 매니페스트의 sha256 해시) 한 벌을 모든 배포 경로가 함께 씁니다. 레지스트리에서 받는 배포는 다이제스트 참조만 받습니다. 폐쇄망 반입 번들은 그 다이제스트의 바이트를 그대로 담고, 적재 스크립트는 적재 뒤에 이미지마다 Id를 대조하고 컨테이너를 하나 만들어 봅니다. 이 글은 다이제스트가 빌드에서 고객 호스트까지 어떻게 이어지는지, 폐쇄망 적재가 무엇을 확인해야 끝나는지를 설명합니다. 설치 전 호스트 점검은 「설치 전 점검은 호스트를 읽기만 하고, 고칠 명령은 사람에게 넘긴다」에서, 관리 화면에서 하는 업데이트는 「업데이트 버튼은 승인만 하고, docker 소켓은 실행기만 쥔다」에서 다룹니다.

태그로 받으면 같은 이름 아래 다른 이미지가 뜬다

같은 태그 아래 다른 내용이 뜨는 경로는 셋입니다.

경로 일어나는 일
태그 덮어쓰기 레지스트리 쓰기 권한이 있는 사람이나 job이 backend:1.4.2에 다른 이미지를 다시 민다. 무엇이 뜰지는 그 태그를 마지막에 민 쪽이 정한다
상류 이미지의 다시 태깅 오픈소스 이미지의 v1.9.0 태그가 보안 패치 등으로 다른 다이제스트에 다시 붙는다. 태그로 받는 배포는 모르는 사이에 바뀐다
환경마다 따로 빌드 같은 커밋을 시험 환경과 운영 환경에서 따로 빌드하면, 그 사이 베이스 이미지나 패키지 저장소가 바뀌어 다른 바이트가 나온다

레지스트리의 불변 태그 설정(Amazon ECR의 태그 불변성 같은)을 켜면 첫째 경로가 막히고, 설정 하나로 끝납니다. 다만 이 설정은 그 레지스트리 안에서만 통하고, 고객사마다 레지스트리가 다르며, 폐쇄망 설치에는 레지스트리가 아예 없습니다. 셋째 경로는 태그를 잠가도 남습니다. 옛 태그로 하는 롤백도 그 사이 태그가 덮였으면 옛 판으로 돌아가지 않습니다. 그래서 배포 대상을 이름 대신 내용으로 정합니다. 다이제스트로 받으면 레지스트리가 다른 내용을 내줄 때 받기가 실패하므로, 같은 다이제스트 아래 다른 이미지가 뜰 수 없습니다.

CI가 한 번 굽고, 다이제스트는 푸시 출력에서 읽는다

푸시 출력의 다이제스트 하나를 레지스트리 배포와 폐쇄망 번들이 함께 쓴다
그림 1. 푸시 출력의 다이제스트 하나를 레지스트리 배포와 폐쇄망 번들이 함께 쓴다

배포 호스트는 이미지를 빌드하지 않습니다. 운영 compose에는 build: 블록이 없고, 이미지 자리는 image: "${OWLAI_IMAGE_BACKEND:?…}" 꼴 변수뿐이라 변수가 비면 compose가 그 변수 이름을 대며 멈춥니다. 아홉 종 가운데 여덟 종은 CI가 직접 빌드하고, 메타스토어 PostgreSQL도 공식 이미지 위에 pgvector를 얹어 CI가 굽습니다. 나머지 하나인 파일 저장소 게이트웨이(오픈소스)는 판 태그와 다이제스트로 지정한 원본을 받아 자체 레지스트리로 옮겨 싣고, 옮겨 실은 쪽의 다이제스트로 배포합니다. 그래서 상류가 같은 태그를 다시 밀어도 배포는 바뀌지 않습니다.

빌드 job은 다이제스트를 푸시 출력에서 읽습니다. 푸시가 끝난 뒤 레지스트리에 그 태그의 다이제스트를 다시 물으면 그 사이 태그가 덮였을 수 있기 때문입니다. 읽은 값은 이미지마다 <레지스트리>/owlai/backend@sha256:… 꼴로 다이제스트 파일에 적고, 빌드한 커밋 SHA도 함께 적습니다. 올리기 전에 아홉 종을 모두 빌드하고, 적기 전에 그 다이제스트로 한 번 받아 봅니다. 넷째 이미지에서 빌드가 깨졌는데 앞의 셋만 올라가는 일이 생기지 않습니다.

배포 job은 이 파일을 호스트의 이미지 고정 파일(.env.images)로 옮기면서 세 가지를 봅니다.

  • 참조가 <저장소>@sha256:… 꼴이 아니면 거절한다. 태그 참조는 들어갈 수 없다.
  • 파일에 적힌 커밋과 체크아웃(compose·설정·스크립트)의 커밋이 같아야 한다. 이미지만 새 판이고 compose는 옛 판인 조합을 막는다.
  • 같은 이름의 CI 변수가 파일 값과 다르면 그 변수 이름을 대며 멈춘다.

셋째 검사는 GitLab CI의 변수 우선순위 때문에 필요합니다. 프로젝트나 파이프라인에 정의한 같은 이름의 변수가 job 산출물의 값보다 앞서므로, 이번 파이프라인이 구운 다이제스트가 조용히 다른 값으로 바뀔 수 있습니다. 그래서 배포는 이미지 참조를 파일에서만 읽습니다.

태그는 사람이 찾아볼 때 쓰는 이름으로만 남습니다. 릴리스 판 태그(0.6.7 같은)는 그 커밋이 게이트 재검증과 이미지 보안 검사를 통과한 뒤에 같은 다이제스트에 붙습니다.

폐쇄망 번들은 레지스트리의 바이트를 그대로 담는다

반입 번들(owlai-images-<버전>.tar)도 같은 다이제스트 파일에서 출발합니다. 번들을 만드는 job은 이미지를 다시 굽지 않고, 다이제스트로 받은 이미지를 OCI 이미지 레이아웃으로 내보냅니다. 레이아웃은 blob마다 blobs/sha256/<다이제스트>라는 이름이 붙는 디렉터리라서, 받는 쪽이 blob을 하나씩 이름과 대조할 수 있습니다. 여러 이미지가 함께 쓰는 레이어는 한 벌만 들어갑니다. 함께 실리는 표(manifest.tsv)에는 이미지마다 이름, 레지스트리 다이제스트, config 다이제스트, compose가 읽을 변수 이름이 있습니다. 적재는 같은 릴리스의 설치 키트에 든 스크립트가 맡습니다.

번들을 만드는 job은 레이아웃에 적힌 다이제스트가 레지스트리 다이제스트와 같은지 확인하고, 다르면 번들을 내지 않습니다. 서명된 릴리스 매니페스트도 같은 다이제스트 파일에서 값을 읽으므로 레지스트리·번들·매니페스트가 같은 다이제스트를 말합니다.

번들 안의 이미지 이름은 localhost/owlai/<이미지>입니다. owlai/backend처럼 레지스트리 자리가 없는 이름은 짧지만, 이미지가 빠졌을 때 Docker Hub의 같은 이름으로 풀립니다. 인터넷이 일부 열린 호스트라면 남의 이미지를 받아 띄울 수도 있습니다. localhost/…는 어느 공개 레지스트리로도 풀리지 않아서, 빠진 이미지가 있으면 받기 실패로 배포가 멈춥니다.

docker load가 0으로 끝나도 적재가 끝난 것은 아니다

폐쇄망 적재 다섯 단계와 단계마다 잡는 문제
그림 2. 폐쇄망 적재 다섯 단계와 단계마다 잡는 문제

폐쇄망 호스트에서는 설치 키트의 tools/ops/images.sh load가 번들을 적재합니다. git이나 python 없이 bash만으로 돌고, 다섯 단계를 차례로 밟습니다.

  1. 표와 레이아웃 머리 파일의 체크섬을 확인한다.
  2. blob마다 내용의 sha256을 계산해 파일 이름과 같은지 본다. 링크 파일은 거절한다.
  3. docker load로 적재한다.
  4. 이미지마다 적재된 Id를 표의 값과 대조한다.
  5. 고정할 참조로 컨테이너를 하나 만들어 보고(docker create --pull never) 바로 지운다.

단계마다 잡는 것이 다릅니다. 2단계는 전송 중에 바뀐 blob을 잡지만, 아예 빠진 blob은 계산할 파일이 없어 지나칩니다. 그런 번들도 docker load는 종료 코드 0으로 받아 이미지 기록을 남기므로, 빠진 blob은 5단계에서 컨테이너를 만들지 못할 때 드러납니다. 4단계는 적재가 깨졌거나 같은 이름에 다른 이미지가 남아 있는 경우를 가립니다. 링크 파일을 거절하는 것은 해시 계산은 링크가 가리키는 대상을 읽는데 엔진에 넘기는 tar는 링크 자체를 담아서, 검증한 바이트와 적재한 바이트가 갈리기 때문입니다.

다섯 단계를 통과하면 compose가 읽을 고정 파일을 씁니다. 무엇으로 고정할지는 호스트의 이미지 저장소가 정합니다. 스크립트가 docker info로 저장소를 판정하고, 어느 쪽이었는지를 로그와 고정 파일 머리말에 남깁니다.

이미지 저장소 적재 뒤 Id 고정
containerd(Docker 29 새 설치의 기본) 레지스트리 다이제스트 localhost/owlai/<이미지>@sha256:…
classic(overlay2)·podman config 다이제스트 localhost/owlai/<이미지>:<버전>

containerd 저장소는 docker load 뒤에도 레지스트리 다이제스트를 Id로 유지하므로, 시험 환경과 같은 다이제스트로 고정합니다. 이 저장소는 같은 이름에 태그가 붙어 있어야 다이제스트 참조를 풀기 때문에, 적재한 :<버전> 태그를 지우면 안 됩니다. classic 저장소에서는 docker load가 레지스트리 다이제스트를 잃어 다이제스트 참조를 만들 수 없습니다. 그래서 태그로 고정하고, 내용은 앞의 blob 해시와 config 다이제스트 대조로 확인합니다. 이름이 localhost/로 시작하므로 이 태그를 외부 레지스트리에서 덮을 길도 없습니다.

같은 번들을 다시 적재해도 Id 대조와 컨테이너 생성은 다시 돕니다. 배포 스크립트는 컨테이너를 바꾸기 전에 고정된 이미지를 모두 확인하고, 하나라도 없으면 아무 컨테이너도 바꾸지 않고 멈춥니다.

롤백은 고정 파일에 남은 다이제스트로 한다

고정 파일을 쓸 때마다 직전 고정은 .env.images.prev에, 모든 고정은 시각과 함께 .env.images.history에 한 줄씩 남습니다. 한 칸 되돌리기는 두 줄입니다.

cp .env.images.prev .env.images
tools/ops/restart_stack.sh deploy

다이제스트는 내용으로 정해지는 주소라서, 되돌리면 그때 띄웠던 것과 바이트까지 같은 이미지가 뜹니다. 이 절차는 이미지만 되돌리고 DB 스키마는 그대로 둡니다. 그래서 고객 설치의 정식 업데이트 경로는 업데이트 전에 DB 덤프를 남기는 되돌리기 절차를 따로 씁니다(「업데이트 버튼은 승인만 하고, docker 소켓은 실행기만 쥔다」). 다이제스트가 레지스트리 보존 기간을 넘겨 지워졌으면 받을 수 없고, 배포 호스트도 지금 고정과 직전 고정 밖의 이미지는 배포 뒤에 정리합니다. 폐쇄망 설치는 그 판의 반입 번들을 다시 적재합니다.

설계에서 고른 것

반입 환경에 맞춰 번들을 따로 굽는 대신 레지스트리의 바이트를 그대로 내보내는 쪽을 골랐습니다. 따로 구우면 반입 환경에 맞춘 이미지를 만들 수 있지만, 같은 Dockerfile도 다시 구우면 다이제스트가 달라져 시험한 이미지와 반입한 이미지가 같다는 근거가 사라집니다. 대가는 번들을 만드는 쪽이 치릅니다. classic 저장소의 docker save는 레이어를 풀어 다시 써서 레지스트리 다이제스트가 남지 않으므로, 번들은 containerd 저장소를 쓰는 빌드 호스트에서만 만들 수 있습니다.

고정 방식은 태그 하나로 통일하는 대신 이미지 저장소에 맞춰 둘로 나눴습니다. 통일하면 절차가 하나로 줄지만 containerd 저장소가 지켜 주는 다이제스트를 버리게 됩니다. 그 대가로 고객 호스트가 저장소를 classic에서 containerd로 바꾸면 이전 이미지가 보이지 않아 번들을 한 번 다시 적재해야 합니다.

다이제스트와 서명은 서로 다른 질문에 답합니다. 다이제스트는 무엇을 띄우는지를 바이트 단위로 정하고, 그 이미지가 개발사가 낸 판이라는 사실은 릴리스 매니페스트의 서명이 증명합니다(「업데이트 버튼은 승인만 하고, docker 소켓은 실행기만 쥔다」). 한 장치가 두 질문을 함께 떠맡지 않으므로 각각을 따로 검증할 수 있습니다.

읽기 쉬운 태그 대신 다이제스트로 배포 대상을 고르는 데도 대가가 있습니다. 롤백할 때는 고정 파일의 기록에서 다이제스트를 찾아야 합니다. 레지스트리 쓰기 권한을 다 통제하기 어렵거나, 같은 커밋을 여러 환경에서 따로 빌드하고 있거나, 레지스트리 없는 망에 이미지를 옮겨야 한다면 이 대가를 치를 만합니다.

어떤 문제를 해결하나요?

설치·운영과 품질의 다른 글