배포 운영¶
배포 전 검증¶
helm lint deploy -f deploy/values-stg.yaml
helm lint deploy -f deploy/values-prd.yaml
helm lint deploy -f deploy/values-onprem.yaml
helm template sohri-graph deploy \
--namespace sohri \
-f deploy/values-stg.yaml
python -m pytest -q dm/tests/test_deploy_docs.py dm/tests/test_deploy_contract.py
values-stg.yaml은 staging(GCP dev), values-prd.yaml은 main(GCP prd),
values-onprem.yaml은 develop(on-prem) 배포에 각각 대응합니다.
On-prem의 실제 최종 manifest를 검증하려면 외부 GitOps 저장소의 현재
image.yaml과 Application이 적용하는 override를 추가해 render해야 합니다.
flowchart LR
VALUES["Chart + environment values"] --> RENDER["helm lint/template"]
RENDER --> DIFF["Argo CD diff"]
DIFF --> SYNC["환경 정책에 따른 sync"]
SYNC --> ROLLOUT["Rollout"]
ROLLOUT --> SMOKE["API smoke test"]
확인할 값:
- 정확한 image repository와 immutable tag
- release/namespace와 Service port
59212 - runtime Secret 이름과
OPENAI_API_KEY - 장기 기억 사용 환경의
maumon.enabled, base URL, timeout과 Secret의MAUMON_API_KEY - replica 1, HPA 비활성
- GCP의 Project/GSA/NEG/ESO 좌표
- On-prem의 외부 image overlay와 Skill Runtime endpoint
Rollout 확인¶
kubectl -n <namespace> get deployment,replicaset,pod,service
kubectl -n <namespace> rollout status deployment/<name>
kubectl -n <namespace> describe pod <pod-name>
kubectl -n <namespace> logs deployment/<name>
Startup probe는 현재 설정상 initial delay 10초 후 10초 간격으로 최대 30회 실패를 허용합니다. Readiness 통과, Service endpoint 등록, Secret resolution과 반복적인 liveness 실패가 없는지 확인합니다. GCP에서는 NEG backend health도 별도로 봅니다.
변경 반영¶
| 변경 | manifest 동작 |
|---|---|
image.tag |
Deployment rolling update |
| ConfigMap 값 | checksum/config 변경으로 rolling update |
| Kubernetes Secret | Reloader가 설치·활성화된 경우 Pod restart |
| Probe/resource/security 값 | Deployment rolling update |
| Service/NEG | Service 갱신 |
ESO의 기본 refresh interval은 1시간입니다. Environment variable은 실행 중인 process에서 자동 갱신되지 않으므로 Secret 변경을 반영하려면 Reloader 또는 수동 rollout이 필요합니다.
Session 저장소¶
sessionStorage.mode=memory이면 session 설정과 대화 상태가 process memory에만
있습니다. Pod가 재시작되면 session이 소실되고 다른 Pod는 그 session을 복원할 수
없으므로, chart validation이 replicaCount<=1과 HPA maxReplicas<=1을 강제합니다.
sessionStorage.mode=redis이면 session 설정·대화 상태·session lock이 전부 공유
Redis에 있고, 어떤 Pod든 어떤 session의 어떤 turn이든 처리할 수 있습니다. prd는
이 모드에 replicaCount 8 / HPA 8~12입니다.
세션 회수는 DELETE /dm/v1/session/{session_id}가 담당합니다. 이 호출이 없으면
session은 TTL(CONTEXT_STORE_TTL_SECONDS, CORE_SESSION_TTL_HOURS)까지 남습니다.
장시간 부하시험에서는 client가 대화 종료 시 반드시 호출해야 memory가 단조 증가하지
않습니다.
| 확인 항목 | 방법 |
|---|---|
| Redis 연결 | Pod log의 Shared Redis client created |
| 공유 여부 | 한 Pod에서 session 생성 후 다른 Pod로 GET /dm/v1/session/{id} |
| 회수 | DELETE /dm/v1/session/{id} 후 같은 id로 GET 시 404 |
| Redis 사용량 | redis-cli info memory 의 used_memory_human (maxmemory 상한 1000mb, 컨테이너 limit 2560Mi). 🔴 used_memory 는 데이터셋만 센다. 커널이 보는 값은 used_memory_rss 이므로 두 값을 같이 봐라 |
| 🔴 OOMKill 여유 | redis-cli info memory 의 mem_fragmentation_ratio (= used_memory_rss / used_memory). 견디는 산식이 RSS = 데이터셋 × ratio + 오버헤드 이므로 OOM 판정에 쓰는 ratio 는 이것 하나뿐이다. 현 형상 상한은 (2560 - 30) / 1000 = 2.53, 2.0 을 넘으면 경보다. 🔴 단, used_memory 가 100 MB 를 넘은 뒤에만 읽어라 — 그 전에는 이 값이 의미가 없다. 분모가 데이터셋인데 RSS 에는 고정 오버헤드 약 7 MB(실측 2026-09-02 배포 직후: 빈 Redis RSS 8.3 MB · used 1.1 MB ⇒ ratio 7.77)가 늘 얹혀 있어, 데이터셋이 작으면 그 오버헤드가 분모를 압도한다. 참 단편화가 1.30 이어도 관측값은 1 MB 에서 8.5 · 10 MB 에서 2.0 · 50 MB 에서 1.44 · 100 MB 에서 1.37 · 200 MB 이상에서 1.34(부풀림 3% 이내)로 나온다. ⚠️ 시험 시작 시점에는 7.77 같은 값으로 읽힌다. 그것은 이상이 아니다 — 그때 경보로 받으면 관제자가 이 지표를 밤새 무시하게 된다. ⚠️ allocator_frag_ratio 로 대신 보지 마라 — jemalloc 이 아직 OS 에 반환하지 않은 dirty/retained page 와 Redis 프로세스 오버헤드가 빠져 있어 RSS 보다 낙관적으로 나온다. 그 값이 1.4 라 안심하는 사이 mem_fragmentation_ratio 가 2.0 이면 OOMKill 이다 |
| Redis 조각모음 | redis-cli info memory 의 active_defrag_running 과 allocator_frag_ratio. defrag 발화 판정은 allocator 기준이 맞다(임계값이 allocator 낭비 200MB 그리고 단편화 25% 다). 평상시 active_defrag_running 은 0 이어야 정상이다 |
| Redis CPU 스로틀 | container_cpu_cfs_throttled_periods_total(Redis 컨테이너). Redis 는 단일 스레드인데 limit 이 500m 다. defrag 가 돌기 시작한 뒤 이 값이 증가하면 조각모음이 명령 처리를 갉아먹고 있다는 뜻이다 |
| Redis 축출 | redis-cli info stats 의 evicted_keys. 🔴 0 이 아니면 이미 session 이 통째로 사라진 것이다 — 키 1개 = session 1개이고, 500 session 이 전부 수 초마다 활동하므로 allkeys-lru 는 사실상 무작위로 고른다 |
| 🔴 turn 당 바이트 실측 | 리허설에서 반드시 재라. redis-cli --no-raw STRLEN session:<id> 를 N turn 간격으로 2회 재고 (len2-len1)/(turn2-turn1) 을 deploy/values.yaml 주석에 반영한다. 8시간 데이터셋 추정치가 여기에 달려 있고, 추정이 낮으면 후반부에 session 이 잘려 나간다 |
Redis Pod 재시작은 전 session 소실입니다. 부하시험 중에는 그 Pod를 건드리는 어떤 작업(노드 드레인, 리소스 변경, 이미지 교체)도 하지 않습니다.
🔴 Redis pod template 을 바꾸는 변경은 sync 되는 순간 전 session 을 지웁니다.
strategy: Recreate + 무영속이므로 sessionStorage.redis 의 resources·args·image 를
바꾸면 Argo CD 가 sync 하는 즉시 빈 데이터셋으로 재생성됩니다. 머지 = 자동 sync 이므로
머지 시각이 곧 세션 소실 시각입니다.
| 변경 | Redis 재생성 |
|---|---|
sessionStorage.redis.resources / maxmemory / activeDefrag / image |
예 |
image.tag(앱 이미지) bump |
아니오 — Redis 템플릿에는 config checksum annotation 도, 앱 image 참조도 없습니다 |
| Redis PDB 추가·변경 | 아니오 — 별개 오브젝트입니다 |
배포 시각 원칙: Redis 형상 변경은 리허설보다 최소 수 시간 앞, 라이브 session 이 없는
시간대(수요일 밤 또는 목요일 아침)에 머지합니다. 그러면 리허설이 새 형상(2560Mi pod 기동 ·
activedefrag 부팅 · ALLOWED DISRUPTIONS 0 · turn 당 바이트 실측)을 검증하는 자리가 됩니다.
리허설과 부하시험 사이에는 아무것도 머지하지 않습니다.
Redis PodDisruptionBudget (prd 전용)¶
2026-09-02 실측: cluster-autoscaler 가 노드 축소를 하면서 Redis Pod를 evict 했고
(kubectl get events --field-selector reason=ScaleDown 에
pod/sohri-graph-builder-redis-... 기록), 교체된 Pod의 DBSIZE 가 6 → 1 로 비었습니다.
autoscaler 는 eviction API 를 쓰고 PDB 를 무기한 존중하므로, prd 는
sessionStorage.redis.podDisruptionBudget 로 minAvailable: 1 예산을 겁니다.
replica 가 1 이므로 disruptionsAllowed 는 항상 0 입니다. 운영자가 알아야 할 결과:
| 동작 | 결과 |
|---|---|
Deployment rollout(image.tag·resource 변경, Argo CD sync) |
막히지 않습니다. PDB 는 pods/eviction 만 관장하고, ReplicaSet 컨트롤러는 Pod를 직접 DELETE 합니다. strategy: Recreate 도 그대로 동작합니다 |
kubectl delete pod |
막히지 않습니다(같은 이유) |
| cluster-autoscaler 노드 축소 | 막힙니다. 의도한 동작이지만, 그 노드는 회수되지 않고 비용이 계속 나갑니다 |
kubectl drain |
멈춥니다. --disable-eviction 을 쓰거나 PDB 를 지워야 진행됩니다 |
| GKE 노드 자동 업그레이드/자동 복구 | PDB 를 1시간만 존중하고 그 뒤엔 지웁니다. sohri-prd-gke 의 일일 유지보수 창은 18:00 UTC + PT4H = 03:00~07:00 KST 라 야간 소킹의 마지막 3시간과 겹칩니다. 이 잔존 위험은 PDB 로 닫히지 않고, 유지보수 제외(maintenance exclusion)로 닫습니다 (아래) |
PDB 가 실제로 막는 것은 cluster-autoscaler 의 상시 노드 통합(축소) 이고, 09-02 에 실제로 발생한 사건이 바로 그것입니다. 노드 업그레이드는 별개 경로이며 아래로 막습니다.
gcloud container clusters update sohri-prd-gke --region asia-northeast3 \
--add-maintenance-exclusion-name soak-2026-09-04 \
--add-maintenance-exclusion-start <RFC3339> \
--add-maintenance-exclusion-end <RFC3339> \
--add-maintenance-exclusion-scope no_upgrades
Autopilot도 지원하며 Terraform 변경이 필요 없고 되돌릴 수 있습니다. Memorystore 승격은 근본 해법이지만 이 창을 닫는 수단은 아닙니다.
부하시험 당일 확인:
Rollback¶
- GCP: 해당 환경 values의
image.tag를 이전 immutable SHA로 commit하고 외부 Argo CD 정책에 따라 반영합니다. - On-prem: 외부
gitops/apps/values/sohri-graph-builder-onprem/image.yaml을 이전 12자 SHA로 commit하고 관리자가 Argo CD diff 후 sync합니다.
latest를 rollback 기준으로 사용하지 않습니다.
장애 점검¶
| 증상 | 우선 확인 |
|---|---|
ImagePullBackOff |
repository/tag, image pull Secret, registry 권한 |
| Pod Ready 실패 | / probe, port 59212, resource, application log |
| OpenAI 인증 실패 | runtime Secret, ESO/GSA 또는 on-prem Secret, rollout 여부 |
| Skill 호출 실패 | sohri-skill-runtime:59213, EndpointSlice, Runtime log |
retrieval_timeout |
MAUMON_RETRIEVAL_TIMEOUT_MS 10000ms budget, Maumon p95 검색 지연 |
MAUMON_TIMEOUT |
MAUMON_TIMEOUT_MS, Pod에서 Maumon까지 DNS/TLS 연결, 개별 요청 지연 |
MAUMON_HTTP_ERROR |
API key, Maumon endpoint와 upstream HTTP status |
| GCP traffic 실패 | NEG 이름, endpoint health, load balancer backend |
| On-prem tag 갱신 실패 | GitHub App 설치/secrets, 외부 main ruleset, rebase log |
최종적으로 /, /v1과 실제 session/message 흐름을 smoke test하고 이전 정상 image
tag를 기록합니다. 장기 기억을 사용하는 환경은 user.id가 있는 Session에서 과거
참조 발화를 보내 details.memory_search.status=retrieved도 확인합니다. 전체 오류
코드는 Maumon 장기 기억 조회를
참고합니다.