장르: 장애 기록(postmortem). 재현 가능한 절차가 아니라 "무엇을 관측했고, 어떤 가설이 어떤 증거에 죽었는가"의 기록입니다. 진단 순서만 필요하면 맨 아래 재발 시 진단 순서로 가세요.

엿새 전에도 비슷한 문구로 봇이 전부 멈춘 적이 있습니다. 그 기록은 봇 전체 무응답 — 무료 모델 소멸(404) + auth.json 권한 사고(401)에 있고, 이 글은 그 문서의 서술 세 곳을 실측으로 정정합니다.

증상 — 경고 문구 한 줄이 전부였다

월요일 오전 9시 48분. 사내 메신저 채널에서 봇을 불렀다.

사용자:  @agents-bot hi
agents-bot:  ⚠️ Provider authentication failed. Check the configured credentials;
             raw provider details are in the gateway logs.

Hermes는 provider 실패를 사용자에게 두 갈래로 감싸서 보여준다. 이 구분이 진단의 첫 분기점이다.

화면에 뜨는 문구 실제 의미
The model provider failed after retries. … 모델/라우팅 실패 (주로 404, 5xx)
Provider authentication failed. … 인증 실패 (401 계열)

엿새 전 장애는 앞의 문구로 시작해 조치 도중 뒤의 문구가 나타났다. 이번엔 처음부터 뒤의 문구다. 요청이 계정에 도달조차 못 했다는 뜻이고, 그래서 "크레딧 부족"이나 "모델이 사라졌다" 같은 원인은 이 시점에 이미 순위가 내려간다. 그 둘은 계정을 식별한 다음에 나오는 에러이기 때문이다.

파일 크기가 최대 단서였다

로그를 뒤지기 전에 파일 목록부터 봤다. 결과적으로 이게 이번 조사에서 가장 값싼 단서였다.

$ docker exec hermes sh -c "ls -la /opt/data/auth.json*"
-rw------- 1 hermes hermes  780 Jul 26 00:58 /opt/data/auth.json
-rw------- 1 hermes hermes 9290 Jul 21 04:16 /opt/data/auth.json.bak-...

780바이트. 엿새 전 백업본은 9,290바이트다. 자격증명 파일이 10분의 1로 줄어 있었다.

동시에 이 한 줄이 가설 하나를 즉시 죽였다. 소유자가 hermes:hermes이고 모드도 600이다. 엿새 전 장애의 원인이었던 "root로 CLI를 돌려 파일이 root 소유가 되는 사고"가 아니다. hermes 사용자로 읽어보니 정상적으로 파싱됐다.

$ docker exec -u hermes hermes ... -c "import json;print('ok', json.load(open('/opt/data/auth.json')).get('active_provider'))"
ok nous

파일은 읽힌다. 그런데 안이 비었다.

200을 받고 안심할 뻔했다

토큰이 유효한지 확인하려고 자격증명을 꺼내 API를 때려봤다.

KeyError: 'agent_key'
HTTP:200

이 200은 아무 의미가 없다. K=$(...)가 죽어서 Authorization: Bearer (빈 값)로 요청이 나갔고, 모델 목록 엔드포인트(/v1/models)는 애초에 인증을 요구하지 않아 200을 돌려준 것이다.

엿새 전 장애에서도 똑같은 함정에 빠진 적이 있다. 그때는 설정에 API 키가 없어 빈 문자열로 curl한 무효 테스트였고, 그 기록을 남겨뒀는데도 이번에 재발했다. 이유는 명확하다. 당시 문서에 적어둔 진단 스니펫 자체에 가드가 없었기 때문이다. 교훈을 글로 적는 것과 그 교훈을 도구에 박아 넣는 것은 다른 일이었다.

지금은 이렇게 고쳤다.

set -e
K=$(...)
[ -n "$K" ] || { echo "KEY EMPTY — 테스트 무효"; exit 1; }
# 그리고 인증 테스트는 /v1/models가 아니라 /v1/chat/completions로

로그가 준 진짜 답

