장르: 구축 기록 + 설계 논의. 관리 대시보드에서 Hermes 채널 봇을 관리하기 위한 REST API(이하 wrapper API)를 하루 만에 설계 검증부터 구현, 실환경 검증까지 끝낸 세션의 기록이다. ChatGPT와 미리 잡아둔 설계안이 실환경 실측에서 어떻게 수정됐는지가 이 글의 본체다.

1. 무엇이 필요했나 — 채널 봇 관리를 터미널 밖으로

우리 Hermes 배치는 Hermes × Mattermost — 채널별 다중 에이전트에서 만든 구조 그대로다. 컨테이너 하나 안에 채널별 profile(default, sales, finance, dev, qna, …)이 각자의 gateway 프로세스로 돌고, 채널마다 봇 계정과 .env(토큰, 사용자 allowlist)가 분리되어 있다.

운영하면서 반복되는 작업은 네 가지였다.

  1. 지금 어떤 profile이 떠 있나 (상태 확인)
  2. 새 채널 봇 추가 (profile 생성)
  3. 특정 봇을 쓸 수 있는 사용자 확인 (MATTERMOST_ALLOWED_USERS 조회)
  4. 사용자 추가/삭제 (allowlist 편집 + 재시작)

전부 docker execsed로 하던 일이다. 관리 대시보드에 붙일 REST API로 옮기는 것이 이번 목표였다.

2. 출발점 — ChatGPT와 잡은 설계안, 그리고 미확인 가정들

구현 전에 ChatGPT와 설계 대화를 먼저 했고, 결과물은 쓸 만한 뼈대였다. FastAPI 서비스가 docker exec로 컨테이너 안의 hermes CLI를 호출하는 wrapper 구조, GET/POST /profiles, start/stop, allowlist CRUD 엔드포인트 목록, HERMES_HOME으로 profile을 타겟팅하는 방법까지는 그대로 채택했다.

그런데 설계안에는 스스로 인정한 미확인 가정이 하나 있었고(ChatGPT도 "start/stop 부분만 현재 Docker 실행 방식에 맞춰 구현해야 한다"라고 못 박았다), 대조 검증 과정에서 그 외에도 두 가지가 더 갈렸다.

설계안의 가정 실측 결과
Docker에서 gateway start/stop이 systemd처럼 동작하지 않을 수 있으니 process lifecycle을 직접 관리해야 할 수도 있다 기우였다. 최신 이미지는 s6가 profile별 gateway를 정식 감독하고, stop 상태가 영속화된다 (§3)
gateway list CLI 출력을 파싱해 상태를 만든다 CLI 출력은 파싱 대상이 아니었다. 상태 파일을 직접 읽는 쪽으로 변경 (§4)
allowlist 변경 후 재시작은 gateway restart 한 번이면 된다 .env 재적용은 그렇게 단순하지 않았다. 재시작을 2모드로 분리 (§5)

이 대조 작업이 사실상 이번 구축의 절반이었다. 나머지 절반은 그 결과를 코드로 옮긴 것에 가깝다.

3. 실측 1 — s6는 이미 profile gateway를 정식으로 감독하고 있었다

라이브 컨테이너를 열어보니 구조가 명확했다. PID 1은 s6-svscan이고, profile마다 동적 s6 서비스 슬롯이 있다.

/run/service/
├── gateway-default
├── gateway-dev
├── gateway-finance
├── gateway-qna
├── gateway-sales
├── dashboard
└── main-hermes

gateway-dev/run 스크립트의 실체는 이렇다.

#!/command/with-contenv sh
set -e
export HOME=/opt/data
cd /opt/data
. /opt/hermes/.venv/bin/activate
export HERMES_S6_SUPERVISED_CHILD=1
exec s6-setuidgid hermes hermes -p dev gateway run --replace

