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

업데이트 버튼은 승인만 하고, docker 소켓은 실행기만 쥔다

구축형 제품의 관리 화면에 업데이트 버튼을 달면 누군가는 컨테이너를 내리고 새 이미지로 다시 띄워야 하고, 웹 서버가 그 일을 직접 하려면 docker 소켓이 필요합니다. docker 소켓을 쥔 프로세스는 호스트 경로를 마운트한 특권 컨테이너를 띄울 수 있으므로 사실상 호스트 root입니다. 이 구성에서는 관리 화면의 취약점 하나나 탈취된 관리자 세션 하나로 호스트를 장악할 수 있고, 공격자가 만든 이미지를 업데이트라며 설치할 수도 있습니다.

그래서 OwlAI 구축형은 업데이트의 승인과 적용을 서로 다른 컨테이너에 맡깁니다. 웹 서버는 관리자가 승인한 판과, 확인 창에 보여 준 매니페스트의 해시를 요청 파일 하나에 적습니다. docker 소켓은 망이 없는 업데이트 실행기(updater)만 쥡니다. 실행기는 요청을 그대로 믿지 않고 서명된 매니페스트로 처음부터 다시 판정한 뒤 적용합니다. 이 글은 그 권한 경계와 판정 순서, 끊겨도 이어 가는 적용 단계, 늘 최신 판 하나로 올리게 하는 마이그레이션 원칙을 설명합니다.

docker 소켓을 웹 서버에 주면 화면의 취약점이 호스트 root가 된다

흔한 방법은 두 가지입니다. 첫째는 웹 서버나 웹 요청을 받는 업데이트 컨테이너에 docker 소켓을 마운트하는 것입니다. 구성 요소가 하나 적어 구현이 단순합니다. 하지만 웹 서버는 사내망 전체의 요청을 받는 구성 요소입니다. 소켓을 읽기 전용(:ro)으로 마운트해도 docker API 호출은 막지 못하므로, 웹 서버에 생긴 취약점은 그대로 호스트 root 탈취로 이어집니다.

둘째는 사람이 서버에 들어가 명령으로 업데이트하게 두는 것입니다. 권한 문제는 없지만 버튼 하나로 끝내고 싶다는 고객의 요구와 맞지 않고, 판의 순서를 잘못 계산하거나 백업을 빠뜨리는 실수가 남습니다.

소켓을 쥔 프로세스가 root와 같다는 사실은 바꿀 수 없으므로, 소켓을 쥐는 컨테이너를 하나로 정하고 그 컨테이너가 받는 입력을 좁혔습니다.

웹 서버가 실행기에 넘기는 것은 요청 파일 하나다

관리자가 확인 창에서 업데이트를 누르면 웹 서버는 먼저 본인을 다시 확인합니다. 현재 비밀번호를 받고, 2단계 인증을 켠 계정이면 인증 코드도 받습니다. 그다음 웹 서버가 하는 일은 요청 파일 하나를 쓰는 것뿐입니다. 요청 파일에는 다음 다섯 칸만 있습니다.

  • 올릴 판(X.Y.Z 꼴)
  • 확인 창이 보여 준 매니페스트의 sha256
  • 릴리스 파일이 들어오는 길(온라인 받기 또는 파일 업로드)
  • 요청 ID
  • 승인한 사람

모르는 칸이 하나라도 있으면 실행기가 요청을 거절합니다. 명령 문자열이나 경로를 담을 칸은 없고, 판은 반입 디렉터리 이름을 고르는 데만 쓰입니다.

요청 파일 자리는 실행기 상태와 다른 볼륨이라, 웹 서버는 실행기의 백업 덤프와 작업 사본에 닿지 못합니다. 웹 서버가 실행기에 닿는 다른 길은 Unix 소켓 하나인데, 상태 읽기와 라이선스 파일 교체만 받습니다. docker 소켓, 서명 신뢰 키, 설치 디렉터리, 실행기 상태를 실행기 말고 다른 서비스가 붙이지 않는지는 compose 파일을 읽는 테스트가 지킵니다.

