DB 운영

PostgreSQL 16 → 17 메이저 업그레이드, replication slot 살리려다 새벽을 태운 이야기

gfrog 2026. 4. 26. 05:13
반응형

지난주 새벽에 우리 팀 메인 OLTP 클러스터를 PG 16.4에서 17.4로 올렸다. 사실 이 업그레이드는 한 달 전부터 일정에 잡혀 있었고, 나는 자신이 있었다. 17부터는 pg_upgrade가 logical replication slot을 살려준다는 그 기능 때문이었다. 16까지는 메이저 올릴 때마다 슬롯을 다 날리고, subscriber 쪽에서 다시 풀 싱크를 떠야 했는데 그게 진짜 끔찍했었다. 우리는 분석계로 빠지는 publication이 4개 있었고, 가장 큰 테이블이 압축 후 1.2TB짜리라 풀 싱크 한 번 뜨면 6시간이 그냥 사라졌다.

근데 그 자신감이 새벽 3시 17분에 박살 났다. 정확히 어디서 박살났는지, 그리고 다음에 같은 작업을 하는 분들이 같은 데서 안 깨지길 바라는 마음으로 적어둔다.

슬롯이 살아남는 조건이 생각보다 빡빡하다

pg_upgrade로 슬롯을 보존하려면 publisher가 17 이상이어야 한다. 그래서 우리는 메인부터 올리고 분석계 subscriber는 다음 주에 따로 올리기로 계획했다. 여기까진 문서대로다. 그런데 실제로 슬롯이 보존되려면 추가 조건이 더 있다는 걸 dry run 직전에야 알았다.

SELECT slot_name, plugin, slot_type, two_phase, confirmed_flush_lsn,
       active, invalidation_reason
FROM pg_replication_slots
WHERE slot_type = 'logical';

이 쿼리를 돌렸을 때 confirmed_flush_lsn이 현재 WAL 위치보다 의미 있게 뒤처져 있으면 안 된다. 우리 슬롯 중 하나가 평소에 70~80MB쯤 lag가 있었는데, dry run 직전 트래픽 피크 때 마침 1.2GB까지 벌어져 있었다. 그 상태로 pg_upgrade --check 돌리니까 친절하게 막아줬다. 막아준 게 다행이었지 안 막아줬으면 그대로 진행하고 슬롯이 invalidated 상태로 넘어갔을 거다.

해결은 단순했다. subscriber가 따라잡을 때까지 5분 정도 publisher 쓰기를 멈추고 기다린 뒤 다시 체크. 근데 이 "쓰기 멈춤"이 운영 클러스터에서는 가볍게 할 수 있는 결정이 아니다. 우리는 maintenance window를 30분 잡아뒀는데, 슬롯 따라잡기 + 실제 업그레이드 + 검증을 그 안에 다 넣으려면 슬롯 lag는 미리 거의 0에 가깝게 만들어 두는 게 안전하다.

교훈: 업그레이드 전날에 한 번, 시작 직전에 또 한 번 슬롯 lag를 체크해라. 평소 모니터링 그래프만 믿지 말고 그 순간의 숫자를 봐라.

--link 모드와 standby 동시 적용의 함정

우리 클러스터는 primary 1대 + sync standby 2대 + async standby 1대 구성이다. pg_upgrade는 primary에서 한 번만 돌리고 standby는 rsync --hard-links로 따라가게 하는 게 PG 공식 가이드인데, 17에서 이 절차 자체는 그대로다. 하지만 한 가지 우리만의 지뢰가 있었다.

우리는 데이터 디렉토리를 ZFS 위에 올려두고 매일 스냅샷을 뜨고 있다. pg_upgrade --link를 쓰면 이전 버전 디렉토리와 새 버전 디렉토리가 같은 inode를 공유한다. 이 상태에서 ZFS 스냅샷이 도는 시점이 겹치면 어떻게 되는지 사실 나도 정확히 몰랐다. 그래서 우리는 그냥 스냅샷 cron을 그날 새벽 2시간 동안 끄기로 했다. 변태같은 절차지만 안전한 게 우선이다.

# 업그레이드 전 (primary)
sudo systemctl stop postgresql-16
/usr/pgsql-17/bin/pg_upgrade \
  --old-datadir=/data/pg16 \
  --new-datadir=/data/pg17 \
  --old-bindir=/usr/pgsql-16/bin \
  --new-bindir=/usr/pgsql-17/bin \
  --link \
  --jobs=8 \
  --check  # 먼저 체크만

# 통과되면 --check 빼고 다시

--jobs=8은 우리 인스턴스 vCPU가 32개라 그렇게 줬다. 너무 크게 주면 디스크 I/O가 포화돼서 오히려 느려진다. 24코어 머신에서 --jobs=24 줬다가 더 느려진 적이 있어서 그 이후로는 vCPU의 1/4 정도로 잡는다.

