Discord 봇 하나 올리는데 스토리지부터 다시 짰다 — 홈랩 K3s에 Hermes Agent 배포기

TL;DR

  • 홈랩 K3s에 자기개선형 AI 에이전트 Hermes를 Discord 봇으로 배포했다
  • state.db(SQLite + WAL + FTS5) 때문에 NFS는 탈락, local-path는 노드 고정 한계로 iSCSI(RWO, ext4) 선택
  • 같은 워크로드를 쓰기 민감(iSCSI) / 백업·산출물(NFS) 둘로 쪼갬 — 백업은 다른 바구니에
  • 배포 도중 멀티아키 빌드, Codex device-code 로그인, Discord Privileged Intents 함정을 줄줄이 밟음

라즈베리 파이 K3s에 자기개선형 AI 에이전트(Hermes)를 Discord 봇으로 올리려던 참이었다. 그런데 “DB를 어디 둘까”에서 시작해 멀티아키 빌드, ChatGPT 구독 OAuth, Discord 인텐트까지 줄줄이 돌부리를 밟았다. Claude Code와 함께한, 봇 하나치고는 꽤 긴 배포기.


발단: state.db 한 개

homelab-hermes는 NousResearch의 Hermes Agent를 홈랩 K3s에 Discord 게이트웨이 봇 + kubectl exec TUI로 띄우는 프로젝트다. LLM 백엔드는 내가 이미 구독 중인 OpenAI Codex(ChatGPT 구독). 에이전트라 스킬·메모리·세션·설정을 전부 ~/.hermes/에 쌓는데, 그 핵심이 state.db — SQLite, FTS5(전문 검색), WAL 모드다.

배포 자체는 단순해 보였다. Deployment 하나, replicas: 1, 끝. 그런데 PVC에 storageClassName을 적는 순간 손이 멈췄다. 우리 홈랩의 기본 스토리지는 Synology NAS의 NFS(nfs-csi)고, 거의 모든 워크로드가 여기 올라간다.

그런데 SQLite는 NFS에 올리면 안 된다. “권장하지 않음” 수준이 아니라 데이터가 깨지느냐 마느냐의 문제다. 여기서부터 스토리지 아키텍처를 다시 들여다보게 됐다.


NFS는 왜 SQLite에 독인가

SQLite는 여러 프로세스의 동시 접근을 파일 잠금(POSIX fcntl lock) 으로 막는다. 한 쪽이 쓰는 동안 다른 쪽을 잠가 같은 페이지를 동시에 건드리지 못하게 하는 것이다. 이 잠금이 정확해야 무결성이 유지된다. 문제는 NFS의 파일 잠금이 신뢰할 수 없다는 점이다.

  • NFS의 fcntl/flock은 클라이언트·서버 구현과 버전(NFSv3 vs v4), 잠금 데몬 상태에 따라 동작이 들쭉날쭉하다. 잠금이 “걸린 척”만 하고 실제로는 안 걸리는 경우가 있다.
  • WAL 모드가 여기에 기름을 붓는다. WAL은 -wal 로그 외에 -shm 공유 메모리 파일을 mmap 으로 띄워 리더/라이터가 공유하는데, NFS 위의 mmap 공유 메모리는 노드 간(심지어 같은 노드의 프로세스 간에도) 일관성이 보장되지 않는다. SQLite 공식 문서가 “WAL은 네트워크 파일시스템에서 쓰지 말라” 고 못 박는 이유다.
  • FTS5 인덱스까지 얹으면 쓰기 경합이 잦아져 잠금이 깨질 확률이 올라간다.

잠금이 한 번 깨지면 두 경로가 같은 DB 페이지를 동시에 쓰고, 그 순간 state.db복구 불가로 손상된다. 봇이 쌓아온 메모리·세션이 한 방에 날아간다.

