한 줄 요약: 전환 직후 “DB가 줄어든 것처럼 보이고”, “
READY 1/1인데 앱은 DB에 못 닿고”, “점검 명령이 고장난 파드를 못 걸러내는” 함정을 연달아 만나, 크기 대신 파일 구성 · 파드 상태 대신pg_stat_activity· phase 대신 readiness로 검증 기준을 바꿔 네 가지를 모두 정리했습니다.
이 글의 위치: 기술 블로그 → 백엔드 · 인프라 운영. 작성·검증 방식은 Editorial Policy 참조.
홈랩 검증기 2편입니다. 1편에서 Postgres를 정적 local PV로 옮긴 직후, 그 전환을 "성공"이라고 판정하는 과정에서 만난 함정들입니다. 재기동이 운영 작업을 조용히 깨뜨리는 이야기는 백업 cron이 깨진 이야기에서도 다뤘습니다.
이 글에서 한 일 — 한눈에
전환 자체는 58초였고 검증이 1시간이었습니다. 그 1시간이 무엇이었는지가 이 글입니다.
- 함정 ① DB가 줄어든 것처럼 보였습니다 — 비교 시점이 만든 착시였습니다.
- 함정 ②
READY 1/1인데 DB에 닿지 못했습니다 — Keycloak 의 관리자 토큰 발급이 실패했습니다. - probe 종류마다 증명하는 범위가 다릅니다 —
READY는 kubelet 이 정한 검사를 통과했다까지만 말합니다. - 함정 ③ 제가 쓴 점검 명령이 고장난 파드를 걸러내지 못했습니다 —
0/1파드도 phase 는Running입니다. - 조치 직후의 스냅샷도 믿으면 안 됩니다 — probe 주기만큼 기다리기 전에는 처방의 성패를 알 수 없습니다.
- 함정 ④ ArgoCD 가 영원히
OutOfSync였습니다 — sync 는 매번Succeeded였습니다. - 네 함정의 뿌리는 하나였습니다 — 대리 지표를 결과로 읽은 것입니다.
아래부터 순서대로 풀어씁니다.
전환은 58초 만에 끝났는데, 검증이 1시간 걸렸습니다
1편의 스토리지 전환 자체는 짧았습니다. Deployment를 내리고 StatefulSet을 올리는 데 58초. 그런데 "이게 정말 성공인가"를 확인하는 데 시간이 훨씬 더 들었습니다.
이유는 단순합니다. 정상처럼 보이는 신호가 여러 개 거짓이었기 때문입니다.
함정 ① DB가 줄어든 것처럼 보였습니다
전환 전후로 DB별 크기를 비교했습니다. 무손실 전환이면 같아야 합니다.
$ diff before.txt after.txt
< script_service_db 9591831
> script_service_db 9429519 ← 162,312 바이트 감소
< template1 7765015
> template1 7602703 ← 162,312 바이트 감소
< user_management_db 8592407
> user_management_db 8430095 ← 162,312 바이트 감소
세 DB가 정확히 같은 값만큼 작아졌습니다. 파일 수도 3개 적었습니다(4,475 → 4,472).
데이터 손실로 읽히기 딱 좋은 모습입니다. 그런데 "정확히 같은 값"이라는 게 단서였습니다. 서로 다른 DB가 우연히 같은 바이트만큼 손실될 리는 없습니다. 같은 종류의 파일 하나가 빠진 것입니다.
정체는 base/<oid>/pg_internal.init — 릴레이션 캐시 파일입니다. PostgreSQL이 시스템 카탈로그 조회를 빠르게 하려고 DB마다 만들어 두는 캐시로, 정상 종료 시 삭제되고 각 DB의 첫 접속 때 다시 만들어집니다.
즉 아직 아무도 접속하지 않은 DB만 그 파일이 없어서 작게 보였던 것입니다. 각 DB에 select 1을 한 번씩 날리자 11개 전부 전환 전과 완전히 일치했습니다.
교훈: 재기동 직후의
pg_database_size비교는 모든 DB에 접속을 한 번 시킨 뒤에 해야 의미가 있습니다. 워밍업 없이 비교하면 정상적인 차이를 손실로 오인합니다.
같은 이유로 정지 전후의 PGDATA 바이트 수 비교도 신뢰할 수 없습니다. 나중에 다른 DB를 옮길 때 사본이 원본보다 123,866바이트 크게 나와서 또 한 번 의심했는데, 계산이 정확히 맞아떨어졌습니다.
정상 종료 시 postmaster.pid 삭제 −94 B
pg_stat/pgstat.stat 생성 +123,960 B
──────────────────────────────────
순증 +123,866 B ← 관측된 차이와 일치
파일 수가 그대로였던 것도 하나 줄고 하나 늘어서였습니다. 크기가 아니라 파일 구성으로 확인해야 하는 이유입니다.
함정 ② READY 1/1인데 DB에 닿지 못했습니다
파드 상태를 봤습니다. 전부 Running, 전부 1/1. Keycloak의 readiness probe도 통과입니다. 그런데 관리자 토큰 발급이 실패했습니다.
org.postgresql.util.PSQLException: This connection has been closed.
at io.agroal.pool.ConnectionHandler.transactionRollback(...)
WARN [io.agroal.pool] Datasource '<default>': This connection has been closed.
DB 쪽에서 보면 이렇습니다.
select datname, count(*) from pg_stat_activity where datname is not null group by 1;
-- keycloak 이 목록에 아예 없음 (연결 0개)
Postgres를 재기동하면 애플리케이션의 커넥션 풀은 끊긴 커넥션을 쥔 채로 남습니다. 풀은 자기가 가진 커넥션이 죽은 걸 모르고, 요청이 올 때야 실패합니다. 그런데 readiness probe는 그 경로를 타지 않으니 계속 통과합니다.
의존 워크로드 9개를 rollout restart 하니 9개 DB 전부 연결이 살아났습니다.
probe 종류가 무엇을 증명하는지 갈립니다
| probe 종류 | 확인하는 것 | DB 재기동 후 | L7 장애 시(3편) |
|---|---|---|---|
exec (pg_isready) |
컨테이너 안에서 명령 성공 | DB 자신은 정확 | 영향 없음 |
tcp |
포트가 열려 있는가 | 풀 상태와 무관하게 통과 | 영향 없음 |
http |
엔드포인트가 200을 주는가 | 앱이 DB를 안 타면 통과 | 전부 실패 |
READY 1/1이 말해주는 것은 "kubelet이 정한 검사를 통과했다"까지입니다. 애플리케이션이 의존하는 백엔드에 실제로 닿는다는 뜻이 아닙니다.
다만 반대 방향의 오해도 조심해야 합니다.
pg_stat_activity의 연결 0이 곧 "풀이 죽었다"는 아닙니다. 유휴 풀도 0입니다. 확정 신호는 위의PSQLException이 로그에 찍히는지입니다. 애초에 구분할 필요가 없도록, 재기동 절차에 의존 서비스 재시작을 처음부터 포함하는 편이 낫습니다.
함정 ③ 제 점검 명령이 고장난 파드를 걸러내지 못했습니다
이게 가장 뼈아팠습니다. 전환 직후 저는 이렇게 확인했습니다.
kubectl -n <ns> get pods | grep -v Running
출력이 비었습니다. "전부 정상"이라고 보고했습니다. 그런데 그때 이미 여러 파드가 0/1이었습니다.
0/1 파드도 STATUS는 Running입니다. READY 열과 STATUS 열은 다른 것을 말하는데, grep -v Running은 STATUS만 봅니다. 같은 이유로 --field-selector=status.phase!=Running도 못 잡습니다. phase는 파드 생명주기(Pending/Running/Succeeded/Failed)이고, 준비 상태는 컨테이너별 ready 플래그입니다.
| 방법 | 무엇을 보는가 | 0/1 Running 파드를 잡는가 |
|---|---|---|
grep -v Running |
STATUS 문자열 | ❌ |
--field-selector=status.phase!=Running |
파드 phase | ❌ |
READY 열 직접 확인 |
컨테이너 ready | ✅ |
containerStatuses[].ready 파싱 |
컨테이너 ready | ✅ |
지금은 이렇게 셉니다.
kubectl get pods -A -o json | python3 -c '
import sys, json
for p in json.load(sys.stdin)["items"]:
if p["status"].get("phase") in ("Succeeded", "Failed"): continue
cs = p["status"].get("containerStatuses") or []
if cs and not all(c.get("ready") for c in cs):
print(p["metadata"]["namespace"] + "/" + p["metadata"]["name"])'
덤 — 조치 직후 스냅샷도 믿으면 안 됩니다
나중에 실제 장애를 고칠 때 또 한 번 걸렸습니다. 조치를 넣고 바로 확인했더니 여전히 NotReady라서 "처방이 틀렸다"고 결론 낼 뻔했습니다.
readiness probe는 주기(15초) × 실패 임계(3회)만큼 지나야 상태가 뒤집힙니다. 최대 45초. 처방은 맞았는데 확인이 성급했던 것입니다.
함정 ④ ArgoCD가 영원히 OutOfSync였습니다
StatefulSet으로 바꾼 뒤, sync는 Succeeded인데 상태는 OutOfSync에서 안 풀렸습니다. 몇 번을 다시 sync해도 같았습니다.
원인은 API 서버가 volumeClaimTemplates에 기본값을 채워 넣기 때문입니다.
volumeClaimTemplates:
- apiVersion: v1 # ← 서버가 추가
kind: PersistentVolumeClaim # ← 서버가 추가
spec:
volumeMode: Filesystem # ← 서버가 추가
status: # ← 서버가 추가. Git 으로는 표현 불가
phase: Pending
Git에 없는 필드가 라이브에 생기니 영원히 다르다고 판단합니다. 조치는 두 갈래입니다 — 표현 가능한 것은 Git에 미리 적고, 표현 불가능한 status는 제외합니다.
ignoreDifferences:
- group: apps
kind: StatefulSet
jqPathExpressions: [".spec.volumeClaimTemplates[].status"]
여기에 딸린 함정이 하나 더 있었습니다. 이 ignoreDifferences를 클러스터에 직접 patch만 하고 Git에 안 넣어두면, 클러스터를 재구축하는 순간 조용히 사라집니다. GitOps를 쓰면서 GitOps 밖에 사는 설정을 만든 셈입니다. Application 매니페스트 자체를 레포에 넣어 해소했습니다.
이때 Application 파일은 앱이 동기화하는 경로 바깥에 둡니다. 자기 자신의
source.path안에 넣으면 앱이 스스로를 관리하게 되고, 잘못된 sync 한 번이 앱을 지울 수 있습니다.
정리 — "정상으로 보인다"와 "정상이다"는 다른 문장입니다
네 가지 함정은 형태가 다르지만 뿌리는 같습니다. 신호가 대리 지표(proxy)일 뿐인데 결과로 읽은 것입니다.
| 본 것 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---|---|
| DB 크기가 줄었다 | 파일 구성이 달라졌다 | 데이터가 손실됐다 |
READY 1/1 |
kubelet 검사 통과 | 앱이 DB에 닿는다 |
grep -v Running이 비었다 |
phase가 전부 Running | 파드가 전부 준비됐다 |
sync Succeeded |
적용이 끝났다 | 선언과 실제가 같다 |
그래서 검증을 이렇게 바꿨습니다 — 크기 대신 파일 구성, 파드 상태 대신 pg_stat_activity와 실제 기능, phase 대신 readiness, sync 결과 대신 드리프트 목록.
다음 편은 이 시리즈에서 유일하게 못 고친 이야기입니다. http probe를 쓰는 파드 9개만 동시에 죽었는데 exec·tcp probe 파드는 멀쩡했습니다. TCP는 붙는데 HTTP만 응답이 없는 상태였고, 원인은 CNI의 L7 프록시 경로에 있었습니다. 계층별로 배제해 나간 기록을 남깁니다.
참고
- PostgreSQL —
pg_internal.init(Relation Cache Initialization File) — 데이터 디렉터리 파일 레이아웃 - PostgreSQL —
pg_stat_activity— 현재 연결 조회 - Kubernetes — Pod Lifecycle: Pod phase vs Container states — phase와 readiness는 다른 축
- Kubernetes — Configure Liveness, Readiness and Startup Probes —
periodSeconds·failureThreshold - Argo CD — Diffing Customization —
ignoreDifferences사용법 - Agroal — connection pool — Quarkus/Keycloak이 쓰는 커넥션 풀
답글 남기기