5.1 ZFS Snapshot과 Clone
Neon 없이 branching 기능의 절반을 구현하는 가장 오래된 방법은 copy-on-write를 파일시스템에 맡기는 것입니다. PGDATA를 ZFS dataset에 두면 zfs snapshot은 수 밀리초 안에 끝납니다. zfs clone도 데이터 크기와 무관하게 끝납니다. clone 위에서 두 번째 PostgreSQL을 다른 port로 기동하면 원본과 블록을 공유하는 독립 인스턴스가 생깁니다.
이 장은 Linux 서버를 기준으로 절차를 설명합니다. 이 노트를 작성한 macOS 환경에는 ZFS 커널 모듈이 없어 명령을 직접 측정하지 않았습니다. 명령과 옵션은 OpenZFS 문서와 PostgreSQL 문서를 기준으로 정리했습니다. 실제 서버에 적용하기 전에 테스트 dataset에서 한 번 반복하기를 권합니다.
요약
- snapshot은 읽기 전용 고정점입니다. clone은 snapshot에서 파생된 쓰기 가능 dataset입니다.
- clone을 만들어도 디스크 사용량은 늘지 않습니다. 원본이나 clone에서 블록을 덮어쓸 때만 새 블록이 할당됩니다.
- PostgreSQL 관점에서 clone은 "crash 직후의 데이터 디렉터리"입니다. 기동 시 crash recovery가 정합성을 맞춥니다.
- clone이 하나라도 남아 있으면 해당 snapshot을 삭제할 수 없습니다. 삭제 순서를 지켜야 합니다.
- clone에는 원본의 replication slot과 archive 설정이 그대로 들어 있습니다. 기동 전에 정리하지 않으면 원본 인프라에 영향을 줍니다.
- 외부 tablespace나 별도
pg_wal볼륨이 있으면 PGDATA snapshot 하나로는 클러스터 전체를 담지 못합니다. 시작 전에 링크를 확인합니다.
ZFS pool과 dataset 준비
테스트용이라면 파일 기반 vdev로도 충분합니다. 운영에서는 별도 디스크를 사용합니다.
# 테스트용 4 GB 파일 vdev (운영에서는 /dev/nvme1n1 같은 실제 장치)
truncate -s 4G /var/tmp/zfs-lab.img
sudo zpool create labpool /var/tmp/zfs-lab.img
# PostgreSQL 데이터용 dataset. 옵션은 PostgreSQL 8 KB 페이지에 맞춘 값입니다
sudo zfs create -o recordsize=16k -o compression=lz4 -o atime=off \
-o logbias=throughput -o mountpoint=/var/lib/postgresql/17/main labpool/pgdata
sudo chown postgres:postgres /var/lib/postgresql/17/main
sudo chmod 700 /var/lib/postgresql/17/main
recordsize는 8k와 16k 사이에서 선택합니다. 8k는 PostgreSQL 페이지와 1:1이지만 압축 효율이 낮습니다. 16k는 순차 읽기와 압축에 유리합니다. atime=off는 읽을 때마다 메타데이터를 갱신하는 비용을 없앱니다. 이 dataset에 initdb를 실행하거나 기존 데이터를 옮긴 뒤 PostgreSQL을 평소처럼 기동합니다.
PGDATA 밖에 있는 항목 확인
물리 백업은 클러스터 전체를 담아야 합니다. pg_wal이 다른 볼륨에 있거나 외부 tablespace가 있으면 PGDATA dataset 하나의 snapshot으로는 클러스터 전체를 담지 못합니다. clone을 기동해도 pg_tblspc 아래 심볼릭 링크는 원본 경로를 그대로 가리킵니다. 따라서 clone이 원본 tablespace의 데이터를 직접 수정하는 사고로 이어집니다.
sudo -u postgres readlink -f /var/lib/postgresql/17/main/pg_wal
sudo -u postgres ls -l /var/lib/postgresql/17/main/pg_tblspc/
pg_wal이 dataset 안에 있고 pg_tblspc가 비어 있으면 이 장의 절차를 그대로 적용합니다. 그렇지 않으면 두 가지 중 하나를 선택합니다. 실습 클러스터라면 외부 tablespace와 별도 WAL 볼륨을 사용하지 않는 편이 가장 단순합니다. 이미 사용하고 있다면 관련 경로를 모두 같은 pool의 하위 dataset으로 옮깁니다. 그런 다음 zfs snapshot -r labpool/pgdata처럼 재귀 snapshot으로 한 번에 고정합니다. clone 쪽 pg_tblspc 링크도 clone 전용 경로로 다시 연결해야 합니다.
일관된 snapshot을 얻는 두 가지 방법
snapshot은 원자적이지만 PostgreSQL이 쓰는 중이라면 shared buffer에서 아직 저장장치로 내려오지 않은 페이지가 있습니다. 다음 두 방법 중 하나를 사용합니다.
첫 번째는 서비스를 멈추고 snapshot을 생성하는 방법입니다. 가장 단순하며 recovery가 필요 없습니다.
sudo systemctl stop postgresql@17-main
sudo zfs snapshot labpool/pgdata@clean-$(date +%Y%m%d-%H%M)
sudo systemctl start postgresql@17-main
두 번째는 서비스를 멈추지 않고 원자적 snapshot만 사용하는 방법입니다. PGDATA와 pg_wal이 한 dataset 안에 있다면 snapshot 자체가 특정 시점의 원자적 사본입니다. 하위 dataset들을 재귀 snapshot으로 묶은 경우도 같습니다. clone에서 기동한 PostgreSQL은 crash recovery로 WAL을 재생해 정합성을 맞춥니다. 직전에 CHECKPOINT를 실행하면 재생할 WAL 양이 줄어 기동이 빨라집니다.
psql -U postgres -c "CHECKPOINT;"
sudo zfs snapshot labpool/pgdata@online-$(date +%Y%m%d-%H%M)
여기서는 주의가 필요합니다. base backup 모드(pg_backup_start)를 사용하는 절차는 위 명령에 함수 호출을 추가하는 것만으로 완성되지 않습니다. PostgreSQL은 백업을 시작한 연결을 끝까지 유지해야 합니다. 또한 같은 연결에서 백업을 중지해야 합니다. psql -c로 시작하면 연결이 즉시 닫혀 백업 모드가 자동으로 중단됩니다. 뒤이은 별도 연결의 pg_backup_stop()도 실패합니다. base backup 모드를 사용하려면 한 세션 안에서 시작, snapshot, 중지를 모두 수행해야 합니다. pg_backup_stop()이 반환한 labelfile 내용은 clone의 PGDATA에 backup_label 파일로 저장합니다. 해당 구간의 WAL도 함께 보존해야 합니다.
# 한 세션 안에서 시작, snapshot, 중지를 모두 수행합니다.
# \! 로 실행되는 셸은 psql을 띄운 사용자 권한이므로 sudo가 가능한 계정에서 실행합니다.
psql -U postgres -X <<'SQL'
SELECT pg_backup_start('zfs-snapshot', fast => true);
\! sudo zfs snapshot labpool/pgdata@online-$(date +%Y%m%d-%H%M)
SELECT labelfile FROM pg_backup_stop();
SQL
함수 이름은 PostgreSQL 15 이상을 기준으로 합니다. 14 이하에서는 pg_start_backup()과 pg_stop_backup()을 사용합니다. 이 장의 나머지 절차는 위의 원자적 snapshot을 전제로 진행합니다. 원자적 snapshot과 crash recovery의 조합만으로 충분한 경우가 대부분입니다. 절차가 짧을수록 실수도 줄어듭니다.
clone 생성과 두 번째 PostgreSQL 기동
SNAP=labpool/pgdata@online-20260907-1500
sudo zfs clone -o mountpoint=/var/lib/postgresql/17/clone1 $SNAP labpool/clone1
# 원본 postmaster의 흔적 제거. 이 파일이 있으면 기동을 거부합니다
sudo rm -f /var/lib/postgresql/17/clone1/postmaster.pid
# clone 전용 설정 파일을 새로 만듭니다. port를 바꾸고 원본 인프라로 나가는 경로를 끊습니다
sudo -u postgres tee /var/lib/postgresql/17/clone1/clone.conf <<'CONF'
port = 5433
archive_mode = off
primary_conninfo = ''
cluster_name = 'clone1'
listen_addresses = '127.0.0.1'
CONF
sudo -u postgres /usr/lib/postgresql/17/bin/pg_ctl \
-D /var/lib/postgresql/17/clone1 -l /var/log/postgresql/clone1.log \
-o "-c config_file=/var/lib/postgresql/17/clone1/clone.conf" start
설정 파일 위치를 먼저 확인해야 합니다. 원본에서 SHOW config_file;과 SHOW hba_file;을 실행합니다. RHEL/Rocky 계열에서는 두 파일이 PGDATA 안에 있어 clone에 그대로 복제됩니다. Debian/Ubuntu 계열에서는 두 파일이 /etc/postgresql/17/main에 있습니다. 따라서 PGDATA clone만으로는 기동에 필요한 설정을 확보할 수 없습니다. 위 예시는 conf.d 같은 include_dir을 가정하지 않습니다. 대신 clone 전용 설정 파일을 직접 만들고 -c config_file=로 지정합니다. 이 파일에는 최소한 data_directory를 제외한 나머지 필수 항목이 있어야 합니다. Debian 계열이라면 원본의 postgresql.conf를 clone 디렉터리로 복사한 뒤 위 항목만 덮어쓰는 방식이 안전합니다. hba_file과 ident_file도 clone 안의 경로로 지정합니다.
기동 로그에는 database system was interrupted와 redo starts at이 기록됩니다. recovery가 끝나면 접속을 받습니다. 두 인스턴스는 같은 system_identifier를 가집니다. 따라서 clone을 원본의 standby로 잘못 인식하는 도구가 없는지 확인합니다.
copy-on-write 확인
기본 zfs list는 snapshot을 제외하고 출력하므로 -t all을 붙입니다.
zfs list -t all -o name,used,refer,origin -r labpool
NAME USED REFER ORIGIN
labpool/pgdata 2.1G 2.1G -
labpool/pgdata@online-20260907 8K 2.1G -
labpool/clone1 120K 2.1G labpool/pgdata@online-20260907
REFER는 dataset이 참조하는 논리 크기입니다. USED는 dataset이 단독으로 점유한 블록의 크기가 아닙니다. dataset 자신과 자식 dataset, 해당 dataset의 snapshot, refreservation 사용량을 합한 값입니다. OpenZFS 문서의 정의는 used = usedbydataset + usedbychildren + usedbysnapshots + usedbyrefreservation입니다. 따라서 어느 항목이 늘었는지 확인하려면 항목을 나눠 측정합니다.
zfs list -t all -o name,used,usedbydataset,usedbysnapshots,written -r labpool
zfs get written@online-20260907 labpool/pgdata
clone1의 used가 수백 KB인 이유는 postmaster.pid 삭제와 설정 파일 추가만 새 블록을 만들었기 때문입니다. clone에서 UPDATE를 실행하고 checkpoint가 지나면 이 값이 늘어납니다. 다만 증가량이 변경한 heap 페이지 수와 같다고 판단하면 안 됩니다. WAL, index, 가시성 맵, 메타데이터가 함께 기록됩니다. recordsize와 압축률도 실제 할당 블록 수를 바꿉니다. 원리만 보면 Neon의 physical size가 branch의 delta layer만큼 늘어나는 것과 같습니다. 자세한 대응은 2.3 Pageserver와 Layer 파일을 참고합니다.
promote, 삭제 순서, 되돌리기
clone을 새 정본으로 삼으려면 zfs promote로 부모 관계를 반전합니다. snapshot의 소유권이 clone으로 넘어가고 원래 dataset이 clone의 자식이 됩니다.
sudo zfs promote labpool/clone1
자식부터 삭제합니다. clone이 남아 있는 snapshot은 dataset is busy 오류와 함께 삭제가 거부됩니다.
sudo -u postgres pg_ctl -D /var/lib/postgresql/17/clone1 stop
sudo zfs destroy labpool/clone1
sudo zfs destroy labpool/pgdata@online-20260907-1500
원본을 snapshot 시점으로 되돌리려면 zfs rollback을 사용합니다. 해당 snapshot 이후에 만든 snapshot이 있으면 -r로 함께 지워야 합니다. PostgreSQL도 먼저 멈춰야 합니다. 이것이 Neon의 restore에 해당합니다. Neon은 branch를 하나 더 만들어 되돌리지만 ZFS rollback은 원본을 덮어씁니다.
성능과 운영에서 주의할 점
ARC는 PostgreSQL의 shared_buffers와 이중 캐시가 됩니다. 따라서 zfs_arc_max를 제한하거나 shared_buffers를 보수적으로 설정하는 편이 안전합니다. 기본 상한은 OpenZFS 버전에 따라 다릅니다. 과거에는 시스템 메모리의 절반이 널리 인용되었습니다. OpenZFS 2.3부터는 max(RAM - 1 GiB, 5/8 x RAM)입니다. 실제 값은 cat /proc/spl/kstat/zfs/arcstats | grep '^c_max'로 확인합니다.
logbias=throughput은 ZIL을 우회하는 옵션이 아닙니다. 동기 쓰기를 별도 pool log device(SLOG)에 두지 않고 처리량 위주로 처리하라는 힌트입니다. 따라서 SLOG 장치를 갖춘 구성이라면 기본값 latency가 WAL 지연에 유리합니다.
clone을 오래 유지하면 원본이 페이지를 덮어쓸 때마다 snapshot이 옛 블록을 계속 참조합니다. 큰 테이블을 자주 VACUUM FULL하거나 재작성하는 워크로드에서는 snapshot 하나가 원본 크기만큼 자랄 수 있습니다. clone의 수명을 정하고 cron으로 정리하는 규칙이 필요합니다. 이 정리를 자동화한 것이 다음 장의 DBLab Engine입니다.
놓치기 쉬운 조건
- clone의
pg_hba.conf가 원본과 같아서 애플리케이션이 clone에 접속할 수 있습니다. port를 바꾸는 것만으로는 부족합니다. 방화벽이나listen_addresses도 함께 조정합니다. - 원본에 logical replication slot이 있으면 clone에도 복사됩니다. clone에서
pg_drop_replication_slot()으로 제거하지 않으면 slot이 clone의 WAL을 보존해 계속 쌓입니다. archive_mode를 끄지 않으면 clone이 원본 아카이브 저장소에 같은 timeline의 WAL을 전송해 백업을 오염시킵니다.archive_command만 비우면 배포판 설정에 따라 원본 값을 다시 읽을 여지가 있습니다. 따라서archive_mode = off도 함께 지정합니다.- clone에서
pg_upgrade를 시험하는 것은 좋은 용도입니다. 다만 결과 디렉터리는 원본과 다른 dataset이므로 승격 절차를 별도로 설계합니다.
연습 문제
- 원본에서 100 MB 테이블을 만든 뒤 snapshot과 clone을 만듭니다.
zfs list -t all의used,usedbydataset,usedbysnapshots를 기록합니다. clone에서 그 테이블의 절반을UPDATE한 뒤 checkpoint를 실행하고 다시 기록합니다. 증가량이 변경한 페이지 크기와 얼마나 다른지 비교합니다. - 온라인 snapshot에서 만든 clone의 첫 기동 로그에서 recovery가 시작된 LSN과 끝난 LSN을 찾습니다.
- clone을
zfs promote한 뒤 원래 dataset을 삭제하고, 데이터가 남아 있는지 확인합니다.