2026-07-25 18:58:01,405 WARNING hermes_cli.auth: Nous OAuth state quarantined (terminal auth death):
  {"auth_json_exists": true, "auth_json_size": 9306, "client_id": "hermes-cli",
   "error_code": "invalid_grant", "reason": "runtime_access_refresh_failure",
   "refresh_token_fp": "38bc885f57cc", "token_already_expired": true}

2026-07-25 18:58:01,409 WARNING hermes_cli.auth: Nous OAuth state quarantined (terminal auth death):
  {..., "auth_json_size": 779, "reason": "credential_pool_refresh_failure",
   "refresh_token_fp": null}

2026-07-27 00:48:41,390 WARNING gateway.run: Primary provider auth failed:
  No access token found for Nous Portal login. — trying fallback

세 줄이 각각 다른 이야기를 한다.

첫 줄: terminal auth death. OAuth 토큰 갱신이 invalid_grant로 거부되자, Hermes는 이를 복구 불가로 판정하고 자격증명을 파일에서 지웠다. 9,306바이트가 779바이트가 된 순간이 이것이다. "손상된 파일을 치웠다"가 아니라 "죽은 자격증명을 격리했다"이고, 자동 복구 경로가 없다.

둘째 줄: 4밀리초 뒤에 다른 경로가 이미 비워진 파일을 읽었다. auth_json_size: 779, refresh_token_fp: null이 그 증거다. 경합처럼 보이지만 순차 실행의 흔적이다.

셋째 줄: 이틀 뒤 사용자 멘션에 대한 응답이다. 00:48:41 UTC는 한국 시간 09:48이다. 화면에서 본 그 시각과 정확히 맞는다. 인과가 확정됐다.

컨테이너는 UTC, 메신저 표시는 KST다. 메신저 시각에서 9시간을 빼면 로그 시각. 이 환산을 안 하면 "로그가 9시간 낡았다"는 착시에 시간을 버린다.

그리고 가설을 네 번 세워 네 번 다 틀렸다

여기서부터가 이 글의 본론이다. 원인이 invalid_grant인 건 알았는데, 거부됐는지가 남았다. 그리고 그 질문에 답하는 과정에서 세운 가설이 차례로 죽었다.

가설 1 — 여러 게이트웨이가 같은 토큰을 나눠 쓰다 회전 충돌

이 시스템은 채널마다 프로필을 하나씩 두고, 한 컨테이너 안에서 프로필별 게이트웨이 여섯 개가 돈다. 로그인은 기본 프로필에서 한 번만 했다. 그러니 여섯이 같은 토큰을 공유하다가, OAuth의 refresh token rotation에서 재사용이 탐지돼 토큰 계열 전체가 폐기된 것 아닐까.

토큰 지문을 비교해봤다. 로그에 refresh_token_fp가 남으므로 비밀값을 노출하지 않고 확인할 수 있다.

1  38bc885f57cc
1  69792cb747ee
1  8be323b08564
2  8c5f1f3acd43
1  b16d60415acf

전부 다르다. 공유가 아니었다.

가설 2 — 프로필 생성 시 자격증명이 복사됐다

지문이 다른 건 회전 때문일 수 있다. refresh token은 갱신할 때마다 값이 바뀌니까, 원본이 같아도 지금 지문은 다를 수 있다. 그리고 OAuth 재사용 탐지는 현재 지문이 아니라 계열(family) 단위로 작동한다. 즉 "지문이 다르다"는 가설 1의 반증이 아니다.

프로필은 profile create --clone으로 만들었다. --clone이 자격증명 파일을 복사했다면 설명된다.

--clone       Copy config.yaml, .env, SOUL.md, and skills from active profile
--clone-all   Full copy of active profile (all state, excluding per-profile history)

--clone의 복사 목록에 자격증명 파일이 없다. 실제로 쓴 것도 --clone이다. 기각.

가설 3 — 재기동 중에 갱신 결과를 저장하지 못했다

장애 당일 낮에 게이트웨이가 SIGTERM으로 네 번 재기동됐다(12:21:17, 12:23:54, 12:36:39, 12:42:58). 재기동 도중 갱신 요청을 보냈고, 서버는 회전을 완료했는데 응답을 저장하기 전에 프로세스가 죽었다면? 클라이언트만 무효 토큰을 들고 남는다.