업데이트는 한 번에 하나만 받습니다. 두 관리자가 거의 동시에 승인하면 두 번째 승인은 「끝나지 않은 업데이트가 있습니다」로 거절되고, 실행기도 진행 중인 업데이트가 있으면 새 요청을 거절합니다.

실행기 컨테이너는 망 없이(network_mode: none) 읽기 전용 루트 파일시스템으로 돕니다. 그래서 온라인 받기는 워커가 맡아 반입 저장소(「재배포 뒤에도 남아야 할 파일은 객체 저장소에만 둔다」)에 두고, 폐쇄망 설치가 관리 화면에서 올린 릴리스 파일(owlai-<버전>.owlai)도 같은 자리에 들어갑니다. 실행기는 그 자리를 읽기 전용으로 봅니다.

웹 서버는 요청 파일만 쓰고, docker 소켓은 실행기만 쥐는 권한 경계
그림 1. 웹 서버는 요청 파일만 쓰고, docker 소켓은 실행기만 쥐는 권한 경계

실행기는 서명된 매니페스트로 처음부터 다시 판정한다

릴리스마다 매니페스트라는 JSON 파일이 하나 나오고, 그 원본 바이트에 Ed25519 분리 서명이 붙습니다. 매니페스트에 담기는 값은 다음과 같습니다.

칸 내용
판 버전과 빌드한 커밋
파일 이미지 번들과 설치 키트의 sha256·크기
이미지 이미지마다 레지스트리 다이제스트(「배포와 폐쇄망 적재는 태그 대신 다이제스트로 확인한다」)
업그레이드 조건 직접 올라올 수 있는 하한 판, 먼저 거쳐야 하는 경유 판, 화면에서 할 수 없는 수동 단계
DB 변경 이번 판의 스키마 변경 성격(expand·contract), 코드만 되돌려도 되는 하한 판
적용 중 영향 교체하는 동안 화면을 계속 쓸 수 있는지, 몇 분 오류가 날 수 있는지, 점검 시간이 필요한지

서명은 보호된 릴리스 태그의 파이프라인에서만 하고, 일반 브랜치의 빌드는 서명 키에 닿지 못합니다. 실행기는 자기 이미지에 구워 둔 공개키로 서명을 확인하고, 반입한 파일에 함께 실린 키는 믿지 않습니다.

실행기는 다음 순서로 판정합니다.

  1. 매니페스트와 서명을 한 번 읽어, 그 바이트로 서명을 확인하고 사본으로 둡니다. 매니페스트의 sha256이 요청에 적힌 값과 다르면 멈춥니다. 관리자가 확인 창에서 본 것과 다른 매니페스트로는 적용하지 않습니다.
  2. 지금 도는 이미지의 버전 라벨을 읽어 대상 판으로 올라갈 수 있는지 봅니다. 같은 판이나 옛 판, 하한 판보다 옛 설치, 사이에 경유 판이 있는 경우, 수동 단계가 걸린 경우는 거절하고 그 사유를 화면에 돌려줍니다.
  3. 이미지 번들과 설치 키트를 한 번 읽으면서 해시를 재고, 같은 바이트를 작업 디렉터리에 사본으로 씁니다. 반입 위치는 웹 서버가 쓸 수 있는 자리라 검증하는 도중에도 내용이 바뀔 수 있습니다. 그래서 이 뒤로는 원본을 다시 열지 않고, 해시를 확인한 바이트를 그대로 펼칩니다.
  4. 사본을 펼칠 때는 일반 파일과 디렉터리, 상대 경로만 받고, 링크나 장치 파일이 있으면 멈춥니다. 서명은 개발사가 만든 파일이라는 사실만 말해 주므로, root로 펼치는 쪽이 받아들일 것은 따로 정해 둡니다.
  5. 번들 안 표에 적힌 이미지 다이제스트를 서명된 매니페스트의 값과 맞춰 봅니다. 적재한 뒤 이미지 Id를 다시 맞춰 보는 과정은 「배포와 폐쇄망 적재는 태그 대신 다이제스트로 확인한다」에서 다뤘습니다.

서명이 매니페스트를, 매니페스트가 번들의 해시와 이미지 다이제스트를, 다이제스트가 적재된 이미지를 가리킵니다. 웹 서버가 탈취되어 요청 파일을 마음대로 써도, 서명되지 않은 판은 1단계를 넘지 못합니다.