gateway run 자체가 s6 이미지 안에서는 감독 서비스로 자동 리다이렉트된다. (--no-supervise로 옛 포그라운드 동작을 강제할 수 있지만, root에서는 거부되고 감독 체계를 깨뜨리므로 wrapper에서는 금지했다.)

stop이 "유지되는" 메커니즘 — wanted state와 desired_state의 2계층

여기서 처음에 헷갈렸던 질문 하나. 우리 운영 문서에는 "프로세스를 죽이면 s6가 자동으로 되살린다, 그것이 재시작 방법이다"라고 적혀 있다. 그렇다면 gateway stop을 해도 s6가 되살려버리는 것 아닌가?

아니었다. s6는 무조건 되살리는 시스템이 아니라 원하는 상태(wanted state)를 유지하는 시스템이다.

  • 프로세스를 그냥 kill하면: wanted state는 여전히 up이므로 즉시 respawn된다. 이것이 재시작 메커니즘이다.
  • gateway stop은: 내부적으로 wanted state 자체를 down으로 바꾼 뒤 프로세스를 내린다. 실제 상태와 원하는 상태가 일치하므로 s6는 아무것도 하지 않는다.

그런데 s6의 wanted state는 /run (tmpfs) 아래에 있어서 컨테이너를 재시작하면 증발한다. 이를 위해 Hermes는 별도의 2계층을 갖고 있었다. stop 시점에 profile의 gateway_state.jsondesired_state: "stopped"를 기록하고(볼륨에 있으므로 살아남는다), 컨테이너 부팅 시 cont-init 스크립트(hermes_cli.container_boot)가 profile을 순회하며 s6 슬롯을 재생성하되 desired_state가 running이었던 것만 기동한다.

조작 s6 wanted state desired_state 파일 결과
PID kill up 유지 running 유지 즉시 respawn (재시작 효과)
gateway stop down으로 변경 stopped 기록 중지 유지
컨테이너 재시작 tmpfs 증발 후 재생성 파일이 기준 running만 복원

설계안이 걱정하던 "Docker에서 start/stop이 안 먹을 수 있다"는 시나리오는 이미 이미지 차원에서 해결되어 있었던 셈이다. wrapper API의 start/stop은 CLI를 그대로 호출하고 결과를 폴링으로 검증하기만 하면 됐다.

4. 실측 2 — CLI 출력은 파싱 대상이 아니다

설계안은 gateway list 출력을 파싱해 상태 API를 만들 생각이었다. 실제 출력을 보면 파싱은 가능해 보인다.

Gateways:
  ✓ default (current)        — PID 192
  ✓ dev                      — PID 201
  ✓ sales                    — PID 179

그러나 profile list 쪽은 컬럼이 고정폭으로 잘려 나가고(stepfun/step-3.7-flash:fre 처럼 모델명이 중간에서 끊긴다), 어느 명령에도 --json 옵션이 없다. 사람 눈을 위한 출력을 기계가 읽는 구조는 업스트림 업데이트 한 번에 깨진다.

그래서 방향을 바꿨다. 상태의 원본은 어차피 파일이다.

  • gateway_state.json: pid, gateway_state, desired_state, 플랫폼 연결 상태(mattermost connected 여부)
  • profile.yaml: 설명(description)
  • .env: Mattermost 설정과 allowlist

wrapper는 컨테이너 안에서 파이썬 collector 스크립트 하나를 실행해 이 파일들을 걷어 JSON 하나로 받는다. docker exec 왕복이 profile 수와 무관하게 1회로 고정되고, 프로세스 생존 확인(/proc/<pid>)도 컨테이너 네임스페이스 안에서 이뤄지므로 pid 해석 문제도 없다. 상태 파일이 stale해도(크래시 직후 등) 생존 확인이 보정한다.

봇 토큰은 collector 단계에서 걸러서 존재 여부(token_set: true/false)만 내보낸다. 토큰 원문이 API 프로세스 메모리에조차 실리지 않게 한 것인데, 관리 화면 스크린샷 한 장으로 토큰이 새는 사고를 구조적으로 막기 위해서다.