1138: tmp_path = auth_file.with_name(f"{auth_file.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}")
4809: os.replace(tmp, path)

저장이 원자적이다. 임시 파일에 쓰고 os.replace로 갈아끼운다. 중간에 죽어도 반쯤 쓰인 파일은 남지 않는다. 기각.

가설 4 — 여섯 게이트웨이가 동시에 갱신하다 충돌

그럼 그냥 여섯이 같은 순간에 갱신을 시도해서 서로를 무효화한 건 아닐까.

_keepalive_lock = threading.Lock()

그리고 로그의 시각을 보면 순차 처리다. 18:58:01:04:06, 약 2초 간격으로 하나씩. 겹치지 않는다. 기각.

진짜 원인 — 주기가 수명의 6배였다

가설들이 죽는 동안 로그에서 낯선 컴포넌트 이름이 눈에 들어왔다.

2026-07-25 18:58:04,023 INFO hermes_cli.nous_auth_keepalive:
  Nous auth keepalive requires re-login: No access token found for Nous Portal login.

주기적 토큰 갱신 스케줄러가 따로 있었다. 실행 이력을 뽑아봤다.

2026-07-25 12:58:04
2026-07-25 18:58:04   ← 여기서 전멸
2026-07-26 00:58:01
2026-07-26 06:58:01
2026-07-26 12:58:01
2026-07-26 18:58:01
2026-07-27 00:58:01

정확히 6시간 간격이다. 소스를 확인했다.

# hermes_cli/nous_auth_keepalive.py:22
NOUS_AUTH_KEEPALIVE_INTERVAL_SECONDS = 6 * 60 * 60     # 21,600초

그런데 이 provider의 access token 수명은 3,599초, 즉 1시간이다.

토큰 수명   ├───1h───┤
keepalive   ├──────────────────6h──────────────────┤
공백                 └──────── 최대 5시간 ────────┘

1시간짜리 토큰을 6시간마다 갱신하고 있었다. 그래서 모든 격리 로그에 token_already_expired: true가 찍혀 있었던 것이다. 그건 예외 상황이 아니라 평상시 상태였다.

주기는 하드코딩이고, 호출부 두 곳 모두 인자 없이 부른다.

gateway/run.py:20778        start_nous_auth_keepalive()
hermes_cli/web_server.py:17007   start_nous_auth_keepalive()

환경변수도 설정 키도 없다. 우리 쪽 설정 실수가 아니라 상류의 결함이고, 이미지를 올려도 그대로 돌아올 가능성이 높다.

그럼 왜 열하루나 멀쩡했나 — 주말이었다

구축한 지 열하루가 지나 있었다. 그동안 주기는 계속 6시간이었는데 왜 이제 터졌나.

평일:  채널 멘션이 들어옴 → 요청 시점에 갱신 → 토큰이 계속 신선함 → 공백이 가려짐
주말:  트래픽 0        → 갱신 트리거 없음   → keepalive만 남음  → 공백이 드러남

공백이 열린 건 주말이었다. 마지막 성공 기록이 12:22:46, 다음 갱신 시도가 18:58:01(UTC). 6시간 35분의 공백이다. 현지 시간으로는 토요일 밤에 토큰이 만료되고 일요일 새벽에 죽었다. 그 사이 아무도 봇을 부르지 않았고, 그래서 갱신을 대신 유발해줄 트래픽이 없었다.

─ 이 장애는 "언제 터지는가"가 트래픽 패턴으로 결정된다. ─

바꿔 말하면, 채널 트래픽이 상류의 결함을 계속 가려주고 있었다. 사람이 앉아서 대화하는 CLI 사용 형태에서는 요청마다 갱신되니 이 구멍이 아예 보이지 않는다. 24시간 무인으로 도는 채널 봇에서만, 그것도 조용한 구간에서만 드러난다.

낮의 재기동 네 번은 방아쇠가 아니라 시계를 맞춘 사건이었다. 그때 여섯 토큰이 한꺼번에 갱신되며 만료 시점이 정렬됐고, 뒤이은 무트래픽 구간이 통째로 공백이 됐다.

대조군 하나로 확정한 것

복구한 뒤에 예상 밖의 일이 생겼다. 기본 프로필만 재로그인했는데 다른 채널 봇도 살아났다.

