Skip to content

cybertec-postgresql/patroni-lab

Repository files navigation

Patroni + etcd tutorial cluster

A small sandbox for learning etcd and Patroni. One image, three ways to run it: a one-container cluster with bare docker run, or one of two docker compose files for multi-node plain or multi-node TLS.

Sandbox only. Weak passwords, no etcd users, no Patroni REST auth. Do not run this configuration in production.

Variants

Single node Multi-node
Plain docker run docker compose
TLS not provided docker compose -f docker-compose.tls.yml

All three use the same containers.cybertec.at/cybertec-academy/patroni-lab:latest image. The compose files differ in which patroni/<variant>.yml they bind-mount and which ports / env vars they set; the TLS compose additionally bind-mounts ./tls/ (for certs) and ./pgbackrest/repo.conf (over the in-image client config) on the pgbackrest container. The image ships pgbackrest and a baked-in client config in every variant, but only the TLS variant runs the pgbackrest repo server container that the client connects to (usage is optional, see Working with pgbackrest).

You need Docker with the compose plugin. To run multi-node or the TLS variant you also need at least 2 GB of free RAM. Nothing else: all client tools (psql, curl, etcdctl, pgbackrest, patronictl) ship inside the image and are reached via docker exec.

Build the image once (used by every variant):

docker build -t containers.cybertec.at/cybertec-academy/patroni-lab:latest .

Variant 1: single-node with docker run

The simplest possible setup: one container running one etcd member and one Patroni-managed PostgreSQL. No env vars, no bind mounts, no port forwards — the baked-in /etc/patroni/patroni.yml (solo config) and the ENV defaults in the Dockerfile do all the work. Everything else (psql, etcdctl, patronictl, curl) ships inside the image, so nothing beyond Docker is needed on the host.

docker run -d --name patroni-lab containers.cybertec.at/cybertec-academy/patroni-lab:latest

Wait ~15s for PostgreSQL to initialize, then:

docker exec patroni-lab patronictl -c /etc/patroni/patroni.yml list
docker exec -u postgres patroni-lab psql -c "SELECT pg_is_in_recovery();"
docker exec patroni-lab etcdctl endpoint health
docker exec patroni-lab tail /var/log/etcd.log        # supervisord captures etcd stdout/stderr
docker exec patroni-lab tail /var/log/patroni.log     # supervisord captures patroni stdout/stderr
docker exec patroni-lab tail /var/log/postgresql.log  # PG's own logging_collector

