secondary 하나가 복구를 못 끝내고 같은 로그만 뱉고 있었어요. WAL을 archive에서 한 번 당겨오고, 곧바로 record 하나를 읽다 실패하고, 다시 같은 파일을 당겨오는 흐름이 2초 간격으로 무한히 돌았어요.
2026-07-24 13:41:10 KST LOG: record with incorrect prev-link 67F/7BAB8 at 67F/40000028
2026-07-24 13:41:12 KST LOG: restored log file "00000068...0067F..." from archive
2026-07-24 13:41:12 KST LOG: record with incorrect prev-link 67F/7BAB8 at 67F/40000028
2026-07-24 13:41:15 KST LOG: restored log file "00000068...0067F..." from archive
2026-07-24 13:41:15 KST LOG: record with incorrect prev-link 67F/7BAB8 at 67F/40000028
디스크가 깨진 것처럼 보이지만 그건 아니었습니다. 이 메시지는 대부분 “여기가 이 timeline에서 유효한 WAL의 끝"이라는 신호입니다. 문제는 secondary가 그 끝에서 다음 timeline으로 못 건너가고 제자리를 맴돌았다는 점입니다.
이 로그가 정확히 어디서 나오나
두 줄은 서로 다른 주체가 찍습니다.
restored log file ... from archive는 recovery 중인 서버가 restore_command로 archive에서 WAL 세그먼트 하나를 가져왔다는 뜻입니다. 우리 환경은 pgBackRest가 archive 역할이라, 이 줄은 pgBackRest에서 세그먼트를 복원해 왔다는 기록입니다.
record with incorrect prev-link A at B는 WAL을 읽는 xlogreader가 찍습니다. PostgreSQL의 WAL record는 헤더에 xl_prev라는 필드를 들고 있는데, 바로 앞 record가 어디서 끝났는지를 가리키는 back-link입니다. reader는 record를 하나 읽을 때마다 “이 record의 xl_prev가 방금 내가 읽은 record의 끝과 같은가"를 확인합니다. 어긋나면 이 메시지를 남기고 그 자리를 유효한 WAL의 끝으로 간주합니다.
메시지의 두 LSN은 순서대로 이렇게 읽습니다. at 뒤(67F/40000028)가 문제의 record가 놓인 위치이고, 앞(67F/7BAB8)이 그 record에 적혀 있던 xl_prev 값입니다. 즉 67F/40000028에 있는 record는 자기 앞 record가 67F/7BAB8에서 끝났다고 주장하는데, reader가 실제로 그 지점까지 읽어온 맥락과 맞지 않는 것입니다.
여기서 위치 67F/40000028이 힌트를 줍니다. WAL 세그먼트는 기본 16MB이고, 오프셋 0x40000028은 세그먼트 경계에서 딱 0x28(40바이트) 들어간 자리입니다. 40바이트는 세그먼트 첫 페이지의 long page header 크기와 같습니다. 다시 말해 이 record는 어느 세그먼트의 맨 첫 record 자리에 있습니다. 그 자리의 xl_prev가 한참 앞인 67F/7BAB8(같은 논리 파일에서 500KB쯤 되는 지점)를 가리킨다는 건, 그 세그먼트 앞부분이 과거에 쓰이고 아직 새 내용으로 덮이지 않은 recycled 세그먼트의 잔재라는 신호에 가깝습니다. PostgreSQL은 성능을 위해 WAL 파일을 지우지 않고 이름만 바꿔 재사용하는데, reader가 그 옛 바이트열을 그럴듯한 record로 오독하면 이런 back-link 불일치가 나옵니다. xlogreader 소스와 메일링 리스트 논의에서도 같은 진단이 나옵니다.
어쩌다 이 상태가 됐나
먼저 분명히 해두면, 아래 순서는 사후에 되짚어 본 유추입니다. 운영 중 이것저것 만지다 이 상태에 도달했고, 어느 한 단계가 범인이라고 특정하지는 못했습니다. 다만 secondary가 이 지경이 되기까지 밟은 대략의 경로는 남겨 둡니다. HA는 pg_auto_failover로 묶여 있고 백업은 pgBackRest로 받는 환경이었습니다.
| 순서 | 한 일 | 부수 효과 |
|---|---|---|
| 1 | 최초 primary/secondary 구성 | timeline 시작 |
| 2 | failover 발생 | 옛 secondary 승격, timeline 증가 |
| 3 | 옛 secondary 노드 drop | 구 노드 제거 |
| 4 | 새 secondary add (pgBackRest 백업본으로 seed) | 과거 시점 데이터로 출발 |
| 5 | 다시 failover | timeline 한 번 더 증가 |
failover는 standby를 primary로 승격시키면서 새 timeline을 엽니다. 이때 .history 파일이 만들어져 “몇 번 timeline은 어느 LSN에서 갈라져 나왔다"를 기록하고, 뒤따르는 standby는 이 history를 보고 분기 지점을 넘어 새 timeline의 WAL로 갈아탑니다. 갈아타려면 recovery_target_timeline이 최신을 따라가도록 서 있어야 하고, 분기 지점 이후의 WAL과 history 파일이 archive에 제대로 올라와 있어야 합니다.
이 증상 자체는 문서화가 잘 되어 있습니다. PostgreSQL 코어 개발자 Michael Paquier(미하엘 파키에)는 같은 로그를 두고 “현재 timeline에서 유효한 WAL의 끝을 가리키는 것이며, 과거에 recycled 세그먼트로 쓰였던 영역을 읽을 때 마주칠 수 있다"고 설명합니다. 같은 스레드에는 두 가지가 더 나옵니다. 하나는 이 오류가 archive_mode가 켜져 있을 때 주로 나타난다는 관찰이고, 다른 하나는 여러 번 승격을 거친 노드를 다시 붙일 때 반복해서 겪었다는 보고입니다. archive에서 WAL을 당겨오고, failover를 여러 번 돌린 우리 상황과 겹치는 대목입니다.
그 틀에 우리 경로를 얹으면 이렇게 읽힙니다. 과거 백업본에서 출발한 secondary가 두 번째 failover로 timeline이 또 올라간 뒤, 자기가 따라가던 timeline의 끝에 도달하고도 다음 timeline으로 건너갈 연결을 archive에서 매끄럽게 잇지 못한 것으로 보입니다. 분기 지점 근처 세그먼트에 recycled 잔재가 남아 있었고, reader가 그 자리를 67F/40000028의 깨진 record로 읽었다는 그림입니다. 단정이 아니라 관측된 증상과 문서화된 메커니즘을 맞춰 본 해석입니다.
왜 하필 무한 루프였나
멈추지 않고 도는 게 이 현상의 성격을 잘 보여줍니다.
flowchart TD
A[restore_command 로
세그먼트 복원] --> B[xlogreader 가
record 읽기]
B --> C{prev-link 일치?}
C -->|불일치| D[유효 WAL 끝으로 판단
incorrect prev-link 로그]
D --> E[다음 WAL 대기]
E --> A
recovery는 다음 WAL을 기다리다 restore_command를 다시 부르고, archive는 같은 세그먼트를 또 건네주고, reader는 같은 자리에서 같은 back-link 불일치를 만납니다. 넘어가야 할 새 timeline의 이력을 집어오지 못하는 한, 이 고리는 스스로 풀리지 않습니다. 로그의 LSN이 67F/40000028로 매번 똑같이 고정돼 있는 게 그 증거였습니다. 앞으로 나아가는 복구라면 LSN이 조금씩이라도 커집니다.
이 대목에서 disk corruption과 헷갈리기 쉬운데, 구분 기준은 단순합니다. 같은 세그먼트를 반복 restore하면서 같은 LSN에 멈춰 있으면 timeline 경계 문제이고, 서로 다른 위치에서 읽기 자체가 깨지면 그때 물리 손상을 의심합니다.
어떻게 걷어냈나
pg_rewind나 recovery_target_timeline=latest 조정으로 붙여보는 길이 먼저 떠오릅니다. 다만 메일링 리스트에도 나오듯 pg_rewind가 timeline 충돌은 정리해도 이 back-link 오류 자체를 없애주지 못하는 경우가 있습니다. 게다가 이 노드는 4번에서 이미 과거 백업본으로 새로 붙인 상태였습니다. 어중간하게 되살리기보다 현재 timeline 기준으로 깨끗이 다시 seed하는 편이 빨랐습니다.
그래서 pg_auto_failover의 노드 재구축으로 정리했습니다.
# 꼬인 secondary 노드를 데이터까지 완전히 제거
pg_autoctl drop node --destroy
# 현재 primary 기준으로 secondary 새로 구축
pg_autoctl create postgres
--destroy는 monitor에서 노드를 지우는 데 그치지 않고 데이터 디렉토리까지 함께 정리합니다. 이어서 pg_autoctl create postgres가 현재 primary의 최신 timeline을 기준으로 base backup을 다시 받아 복제를 새로 세웁니다. recycled 잔재를 담고 있던 옛 데이터가 통째로 사라지니 back-link 불일치도 같이 사라졌고, 복제는 정상으로 따라붙었습니다.
남는 메모
재구축이 정공법은 아닐 수 있습니다. 원인을 LSN 단위로 끝까지 파고들면 어느 failover에서 archive에 어떤 공백이 생겼는지 특정할 수도 있었을 것입니다. 다만 pg_auto_failover처럼 오케스트레이션이 노드 수명주기를 쥐고 있는 환경에서는 백업본에서 노드를 다시 붙이는 비용이 낮은 만큼, 포렌식보다 재구축이 대체로 실용적이었습니다.
정리하자면 이래요. standby가 archive만 반복해서 restore하면서 고정된 LSN에 record with incorrect prev-link로 멈춰 있으면, 물리 손상보다 timeline 경계를 못 넘은 상황을 먼저 의심하는 게 맞아요. 그리고 failover를 여러 번 돌리는 사이에 과거 백업본으로 노드를 되붙이는 조합은 timeline 이력이 엉키기 딱 좋은 지점이라, 그 앞뒤로는 archive에 history 파일과 분기 이후 WAL이 온전히 올라와 있는지 한 번 더 확인해 둘 만해요.
참고
- PostgreSQL xlogreader.c 소스 (prev-link 검증 로직)
- pgsql 메일링 리스트: record with incorrect prev-link (recycled 세그먼트와 유효 WAL 끝 판정)
- pgsql 메일링 리스트: pg_rewind와 prev-link 오류
- pg_auto_failover 문서 (
pg_autoctl drop node/pg_autoctl create postgres) - pgBackRest 문서 (archive_command / restore_command 연동)
- PostgreSQL WAL 내부 구조 (timeline과
.history)