파일 목록을 보니 각 프로필이 자기 자격증명 파일을 따로 갖고 있었다. 그런데 로그인은 한 번만 했다. 그렇다면 프로필들은 그 자격증명을 참조하는 걸까, 복사해 가는 걸까. 그리고 어디서 가져오는 걸까.

네 채널에만 멘션을 보내고 파일 크기를 확인했다.

프로필 멘션 크기 시각 (UTC)
기본 (로그인) 9,306 B 01:25
dev 보냄 9,594 B 01:28
finance 보냄 9,594 B 01:38
sales 보냄 9,594 B 01:38
qna 보냄 9,594 B 01:39
guest 안 보냄 780 B (그대로)

멘션한 것만 채워졌다. 마지막 줄이 결정적이다. 의도적으로 건드리지 않은 프로필 하나가 780바이트로 남아 있다는 사실이, "시간이 지나서"도 "게이트웨이가 떠서"도 아니라 요청이 트리거임을 증명한다. 대조군 없이 네 개만 봤다면 그냥 시간 경과로 오해했을 것이다.

그리고 네 개가 바이트 단위로 정확히 같다. 서로 다른 시각에 각각 만들어졌는데 9,594바이트로 일치한다는 건, 같은 자격증명의 복사본에 프로필 공통 필드(288바이트)만 덧붙었다는 뜻이다.

그럼 어디서 가져오는가. 나중에 재로그인을 한 번 더 하다가 답이 나왔다.

Found existing Nous OAuth credentials at /opt/data/shared/nous_auth.json
Rehydrating Nous session from shared credentials...

공유 저장소가 따로 있었다. 코드에도 그대로 적혀 있다.

auth.py:4632  # File lives at ${HERMES_SHARED_AUTH_DIR}/nous_auth.json, defaulting to
auth.py:4633  # `<hermes-root>/shared/nous_auth.json`
auth.py:4646  NOUS_SHARED_STORE_FILENAME = "nous_auth.json"

같은 디렉터리에 nous_auth.lock(0바이트)도 있다. 여러 게이트웨이가 하나의 공유 자격증명을 다루면서 경합을 막으려고 둔 잠금 파일이다.

결론은 이렇다.

로그인 1회  →  공유 저장소 <hermes-root>/shared/nous_auth.json 에 기록
   └─ 각 프로필 게이트웨이가 첫 요청 때 공유 저장소에서 rehydrate → 자기 파일에 기록
        └─ 이후 각자 독립적으로 회전 → 지문이 분화 (가설 1이 지문으로 죽은 이유)
             └─ 하지만 갱신 스케줄은 여섯 모두 같은 keepalive 6시간 주기
                  └─ 조용한 구간이 오면 여섯이 함께 공백에 빠진다

파일은 분리돼 있지만 운명은 공유한다. 엿새 전 기록에 "자격증명 파일은 전 프로필이 공유한다"고 적었던 건 메커니즘으로는 틀렸지만, "전 채널이 함께 죽는다"는 예측은 맞았다.

처음 이 글을 쓸 때는 "각 프로필이 기본 프로필의 파일에서 복사해 간다"고 적었다. 대조군 실험의 관측은 맞았지만 출처를 잘못 지목한 것이었다. 실제 출처는 위의 공유 저장소이고, HERMES_SHARED_AUTH_DIR로 위치를 바꿀 수 있다. 프로필별로 인증을 분리해야 할 일이 생기면 그 환경변수가 손잡이다.

조치 — 재로그인 한 번, 재기동 없음

docker exec -it -u hermes hermes /opt/hermes/.venv/bin/hermes \
  auth add --type oauth --no-browser nous
  • -u hermes 필수. root로 실행하면 엿새 전의 권한 사고가 그대로 재발한다.
  • --no-browser 필수. 컨테이너 안에 브라우저가 없어서 자동 실행을 시도하면 멈춘다.
  • 기본 프로필 하나만 하면 된다. 나머지는 첫 요청 때 전파받는다.

그리고 여기서 두 번째 정정이 나왔다.

