콘텐츠로 이동

배포 운영

배포 전 검증

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 승격은 근본 해법이지만 이 창을 닫는 수단은 아닙니다.

부하시험 당일 확인:

kubectl -n sohri get pdb sohri-graph-builder-redis
# ALLOWED DISRUPTIONS 가 0 이어야 정상입니다.

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 장기 기억 조회를 참고합니다.