Pular para conteúdo

b. Docker Compose

O cluster é um projeto independente: ele não precisa dos microsserviços dos hands-on anteriores. Crie uma pasta nova com estes arquivos:

📁 pgcluster/
├── YAML compose.yaml
└── 📁 scripts/
    ├── GNU Bash node-entrypoint.sh
    ├── GNU Bash init-primary.sh
    └── GNU Bash rejoin-as-replica.sh

O rejoin-as-replica.sh só é usado em Failover; crie-o agora para que a pasta fique completa.

Os arquivos

Código-fonte

compose.yaml
name: pgcluster

x-pg-env: &pg-env
  POSTGRES_USER: ${DB_USER:-store}
  POSTGRES_PASSWORD: ${DB_PASSWORD:-devpass}
  POSTGRES_DB: ${DB_NAME:-store}
  REPLICATION_USER: replicator
  REPLICATION_PASSWORD: ${REPLICATION_PASSWORD:-replpass}

x-pg-node: &pg-node
  image: postgres:17
  entrypoint: ["bash", "/scripts/node-entrypoint.sh"]
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
    interval: 2s
    timeout: 3s
    retries: 30

services:

  pg-primary:
    <<: *pg-node
    hostname: pg-primary
    # cluster_name: the node's name in queries and the name a replica reports to its primary
    command: ["postgres", "-c", "cluster_name=pg-primary"]
    environment:
      <<: *pg-env
    ports:
      - 5432:5432
    volumes:
      - primary_data:/var/lib/postgresql/data
      - ./scripts:/scripts:ro

  pg-replica1:
    <<: *pg-node
    hostname: pg-replica1
    command: ["postgres", "-c", "cluster_name=pg-replica1"]
    environment:
      <<: *pg-env
      PRIMARY_HOST: pg-primary  # makes this node a replica (see node-entrypoint.sh)
    ports:
      - 5433:5432
    volumes:
      - replica1_data:/var/lib/postgresql/data
      - ./scripts:/scripts:ro

  pg-replica2:
    <<: *pg-node
    hostname: pg-replica2
    command: ["postgres", "-c", "cluster_name=pg-replica2"]
    environment:
      <<: *pg-env
      PRIMARY_HOST: pg-primary
    ports:
      - 5434:5432
    volumes:
      - replica2_data:/var/lib/postgresql/data
      - ./scripts:/scripts:ro

  client:
    image: postgres:17
    profiles: [tools]
    entrypoint: ["psql"]
    environment:
      PGUSER: ${DB_USER:-store}
      PGPASSWORD: ${DB_PASSWORD:-devpass}
      PGDATABASE: ${DB_NAME:-store}

volumes:
  primary_data:
  replica1_data:
  replica2_data:
scripts/node-entrypoint.sh
#!/bin/bash
# Entrypoint of every node of the cluster.
#   PRIMARY_HOST unset -> primary: the official entrypoint creates the database
#                         and runs /docker-entrypoint-initdb.d (none here: see below).
#   PRIMARY_HOST set   -> replica: on the first start, clones the primary with
#                         pg_basebackup and starts as a hot standby.
set -euo pipefail

PGDATA=${PGDATA:-/var/lib/postgresql/data}

if [ -s "$PGDATA/PG_VERSION" ]; then
  echo "node: data directory already initialised, starting PostgreSQL"

elif [ -z "${PRIMARY_HOST:-}" ]; then
  echo "node: empty data directory, initialising a new primary"
  # Runs once, right after initdb: the replication role and its pg_hba rule.
  cp /scripts/init-primary.sh /docker-entrypoint-initdb.d/10-init-primary.sh

else
  echo "node: empty data directory, cloning $PRIMARY_HOST"
  until pg_isready -h "$PRIMARY_HOST" -U "$POSTGRES_USER" -q; do
    echo "node: waiting for $PRIMARY_HOST..."
    sleep 1
  done
  mkdir -p "$PGDATA"
  chown postgres:postgres "$PGDATA"
  chmod 700 "$PGDATA"
  # -X stream: also copy the WAL written during the backup
  # -R: write standby.signal + primary_conninfo, so the node starts as a standby
  PGPASSWORD="$REPLICATION_PASSWORD" gosu postgres \
    pg_basebackup -h "$PRIMARY_HOST" -U "$REPLICATION_USER" \
                  -D "$PGDATA" -X stream -R --progress
fi

exec docker-entrypoint.sh "$@"
scripts/init-primary.sh
#!/bin/bash
# Runs once, when the primary creates its database (docker-entrypoint-initdb.d).
# Everything here lives in the data directory, so pg_basebackup copies it to every replica.
set -euo pipefail

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<SQL
-- A role that may only stream WAL: replicas connect with it, never the application.
CREATE ROLE $REPLICATION_USER WITH REPLICATION LOGIN PASSWORD '$REPLICATION_PASSWORD';
-- WAL kept on disk for a replica that falls behind (or is stopped for a while).
ALTER SYSTEM SET wal_keep_size = '256MB';
-- Lets pg_rewind turn an old primary into a replica after a failover.
ALTER SYSTEM SET wal_log_hints = on;
SQL

# "all" in pg_hba.conf does not match replication connections: they need their own rule.
echo "host replication $REPLICATION_USER all scram-sha-256" >> "$PGDATA/pg_hba.conf"
scripts/rejoin-as-replica.sh
#!/bin/bash
# Turns a stopped old primary into a replica of the new primary.
# Usage (from the folder of compose.yaml, with the node stopped):
#   docker compose run --rm --no-deps --entrypoint bash pg-primary \
#     /scripts/rejoin-as-replica.sh pg-replica1
set -euo pipefail

NEW_PRIMARY=${1:?usage: rejoin-as-replica.sh <new-primary-host>}
PGDATA=${PGDATA:-/var/lib/postgresql/data}

# pg_rewind copies back only the blocks that changed since the timelines split,
# discarding the writes the old primary made that never reached the new one.
gosu postgres pg_rewind --target-pgdata="$PGDATA" --progress \
  --source-server="host=$NEW_PRIMARY user=$POSTGRES_USER password=$POSTGRES_PASSWORD dbname=$POSTGRES_DB"

# Same as pg_basebackup -R: start as a standby, streaming from the new primary.
gosu postgres touch "$PGDATA/standby.signal"
echo "primary_conninfo = 'host=$NEW_PRIMARY user=$REPLICATION_USER password=$REPLICATION_PASSWORD'" \
  | gosu postgres tee -a "$PGDATA/postgresql.auto.conf" > /dev/null
echo "rejoin: $(hostname) will follow $NEW_PRIMARY on its next start"

Fim de linha no Windows

Os scripts rodam dentro de containers Linux. Se o seu editor os salvar com fim de linha do Windows (CRLF), o bash falha com $'\r': command not found. Salve-os como LF (no VS Code: canto inferior direito, CRLF → LF).

Como funciona

compose.yaml

  • x-pg-env e x-pg-node são âncoras YAML: os três nós compartilham a imagem, o ambiente, o entrypoint e o health check (<<: *pg-node). Só mudam o nome, a porta e o PRIMARY_HOST.
  • cluster_name dá nome a cada nó. Você vai lê-lo de volta com SELECT current_setting('cluster_name') para saber qual servidor respondeu, e uma réplica o usa como o seu nome no primário (application_name em pg_stat_replication) — a replicação síncrona se refere às réplicas por esse nome.
  • O health check usa -h 127.0.0.1 de propósito: enquanto a imagem cria o banco de dados, um servidor temporário escuta apenas em um socket Unix. Verificar por TCP marca o nó como healthy só quando o servidor de verdade está no ar.
  • Cada nó tem o seu próprio volume: uma réplica é uma cópia completa, não um disco compartilhado.
  • As réplicas não têm depends_on: o node-entrypoint.sh já espera pelo primário. Com depends_on, docker compose start pg-replica1 também reiniciaria um primário que você parou de propósito — o que estragaria os experimentos de failover.
  • O client está no profile tools: docker compose up não o sobe. docker compose run --rm client ... roda o psql dentro da rede do cluster, então você não precisa ter o PostgreSQL instalado na sua máquina.

node-entrypoint.sh decide o papel de um nó quando o seu diretório de dados está vazio:

flowchart TD
    start(["o container sobe"]) --> data{"diretório de dados\njá inicializado?"}
    data -->|sim| run["inicia o PostgreSQL\n(primário ou standby, como era)"]
    data -->|não| role{"PRIMARY_HOST definido?"}
    role -->|não| init["initdb + init-primary.sh\n→ novo primário"]
    role -->|sim| wait["espera pelo primário"] --> clone["pg_basebackup -X stream -R\n→ cópia do primário"]
    clone --> standby["sobe como hot standby\n(somente leitura, recebendo o WAL)"]
    init --> run

O pg_basebackup copia o diretório de dados inteiro do primário por uma conexão de replicação. -X stream também copia o WAL gravado durante a cópia, e -R grava standby.signal e primary_conninfo (de onde fazer o streaming) no novo diretório de dados: quando o PostgreSQL sobe e encontra standby.signal, ele sobe como standby.

init-primary.sh roda uma única vez, quando o primário cria o seu banco de dados. Ele cria o role replicator (que só pode fazer streaming do WAL), define wal_keep_size e wal_log_hints com ALTER SYSTEM e adiciona uma regra no pg_hba.conf para as conexões de replicação. Tudo isso fica guardado no diretório de dados, então o pg_basebackup o copia para cada réplica — qualquer uma delas pode ser promovida mais tarde e já tem a mesma configuração.

Subir o cluster

A partir da pasta pgcluster/:

docker compose up -d --wait

O --wait só retorna quando todos os nós estão healthy. Depois:

docker compose ps

Você deve ver três nós, todos healthy:

NAME                      SERVICE       STATUS                    PORTS
pgcluster-pg-primary-1    pg-primary    Up 20 seconds (healthy)   0.0.0.0:5432->5432/tcp
pgcluster-pg-replica1-1   pg-replica1   Up 20 seconds (healthy)   0.0.0.0:5433->5432/tcp
pgcluster-pg-replica2-1   pg-replica2   Up 20 seconds (healthy)   0.0.0.0:5434->5432/tcp

Veja como uma réplica nasceu:

docker compose logs pg-replica1
node: empty data directory, cloning pg-primary
...
LOG:  entering standby mode
LOG:  consistent recovery state reached at 0/30001D0
LOG:  database system is ready to accept read-only connections
LOG:  started streaming WAL from primary at 0/4000000 on timeline 1

Verificar a replicação

No primário, pg_stat_replication tem uma linha por réplica conectada:

docker compose exec pg-primary psql -U store -c "SELECT application_name, client_addr, state, sync_state FROM pg_stat_replication;"
 application_name | client_addr | state     | sync_state
------------------+-------------+-----------+------------
 pg-replica1      | 172.18.0.3  | streaming | async
 pg-replica2      | 172.18.0.4  | streaming | async

Em uma réplica, pg_is_in_recovery() é true (um standby está permanentemente "em recuperação": reexecutando o WAL) e pg_stat_wal_receiver mostra de onde ela recebe o stream:

docker compose exec pg-replica1 psql -U store -c "SELECT pg_is_in_recovery();" -c "SELECT status, sender_host FROM pg_stat_wal_receiver;"
 pg_is_in_recovery
-------------------
 t

  status   | sender_host
-----------+-------------
 streaming | pg-primary

Conectando a partir da sua máquina

Cada nó é publicado na sua própria porta: 5432 (primário), 5433 e 5434 (réplicas), usuário store, senha devpass, banco store. Você pode abrir os três lado a lado no DBeaver, no pgAdmin ou na visão de banco de dados da sua IDE.

Solução de problemas

Sintoma Causa e correção
$'\r': command not found nos logs os scripts têm fim de linha CRLF: salve-os como LF e rode docker compose down -v && docker compose up -d --wait
uma réplica reinicia em loop com no pg_hba.conf entry for replication connection o primário foi criado antes de o init-primary.sh existir: docker compose down -v e suba de novo
port is already allocated na 5432 o db de um hands-on anterior está rodando: pare-o (docker compose down naquela pasta) ou mude as portas publicadas
uma réplica fica em waiting for pg-primary... o primário não está no ar: docker compose logs pg-primary

Recomeçando do zero

Os volumes guardam o estado do cluster, incluindo qualquer failover que você tenha feito. Para voltar ao estado inicial (um primário, duas réplicas), remova-os:

docker compose down -v
docker compose up -d --wait