게이트웨이 재기동은 필요 없었다. 재로그인 직후, 아무것도 재시작하지 않았는데 봇이 응답했다. .envconfig.yaml은 기동 시점에 고정되는 게 맞지만 인증은 런타임에 다시 읽힌다. 근거는 하나 더 있다. 일요일 새벽의 격리 로그를 기록한 주체가 그때 이미 돌고 있던 게이트웨이다. 실행 중에 자격증명 파일을 읽고, 갱신을 시도하고, 다시 썼다.

엿새 전에 재기동이 필요했던 건 모델 교체를 함께 했기 때문이다. 두 조치를 묶어서 하면 어느 쪽이 효과를 냈는지 알 수 없다는 걸, 이번에 하나만 건드려보고서야 알았다.

hermes login은 사라졌다

복구 도중 알게 된 것 하나. 예전 명령이 없어졌다.

$ hermes login
The 'hermes login' command has been removed.
Use 'hermes auth' to manage credentials,
'hermes model' to select a provider, or 'hermes setup' for full setup.

현재 구조는 이렇다.

hermes auth {add,list,remove,reset,status,logout,spotify}

hermes auth                    # 인자 없이 → 대화형 Credential Pool 메뉴
hermes auth status <provider>  # provider 인자 필수
hermes auth list               # pool이 비면 아무것도 출력하지 않는다
hermes auth add [--type {oauth,api-key}] [--label L] [--no-browser] ... provider

provider 예시로 anthropic, openai-codex, openrouter 가 안내된다. 대화형 메뉴에는 Reset cooldowns for a provider, Set rotation strategy for a provider가 있다.

즉 provider마다 자격증명을 여러 개 등록하고 회전시키는 기능이 원래 있었다. 엿새 전 기록에서 "fallback provider가 없다"를 구조적 한계처럼 적었는데, 사실은 그냥 등록을 안 한 상태였다. 이게 세 번째 정정이다.

참고로 에러 메시지가 안내하는 hermes setup운영 중인 시스템에 쓰면 안 된다. 모델·프로필·설정을 함께 건드리기 때문에, 인증 하나만 복구하면 되는 상황에는 범위가 너무 넓다.

남은 문제 두 가지

기록은 끝났지만 시스템은 아직 같은 상태다.

1. 다음 주말에 그대로 재발한다

새로 받은 토큰도 똑같은 6시간 keepalive가 지키고 있다. 조용한 주말이 오면 같은 일이 반복된다.

선택지는 셋이다.

방법 장점 단점
A 정적 API 키를 쓴다 keepalive와 무관해진다. 회전·만료·재로그인이 전부 사라진다 아래 참조 — 넣는 자리를 잘못 고르면 동작하지 않는다
B 소스 상수를 패치하고 마운트로 덮어쓰기 근본 수정 이미지 업그레이드마다 재적용. 상류와 갈라진다
C 외부에서 6시간보다 짧게 봇을 깨우기 코드 미변경 우회책. 공백을 줄일 뿐 없애지 못한다

A를 시도했다가 봇을 한 번 더 죽였다

이 글의 초고에는 "A가 맞다. 정적 키를 credential pool에 추가하면 된다"고 적혀 있었다. 실제로 해봤더니 틀렸다. pool에 정적 키를 넣고 OAuth를 빼자 봇이 그대로 멎었다.

Primary provider auth failed: Hermes is not logged into Nous Portal. — trying fallback

코드를 읽고 나서야 이유를 알았다. credential_poolfallback 은 다른 층이다.

try:
    runtime = resolve_runtime_provider()          # ← 1차 인증. 여기서 AuthError
except AuthError as auth_exc:
    fb_config = _try_resolve_fallback_provider()  # ← 대체 경로 (config.yaml)
    ...

# 성공했을 때의 반환값
return {
    "api_key": runtime.get("api_key"),
    "credential_pool": runtime.get("credential_pool"),   # ← pool은 여기 실려 나간다
    ...
}

credential_pool1차 인증을 통과한 다음에 실려 나간다. 운영 중에 자격증명을 회전시키는 장치(소진·쿨다운 대응)이지, 인증을 통과시키는 수단이 아니다. 그래서 pool에 정적 키만 남기고 OAuth를 빼면 1차 인증 자체가 죽고, pool은 실려 나갈 기회조차 없다.