확인 창도 같은 서명된 값을 씁니다. 적용 중 영향 안내는 매니페스트의 값 그대로이고, 되돌리는 방법 안내는 웹 서버가 실행기와 같은 판정 함수로 계산한 결과입니다.

서명에서 적재된 이미지까지: 실행기가 맞춰 보는 값의 순서
그림 2. 서명에서 적재된 이미지까지: 실행기가 맞춰 보는 값의 순서

적용은 15단계로 나누고, 끊긴 단계부터 다시 돈다

판정을 통과하면 실행기는 다음 단계를 차례로 돕니다. 관리 화면의 진행 상황에 같은 이름으로 보이고, 단계마다 걸린 시간이 붙습니다.

  1. 바이너리 다운로드 또는 바이너리 업로드(릴리스 파일이 다 들어왔는지 확인)
  2. 서명 확인
  3. 올릴 수 있는 버전인지 확인
  4. 파일 복사·검증
  5. 디스크 공간 확인
  6. 파일 펼치기
  7. 데이터베이스 백업
  8. 이미지 적재
  9. 설치 파일 교체
  10. 설정 파일 변환
  11. 작업 정리 대기
  12. 서비스 교체
  13. 기동 확인
  14. 정리
  15. 실행기 갱신

설치에 손을 대는 것은 8단계부터입니다. 그 앞에서 멈추면 지금 판이 그대로 돕니다. 7단계는 매니페스트의 값과 상관없이 매번 메타스토어를 pg_dump로 덤프하고 최근 2개를 남깁니다. 이 덤프로 무엇을 되돌리는지는 「업데이트가 실패하면 스키마 변경에 따라 코드만 되돌리거나 덤프로 복원한다」에서, 11단계에서 돌던 작업을 어떻게 끝내고 내리는지는 「워커를 재시작해도 돌던 턴은 답을 내거나 실패를 남긴다」에서 다룹니다.

실행기는 단계마다 상태 파일을 임시 이름으로 쓴 뒤 이름을 바꿔 원자적으로 갱신합니다. 재부팅이나 전원 차단으로 끊기면 다음 기동이 기록된 단계부터 다시 돌므로, 단계는 다시 돌려도 같은 결과가 나오게 짰습니다. 되돌릴 때 쓸 사본과 덮어쓸 파일 목록은 처음 한 번만 정합니다. 서비스를 교체하는 도우미 컨테이너는 이름을 요청 ID로 정해 두어, 같은 이름이 이미 있으면 새로 띄우지 않고 그 결과를 기다립니다.

서비스 교체는 일회성 도우미 컨테이너가 맡습니다. 도우미는 호스트 네임스페이스에서 설치 디렉터리의 tools/ops/restart_stack.sh deploy를 돌리므로, 사람이 손으로 배포할 때와 같은 스크립트와 같은 설치 전 점검(「설치 전 점검은 호스트를 읽기만 하고, 고칠 명령은 사람에게 넘긴다」)을 거칩니다. 도우미가 부르는 하위 명령은 배포, 기동 판정, 옛 이미지 정리, 실행기 교체 넷이고 요청이 고르지 못합니다.

시작한 업데이트는 화면에서 멈출 수 없습니다. 교체 도중에 멈추면 반쯤 바뀐 상태가 남으므로, 실행기는 끝까지 가거나 스스로 되돌립니다. 실행기 자신은 맨 마지막 단계에서, 업데이트가 성공한 뒤에만 새 판으로 바꿉니다. 자기를 먼저 바꾸면 적용 도중에 자기 프로세스가 끊깁니다.

늘 최신 판 하나로 올리려면 마이그레이션이 경유 판을 만들지 않아야 한다