state.dbPOSIX 파일 잠금이 정확히 동작하는 로컬 블록 파일시스템(ext4 등) 을 요구한다. RWX NFS는 탈락. 선택지는 둘로 좁혀졌다.

  1. local-path — 노드의 로컬 디스크에 디렉토리를 잡아 마운트
  2. iSCSI — NAS의 블록 스토리지를 RWO로 붙여 ext4로 포맷

둘 다 “로컬 블록 + 제대로 된 파일 잠금”이라는 요구를 만족한다. 그럼 뭐가 다른가.


local-path는 왜 매력적이고, 왜 탈락했나

처음엔 local-path가 가장 끌렸다. 추가 인프라가 0(K3s 기본 내장)이고, 진짜 로컬 디스크라 파일 잠금이 완벽하며, 네트워크를 안 타니 지연도 최저다. SQLite만 보면 만점이다.

그런데 쿠버네티스 스케줄링을 함께 보면 결정적 약점이 드러난다. “Pod가 노드 한 대에 영원히 묶인다.”

local-path PVC는 처음 바인딩된 노드의 로컬 디스크에 데이터를 만든다. 데이터가 그 노드에만 있으니 이후 Pod는 무조건 그 노드로만 스케줄된다. 시나리오를 따져봤다.

  • 노드 재부팅 → 돌아오면 같은 노드로 복귀. 손실 없음.
  • 노드 일시 다운 → 살아날 때까지 Pod 정지. 데이터는 안전.
  • 노드 꽉 참 → 여기가 진짜 문제다.

Hermes가 node-2에 묶였는데 나중에 node-2가 다른 Pod로 꽉 차면? 재배포해야 하는데 데이터가 node-2에 있으니 다른 노드로 못 가고 Pending에 멈춘다. 우리 워커는 라즈베리 파이라 메모리가 빡빡하다. “노드 한 대가 꽉 차는” 일은 가설이 아니라 충분히 일어날 일이었다.

백업만 있으면 디스크 파손은 감당한다. 하지만 “멀쩡한 데이터를 두고도 스케줄이 막혀 봇이 안 뜨는” 상황은 백업으로도 못 푼다. local-path의 본질적 한계다.


iSCSI: 데이터를 노드에서 떼어내기

iSCSI는 이 문제가 없다. 데이터가 NAS의 블록 볼륨에 있고 노드는 그걸 네트워크로 마운트만 한다.

  • node-2가 꽉 차면 스케줄러가 그냥 node-3로 옮겨서 LUN을 다시 붙인다. Pod가 노드에 묶이지 않는다.
  • 마운트한 LUN 위에 ext4를 직접 올리니 POSIX 파일 잠금도 로컬처럼 정확하다.

“로컬 블록의 파일 잠금”과 “노드 독립성”을 동시에 가져가는 절충이다. 그래서 iSCSI로 결정했다. (Synology에 democratic-csi로 RWO 블록 StorageClass를 붙였다. 실제 시공 과정에서 밟은 함정 — DSM의 2FA 강제 정책, 클론한 라즈베리 파이 4대의 IQN이 전부 똑같았던 일, 테스트 Pod가 하필 master로 가서 마운트가 막힌 일 — 은 인프라 쪽 작업 기록에 따로 정리해 뒀다.)


한 워크로드, 두 스토리지

여기서 한 발 더 나갔다. “그럼 전부 iSCSI에 올리면 되나?” 답은 아니오였다. Hermes가 ~/.hermes/에 쌓는 데이터는 성격이 둘로 갈린다.

데이터성격스토리지
state.db(SQLite/FTS5/WAL), 워크스페이스쓰기 잦음, 잠금 민감iSCSI (RWO, ext4) → /opt/data
DB 백업본, 산출물, 스킬 백업, TTS 오디오append/read 위주, 손상 위험 낮음NFS (nfs-csi, RWX) → /shared