정적 키의 진짜 자리는 config.yamlfallback provider chain이었다.

def _try_resolve_fallback_provider():
    fb_list = get_fallback_chain(cfg)             # config.yaml의 fallback_providers
    for entry in fb_list:
        explicit_api_key = entry.get("api_key")
        if not explicit_api_key:
            key_env = entry.get("key_env") or entry.get("api_key_env")
            if key_env:
                explicit_api_key = os.getenv(key_env, "").strip() or None
        runtime = resolve_runtime_provider(
            requested=entry.get("provider"),
            explicit_base_url=entry.get("base_url"),
            explicit_api_key=explicit_api_key,
        )

key_env 필드가 있다는 게 반갑다. 키를 config.yaml에 평문으로 박지 않고 환경변수 이름만 적으면 된다. 설정 파일 백업본에 비밀값이 남지 않는다.

그리고 같은 provider를 정적 키로 다시 가리킬 수 있는지 함수를 직접 불러 확인했다.

OK  provider= nous  base_url= https://inference-api.nousresearch.com/v1  api_mode= chat_completions

된다. 즉 최종 형태는 이렇게 짧다.

# config.yaml
fallback_providers:
  - provider: nous
    model: stepfun/step-3.7-flash:free
    key_env: NOUS_API_KEY

1차는 OAuth, 실패하면 같은 모델·같은 엔드포인트를 정적 키로. 응답 품질도 안 바뀐다. 다른 provider로 넘기면 모델 슬러그가 달라 404가 나는데, 그 문제가 통째로 사라진다.

그런데 이걸 적용하지 않기로 했다

설정 자체는 세 줄이지만, config.yaml.env공유 저장소가 없다. 자격증명과 달리 프로필 사이에 전파되지 않으므로 채널 수만큼 반복해야 하고, config.yaml은 기동 시점 고정이라 게이트웨이도 각각 재기동해야 한다.

그리고 이 시스템은 곧 로컬 LLM으로 옮길 예정이다. 그러면 provider도 OAuth도 개념 자체가 사라지고, 방금 만든 설정은 전부 버려진다.

그래서 이렇게 정리했다.

결정
정적 키 fallback 보류. 검증은 끝났으니 필요해지면 위 세 줄로 바로 적용 가능
당장 할 것 heartbeat + 알림 (아래)
근본 해결 로컬 LLM 전환

판단 근거는 하나다. 30시간 중 실제 사용자 영향은 월요일 아침 몇 분이었다. 나머지는 아무도 봇을 쓰지 않는 주말이었고, 재로그인 자체는 2분짜리 작업이다. 그러니 지금 필요한 건 "안 죽게 만들기"가 아니라 "죽으면 바로 알기"다. 그리고 알림은 로컬 LLM으로 가도 그대로 쓴다.

2. 30시간 동안 아무도 몰랐다

일요일 새벽에 죽어서 월요일 아침에 발견됐다. 그것도 사람이 우연히 멘션해서. 총 30시간 26분, 그중 29시간 50분이 무감지였다. 피해의 대부분은 장애 자체가 아니라 감지 공백이었다.

로그의 문자열 두 개만 감시해도 즉시 잡힌다.

terminal auth death
Primary provider auth failed

다만 더 나은 방법이 있다. 감시와 heartbeat를 하나로 묶는 것이다.

이 장애의 방아쇠는 트래픽 공백이었으므로, 6시간보다 짧은 주기로 토큰 갱신을 유발하면 그 공백이 사라진다. 그리고 그 호출이 실패하면 그게 곧 장애 신호다. 로그를 파싱할 필요도 없다.

30분마다:
  자격증명 해석을 시도한다
    성공 → 토큰이 갱신되거나 이미 신선함 (heartbeat 역할)
    실패 → 알림 발송 (감시 역할)

30분 주기면 토큰 수명(1시간)의 절반이라 한 번 실패해도 다음 주기가 커버하고, 감지 지연도 최대 30분이다. 이번 장애의 30시간이 30분이 된다. 설정 변경도 재기동도 필요 없고, 모델을 호출하지 않으므로 비용도 들지 않는다.

