EKS에서 TimescaleDB PVC 연결 오류로 인한 데이터 소실 원인 분석

배경

  • 워크로드: tsdb-weather 네임스페이스의 timescaledb-weather (TimescaleDB/PostgreSQL)
  • 배포 방식: Kustomize + Argo CD, StatefulSet + PersistentVolumeClaim(gp3, 100Gi)
  • 외부 접근: 내부 NLB (timescaledb-weather Service, type LoadBalancer)

증상은 크게 두 가지였다.

  1. Pod OOM 및 재시작 이후 데이터가 사라진 것처럼 보임
  2. Argo CD에서 StatefulSet 삭제 → 재생성했을 때, 새로 만든 테이블이 사라지는 현상

PVC(timescaledb-weather-data)는 삭제되지 않았고 계속 Bound 상태였기 때문에, 처음에는 "DB가 정말 PVC 위에 데이터를 쓰고 있는가?"가 핵심 의심 포인트였다.

1차 분석 – 매니페스트와 DB 설정

관련 매니페스트:

  • app-manifest/timescaledb-weather/base/statefulset.yaml
  • app-manifest/timescaledb-weather/base/pvc.yaml
  • app-manifest/timescaledb-weather/base/configmap.yaml (postgresql.conf)
  • app-manifest/timescaledb-weather/base/init-script-configmap.yaml

1.1 StatefulSet의 볼륨 마운트

초기 설정:

volumeMounts:
  # PVC는 /var/lib/postgresql 에 마운트되고,
  # PostgreSQL 기본 PGDATA(/var/lib/postgresql/data)는 이 안의 서브디렉토리를 사용
  - name: timescaledb-weather-data
    mountPath: /var/lib/postgresql
  • PVC는 /var/lib/postgresql 에 마운트.
  • 공식 Timescale/Postgres 이미지의 기본 PGDATA/var/lib/postgresql/data.
  • 컨테이너는 공식 entrypoint (docker-entrypoint.sh postgres)를 그대로 사용.

이론상으로는 /var/lib/postgresql(PVC) 아래의 data 디렉토리를 PGDATA로 쓰게 되므로 문제가 없어 보였다.

1.2 ConfigMap / Init 스크립트

postgresql.conf:

  • 성능/복제 파라미터만 설정 (shared_buffers, wal_level=replica, max_wal_senders, …).
  • 데이터 삭제/초기화 관련 설정 없음.

init-extensions.sql:

  • CREATE EXTENSION timescaledb, CREATE EXTENSION pg_cron, CREATE EXTENSION postgres_fdw.
  • replicator 유저 생성.
  • DROPTRUNCATE는 전혀 없음.

즉, 매니페스트 상으로는 커밋된 데이터/테이블을 지울 만한 동작은 보이지 않았다.

2차 분석 – 실제 런타임 파일시스템 상태 확인

Pod 안에서 df -hSHOW data_directory; 를 확인했다.

kubectl exec -it timescaledb-weather-0 -n tsdb-weather -- df -h

요약된 결과:

/dev/nvme3n1   97.9G  ...  /var/lib/postgresql        # → PVC(EBS)
/dev/nvme0n1p1 99.9G  ...  /var/lib/postgresql/data   # → 노드 로컬 디스크
SHOW data_directory;
-- 결과: /var/lib/postgresql/data

정리하면:

  • PVC(EBS)는 /var/lib/postgresql 에 마운트되어 있었고,
  • /var/lib/postgresql/data는 다시 노드 루트 디스크에 마운트되어 있었다.
  • PostgreSQL의 data_directory/var/lib/postgresql/data를 가리키고 있었기 때문에, 실제 DB 파일은 PVC가 아니라 노드 로컬 디스크 위에 저장되고 있었다.

이 패턴은, 공식 이미지에서 VOLUME /var/lib/postgresql/data 가 선언되어 있고, Kubernetes가 이 VOLUME을 위해 익명 볼륨을 생성해 /var/lib/postgresql/data에 한 번 더 마운트하면서 발생하는 전형적인 문제다.

결과적으로 일어난 일

  • Pod가 특정 노드에서 동작할 때는, 그 노드의 로컬 디스크 위에 데이터가 쌓인다.
  • StatefulSet 삭제 후 다른 노드에 새 Pod가 잡히면,
    • PVC는 여전히 유지되지만,
    • 새 노드에는 /var/lib/postgresql/data에 해당하는 로컬 데이터가 없으므로 빈 클러스터처럼 보이고, 기존 테이블이 모두 사라진 것처럼 보이는 현상이 발생한다.
  • PVC가 살아 있어서 더더욱 디버깅이 혼란스러웠다.

3차 수정 – PGDATA를 PVC에 직접 연결

3.1 첫 번째 시도 – 마운트를 /var/lib/postgresql/data로 변경

처음에는 PVC를 PGDATA 경로에 직접 마운트했다.

volumeMounts:
  - name: timescaledb-weather-data
    mountPath: /var/lib/postgresql/data

그 결과, ArgoCD/Pod 로그에서 다음 에러가 발생했다.

The files belonging to this database system will be owned by user "postgres".
...
initdb: error: directory "/var/lib/postgresql/data" exists but is not empty
initdb: hint: If you want to create a new database system, either remove or empty the directory "/var/lib/postgresql/data" or run initdb with an argument other than "/var/lib/postgresql/data".