5. 실측 3 — .env는 기동 시에만 읽히고, restart는 한 종류로 부족하다

allowlist(MATTERMOST_ALLOWED_USERS)는 profile의 .env에 있고, .env는 gateway가 기동할 때만 읽는다. 파일을 고쳐도 실행 중인 봇에는 반영되지 않는다. 여기까지는 설계안도 알고 있었고, "변경 후 restart" 흐름도 잡아두었다.

문제는 그 restart의 구현이다. 이전 이관 작업에서 이미 확인한 사실이 있는데, hermes gateway run --replace로는 프로세스가 실제로 갈아끼워지지 않아 .env가 다시 읽히지 않는 경우가 있다. 표준 방법은 해당 profile의 PID를 죽여 s6가 되살리게 하는 것이고, 그마저 불충분했던 사례에서는 컨테이너 재시작까지 갔다.

wrapper API는 이 현실을 그대로 인터페이스에 반영했다. restart를 한 종류로 두지 않고 2모드로 분리했다.

mode 동작 영향 범위 용도
process (기본) 해당 profile PID kill 후 s6 respawn, PID 변경 확인 후 응답 해당 profile만 일상적인 .env 반영
container docker restart 모든 profile 재시작 process로 반영이 안 될 때의 보장 수단

allowlist 변경 응답에는 pending_restart 필드를 넣었다. "변경은 저장됐지만 재시작 전이라 아직 미반영"이라는 상태를 API가 명시적으로 알려주지 않으면, 화면에서 사용자를 추가하고도 봇이 반응하지 않는 이유를 운영자가 다시 터미널에서 찾게 된다.

같은 이유로 allowlist에는 전체 교체(PUT) 를 따로 두었다. 사용자 세 명을 바꾸면서 매번 재시작하면 봇이 그때마다 끊긴다. 화면에서는 변경을 쌓아두었다가 최종 상태를 PUT 한 번 + 재시작 1회로 반영하는 편이 낫다. 1건 추가(POST)는 멱등으로 만들어 재시도에 안전하게 했다.

6. API 표면과 보호 로직

최종 엔드포인트는 이렇다.

Method Path 동작
GET /health API + 컨테이너 도달성 (인증 불필요)
GET /profiles 전 profile 목록 (상태, PID, desired_state, 연결 상태)
POST /profiles 생성. clone_from 지정 시 전체 복제, 미지정 시 .env 빈 템플릿
GET /profiles/{name} 상세 (Mattermost 설정 포함, 토큰은 token_set만)
DELETE /profiles/{name} 삭제 (실행 중이면 먼저 중지). default 차단
POST /profiles/{name}/start 기동 + 완료 폴링 (통상 20초 내외)
POST /profiles/{name}/stop 중지 (재시작에도 유지). default 차단
POST /profiles/{name}/restart {mode: process | container}
GET/PUT/POST/DELETE /profiles/{name}/mattermost/users… allowlist 조회 / 전체 교체 / 1건 추가 / 1건 삭제 (+restart 플래그)