이렇게 나눈 이유:

  1. 백업을 같은 바구니에 두면 백업이 아니다. iSCSI LUN이 깨지는 사고에 대비한 백업인데 그 백업을 같은 iSCSI에 두면 의미가 없다. 백업은 다른 스토리지(NFS) 에 둬야 LUN 사고와 독립적이다.
  2. NFS의 RWX가 여기선 장점이 된다. 백업 CronJob이 메인 파드와 별개로 /shared에 붙어야 하는데 RWX라 동시 마운트가 자유롭다. 백업 파일은 append/read라 NFS의 잠금 약점과 무관하다.
  3. PVC 이전이 쉬워진다. 쓰기 민감한 부분만 블록에 격리하니, 나중에 스토리지를 갈아탈 때 고려할 표면이 줄어든다.

NFS가 “나쁜 스토리지”여서 피한 게 아니다. SQLite라는 특정 워크로드에만 독일 뿐 append/read 데이터엔 여전히 훌륭하다. 그래서 적재적소에 나눠 썼다. 백업은 CronJob이 매일 SQLite 온라인 백업(.backup) 으로 일관된 스냅샷을 떠서 /shared(NFS)에 14일 보관한다. 파드를 멈출 필요 없이 WAL까지 안전하게 복사된다.


iSCSI의 그림자, 그리고 대비

iSCSI도 만능은 아니다. 가장 무서운 건 아이러니하게도 또 데이터 손상 — RWO 블록의 고질병 Multi-Attach다. 노드가 깔끔히 죽지 않고 네트워크만 끊기면, 쿠버네티스는 Pod를 다른 노드로 옮기려는데 이전 노드가 LUN을 붙들고 있어 두 노드가 같은 블록에 동시 쓰기를 할 위험이 있다. NFS의 잠금 문제를 피해 왔는데 다른 문으로 같은 사고가 들어오는 셈이다.

그리고 단일 장애점(SPOF)이 노드에서 NAS로 이동한다. local-path는 “노드 1대” 의존이지만 iSCSI는 “NAS 1대” 의존이다.

대비는 위의 두 스토리지 분리가 그대로 답이 된다. iSCSI가 깨져도 NFS 백업본에서 복원하면 “데드락은 겪되 데이터는 산다”로 떨어진다. 추가로 iSCSI를 쓰는 메인 파드는 워커에만 스케줄되도록 못 박았다 (우리 master엔 iSCSI initiator가 없다).

affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
        - matchExpressions:
            - key: node-role.kubernetes.io/control-plane
              operator: DoesNotExist

그런데 이 “워커에만” 한 줄이, 엉뚱한 데서 청구서를 내밀었다.


함정 ①: 스케줄을 묶으니 이미지도 묶였다 (arm64)

우리 워커 4대는 라즈베리 파이(arm64), master만 amd64다. 위 nodeAffinity로 메인 파드를 worker로 고정하니 파드는 항상 arm64 노드에 떨어진다. 그런데 GitHub Actions로 처음 구운 커스텀 이미지는 amd64 단일 아키였다. 결과:

Failed to pull image "ghcr.io/.../homelab-hermes:...":
  no match for platform in manifest: not found

ImagePullBackOff. 스토리지(데이터를 어디 두나) 결정이 스케줄링(어느 노드에 뜨나)으로 이어졌고, 그게 다시 이미지 빌드(어느 아키로 굽나) 까지 연쇄된 것이다. 빌드를 linux/amd64,linux/arm64 멀티아키(QEMU 크로스 빌드)로 고치고 나서야 파드가 떴다. (arm64 이미지 ~1.1GB 첫 pull에 7분 45초 걸린 건 덤.) 홈랩이 혼합 아키라면 한 번쯤 밟는 돌부리다.


함정 ②: Codex 구독은 있는데 “장치 코드 로그인”이 꺼져 있었다

