Running bare metal PostgreSQL on ZFS

Ellie Huxtable

베어메탈에서 ZFS 위에 PostgreSQL 운영하기

원문은 Ellie Huxtable님이 에 게재했습니다. 이 블로그 구독하기

Atuin을 위한 새로운 postgres 서버를 구축 중이다! 이번에는 핫 레플리카를 도입해서 안정성을 훨씬 더 높이려고 한다. Atuin은 지난 몇 년간 장애나 데이터베이스 문제가 한 번도 없었지만, 더는 운에 맡기고 싶지 않다.

Atuin API 이미지를 위해 작업했던 hetzner k3s 설정도 참고해 보면 좋을 것이다

우선은 최대한 단순하고 최소한의 설정으로 시작하고 나중에 튜닝할 생각이다. 어떤 옵션이 내 워크로드에 가장 잘 맞을지 아직 확신이 없어서 단순하게 유지하려 한다.

참고로 Atuin의 쿼리는 복잡하지 않다. 대부분 꽤 많은 양의 데이터를 저장하고 (대체로) 순차적으로 읽는 형태다. 조인도 거의 없고 복잡한 쿼리도 거의 없다.

종종 수만에서 수십만 건의 행을 최대한 빠르게 쓰거나 읽어야 하는 버스트 구간이 있는데, 이 역시 대체로 순차적이고 전혀 “복잡”하지는 않다.

가능하면 데이터를 최대한 압축하고 싶다. Atuin은 대부분 압축이 잘 안 되는 암호화된 데이터를 저장하지만, JSON 패딩이나 타임스탬프 등 적지 않은 양의 비암호화 데이터도 있다. 이건 압축하자! 약간의 CPU 비용을 감수하면 디스크 사용량과 IO를 모두 꽤 줄일 수 있다.

쿼리 자체가 단순하니 CPU 비용은 감수할 만하다.

환경 구성

경매로 구매한 Hetzner 머신 두 대에서 진행하고 있다. Ryzen CPU에 1TB NVMe SSD가 4개씩 달려 있다.

SSD 두 개는 단순한 RAID 미러로 구성해 OS와 로그를 저장할 예정이다. 나머지 두 개에는 ZFS 파일시스템과 postgres를 올릴 것이다.

미러링 때문에 저장 공간의 50%를 희생하지만, 베어메탈 하드웨어인 만큼 디스크가 고장 날 가능성은 있다. 장애가 발생하더라도 레플리카로 페일오버하고 기술자가 디스크를 교체할 때까지 데이터베이스가 계속 동작할 수 있도록 하고 싶다.

# Install zfs
apt install zfsutils-linux

# check version
zfs version

lsblk로 디스크 구성을 확인한다

NAME        MAJ:MIN RM   SIZE RO TYPE  MOUNTPOINTS
nvme1n1     259:0    0 953.9G  0 disk
├─nvme1n1p1 259:1    0    32G  0 part
│ └─md0       9:0    0    32G  0 raid1 [SWAP]
├─nvme1n1p2 259:2    0     1G  0 part
│ └─md1       9:1    0  1022M  0 raid1 /boot
├─nvme1n1p3 259:3    0   128G  0 part
│ └─md2       9:2    0 127.9G  0 raid1 /var
├─nvme1n1p4 259:4    0     1K  0 part
└─nvme1n1p5 259:5    0 792.9G  0 part
  └─md3       9:3    0 792.7G  0 raid1 /
nvme0n1     259:6    0 953.9G  0 disk
nvme3n1     259:7    0 953.9G  0 disk
nvme2n1     259:8    0 953.9G  0 disk
├─nvme2n1p1 259:9    0    32G  0 part
│ └─md0       9:0    0    32G  0 raid1 [SWAP]
├─nvme2n1p2 259:10   0     1G  0 part
│ └─md1       9:1    0  1022M  0 raid1 /boot
├─nvme2n1p3 259:11   0   128G  0 part
│ └─md2       9:2    0 127.9G  0 raid1 /var
├─nvme2n1p4 259:12   0     1K  0 part
└─nvme2n1p5 259:13   0 792.9G  0 part
  └─md3       9:3    0 792.7G  0 raid1 /

/dev/nvme1n1/dev/nvme2n1은 이미 OS용으로 사용 중이고, /dev/nvme0n1/dev/nvme3n1은 ZFS 미러용으로 남아 있다.

미러를 생성한다

zpool create postgres mirror -o ashift=12 /dev/nvme0n1 /dev/nvme3n1

아주 빠르게 완료됐다!

다시 lsblk로 확인해 보면 사용 중인 게 보인다

nvme0n1     259:6    0 953.9G  0 disk
├─nvme0n1p1 259:14   0 953.9G  0 part
└─nvme0n1p9 259:15   0     8M  0 part
nvme3n1     259:7    0 953.9G  0 disk
├─nvme3n1p1 259:18   0 953.9G  0 part
└─nvme3n1p9 259:19   0     8M  0 part

zpool status로 확인했다

  pool: postgres
 state: ONLINE
config:

	NAME         STATE     READ WRITE CKSUM
	postgres     ONLINE       0     0     0
	  mirror-0   ONLINE       0     0     0
	    nvme0n1  ONLINE       0     0     0
	    nvme3n1  ONLINE       0     0     0

errors: No known data errors

풀은 기본적으로 /postgres에 마운트된다. 좋다!

Postgres

데이터셋을 만들기 전에 먼저 postgres를 설정해야 한다. Postgres는 ZFS로 옮기기 전에 초기화를 실행해야 한다. 번거롭지만 어쩔 수 없다.

Ubuntu 22.04 저장소에 있는 버전은 꽤 오래됐기 때문에 postgres 공식 저장소의 것을 설치한다

sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'

wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -

apt update
apt install postgresql-16 postgresql-contrib-16
systemctl enable postgresql
systemctl start postgresql

그런 다음 postgres를 중지한다. 데이터를 임시 위치로 옮기고, 데이터셋을 만든 뒤 다시 옮겨온다. 권한을 유지하려면 mv/cp 대신 rsync를 쓰는 게 더 나을 수도 있다.

systemctl stop postgresql

# move postgres data to temp
mv /var/lib/postgresql/16/main/pg_wal /tmp/pg_wal
mv /var/lib/postgresql /tmp/postgresql

# create the datasets
zfs create postgres/data -o mountpoint=/var/lib/postgresql
zfs create postgres/wal -o mountpoint=/var/lib/postgresql/16/main/pg_wal

# switcharoo the data back
cp -r /tmp/postgresql/* /var/lib/postgresql
cp -r /tmp/pg_wal/* /var/lib/postgresql/16/main/pg_wal

# sort perms
chmod -R 0700 /var/lib/postgresql
chmod -R 0700 /var/lib/postgresql/16/main/pg_wal
chown -R postgres: /var/lib/postgresql

# start postgres once more
systemctl start postgresql

모두 정상인지 확인한다:

zfs list

NAME                 USED  AVAIL     REFER  MOUNTPOINT
postgres             600K   922G       24K  /postgres
postgres/db           72K   922G       24K  /postgres/db
postgres/db/base      24K   922G       24K  /postgres/db/base
postgres/db/pg_wal    24K   922G       24K  /postgres/db/pg_wal

ZFS 설정

다음을 포함해 여러 자료를 참고했다:

  • https://vadosware.io/post/everything-ive-seen-on-optimizing-postgres-on-zfs-on-linux/
  • https://bun.uptrace.dev/postgres/tuning-zfs-aws-ebs.html

위 글 중 하나에서는 일부 NVMe 하드웨어가 fdatasync를 사용할 때 쓰기가 성공했다고 보고하지만 실제로는 제대로 기록되지 않는 문제가 언급되어 있었다. 많은 드라이브가 데이터가 휘발성 쓰기 캐시에 저장되자마자 쓰기 성공을 보고하지만, 실제로는 디스크에 저장되지 않은 상태다. 다음 명령으로 이 기능을 비활성화한다:

apt install nvme-cli
nvme set-feature -f 6 -v 0 /dev/nvme0n1
nvme set-feature -f 6 -v 0 /dev/nvme3n1

최적의 recordsize에 대해 한참 고민했다. recordsize를 postgres 블록 크기와 같은 8kb로 맞추면 TPS가 더 높게 나오겠지만, Atuin은 대체로 순차적이고 대량의 데이터를 읽고 쓰는 워크로드다. postgres 블록 크기를 바꾸는 것도 가능한데(컴파일 옵션이다), 이건 나중에 시도해 볼 생각이다.

우선은 기본값인 128k로 시작해서 어떻게 돌아가는지 지켜보기로 했다. 더 작은 값은 잠재적으로 더 빠를 수 있지만, 더 큰 값은 압축률이 더 좋은 경향이 있다. 여러 요인에 따라 달라지니 직접 측정해 보고 판단할 예정이다.

# enable compression
zfs set compression=zstd-3 postgres

# disable access time (so, so many writes...)
zfs set atime=off postgres

# enable improved extended attributes
zfs set xattr=sa postgres

# zfs set recordsize=16k postgres

그리고 postgres 쪽에서는 full_page_writes = off로 설정했다. ZFS는 부분 페이지를 기록하지 않으니 이 옵션은 사실상 중복이다.

다음으로 ARC(ZFS 페이지 캐시)에 시스템 메모리의 75%를 할당할 것이다. 나머지는 postgres의 shared_buffers가 사용하게 된다.

echo 51539607552 >> /sys/module/zfs/parameters/zfs_arc_max

재부팅 후에도 유지되도록 /etc/modprobe.d/zfs.conf에 이렇게 설정한다

options zfs zfs_arc_max=51539607552

Postgres 설정

ZFS 전용 설정은 아니고 일반적인 postgres 튜닝이다

# 25% of 64GB
shared_buffers = 16GB

work_mem = 8MB

# make vaccuums/etc faster
maintenance_work_mem = 1GB

# tell the planner how much the ZFS ARC will likely cache
effective_cache_size = 48GB

더 튜닝할 수 있는 항목은 많지만, 현실적으로는 이 설정이 가장 큰 도움이 된다. Postgres는 기본적으로 메모리가 정말 적게 설정되어 있다!

이 시점에서 모든 것이 정상이고 설정이 제대로 유지되는지 확인하기 위해 재부팅했다.

부하 걸어 보기

시스템이 적어도 어느 정도는 제대로 동작하는지 확인하고 싶어서 pgbench를 돌려봤고 결과는 다음과 같았다

scaling factor: 50
query mode: simple number of clients: 20 
number of threads: 4 
maximum number of tries: 1 
number of transactions per client: 100000 
number of transactions actually processed: 2000000/2000000 
number of failed transactions: 0 (0.000%) 
latency average = 2.863 ms 
initial connection time = 10.287 ms 
tps = 6984.804317 (without initial connection time)

나쁘지 않다! 내 워크로드와 완전히 일치하는 테스트는 아니지만, 적어도 시스템이 완전히 망가진 건 아니라는 걸 알 수 있다. 나중에 좀 더 튜닝해 볼 예정이다.

다음 단계

다음으로는 pgbackrest로 백업을 구성하고, 문제 발생 시 페일오버할 수 있는 핫 스탠바이를 구축할 예정이다!

그런 다음 데이터셋을 옮겨서 이 새로운 데이터베이스를 프로덕션으로 전환할 계획이다 🚀

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글