보호 로직은 과거 장애에서 역산했다.

  • default profile의 삭제·중지는 403. default는 데이터 루트 자체가 홈이라 다른 profile과 구조가 다르고, 공유 인증 저장소 등 전체의 기반이 얽혀 있다. 운영 불변식("default를 지우거나 바꾸지 말 것")을 API가 강제한다. 반면 allowlist 편집은 default 포함 전 profile에서 정상 작업이므로 막지 않았다.
  • 모든 쓰기 명령은 컨테이너의 hermes 사용자로 실행한다. root로 CLI를 돌렸다가 auth.json 소유권이 꼬여 봇 전체가 401로 죽었던 봇 전체 무응답 — 무료 모델 소멸(404) + auth.json 권한 사고(401) 사건의 재발 방지다. docker exec는 기본이 root라서, wrapper의 실행 계층이 -u hermes를 강제로 끼워 넣는다.
  • profile명은 ^[a-z0-9][a-z0-9_-]*$만 통과. 이 이름이 그대로 s6 서비스 디렉토리명이 되므로, 업스트림 검증 규칙을 API 계층에 미러링했다.
  • user_id는 26자 Mattermost ID만 통과. 초기 문서에 "username을 넣는다"라고 잘못 적혀 있던 항목인데, 실제 값은 26자 ID다. 형식 검증이 없으면 "사용자명을 넣었는데 왜 안 되지" 유형의 문의가 반드시 온다.
  • allowlist 응답에 pairing_active를 노출. DM pairing으로 승인된 사용자는 allowlist에 없어도 봇을 쓸 수 있다. pairing 저장소가 allowlist와 합집합으로, 그것도 먼저 평가되기 때문이다(문서가 코드와 어긋난 네 곳 — 게이트웨이 인가 경로를 실측으로 다시 그렸다에서 실측한 내용이다). 화면이 allowlist만 보여주면 관리자는 접근 제어를 오판한다.
  • .env 쓰기 전 자동 백업. Hermes는 config.yaml만 자동 백업하고 .env는 하지 않는다. wrapper가 변경 전 내용을 타임스탬프 백업으로 남긴다.

한 가지 소소한 개선: clone 없이 profile을 만들 때 .env를 빈 템플릿으로 초기화하게 했다. 서버 주소(MATTERMOST_URL)만 default에서 복사하고 토큰·allowlist·채널은 빈 값으로 둔다. 새 profile이 "봇 연결은 안 되지만 구조는 완성된" 상태로 태어나므로, 화면에서 토큰과 채널만 채우면 되는 흐름이 된다.

7. 인증과 배포 형태 — docker.sock을 쥔 컨테이너라는 문제

wrapper API 자체도 Docker 컨테이너로 배포한다. 그런데 이 컨테이너는 docker exec를 해야 하므로 /var/run/docker.sock을 마운트한다. socket을 쥔 컨테이너는 호스트 root와 동급이다. 이 사실이 보안 설계 전체를 규정했다.

  • 운영 환경에서는 대시보드와 API가 서로 다른 호스트에 놓인다. 요청이 호스트 경계를 넘으므로 "Docker 네트워크 내부니까 인증 생략" 같은 선택지는 애초에 성립하지 않는다.
  • 1층: X-API-Key 정적 키. 상수 시간 비교(secrets.compare_digest)로 검사하고, 키가 설정되지 않으면 서버가 기동 자체를 거부한다(fail-closed).
  • 2층: 허용 소스 IP CIDR 목록(선택) + compose에서 바인드 주소를 내부 IP로 제한. 로컬 개발은 127.0.0.1 바인드가 기본값이다.
  • CORS는 의도적으로 비활성. 브라우저가 API를 직접 부르는 구조를 지원하지 않고, 대시보드의 서버 라우트가 키를 붙여 프록시하는 경로만 상정한다. 키가 브라우저에 노출될 이유가 없다.

동시성도 한 가지 실측에 기반한 결정이 있다. 과거 여러 gateway를 루프로 연달아 기동하면 s6 핸드오프가 블로킹되는 현상을 겪었기 때문에, wrapper는 모든 변경 작업(기동·중지·생성· 삭제·.env 쓰기)을 전역 락으로 직렬화한다. 관리 API에 동시 쓰기 처리량은 필요 없다. 정확성이 전부다.

DB는 없다. 상태의 원본이 Hermes 파일시스템 하나뿐인데 API가 캐시나 DB를 두는 순간 진실이 둘이 되고 동기화 문제가 시작된다. 매 요청 docker exec 왕복(수백 ms)은 관리 API가 감당할 만한 비용이다.

