
회의를 녹음하면 브라우저는 5초마다 녹음 조각을 웹 백엔드로 올리고, 다른 컨테이너에서 도는 워커가 그 조각을 받아씁니다. 웹 백엔드가 조각을 자기 컨테이너 디스크에 쓰면 워커는 그 파일을 볼 수 없습니다. 같은 호스트의 볼륨을 함께 붙이면 구축형에서는 돌아가지만, OwlAI SaaS의 컨테이너는 배포 때마다 교체되고 서로 디스크를 나눠 쓰지 않습니다. 문서 원본, 모델이 만든 보고서, 대화 첨부도 같은 길을 걷습니다. 한 컨테이너가 쓰고 다른 컨테이너가 읽으며, 컨테이너가 바뀐 뒤에도 남아 있어야 합니다.
그래서 규칙을 하나로 정했습니다. 재배포와 컨테이너 재생성 뒤에도 있어야 하는 파일은 S3 호환 객체 저장소에만 두고, 컨테이너 디스크에는 지워져도 다시 만들 수 있는 것만 둡니다. 구축형과 OwlAI SaaS는 같은 S3 API 코드를 쓰고, 다른 것은 저장소 주소와 자격증명뿐입니다. 이 글은 그 규칙을 지키려고 버킷을 어떻게 나눴는지, 두 저장소에서 같이 쓸 수 있는 S3 기능만 고르면서 빠진 기능을 무엇으로 메웠는지 설명합니다.
컨테이너 디스크와 공유 볼륨은 배포 형태 하나에만 맞는다
파일을 둘 흔한 자리는 셋이고, 각각 깨지는 조건이 있습니다.
| 둘 자리 | 깨지는 조건 |
|---|---|
| 컨테이너 안 경로 | 컨테이너를 새로 만들면 쓰기 가능 레이어가 함께 사라진다. 업데이트가 컨테이너를 다시 만들 때마다 파일을 잃는다 |
| 서비스마다 붙인 볼륨 | 재생성에는 살아남지만 다른 서비스가 읽지 못한다. 웹이 받은 녹음을 워커가 받아쓸 수 없다 |
| 여러 서비스가 함께 붙인 볼륨 | 같은 호스트에 묶인다. 구축형은 호스트 한 대의 compose라 돌아가지만, OwlAI SaaS의 컨테이너 서비스에서는 쓸 수 없다 |
로컬 디렉터리로 떨어지는 폴백을 두는 방법도 있습니다. 객체 저장소가 잠깐 안 될 때도 서비스가 계속 돈다는 장점이 있습니다. 대신 저장소 설정이 빠진 설치는 아무 경고 없이 컨테이너 디스크에 파일을 쌓다가, 다음 업데이트에서 그 파일을 한꺼번에 잃습니다. 잃은 뒤에야 설정이 빠졌다는 것을 알게 됩니다. 그래서 백엔드와 워커는 기동할 때 객체 저장소 좌표를 확인하고, 없으면 기동을 거부합니다. 구축형 compose도 접근 키가 비어 있으면 어떤 서비스도 띄우지 않습니다.
두 에디션은 같은 S3 API로 다른 저장소를 가리킨다
백엔드와 워커는 S3 API 하나로 파일을 읽고 씁니다. 구축형에서는 같은 호스트에서 도는 오픈소스 S3 호환 게이트웨이를 가리킵니다. 이 게이트웨이는 POSIX 백엔드로 돌아서, 객체 키가 곧 디스크의 파일 경로입니다. OwlAI SaaS에서는 같은 코드가 AWS S3를 가리킵니다.
두 에디션의 차이는 .env의 설정 값 넷입니다.
| 설정 | 구축형 | OwlAI SaaS |
|---|---|---|
| 엔드포인트 | 같은 호스트의 게이트웨이 주소 | 비워 두면 AWS S3 |
| 버킷 접두어 | 설치마다 정한 값 | 설치마다 정한 값 |
| 리전 | 게이트웨이에 설정한 값 | 버킷이 있는 리전 |
| 자격증명 | 게이트웨이의 루트 키 | 비워 두면 컨테이너에 붙은 IAM 역할 |
파일을 읽고 쓰는 코드, 키를 만드는 규칙, 정리 작업, 재시도 정책은 두 에디션이 같습니다. 재시도는 네트워크 오류, 5xx, 속도 제한 응답에 걸고 짧은 간격으로 최대 4번 시도합니다. 상대가 같은 장비의 게이트웨이이거나 같은 리전의 S3라서 간격을 짧게 잡았습니다.
이렇게 하면 OwlAI SaaS의 컨테이너 교체와 구축형의 재시작·업데이트가 같은 일이 됩니다. 컨테이너가 사라져도 파일은 객체 저장소에 있고, 새 컨테이너는 필요할 때 받아 옵니다. 웹 백엔드와 워커가 몇 벌로 늘어도 모두 같은 저장소를 봅니다.
버킷은 용도마다 하나, 구축형에서는 버킷이 곧 볼륨이다
버킷은 용도마다 하나씩 넷입니다. 이름은 버킷 접두어 뒤에 용도를 붙여 만듭니다.
- 샌드박스: 모델이 만든 파일, 대화 첨부, 도구 결과 보존본
- 문서 원본
- 회의 음성
- 반입 릴리스: 구축형 업데이트로 들여온 판
키에는 용도 대신 조직·사용자, 문서 컬렉션, 회의 식별자가 들어갑니다. 샌드박스 키는 조직과 사용자 아래에 놓이고, 이 경로가 곧 권한 경계입니다. 저장소에 요청을 보내기 직전에 키 모양을 다시 검사해서, 상위 경로를 가리키는 마디나 빈 마디가 든 키는 거절합니다.
버킷 하나를 접두사로 나누면 버킷 관리는 더 단순합니다. 구축형 게이트웨이가 이 방식에서 실패해서 고르지 않았습니다. 게이트웨이는 업로드하는 동안 버킷 루트에 임시 디렉터리를 만듭니다. 버킷 하나를 용도별 볼륨 여러 개로 나눠 마운트하면 이 임시 디렉터리와 최종 파일이 서로 다른 볼륨에 놓입니다. 그러면 권한 문제로 업로드가 실패하고, 권한이 맞더라도 같은 파일시스템 안의 이름 바꾸기가 볼륨 사이 복사로 바뀝니다.
용도별 버킷은 구축형 이행도 쉽게 만들었습니다. POSIX 백엔드에서는 버킷 디렉터리가 곧 도커 볼륨입니다. 로컬 볼륨에 파일을 두던 판에서 객체 저장소 판으로 올릴 때, 기존 볼륨을 그 용도의 버킷 자리에 마운트하는 것으로 이행이 끝납니다. 데이터를 한 바이트도 옮기지 않고, 백업 대상도 종전과 같은 볼륨입니다.
두 저장소가 함께 지원하는 S3 기능만 쓴다
S3 API라는 이름이 같아도 저장소마다 지원이 갈리는 기능이 있습니다. 조건부 쓰기(If-Match), 수명 주기 규칙, 이벤트 알림, ETag가 그렇습니다. AWS S3에서만 되는 기능을 쓰면 구축형에서 깨지고, 게이트웨이에서만 되는 동작에 기대면 OwlAI SaaS에서 깨집니다. 실제로 이 게이트웨이는 게이트웨이를 거치지 않고 볼륨에 놓인 파일, 곧 이행 전부터 있던 파일에 ETag를 돌려주지 않습니다. ETag로 변경을 판정하는 코드는 그 파일 앞에서 비교할 값이 없습니다.
그래서 이 기능들이 하던 변경 판정, 정리, 동시 쓰기 방지를 앱이 맡습니다.
| 저장소 기능 | 앱이 대신 하는 일 |
|---|---|
| ETag로 변경 판정 | 크기와 수정 시각을 비교한다 |
| 수명 주기 규칙으로 오래된 파일 정리 | 앱의 정리 작업이 나이를 보고 지운다 |
| 조건부 쓰기로 동시 쓰기 방지 | DB, 그리고 파일 하나는 턴(질문 하나를 받아 답을 내기까지의 한 번의 질의응답) 하나가 맡아 고친다는 소유 규칙 |
업로드는 대부분 PUT 한 번입니다. 문서·회의 업로드 상한이 500 MB라 S3의 단일 PUT 한도(5 GB)보다 한참 작기 때문입니다. 본문 해시는 파일을 덩어리로 읽으며 계산해서, 큰 파일도 메모리에 통째로 올리지 않습니다. GB 단위인 반입 릴리스만 관리 화면이 파트로 나눠 올리고 멀티파트 업로드로 잇습니다. 완료할 때는 클라이언트가 보낸 파트 목록 대신, 완료 직전에 저장소에서 다시 읽은 파트 목록과 ETag를 씁니다.
게이트웨이에는 백엔드와 워커만 닿는다
구축형 게이트웨이는 저장소 전용 내부 네트워크 하나에만 붙고, 호스트 포트를 열지 않습니다. 그 네트워크에 함께 붙은 백엔드와 워커만 이름으로 게이트웨이에 닿습니다. 화면을 내보내는 컨테이너와 모델이 쓴 스크립트를 실행하는 사이드카는 저장소 API에 닿지 못하고, 게이트웨이도 밖으로 나가는 길이 없습니다.
게이트웨이 프로세스는 root가 아닌 사용자로, 리눅스 권한(capability) 없이, 읽기 전용 루트 파일시스템 위에서 돕니다. 게이트웨이 루트에는 버킷 자리 마운트만 있어서, 설치가 정한 버킷 넷 말고는 새 버킷을 만들 수 없습니다. 상태는 볼륨에만 있으므로 배포 때마다 게이트웨이 컨테이너를 다시 만들어도 됩니다.
게이트웨이는 객체 메타데이터를 파일의 user 확장 속성(xattr)에 둡니다. 그래서 볼륨이 놓인 파일시스템과 도커가 쓰는 파일시스템 모두 xattr을 지원해야 합니다. ext4와 xfs는 기본으로 지원합니다. 백업 도구도 볼륨을 뜰 때 확장 속성과 숫자 소유자를 함께 담습니다.
컨테이너에 남는 것은 다시 만들 수 있는 작업 볼륨 하나다
컨테이너 디스크에 남는 것은 작업 볼륨 하나이고, 들어가는 것은 모두 다시 만들 수 있습니다.
| 들어가는 것 | 다시 만드는 법 |
|---|---|
| 턴 폴더 | 턴이 시작할 때 객체 저장소에서 그 대화의 파일을 받아 놓고, 끝날 때 남길 것을 올린 뒤 지운다 |
| 미리보기 캐시 | 내용 해시가 키라 지우면 다음에 다시 그린다 |
| 문서 파싱처럼 로컬 경로가 필요한 처리의 임시 사본 | 처리가 끝나면 지우고, 필요하면 다시 받는다 |
| 회의 업로드를 디코딩한 PCM | 지워지면 원본에서 다시 디코딩한다 |
턴 폴더는 턴마다 그 대화의 파일을 받아 놓으므로, 작업 볼륨의 크기는 동시에 도는 턴 수에 비례합니다. 그래서 크기를 상한 두 개로 묶었습니다. 대화 하나(플레이북은 실행 한 번)가 객체 저장소에 남길 수 있는 총량은 기본 512 MiB이고, 넘는 몫은 올리지 않습니다. 턴 폴더는 턴 도중 기본 1 GiB까지 자랄 수 있고, 넘으면 그 턴만 실패합니다. 앞의 값은 뒤의 값보다 작아야 하고, 설정이 어긋나면 기동할 때 거절합니다. 받아 놓은 직후에도 턴에 작업할 여유가 남아야 하기 때문입니다. 설치 전 점검은 이 두 값과 동시 턴 수로 디스크 최소치와 권장치를 계산합니다(「설치 전 점검은 호스트를 읽기만 하고, 고칠 명령은 사람에게 넘긴다」).
작업 볼륨은 PostgreSQL 데이터와 다른 파일시스템에 두기를 권합니다. 동시 턴이 몰려 작업 볼륨이 가득 차도, 파일시스템이 다르면 그 턴들만 실패하고 DB 쓰기는 계속됩니다. 같은 파일시스템이면 DB 쓰기까지 실패해 서비스 전체가 멈춥니다.
설계에서 고른 것
로컬 디렉터리 폴백 대신 기동 거부를 골랐습니다. 폴백은 저장소가 잠깐 안 될 때 서비스를 살려 두지만, 설정이 빠진 설치를 파일을 잃는 순간까지 드러내지 못합니다. 기동 거부는 설치 첫날 바로 드러납니다.
구축형의 기본 객체 저장소로는 별도 저장소 클러스터 대신 같은 호스트의 게이트웨이를 골랐습니다. 호스트 한 대로 제품이 서게 하려는 것입니다. 그 대가로 파일의 내구성은 볼륨이 놓인 디스크(RAID, 사내 스토리지)가 맡습니다. 대신 버킷 디렉터리가 곧 도커 볼륨이라 백업이 단순합니다. 샌드박스·문서 원본·회의 음성 볼륨 셋을 DB 덤프 직후에 함께 뜨면 되고, 이 정기 백업은 고객사의 백업 체계가 가져갑니다. 반입 릴리스 볼륨은 판마다 수 GB이고 서명된 판을 다시 받으면 되므로 백업에서 뺐습니다. 업데이트 직전에 뜨는 덤프(「업데이트가 실패하면 스키마 변경에 따라 코드만 되돌리거나 덤프로 복원한다」)는 DB만 담으므로 이 정기 백업을 대신하지 못합니다.
게이트웨이는 프로세스 하나로 돌립니다. 같은 디스크 위에서 게이트웨이를 여러 대로 늘려도 얻는 것이 없고, 프로세스가 하나면 업로드·업데이트·백업 경로를 한 구성에서 모두 검증할 수 있습니다.
S3 기능 전부 대신 두 저장소의 공통 기능만 골랐습니다. 수명 주기 규칙이 하던 정리를 앱 작업이 직접 해야 하고, 조건부 쓰기 대신 턴 단위 소유 규칙을 지켜야 합니다. 그 대신 구축형과 OwlAI SaaS가 같은 코드로 돌고, 한쪽에서 검증한 파일 경로가 다른 쪽에서도 성립합니다.
같은 제품을 호스트 한 대의 compose와 클라우드 컨테이너 서비스 양쪽에 내보낸다면 이 규칙을 처음부터 두는 편이 낫습니다. 파일 경로를 가정한 코드는 다른 배포 형태로 옮기는 순간 다시 써야 하고, 그때는 이미 그 경로에 고객 파일이 쌓여 있습니다.
어떤 문제를 해결하나요?
설치·운영과 품질의 다른 글

엔지니어링 노트 · 설치·운영과 품질
설치 전 점검은 호스트를 읽기만 하고, 고칠 명령은 사람에게 넘긴다
구축형 OwlAI는 고객이 고른 호스트 한 대에 Docker Compose로 올라갑니다. 배포판과 커널, 도커 판, 보안 모듈, 인증서는 고객마다 다릅니다.

엔지니어링 노트 · 설치·운영과 품질
도커 주소 풀은 설치하는 사람이 정하고, 점검은 사내 대역과 대조한다
구축형 OwlAI를 기업망 호스트에 설치하고 몇 주 뒤, 어제까지 붙던 사내 DB나 추론 서버에 컨테이너에서 닿지 않는 날이 올 수 있습니다. 같은 호스트의 셸에서도 닿지 않습니다.

엔지니어링 노트 · 설치·운영과 품질
사내 프록시 뒤에서는 NO_PROXY를 대역 대신 이름과 IP로 적는다
사내 프록시로만 밖에 나갈 수 있는 기업망에 구축형 OwlAI를 설치하면, 클라우드 LLM으로 가는 호출은 HTTPSPROXY를 따라 프록시로 나가야 합니다.