LLM 백엔드로 OpenAI Codex 구독을 붙이기로 했다. Hermes는 고맙게도 openai-codex provider를 OAuth device-code 플로우로 네이티브 지원한다 — API 키 없이 ChatGPT 계정으로 로그인하고, 크레덴셜은 ~/.hermes/auth.json(우리 iSCSI PVC)에 저장된다. 헤드리스 컨테이너에 딱 맞는 방식이다.

kubectl exec -it -n hermes deploy/hermes -- hermes auth add openai-codex

그런데 device code 화면에 빨간 경고가 떴다. “ChatGPT 보안 설정에서 Codex용 장치 코드 인증을 활성화한 뒤 다시 실행하세요.”

device-code 로그인은 사회공학 공격에 상대적으로 취약해서 계정에서 기본 비활성이다. 직접 켜야 한다: ChatGPT → 설정 → 보안 → “장치 코드 로그인 허용” ON. (워크스페이스 계정이면 관리자가 Workspace Settings에서.) 켜고 재실행하니 device code가 정상 발급됐고, hermes doctorOpenAI Codex auth (logged in)을 찍었다. 기본 모델은 gpt-5.5.


함정 ③: 봇은 떴는데 말이 없다 (Discord)

가장 삽질이 길었던 구간. Discord 봇은 개발자 포털에서 앱·봇 생성 → 토큰 → 서버 초대 → Hermes에 등록(hermes gateway setup) 순서인데, 단계마다 걸렸다.

(1) 게이트웨이는 사는데 Discord만 타임아웃. 처음엔 discord connect timed out after 30s. egress를 의심했다 — 이 봇은 셸 실행 권한이 큰 에이전트라 NetworkPolicy로 egress를 조였으니까. 그런데 파드에서 직접 찍어보니 DNS도 되고 discord.com/api도 HTTP 200. egress는 멀쩡했다. 진짜 원인은 로그 더 아래에 있었다.

discord.errors.PrivilegedIntentsRequired:
  ...requesting privileged intents that have not been explicitly enabled in the developer portal.

Privileged Gateway Intents(특히 Message Content Intent)가 포털에서 꺼져 있던 것. 봇이 일반 메시지 내용을 읽으려면 이 인텐트를 명시적으로 켜야 한다(Discord의 옵트인 보안 정책). 포털 → Bot → Privileged Gateway Intents에서 셋 다 켜고 저장 → 재시작하니 ✓ discord connected.

(2) 봇이 온라인인데 응답이 없다. DISCORD_ALLOWED_USERS내 user ID 대신 엉뚱한 값(봇 자신의 ID로 보이는 것) 이 들어가 있었다. 이 목록이 비거나 틀리면 Hermes는 안전상 전원 차단한다 — 즉 봇 주인인 나조차 응답을 못 받는다. 개발자 모드를 켜고 내 계정 우클릭 → “사용자 ID 복사”로 받은 순수 숫자로 고쳤다.

(3) @Hermes라고 쳤는데 무시당했다. 봇 이름이 기본값(bot... 숫자열)이라 @Hermes는 그냥 흰 글씨 텍스트였고, 실제 멘션이 아니었다. 채널에선 require_mention이 켜져 있어 진짜 멘션(파란 알약) 이어야 반응한다. 가장 확실한 검증은 DM — DM은 멘션이 필요 없다.

이걸 다 통과하자 로그에 전 구간이 찍혔다.

inbound message: platform=discord user=Jaypy msg='자기 소개해봐.'
provider=openai-codex base_url=.../backend-api/codex model=gpt-5.5
API call #1: in=14868 out=62 latency=7.1s cache=11776/14868 (79%)
[Discord] Sending response (129 chars) to ...

Discord ⟷ Hermes(K3s/arm64) ⟷ Codex(gpt-5.5) 체인이 드디어 한 바퀴 돌았다. 프롬프트 캐싱이 79% 먹은 것도 보였다.

Discord에서 Hermes와 처음 나눈 대화 — "너 어떤 일을 할 수 있어?"에 자기 능력을 줄줄이 답하는 봇