원인:

  • PVC에 마운트된 ext4 루트에는 기본적으로 lost+found 디렉터리가 존재한다.
  • Postgres initdbPGDATA 디렉터리가 완전히 비어 있지 않으면 실패한다.

즉, 경로는 맞췄지만, ext4 기본 디렉터리 때문에 initdb가 실패한 것이다.

3.2 최종 해결 – PGDATA를 하위 디렉터리로 이동

해결책은 **“PVC는 /var/lib/postgresql/data에 마운트하고, 실제 PGDATA는 그 안의 하위 디렉터리를 쓰도록 바꾸는 것”**이다.

statefulset.yaml 수정:

volumeMounts:
  # PVC는 PostgreSQL 기본 PGDATA 경로에 직접 마운트하여
  # 데이터가 항상 PVC(EBS)에 기록되도록 한다.
  - name: timescaledb-weather-data
    mountPath: /var/lib/postgresql/data

env:
  - name: POSTGRES_USER
    valueFrom:
      secretKeyRef:
        name: timescaledb-weather-credentials
        key: postgres-user
  - name: POSTGRES_PASSWORD
    valueFrom:
      secretKeyRef:
        name: timescaledb-weather-credentials
        key: postgres-password
  - name: POSTGRES_DB
    valueFrom:
      secretKeyRef:
        name: timescaledb-weather-credentials
        key: postgres-db
  # PGDATA를 PVC 내부의 하위 디렉터리로 지정하여
  # 상위 디렉터리에 존재하는 lost+found 등으로 인한 initdb 오류를 방지한다.
  - name: PGDATA
    value: /var/lib/postgresql/data/pgdata
  # pg_cron을 사용하기 위해 shared_preload_libraries 설정
  - name: POSTGRES_SHARED_PRELOAD_LIBRARIES
    value: "timescaledb,pg_cron"

이제 동작 방식은 다음과 같다.

  • PVC(EBS)는 /var/lib/postgresql/data 에 마운트된다.
  • Postgres는 PGDATA=/var/lib/postgresql/data/pgdata 를 사용한다.
    • pgdata 디렉터리는 PVC 내부의 하위 디렉터리이므로,
    • 상위 디렉터리의 lost+found와 무관하게 initdb가 정상 수행된다.
  • SHOW data_directory; 결과는 /var/lib/postgresql/data/pgdata 가 된다.
  • df -h /var/lib/postgresql/data /var/lib/postgresql/data/pgdata 를 확인해 보면 두 경로 모두 PVC(EBS 디바이스)를 가리킨다.

4. 최종 상태 및 검증

수정 후 검증한 내용:

  1. 파일시스템

    kubectl exec -it timescaledb-weather-0 -n tsdb-weather -- \
      df -h /var/lib/postgresql/data /var/lib/postgresql/data/pgdata
    
    • 두 경로 모두 /dev/nvme… (EBS gp3) 디바이스로 표시됨.
  2. PostgreSQL 설정

    SHOW data_directory;
    -- /var/lib/postgresql/data/pgdata
    
  3. StatefulSet 삭제 / Pod 재스케줄 후에도 데이터 유지

    • CREATE TABLE 및 간단한 테스트 데이터를 넣은 뒤,
    • kubectl delete pod 또는 Argo CD로 StatefulSet 롤링 재시작을 수행.
    • 재기동 후에도 해당 테이블과 데이터가 그대로 존재함을 확인.

5. 교훈 / 베스트 프랙티스

  1. 이미지의 VOLUME 선언을 항상 확인하기

    • Postgres/Timescale 공식 이미지는 VOLUME /var/lib/postgresql/data 를 선언하고 있다.
    • Kubernetes에서 PVC를 마운트할 때, 이 VOLUME이 가리키는 경로와 겹치지 않게 설계해야 한다.
  2. PVC는 “PGDATA의 부모”가 아니라, “PGDATA 혹은 그 상위에 딱 한 번만” 마운트하기

    • 부모에 PVC, 자식에 또 다른 볼륨(이미지 VOLUME)이 올라가면, PVC가 가려져 버린다.
  3. PGDATA를 하위 디렉터리로 두는 패턴을 활용하기

    • mountPath: /var/lib/postgresql/data
    • PGDATA=/var/lib/postgresql/data/pgdata
    • ext4의 lost+found 문제를 피하면서도 PVC 위에 안전하게 데이터를 저장할 수 있다.
  4. 의심스러울 때는 항상 런타임에서 확인

    # DB가 실제로 어디에 쓰고 있는지 확인
    kubectl exec -it <pod> -- df -h /var/lib/postgresql /var/lib/postgresql/data
    kubectl exec -it <pod> -- psql -U postgres -c "SHOW data_directory;"
    
    • data_directorydf 출력이 일치하는지 보는 것이 문제를 추적하는 핵심이었다.

이번 이슈의 핵심 원인은 **“PVC는 제대로 붙어 있었지만, Docker 이미지의 VOLUME 마운트가 그 위를 덮어쓰고 있었다”**는 점이었다.
최종적으로는 PGDATA를 PVC 내부의 하위 디렉터리로 옮기는 것으로 문제를 해결했다.