진짜로 깨졌던 부분: two_phase subscription

새벽 3시 17분에 깨진 건 슬롯 보존이 아니라 subscriber 쪽이었다. 우리 publication 중 하나는 분산 트랜잭션을 위해 two_phase = true로 만들어진 subscription이 붙어 있었는데, 이게 17에서는 약간의 동작 변화가 있었다. 정확히는 prepared transaction이 남은 상태에서 subscription을 disable했다가 enable하는 절차에 추가 검증이 들어왔다.

우리 절차는 이랬다.

  1. publisher 업그레이드 시작 전에 모든 subscription을 DISABLE
  2. publisher 업그레이드
  3. publisher 켜고 헬스체크
  4. subscription을 다시 ENABLE

여기서 4번에서 한 subscription이 안 켜졌다. 로그에는 subscription has prepared transactions, cannot enable until cleared 비슷한 메시지. 16에서는 이게 경고로 끝났는데 17에서는 아예 막힌다. 그래서 subscriber 쪽에서 pg_prepared_xacts를 뒤져서 prepared 상태로 남은 xact들을 일일이 commit/rollback 처리해야 했다.

-- subscriber에서
SELECT * FROM pg_prepared_xacts;
-- gid 확인 후
COMMIT PREPARED 'pgsql_subscription_xxx_yyy';
-- 또는
ROLLBACK PREPARED 'pgsql_subscription_xxx_yyy';

이걸 새벽에 처음 마주치면 멘탈이 나간다. prepared xact를 어느 쪽으로 처리해야 하는지는 결국 publisher 쪽 WAL 상태와 비교해야 알 수 있다. 우리는 결국 그 4개를 다 rollback하고, 그 부분 데이터는 다음 사이클에 다시 들어오게 두는 방향으로 결정했다. 데이터 정합성 검증은 다음날 따로 잡고.

그래도 17은 좋다

여기까지 읽으면 17 업그레이드가 지뢰밭처럼 보일 수 있다. 사실 그렇긴 한데, 한번 넘어오고 나면 진짜 좋다. 우리가 체감한 가장 큰 차이는 VACUUM의 메모리 효율이다. 17부터는 dead tuple 추적 자료구조가 새 TID store로 바뀌면서 같은 워크로드에서 maintenance_work_mem 사용량이 한참 줄었다. 우리는 1GB로 잡고 있던 걸 256MB로 내렸는데도 VACUUM이 한 사이클에 더 많은 페이지를 처리한다. 큰 테이블 vacuum 시간이 우리 환경에서는 35% 정도 빨라졌다.

또 하나, EXPLAIN에 메모리/디스크 정보가 더 자세히 찍힌다. 옛날에는 hash join 디버깅할 때 pg_stat_statements랑 EXPLAIN을 왔다 갔다 해야 했는데, 이제는 EXPLAIN 한 방으로 거의 다 보인다. 슬로우 쿼리 분석할 때 매번 느낀다.

logical replication 쪽에서는 처음에 말한 슬롯 보존 외에도 failover slot이 정식 기능이 됐다. standby로 failover했을 때 logical slot이 따라가게 만들 수 있다. 우리는 아직 안 켰는데, 다음 분기에 멀티 리전 셋업하면서 같이 도입하려고 한다.

다시 한다면 바꿀 것들

  • dry run을 staging이 아니라 prod 데이터의 ZFS 스냅샷 클론에서 한다. staging은 데이터 분포가 prod랑 다르고, prepared xact 같은 운영 잔여물이 없어서 같은 함정에 못 걸린다.
  • maintenance window를 30분이 아니라 60분 잡는다. 30분은 슬롯이 깔끔할 때 얘기지, 한 군데라도 꼬이면 절대 못 끝낸다.
  • subscription disable 직후에 pg_prepared_xacts를 publisher와 subscriber 양쪽에서 비교하는 단계를 절차에 명시한다. 우리는 이 단계가 absent였다.
  • 알람을 늘린다. logical slot이 invalidated로 떨어지는 순간 PagerDuty 호출하게 했어야 하는데, 우리는 lag만 보고 있었다.

PG 18은 이미 GA됐고, 18부터는 pg_createsubscriber --all 같은 운영 친화적 옵션이 들어왔다. 18로 바로 점프하려다가 우리는 17에서 한 분기는 묵힌 다음에 가기로 했다. 한 번에 두 단계 메이저를 점프하면 같은 시간에 두 배의 함정이 기다리고 있을 거 같아서. 다음 글에서는 18로 갈 때 뭐가 바뀌는지 따라가보려고 한다.

혹시 비슷한 환경에서 슬롯 보존 업그레이드 해보신 분 계시면 어떻게 푸셨는지 댓글 부탁드립니다. 특히 standby까지 같이 보존하는 부분에서 다른 방법 쓰는 분이 있다면 진짜 궁금하다.

반응형