긴 배포기 끝에 마주한 첫 대화. “너 어떤 일을 할 수 있어?”에 봇이 제 할 일을 줄줄이 읊는다.


곁들여: kubectl exec에서 잔돌부리 둘

마지막으로, 컨테이너에 들어가 설정하면서 밟은 작은 것 둘.

  • s6-setuidgid: not found. 이 이미지는 s6-overlay가 PID 1으로 root로 떠서 볼륨 권한을 맞춘 뒤 워크로드를 UID 10000으로 떨어뜨린다. 그래서 명령을 hermes 유저로 돌리려고 s6-setuidgid를 앞에 붙였는데, 이 바이너리는 /command에 있어 kubectl exec의 기본 PATH에 없다. 풀패스 /command/s6-setuidgid를 써야 했고, 사실 hermes 명령 자체가 root로 호출되면 알아서 UID 10000으로 재실행하는 권한강하 shim이라 대부분 접두사조차 불필요했다.
  • Unable to use a TTY. OAuth device-code, gateway setup 같은 대화형은 TTY가 필요하다. 셸 래퍼/비대화형으로 돌리면 실패하니 진짜 터미널에서 -it로 실행해야 한다. (그리고 당연히 KUBECONFIG이 그 클러스터를 가리키고 있어야 한다 — 다른 컨텍스트를 보면 namespace not found로 헛다리.)

정리: 결정 트리와 교훈

state.db = SQLite + WAL + FTS5  → 정확한 파일 잠금 필요
  └ NFS?          ✗  파일 잠금 / WAL shm·mmap 불안정 → 손상 위험
  └ local-path?   △  잠금은 완벽하나 Pod가 노드에 고정 → 꽉 차면 Pending
  └ iSCSI?        ✓  로컬 ext4 잠금 + 노드 독립 (단 multi-attach·SPOF 대비)

데이터 성격으로 분리:
  쓰기 잦고 잠금 민감(state.db)  → iSCSI (RWO, ext4)
  백업·산출물(append/read)       → NFS  (RWX)  ← 백업은 다른 바구니에

봇 하나 올리는 일이 스토리지·빌드·인증·메신저로 줄줄이 번졌다. 남는 교훈 몇 가지.

  • 기본 스토리지가 만능은 아니다. NFS는 대부분의 워크로드에 훌륭하지만 파일 잠금에 민감한 DB 앞에선 무력하다. 워크로드의 I/O 성격을 먼저 보고 스토리지를 고르자.
  • 편한 선택일수록 묶임을 본다. local-path는 가장 편하지만 노드에 묶이고, 그 묶임은 스케줄·아키텍처(멀티아키)까지 번진다. 결정은 단독으로 끝나지 않는다.
  • “되는데 안 되는” 것들은 대개 옵트인이다. ChatGPT 장치 코드 로그인, Discord Privileged Intents — 둘 다 보안상 기본 꺼짐이라, 켜기 전엔 “분명 맞게 했는데 왜 안 되지”의 연속이다. 에러 메시지를 끝까지 읽는 게 답이었다.

이제 Hermes의 state.db는 NAS의 iSCSI 블록 위 ext4에서, 백업은 NFS에서, 봇은 라즈베리 파이 워커에서, 두뇌는 Codex 구독에서 — 각자 제자리를 잡고 돌아간다. 그 위에서 봇이 무슨 사고를 칠지는 또 다른 이야기지만.


이 글은 Claude Code와 함께 홈랩 K3s에 Hermes Agent를 배포한 실제 작업 기록을 정리한 것이다. 스토리지를 NAS에 실제로 시공한 인프라 쪽 이야기(DSM 2FA 강제, 클론된 노드의 IQN 충돌, master 스케줄 함정)는 별도 작업 기록 「SQLite 하나 때문에 홈랩 NAS에 iSCSI를 붙였다」에 정리돼 있고, 이 두 기록을 묶어 다듬을 예정이다.