docker logs patroni-lab only shows the supervisord process itself (supervisord's own logfile is /dev/null); the actual etcd, patroni, and PostgreSQL logs are files in /var/log/ inside the container.

Tear down:

docker stop patroni-lab && docker rm patroni-lab

(No volumes were created, so nothing extra to clean up.)


Variant 2: multi-node plain with docker compose

Three etcd members (etcd1, etcd2, etcd3) and two Patroni nodes (patroni1, patroni2) on a private bridge network. patroni/multi.yml is bind-mounted over the baked-in solo config so each Patroni node has the 3-endpoint etcd hosts list.

The bridge gives every service DNS-based reachability under its service name (etcd1, patroni2, etc.), so nothing is published on the host — the cluster is only reachable via docker exec.

docker compose up -d
docker compose ps

~10–20s after the etcd healthcheck passes, one of the patroni nodes wins the DCS lock and initializes the cluster as leader; the other joins as a streaming replica. Whichever wins is timing-dependent (usually patroni1 because it tends to start a hair sooner, but don't rely on it — check patronictl list).

docker exec etcd1 etcdctl member list -w table
docker exec patroni1 patronictl -c /etc/patroni/patroni.yml list
docker exec -u postgres patroni1 psql -tAc "SELECT pg_is_in_recovery();"
docker exec -u postgres patroni2 psql -tAc "SELECT pg_is_in_recovery();"

Wipe:

docker compose down -v

Variant 3: multi-node TLS with docker compose

Same shape as variant 2, but every connection is encrypted: etcd peer + client HTTPS, Patroni REST HTTPS, PostgreSQL with ssl=on and only hostssl rules in pg_hba.conf (so plain TCP is rejected). Nothing is published on the host.

The plain and TLS compose files use the same container names and named volumes, so down -v variant 2 before up-ing variant 3:

docker compose down -v
docker compose -f docker-compose.tls.yml up -d
docker compose -f docker-compose.tls.yml ps

Sanity-check that everything is actually encrypted:

docker exec etcd1 etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/etcd/tls/ca.crt --cert=/etc/etcd/tls/server.crt --key=/etc/etcd/tls/server.key member list -w table
docker exec patroni1 patronictl -c /etc/patroni/patroni.yml list
docker exec -u postgres patroni1 psql "host=127.0.0.1 sslrootcert=/etc/patroni/tls/ca.crt sslmode=verify-full password=postgres" -c "SELECT pid, ssl, version FROM pg_stat_ssl WHERE pid = pg_backend_pid();"

TLS layout

One self-signed CA + one shared cert, used both as a server cert (etcd peer + client, Patroni REST, PostgreSQL, pgbackrest repo server) and as a client cert (Patroni → etcd, pgbackrest client → pgbackrest repo server). extendedKeyUsage=serverAuth,clientAuth. SANs: etcd1, etcd2, etcd3, pgbackrest, patroni1, patroni2, localhost, 127.0.0.1.

Surface TLS mode
etcd peer (2380) https with --peer-client-cert-auth=true
etcd client (2379) https with --client-cert-auth=true
Patroni REST (8008) https
PostgreSQL (5432) ssl=on; pg_hba has only hostssl rules, so plain TCP is rejected
pgbackrest (8432) https with mutual TLS; only the repo container runs a pgbackrest server, the client uses the shared server.crt

Working with pgbackrest

The TLS compose file runs a pgbackrest repo server container. Patroni containers just run the pgbackrest client, which talks to the repo server over mutual TLS (repo1-host-type=tls) and reads the local PG via pg1-path only — pg-socket-path, pg-user, and pg-database all default to the OS user / standard socket directory / postgres, so the [patroni] stanza stays minimal. The repo server is a pure file server — it has no [patroni] stanza, just the [global] repo path and the [global:server] TLS listener.

Patroni leader election is timing-dependent — patroni1 doesn't always win. Pin the primary to patroni1 first (pgbackrest's [patroni] stanza uses pg1-path locally, so each node can only back itself up, and the example commands below assume patroni1 is primary):

docker exec patroni1 patronictl -c /etc/patroni/patroni.yml switchover --force --candidate=patroni1
docker exec -u postgres patroni1 pgbackrest --stanza=patroni stanza-create
docker exec -u postgres patroni1 pgbackrest --stanza=patroni check
docker exec -u postgres patroni1 pgbackrest --stanza=patroni backup
docker exec -u postgres patroni1 pgbackrest info

(The switchover is a no-op when patroni1 is already the leader — it prints Error: Member patroni1 is already the leader of cluster demo and exits non-zero, which is fine. The command is idempotent: it just ensures patroni1 is primary regardless of who won the race.)

Always run pgbackrest as postgres (the data dir is owned by postgres, so peer auth over the unix socket works without a password). check also confirms WAL archiving is wired up — archive_mode=on and archive_command='pgbackrest --stanza=patroni archive-push %p' are set in patroni/tls.yml, so each completed WAL segment gets pushed to the repo container as soon as it's produced.

stanza-create is a one-time bootstrap per cluster (it catalogs the current WAL location). After that, backup just works and you can restore into a fresh patroni cluster by pointing a new container at the same repo.

Wipe pgbackrest state separately from the cluster — its data lives in the pgbackrest_repo named volume:

docker compose -f docker-compose.tls.yml down    # keeps pgbackrest_repo
docker compose -f docker-compose.tls.yml down -v # also wipes backups

Working with the cluster

A few commands that are useful across all variants:

# Inspect the cluster state.
docker exec patroni1 patronictl -c /etc/patroni/patroni.yml list      # multi-node
docker exec patroni-lab patronictl -c /etc/patroni/patroni.yml list    # solo

# Write a row, then read it back from a replica to see replication working.
docker exec -u postgres patroni1 psql -c "CREATE DATABASE demo;"
docker exec -u postgres patroni1 psql -d demo -c "CREATE TABLE t (id int PRIMARY KEY, note text); INSERT INTO t VALUES (1, 'hello from the leader');"
docker exec -u postgres patroni2 psql -d demo -c "SELECT * FROM t;"     # streaming replica

# Stop the leader and watch a replica get promoted (multi-node only).
docker exec patroni1 patronictl -c /etc/patroni/patroni.yml pause
docker exec patroni2 patronictl -c /etc/patroni/patroni.yml list     # patroni2 is now Leader
docker exec patroni1 patronictl -c /etc/patroni/patroni.yml resume

File layout

  • Dockerfile — single Debian image containing etcd, PostgreSQL 18, Patroni, pgbackrest, and supervisor. The baked-in patroni/solo.yml is the solo config; the default entrypoint (entrypoint/all.sh) runs both etcd and Patroni together.
  • entrypoint/etcd.sh — entrypoint for the etcd-only containers in the multi-node setups. Stages TLS if mounted, installs supervisord/etcd.conf, execs supervisord.
  • entrypoint/patroni.sh — same idea for the Patroni containers. Also stages TLS into /etc/pgbackrest/tls/ so pgbackrest commands can be run from the patroni container.
  • entrypoint/all.sh — default entrypoint. Runs etcd + Patroni under one supervisord.
  • entrypoint/pgbackrest.sh — entrypoint for the pgbackrest repo server container (TLS compose only). Stages TLS and execs supervisord.
  • patroni/solo.yml — solo Patroni config (1 etcd endpoint at 127.0.0.1:2379, name: patroni1, connect_address hardcoded). Baked into the image.
  • patroni/multi.yml — multi-node plain Patroni config. Bind-mounted by docker-compose.yml.
  • patroni/tls.yml — multi-node TLS Patroni config. Bind-mounted by docker-compose.tls.yml.
  • pgbackrest/client.conf — pgbackrest config baked into the image. Patroni containers use this to talk to the pgbackrest repo server named pgbackrest over mutual TLS, with just pg1-path set explicitly (socket path / user / database all default).
  • pgbackrest/repo.conf — pgbackrest config for the repo server container. Bind-mounted over the client config by the TLS compose. Pure file server: [global] for the repo path, [global:server] for the TLS listener, no [patroni] stanza.
  • supervisord/etcd.conf, supervisord/patroni.conf, supervisord/all.conf, supervisord/pgbackrest.conf — picked by the respective entrypoints.
  • docker-compose.yml — multi-node plain.
  • docker-compose.tls.yml — multi-node TLS (includes the pgbackrest repo server).
  • tls/, generate-certs.sh — generated CA and shared cert (used by TLS).

About

Docker compose to bootstrap a simple etcd+patroni cluster, with optional TLS and pgbackrest for training

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors