내 셸에선 되는데: systemd PATH 와 fail-closed 가드가 겹친 자리
사용자 설치 바이너리를 못 찾은 서비스, 조회 실패를 전면 차단으로 처리하는 가드, 원인을 삼키는 except. 각각은 사소하거나 심지어 의도된 설계였는데, 셋이 겹치니 로그에 증거 한 줄 없는 전면 장애가 됐다.
증상
LINE 봇이 에이전트 명령마다 같은 말만 반복했다.
Codex 주간 사용량을 확인하지 못해 에이전트 호출을 보호 차단했습니다. 잠시 후 다시 시도해 주세요.
전날 넣은 쿼터 가드가 낸 메시지였다. Codex 구독의 주간 한도를 다 태우지 않도록, 에이전트를 부르기 전에 codex app-server 의 account/rateLimits/read 로 현재 사용률을 읽고 페이스가 너무 빠르면 막는 장치다. "잠시 후"는 오지 않았다. 계정 사용량은 24% 뿐이었으니 막힐 이유가 없었다.
로그에 아무것도 없다
가장 먼저 한 일은 서비스 로그 확인이었고, 여기서 막혔다.
journalctl -u bland-ai --since "6 hours ago" | grep -iE "quota|codex|rateLimit|guard"
# → 아무것도 안 나옴
사용자에게는 분 단위로 뜨는 메시지가 로그에는 단 한 줄도 없었다. 이 자체가 첫 번째 단서였지만, 그걸 단서로 읽기까지 시간이 걸렸다. 조회 코드를 열어 보니 이유가 있었다.
try:
snapshot = await fetch_codex_weekly_snapshot(...)
except Exception as exc:
raise CodexQuotaError(
"Codex 주간 사용량을 확인하지 못해 에이전트 호출을 보호 차단했습니다. "
"잠시 후 다시 시도해 주세요."
) from exc
from exc 로 원인을 체인에 매달아 두긴 했다. 하지만 이 예외는 위층에서 사용자 메시지로 변환돼 LINE 으로 나갈 뿐, 아무도 트레이스백을 찍지 않았다. 원인 예외는 체인에 매달린 채로 조용히 사라졌다. 사용자만 아는 장애가 된 것이다.
내 셸에선 되는데
조회 함수를 직접 불러 봤다.
$ python3 -c "import asyncio; from bland_ai_bot.codex_quota import fetch_codex_weekly_snapshot; \
print(asyncio.run(fetch_codex_weekly_snapshot('codex', 10.0)))"
OK WeeklyQuotaSnapshot(used_percent=24.0, resets_at=1784519672, ...)
잘 된다. 여기서 재현이 갈렸다. 내 셸에서는 되고 서비스에서는 안 된다면, 차이는 코드가 아니라 환경이다. 서비스 프로세스의 환경을 그대로 들여다봤다.
$ sudo tr '\0' '\n' < /proc/$(systemctl show -p MainPID --value bland-ai)/environ | grep ^PATH=
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/snap/bin
$ which codex
/home/…/.local/bin/codex
범인이 여기 있었다. codex 는 사용자 홈의 ~/.local/bin 에 설치돼 있는데, systemd 유닛의 PATH 에는 그 경로가 없다. 로그인 셸이 .zshrc 로 넣어 주는 경로를 systemd 는 넣어 주지 않는다. User= 를 지정해도 마찬가지다. 그건 로그인 셸을 띄우는 게 아니라 그냥 그 uid 로 프로세스를 실행하는 것뿐이라서, 셸 설정 파일은 읽히지 않는다.
| 환경 | PATH 에 ~/.local/bin | codex 실행 |
|---|---|---|
| 내 대화형 셸 | 있음 (.zshrc 가 추가) | 성공 |
| systemd 서비스 | 없음 (기본 PATH 5개 경로뿐) | FileNotFoundError |
그래서 asyncio.create_subprocess_exec('codex', ...) 가 FileNotFoundError 로 죽고, 가드는 설계대로 동작했다. "조회 실패 = 차단"은 의도된 규칙이다. 사용량을 모르는 채로 호출을 통과시키면 한도를 넘겨 태울 수 있으니, 모르면 막는 쪽이 안전하다. 문제는 그 fail-closed 규칙이 영구히 참인 조건 위에서 돌기 시작했다는 점이다. 바이너리는 다음 시도에도, 그 다음 시도에도 없다. "잠시 후 다시 시도"는 영원히 실패한다.
세 겹
이 장애는 버그 하나가 아니라 셋이 겹친 결과다.
- PATH 가정: 바이너리를 이름으로만 부르면서, 실행 환경의 PATH 가 내 셸과 같다고 가정했다.
- fail-closed 가드: 옳은 설계다. 다만 실패가 일시적일 거라 가정했다. 영구 실패와 일시 실패를 구분하지 않았다.
- 침묵하는
except: 원인을 사용자 메시지로 갈아끼우고 로그에 아무것도 남기지 않았다. 앞의 둘을 진단 불가능하게 만든 게 이것이다.
앞의 둘만 있었다면 로그의 FileNotFoundError: 'codex' 한 줄로 30초 만에 끝났을 일이다. 세 번째가 그걸 조사로 만들었다.
수정
바이너리 해석을 코드에 넣었다. 설정값이 절대경로면 그대로 존중하고, 이름만 주어졌을 때 which 가 실패하면 표준 설치 경로를 확인한다.
_CODEX_FALLBACK_PATHS = (
Path.home() / ".local/bin/codex",
Path.home() / ".codex/packages/standalone/current/bin/codex",
)
def _resolve_codex_bin(codex_bin: str) -> str:
if os.path.sep in codex_bin:
return codex_bin
found = shutil.which(codex_bin)
if found is not None:
return found
for candidate in _CODEX_FALLBACK_PATHS:
if os.access(candidate, os.X_OK):
return str(candidate)
raise FileNotFoundError(
f"Codex binary {codex_bin!r} not found in PATH or standard locations"
)
그리고 삼키던 자리에 로그 한 줄.
except Exception as exc:
_LOG.error("Codex weekly quota fetch failed: %s: %s", type(exc).__name__, exc)
raise CodexQuotaError(...) from exc
환경변수로 때울 수도 있었다
사실 이 코드에는 이미 탈출구가 있었다. BLAND_AI_CODEX_QUOTA_GUARD_BIN 에 절대경로를 넣으면 끝난다. 코드 변경 0줄. 그런데 이 저장소의 운영 .env 는 시크릿 관리 시스템이 렌더링하는 파일이라 직접 수정이 금지돼 있고, 그쪽에서 값을 바꾸면 그 변경은 git push 로 나가지 않는다.
즉 고장의 원인은 코드에 남지 않고 운영 설정에만 남는다. 다른 머신에 clone 하면 같은 증상이 재발하고, 그때마다 "아 이거 env 넣어야 하는 거였지"를 다시 알아내야 한다. 게다가 경로는 머신마다 다른데 그걸 시크릿 저장소에 하드코딩하는 것도 어색하다.
그래서 코드 폴백을 골랐다. 환경변수 탈출구를 없앤 것도 아니다. 절대경로가 설정되면 그대로 쓰므로 비표준 위치는 여전히 env 로 덮어쓸 수 있다. 기본값은 코드가 똑똑하게, env 는 예외 상황용으로.
검증은 서비스의 환경에서
"내 셸에서 되는데"가 이 버그의 본질이었으므로, 검증도 내 셸에서 하면 의미가 없다. 서비스와 똑같은 환경을 만들어 돌렸다.
env -i HOME=/home/… PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/snap/bin \
.venv/bin/python -c "..."
resolved: /home/…/.local/bin/codex
used%: 24.0 / allowed%: 32.3 (elapsed 22.3 + margin 10)
가드 통과: True
env -i 로 환경을 통째로 비우고 systemd 가 주는 것만 넣는다. 이렇게 하면 "내 셸의 오염" 때문에 통과하는 가짜 검증을 피할 수 있다.
남는 것
이 커밋에서 가장 오래 값어치할 부분은 폴백이 아니라 로그 한 줄일 것이다. 폴백은 이 원인 하나를 막는다. 로깅은 앞으로 나올 다른 원인들(인증 만료, RPC 스키마 변경, 타임아웃)까지 전부 진단 가능하게 만든다.
fail-closed 는 "막는다"까지만 설계하기 쉽다. 하지만 막는 쪽을 고르는 순간 왜 막았는지를 남기는 것이 설계의 절반이 된다. 그러지 않으면 안전 장치가 스스로를 디버깅 불가능한 장애로 바꾼다.