지난주 새벽에 우리 팀 메인 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하는 절차에 추가 검증이 들어왔다.
우리 절차는 이랬다.
- publisher 업그레이드 시작 전에 모든 subscription을
DISABLE - publisher 업그레이드
- publisher 켜고 헬스체크
- 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까지 같이 보존하는 부분에서 다른 방법 쓰는 분이 있다면 진짜 궁금하다.