관리 화면의 업데이트는 언제나 최신 판 하나로 가지만, 한 고객은 매번 올리고 다른 고객은 반년 만에 올립니다. 마이그레이션 리비전 사슬을 하나의 베이스라인으로 접으면(squash) 그 경계보다 옛 설치는 경유 판을 먼저 올려야 합니다. 그래서 메타스토어 리비전은 0.6.0 베이스라인을 마지막으로 쌓기만 하고, 0.6.0 이후의 어느 판에서든 최신 판으로 한 번에 올라옵니다. 리비전은 앞선 어느 판의 DB에서도 돌 수 있게 IF EXISTS 같은 멱등 문장으로 쓰고, 데이터 변환은 리비전 안에 고정된 로직으로 둡니다. 모델을 바꾸고 리비전을 빠뜨리면, 빈 DB에 리비전을 다 올린 뒤 모델과 비교하는 CI 검사가 머지를 막습니다.

마지막 squash 경계보다 옛 판의 DB만 예외입니다. 백엔드는 squash로 사라진 리비전 목록을 갖고 있다가 그 리비전에 있는 DB로는 기동을 거부합니다. 모르는 리비전을 "더 새 코드가 올린 스키마"로 읽고 건너뛰면 옛 스키마 그대로 서비스가 뜨기 때문입니다. 실행기도 이런 업데이트는 2단계에서 경유 판이 필요하다는 사유로 거절하고, 관리자는 경유 판을 먼저 올린 뒤 최신 판으로 갑니다.

릴리스마다 사람이 판단해 적는 값도 하나 있습니다. 직전 릴리스 이후 들어온 리비전이 옛 코드와 함께 돌 수 있는 expand인지, 그렇지 못한 contract인지입니다. 컬럼이나 제약을 지우거나 이름을 바꾸거나, NOT NULL·CHECK·외래 키를 더하거나, 옛 코드가 읽는 값을 옮기면 contract입니다. 이 판단은 어느 리비전까지 보았는지와 함께 적고, 그 뒤에 리비전이 더 들어온 채 태그를 달면 매니페스트를 만드는 단계가 서명 전에 멈춥니다. 이 값이 확인 창의 되돌리는 방법 안내와 실패했을 때 실행기가 하는 일을 정하며, 그 과정은 「업데이트가 실패하면 스키마 변경에 따라 코드만 되돌리거나 덤프로 복원한다」에서 이어집니다.

설계에서 고른 것

웹 서버에 소켓을 주는 대신 소켓만 쥐는 실행기를 따로 두었습니다. 구성 요소가 하나 늘고, 실행기에 망이 없어 받기는 웹 서버 쪽이 맡습니다. 대신 받는 쪽과 판정하는 쪽이 갈려, 받는 쪽이 탈취돼도 판정을 건너뛸 수 없습니다.

실행기의 컨테이너 권한(capability)은 파일 소유에 필요한 두 개만 남기고, 받는 입력은 다섯 칸짜리 요청 파일 하나로 닫았습니다. 소켓을 쥔 컨테이너에서는 권한 목록을 줄이는 것보다 받아들이는 입력을 좁히는 쪽이 할 수 있는 일의 범위를 실제로 정하기 때문입니다.

화면에서 할 수 없는 수동 단계가 있는 판이면 확인 창이 승인을 잠그고 설치 디렉터리의 문서를 안내합니다. 버튼 하나로 끝나는 경험보다 반쯤 적용된 상태를 남기지 않는 쪽을 골랐습니다.

도우미가 호스트 네임스페이스에서 배포 스크립트를 돌리므로 기준 런타임은 rootful Docker입니다. rootless Docker나 podman에서는 사람이 키트를 덮고 같은 restart_stack.sh deploy를 돌리는 손 절차로 업데이트합니다.

실행기를 맨 마지막에, 성공한 뒤에만 바꾸는 데도 대가가 있습니다. 실행기 자체에 결함이 있으면 화면 업데이트로는 고칠 수 없고, 새 판 번들에 든 실행기만 손으로 먼저 바꿔 끼운 뒤 같은 판을 다시 승인해야 합니다. 적용 도중에 실행기가 스스로를 끊는 위험보다 이 절차 하나가 낫다고 판단했습니다.

고객 호스트에서 자기 자신을 갱신하는 설치형 제품이라면 이 나눔을 그대로 옮길 수 있습니다. 소켓을 쥘 프로세스를 하나로 정하고, 그 프로세스가 받는 입력을 닫힌 꼴로 좁히고, 무엇을 적용할지는 서명된 파일이 정하게 합니다.

어떤 문제를 해결하나요?

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