8. 검증 — 일회용 profile로 전 시나리오 실측

단위 테스트(20건, collector를 mock으로 대체해 Docker 없이 돈다)와 별개로, 일회용 profile apitest를 만들어 라이브 컨테이너에서 전 시나리오를 돌렸다. 운영 profile은 건드리지 않는다.

시나리오 결과
생성(clone_from: dev) .env 상속 확인
start 약 20초에 running, s6 슬롯 gateway-apitest 생성 확인
stop respawn 없이 stopped 유지, desired_state=stopped 기록 확인
컨테이너 재시작 운영 profile 전부 자동 복원, apitest만 stopped 유지 (§3의 2계층이 실제로 동작)
allowlist 추가(restart: true) .env 반영, 백업 파일 생성, PID 412 → 554 변경 확인
PUT 전체 교체 / DELETE 1건 반영 + pending_restart 경고 동작
default 삭제/중지 403
인증 없는 요청 401
삭제 204, profile 디렉토리와 s6 슬롯까지 정리 확인

검증 항목 중 컨테이너 재시작 건이 이번 작업의 백미였다. 설계안 시점의 최대 불확실성이 "stop 상태가 재시작을 견디는가"였는데, 실측으로 견딘다는 것을 확인하며 설계의 마지막 가정이 해소됐다.

9. 밟은 함정들

clone은 토큰까지 복사한다. clone_from으로 만든 테스트 profile을 그대로 기동하면 원본과 같은 봇 토큰으로 gateway가 두 개 붙는다. 원본 봇이 채널에서 이중 응답할 수 있는 상태다. 검증 때는 기동 전에 토큰을 비우는 절차를 끼워 넣었고, 이 함정이 §6의 "clone 없는 생성은 빈 템플릿" 개선으로 이어졌다.

profile.yaml의 한글은 YAML 이스케이프로 저장된다. collector가 처음에 description: 줄을 문자열로 잘라 읽었더니 개발... 같은 이스케이프가 그대로 나왔다. Hermes가 한글 설명을 "\uXXXX" 형태로 기록하기 때문이다. PyYAML은 Hermes 자신이 쓰는 라이브러리라 컨테이너 venv에 반드시 있으므로, collector가 yaml.safe_load로 읽게 바꿨다.

stop 직후의 pid는 거짓말을 한다. stop 응답에 죽어가는 프로세스의 pid가 섞여 나오는 레이스가 있었다. 상태 파일은 이미 stopped인데 프로세스가 아직 종료 중인 창이 존재한다. collector가 "running일 때만 pid를 노출"하도록 정리했다.

8600 포트의 유령. 검증 중 API가 계속 401을 뱉어서 코드를 의심했는데, 범인은 포트였다. 테스트 가이드를 따라 미리 띄워둔 (구버전 코드의) API 컨테이너가 8600을 점유하고 있었고, 새로 띄운 서버는 바인드에 실패한 채 요청이 옛 컨테이너(다른 키)로 가고 있었다. lsof로 리스너를 확인하는 습관이 또 한 번 값을 했다.

10. 남은 것

  • 개발/운영 서버 배포. compose의 바인드 주소와 허용 IP만 환경값으로 조정하면 되도록 만들어 두었다.
  • 관리 대시보드 쪽 프록시 라우트와 화면 연동.
  • config.yaml/SOUL.md 편집 API와 pairing 관리 API는 이번 범위에서 의도적으로 뺐다. allowlist와 달리 잘못 만지면 프롬프트 캐시나 인가 체계가 깨지는 영역이라, 화면에서 다룰 만큼 이해가 쌓인 뒤에 붙일 생각이다.

사내 저장소에는 이 세션의 상세 기록(설계 결정 근거, 팀원용 로컬 테스트 가이드, API 명세)을 별도 문서로 남겼다. 이 글은 그중 공개 가능한 부분의 정리다.

관련 글