그리고 알림은 실제로 도착하는지 눈으로 확인해야 한다. 일부러 실패시켜 보는 것까지가 작업이다. "넣어뒀으니 되겠지"가 이번 장애의 원인이었다. 엿새 전 기록에 위험으로 적어둔 항목이 조치되지 않은 채 그대로 실현됐으니까.

재발 시 진단 순서

# 0) 시각 기준 (메신저 시각 − 9h = 로그 시각)
docker exec hermes date -u

# 1) 파일 크기부터 — 780 B면 격리된 것, 9 KB대면 정상
docker exec hermes sh -c "ls -la /opt/data/auth.json /opt/data/profiles/*/auth.json"

# 2) 예외 원문
docker exec hermes sh -c "tail -60 /opt/data/logs/errors.log"

# 3) 에러코드로 분기
#    invalid_grant + terminal auth death → 이 글 (재로그인, 재기동 불필요)
#    401 + Permission denied            → 소유권 사고 (엿새 전 글)
#    404                                → 모델 소멸 (엿새 전 글)
#    429 / 402                          → 한도·크레딧

# 4) keepalive 실행 이력 — 갱신 공백이 얼마나 벌어졌나
docker exec hermes sh -c "grep -rh 'nous_auth_keepalive' \
  /opt/data/logs/*.log /opt/data/profiles/*/logs/*.log 2>/dev/null \
  | awk '{print \$1, \$2}' | sort -u | tail -20"

# 5) pool 상태
docker exec -u hermes hermes ... hermes auth status nous
docker exec -u hermes hermes ... hermes auth list

# 6) 복구 — 기본 프로필 하나만. 나머지는 첫 요청 때 자동 전파
docker exec -it -u hermes hermes ... hermes auth add --type oauth --no-browser nous

# 7) 검증 — 채널에서 직접 멘션 (gateway list의 ✓는 검증이 아니다)

이번에 얻은 진단 함정 목록

# 함정 방어
1 빈 키로 curl해서 200을 받고 안심 set -e + [ -n "$K" ] 가드. 인증 테스트는 모델 목록이 아니라 채팅 엔드포인트로
2 게이트웨이 목록이 전부 면 정상이라고 판단 프로세스 생존은 응답과 다르다. 실제 멘션만이 검증이다
3 토큰 지문이 다르면 공통 원인이 아니라고 판단 refresh token은 회전하면 지문이 바뀐다. 동시성이 지문보다 강한 신호
4 문서에 적힌 CLI 명령이 최신이라고 가정 hermes login은 사라졌다. 항상 --help로 확인
5 파일 크기를 무시 780 B와 9,306 B의 차이가 이번 조사 최대 단서였다
6 컨테이너 UTC와 메신저 KST를 섞어 봄 9시간 차이. 로그가 낡아 보이면 대개 이것
7 이름이 비슷한 두 층을 하나로 봄 credential_poolfallback chain은 다르다. "등록됐다"와 "그게 쓰인다"는 별개

5번을 강조하고 싶다. 로그를 grep하기 전에 ls -la를 먼저 봤다면 훨씬 빨리 갔을 것이다. "파일이 있느냐"가 아니라 "파일이 얼마나 크냐"가 질문이었다.

그리고 3번은 이번에 배운 것 중 가장 값진 것이다. 나는 지문이 다르다는 이유로 공유 가설을 버렸는데, 그 판단이 틀렸다는 걸 나중에 알았다. 회전하는 값은 같은 뿌리에서 나와도 지금은 달라 보인다. 지문 비교로 "다르다"를 확인했을 때 죽여야 했던 건 가설이 아니라, 지문을 판별 기준으로 삼은 내 방법이었다.

7번은 이 글을 쓰는 도중에 배웠다. hermes auth listnous (1 credentials): #1 static-key라고 출력하면 등록이 끝난 것처럼 보인다. 그런데 게이트웨이는 그걸 쓰지 않았다. CLI가 "있다"고 말하는 것과 런타임이 "그걸 쓴다"는 것은 다른 문장이고, 그 차이를 확인하는 유일한 방법은 실제로 한 번 죽여보는 것이었다. 조사용으로 만든 검증 단계가 없었다면 이 오해를 안고 다음 장애를 맞았을 것이다.

관련 글