← PostgreSQL DBA← PostgreSQL DBA
PostgreSQL DBAPostgreSQL DBA19 Th7, 2026Jul 19, 202628 phút đọc23 min read

Automation & Infrastructure as CodeAutomation & Infrastructure as Code

Thuộc bộ kiến thức PostgreSQL DBA Roadmap.

Tổng quan

Có một thời điểm rất cụ thể phân biệt một DBA đang quản lý một database với một DBA đang quản lý cả một hạm đội server: đó là khi một tác vụ cần được thực hiện giống hệt nhau, nhiều hơn một lần, dưới áp lực thời gian. SSH vào server và gõ ALTER SYSTEM SET ... hay tự tay sửa pg_hba.conf thì không sao ở lần đầu tiên, trên một máy, khi mọi thứ đang yên bình. Nhưng nó sẽ hỏng ngay khi cùng một thay đổi cần áp dụng lên một primary, hai replica và một DR standby mà không ai được phép quên bước nào, và nó hỏng hoàn toàn lúc 3 giờ sáng khi runbook failover chỉ tồn tại dưới dạng một trang wiki với hai mươi bước đánh số mà ai đó giờ phải thực hiện hoàn hảo theo trí nhớ, trong khi bị page và còn chưa tỉnh táo.

Khoảng cách đó — giữa “tôi biết làm việc này” và “việc này sẽ được làm đúng, theo cùng một cách, mỗi lần, bởi bất kỳ ai, kể cả dưới áp lực” — chính là thứ mà automation và infrastructure as code (IaC) thu hẹp lại. Ba đặc tính quan trọng hơn bất kỳ công cụ cụ thể nào: tính nhất quán (mọi server lẽ ra phải giống nhau thì thực sự giống nhau, vì chúng được dựng từ cùng một định nghĩa chứ không phải bằng tay); tính lặp lại được (một buổi diễn tập disaster recovery hay một lần failover là chạy một script hay một playbook, không phải một bài kiểm tra trí nhớ); và khả năng kiểm toán (thay đổi làm shared_buffers đổi trên mọi replica là một commit có tác giả, có thời gian, có diff, không phải một dòng trong shell history đã trôi mất khỏi ba server từ lâu). Không có gì trong số này là tư tưởng DevOps xa lạ được nhập khẩu vào thế giới database cho vui — đây là câu trả lời trực tiếp, thực dụng cho hai kiểu thất bại gây hại nhiều nhất cho DBA: cấu hình âm thầm lệch nhau giữa primary và replica từ sáu tháng trước mà không ai nhận ra cho đến khi một lần failover phơi bày sự khác biệt đó, và quy trình khôi phục đúng trong đầu ai đó nhưng sai trên giấy (hoặc không có ở đâu cả) đúng cái lần nó thực sự phải chạy.

Bài này bàn về automation và IaC cụ thể dưới góc nhìn của một PostgreSQL DBA: bắt đầu từ đâu (script thuần túy, trước khi với tới một công cụ configuration management đầy đủ), configuration management tool áp dụng vào PostgreSQL cụ thể như thế nào, một Ansible playbook thật để provision và cấu hình một PostgreSQL server, năng lực hạ tầng rộng hơn mà một DBA hiện đại cần ngoài SQL, và cách tự động hóa các tác vụ vận hành lặp lại — kiểm tra backup, kiểm thử failover, tạo partition — chính là những việc mà một con người mệt mỏi hoặc vội vàng dễ làm sai nhất.

Kiến thức nền tảng

Tự động hóa phần nhàm chán, lặp lại trước tiên

Rất dễ nghe đến “automation” rồi nhảy thẳng tới một nền tảng configuration management đầy đủ, một lớp orchestration, và một pipeline. Trên thực tế, phần lớn automation có giá trị cao nhất mà một PostgreSQL DBA viết ra nhỏ hơn thế rất nhiều: một shell script bọc quanh một lệnh mà lẽ ra bạn phải gõ tay, hoặc một pipeline awk/sed/grep biến cả một bức tường log thành đúng một dòng cần quan tâm. Việc này đáng làm trước, trước khi với tới Ansible hay Terraform, vì một lý do đơn giản — một script mười lăm dòng mà bạn hiểu hoàn toàn, chạy từ cron hay terminal, có tỷ lệ chi phí/lợi ích tốt hơn nhiều cho một tác vụ bạn làm vài lần một tuần so với việc dựng cả một nền tảng configuration management chỉ để làm việc tương tự. Dành công cụ nặng hơn cho việc chúng thực sự giỏi: giữ nhiều server ở một trạng thái nhất quán, đã khai báo. Dùng script cho việc script giỏi: biến một hành động thủ công, dễ sai, lặp lại thành một lệnh một dòng.

File log của PostgreSQL và output của các lệnh chẩn đoán (psql, pg_stat_activity, pg_stat_statements) đều là văn bản, và văn bản chính là thứ grep, awk, sed sinh ra để xử lý. Xem ./16-monitoring-logging-and-diagnostics.md để biết các công cụ và view chẩn đoán mà các script này bọc quanh — bài này bàn về việc biến một câu truy vấn chẩn đoán một lần bạn vẫn gõ tay vào psql thành thứ chạy tự động và cảnh báo cho bạn.

#!/usr/bin/env bash
# check_slow_queries.sh — grep the PostgreSQL log for statements over a threshold,
# extract duration and query text with awk, and alert if any exceed a hard limit.
set -euo pipefail

LOG_FILE="/var/log/postgresql/postgresql-16-main.log"
THRESHOLD_MS=5000   # alert on anything slower than 5 seconds
SINCE_MINUTES=15

# log_min_duration_statement must be set (e.g. to 1000) in postgresql.conf
# for "duration: ... ms  statement: ..." lines to appear at all.
recent_slow=$(
  find "$LOG_FILE" -newermt "-${SINCE_MINUTES} minutes" -printf '' 2>/dev/null || true
  grep --text 'duration:' "$LOG_FILE" \
    | awk -v threshold="$THRESHOLD_MS" '
        {
          for (i = 1; i <= NF; i++) {
            if ($i == "duration:") {
              dur = $(i + 1) + 0     # milliseconds, as a number
              if (dur >= threshold) print
            }
          }
        }'
)

if [[ -n "$recent_slow" ]]; then
  count=$(echo "$recent_slow" | wc -l)
  echo "ALERT: ${count} statement(s) over ${THRESHOLD_MS}ms in the last check:" >&2
  echo "$recent_slow" | tail -n 5 >&2
  # Wire this into your alerting channel instead of just stderr:
  # curl -s -X POST "$SLACK_WEBHOOK_URL" -d "{\"text\": \"${count} slow queries detected\"}"
  exit 1
fi

echo "OK: no statements over ${THRESHOLD_MS}ms"

Đây là điểm khởi đầu thực dụng: một script mà ai trong nhóm cũng đọc hết từ trên xuống dưới trong ba mươi giây, làm đúng một việc, mà bạn có thể chạy tay khi đang debug hoặc thả vào cron hay một systemd timer một khi đã tin tưởng nó. Cùng một mẫu hình đó — grep để tìm dòng cần quan tâm, awk để rút trường ra khỏi chúng, sed để định dạng lại — bao phủ một phần rất lớn công việc phân loại log DBA thường ngày: đếm số dòng FATAL/ERROR mỗi giờ, trích thời gian checkpoint (grep 'checkpoint complete'), hay theo dõi could not receive data from client khi kết nối bị reset. Chỉ nên với tới configuration management khi bài toán trở thành “giữ N server ở trạng thái này,” chứ không phải trước đó — một script chỉ mình bạn chạy, trên một máy, để trả lời một câu hỏi thì không cần control node, inventory, hay một phương ngữ YAML nào cả.

Configuration management cho PostgreSQL

Một khi cùng một tác vụ cần diễn ra nhất quán trên cả một hạm đội — cài PostgreSQL giống hệt nhau trên mọi node, giữ postgresql.confpg_hba.conf đồng bộ giữa primary và replica, đảm bảo một DR node vừa dựng lại y hệt từng bit với node nó thay thế — script thuần túy không còn là công cụ phù hợp nữa, và configuration management (CM) tiếp quản. ../devops/en/13-configuration-management.md đã bàn sâu về cơ chế chung của Ansible, Chef, Puppet, Salt — mô hình push vs. pull, idempotency, bức tranh công cụ — và đó là nơi nên đọc để hiểu các tool này hoạt động thế nào nói chung. Phần này chỉ tập trung hẹp vào những gì thay đổi khi thứ được cấu hình là một PostgreSQL server thay vì một web tier bình thường.

Áp dụng vào PostgreSQL, một CM tool thường đảm nhận bốn trách nhiệm:

Bức tranh công cụ tự nó không thay đổi vì là PostgreSQL: Ansible vẫn là lựa chọn phổ biến nhất cho automation do DBA viết vì nó agentless (không có tiến trình agent thường trực cạnh tranh tài nguyên với PostgreSQL trên host database) và tập module của nó (postgresql_db, postgresql_user, postgresql_privs, postgresql_query trong collection community.postgresql) bao phủ trực tiếp các object cấp database, không chỉ trạng thái OS. Puppet và Chef xuất hiện nhiều hơn ở những nơi đã chuẩn hóa dùng chúng cho phần còn lại của hạm đội và chỉ đơn giản mở rộng cùng mô hình pull-based, hội tụ liên tục đó sang tầng database.

Một Ansible playbook thật cho PostgreSQL

Playbook dưới đây cài PostgreSQL từ repo PGDG, template hóa postgresql.conf từ biến (nên cùng một role sinh ra một cấu hình cỡ lớn cho primary và một cấu hình nhỏ hơn cho replica/dev chỉ bằng cách đổi biến), và đảm bảo service đang chạy và được enable — cùng mô hình “mô tả đích đến, không phải hành trình” đã nói ở ../devops/en/13-configuration-management.md, áp dụng vào một database thay vì một web tier.

# postgresql.yml — provision and configure a PostgreSQL server
- name: Configure PostgreSQL server
  hosts: postgresql_servers
  become: true
  vars:
    pg_version: "16"
    pg_data_dir: "/var/lib/postgresql/{{ pg_version }}/main"
    pg_config_dir: "/etc/postgresql/{{ pg_version }}/main"
    pg_listen_addresses: "*"
    pg_max_connections: 200
    pg_shared_buffers: "4GB"
    pg_effective_cache_size: "12GB"
    pg_wal_level: "replica"
    pg_hba_rules:
      - { type: "host", database: "all", user: "app_user", address: "10.0.1.0/24", method: "scram-sha-256" }
      - { type: "host", database: "replication", user: "replicator", address: "10.0.2.0/24", method: "scram-sha-256" }

  tasks:
    - name: Add PGDG APT signing key
      ansible.builtin.apt_key:
        url: https://www.postgresql.org/media/keys/ACCC4CF8.asc
        state: present

    - name: Add PGDG APT repository
      ansible.builtin.apt_repository:
        repo: "deb http://apt.postgresql.org/pub/repos/apt {{ ansible_distribution_release }}-pgdg main"
        state: present
        filename: pgdg

    - name: Install PostgreSQL {{ pg_version }} and client tools
      ansible.builtin.apt:
        name:
          - "postgresql-{{ pg_version }}"
          - "postgresql-client-{{ pg_version }}"
          - "python3-psycopg2"   # required by Ansible's postgresql_* modules
        state: present
        update_cache: true

    - name: Template postgresql.conf from version-controlled source
      ansible.builtin.template:
        src: templates/postgresql.conf.j2
        dest: "{{ pg_config_dir }}/postgresql.conf"
        owner: postgres
        group: postgres
        mode: "0644"
      notify: Reload PostgreSQL

    - name: Template pg_hba.conf from version-controlled source
      ansible.builtin.template:
        src: templates/pg_hba.conf.j2
        dest: "{{ pg_config_dir }}/pg_hba.conf"
        owner: postgres
        group: postgres
        mode: "0640"
      notify: Reload PostgreSQL

    - name: Ensure PostgreSQL is running and enabled on boot
      ansible.builtin.service:
        name: "postgresql@{{ pg_version }}-main"
        state: started
        enabled: true

    - name: Wait for PostgreSQL to accept connections
      ansible.builtin.wait_for:
        port: 5432
        host: "{{ ansible_default_ipv4.address }}"
        timeout: 30

  handlers:
    - name: Reload PostgreSQL
      ansible.builtin.service:
        name: "postgresql@{{ pg_version }}-main"
        state: reloaded

Template Jinja2 mà nó render ra — điểm quan trọng là mọi giá trị hợp lý khác nhau giữa một primary mạnh và một replica nhỏ đều là biến, không phải con số gõ tay:

{# templates/postgresql.conf.j2 #}
listen_addresses = '{{ pg_listen_addresses }}'
port = 5432
max_connections = {{ pg_max_connections }}

shared_buffers = '{{ pg_shared_buffers }}'
effective_cache_size = '{{ pg_effective_cache_size }}'

wal_level = '{{ pg_wal_level }}'
max_wal_senders = 10
wal_keep_size = '1GB'
hot_standby = on

log_destination = 'stderr'
logging_collector = on
log_directory = 'log'
log_min_duration_statement = 1000   # ms — feeds the check_slow_queries.sh script above
log_line_prefix = '%m [%p] %q%u@%d '

pg_hba.conf.j2, được sinh ra từ danh sách pg_hba_rules thay vì duy trì thủ công, nên thêm một application hay replica mới nghĩa là thêm một entry vào danh sách biến, không phải nhớ chính xác cách căn cột của file:

{# templates/pg_hba.conf.j2 #}
# TYPE  DATABASE  USER  ADDRESS  METHOD
local   all       postgres              peer
{% for rule in pg_hba_rules %}
{{ rule.type }}  {{ rule.database }}  {{ rule.user }}  {{ rule.address }}  {{ rule.method }}
{% endfor %}

Chạy nó bằng ansible-playbook -i inventory/prod postgresql.yml --check --diff trước để xem trước chính xác điều gì sẽ thay đổi trên từng server — một diff của postgresql.confpg_hba.conf dễ review đúng hơn nhiều so với việc tin rằng một lần sửa tay đã được áp dụng giống hệt trên ba replica — rồi mới chạy không có --check để apply thật. Vì module template chỉ báo changed khi file được render thực sự khác với những gì đang có trên đĩa, một lần chạy thứ hai trên server đã hội tụ sẽ báo không có thay đổi nào, và handler notify: Reload PostgreSQL chỉ kích hoạt — reload, không bao giờ restart, nên các kết nối hiện có vẫn sống sót — khi file config thực sự đổi.

Khái niệm chính

Năng lực hạ tầng: bộ kỹ năng DBA ngoài SQL

Một PostgreSQL DBA hai mươi năm trước hoàn toàn có thể dành cả sự nghiệp đắm chìm trong SQL, query plan, và công cụ backup mà không bao giờ đụng đến một firewall rule. Điều đó không còn đúng nữa. Database bây giờ chạy trên hạ tầng mà DBA được kỳ vọng tự lý giải trực tiếp — một VM cloud hay một pod Kubernetes, không phải một máy chuyên dụng ai đó khác đã rack và đấu cáp sẵn — và ba kỹ năng liên quan đã trở thành một phần của công việc chứ không còn là vấn đề của người khác:

Không điều gì trong số này thay thế kiến thức sâu về SQL và internals của PostgreSQL — nó nằm cạnh kiến thức đó. Cách đóng khung thực tế là một DBA hiện đại gần với một kỹ sư hạ tầng chuyên về PostgreSQL hơn là một chuyên gia SQL thuần túy tình cờ có quyền truy cập server, và các thói quen automation trong bài này chính là nơi bộ kỹ năng rộng hơn đó được áp dụng hàng ngày.

Tự động hóa các tác vụ vận hành thường nhật

Automation có đòn bẩy cao nhất mà một DBA viết ra không hào nhoáng gì — đó là biến các tác vụ lẽ ra phải diễn ra đều đặn, và là thảm họa nếu bị bỏ qua hoặc làm sai, thành thứ chạy mà không cần một con người nhớ để làm nó.

Kiểm tra backup bằng script. ./14-backup-and-recovery.md đã bàn về điều gì thực sự làm nên một backup hợp lệ — một backup chưa ai từng restore chỉ là một giả thuyết, không phải một backup. Tự động hóa việc kiểm tra đó nghĩa là một job theo lịch sẽ restore backup mới nhất vào một instance scratch, chạy pg_verifybackup (với backup lấy bằng pg_basebackup) hoặc kiểm tra hợp lý bằng pg_restore --list (với archive định dạng custom của pg_dump), rồi chạy vài kiểm tra cấp ứng dụng — đếm số dòng trên các bảng quan trọng, timestamp gần nhất trong một audit log — trước khi công nhận backup là tốt:

#!/usr/bin/env bash
# verify_backup.sh — restore last night's base backup into a scratch instance
# and confirm it actually comes up and holds recent data.
set -euo pipefail

BACKUP_DIR="/backups/basebackup/$(date +%F)"
SCRATCH_DATA_DIR="/var/lib/postgresql/16/scratch_verify"
SCRATCH_PORT=5433

rm -rf "$SCRATCH_DATA_DIR"
cp -a "$BACKUP_DIR" "$SCRATCH_DATA_DIR"

pg_verifybackup "$BACKUP_DIR" || { echo "FAIL: pg_verifybackup reported corruption"; exit 1; }

pg_ctl -D "$SCRATCH_DATA_DIR" -o "-p ${SCRATCH_PORT}" -l /tmp/verify_restore.log start
trap 'pg_ctl -D "$SCRATCH_DATA_DIR" stop -m immediate' EXIT

for i in $(seq 1 30); do
  pg_isready -p "$SCRATCH_PORT" && break
  sleep 2
done

row_count=$(psql -p "$SCRATCH_PORT" -d appdb -tAc "SELECT count(*) FROM orders;")
latest_row=$(psql -p "$SCRATCH_PORT" -d appdb -tAc "SELECT max(created_at) FROM orders;")

if [[ "$row_count" -lt 1000 ]]; then
  echo "FAIL: restored orders table has suspiciously few rows ($row_count)"; exit 1
fi

echo "OK: backup restored successfully — ${row_count} rows, latest order at ${latest_row}"

Chạy script này mỗi đêm từ cron hoặc lịch CI, cảnh báo khi exit code khác 0, và câu hỏi “backup của chúng ta có thực sự cứu được chúng ta không” thôi là thứ bạn phải phát hiện ra giữa một sự cố thật.

Kiểm thử failover bằng script. ./13-replication-and-high-availability.md đã bàn về cơ chế failover do Patroni quản lý; góc nhìn automation ở đây là một runbook failover chưa ai thực hiện trong sáu tháng chính là kịch bản hai mươi bước dưới áp lực đã mở đầu bài này. Cách khắc phục là chủ động kích hoạt một lần failover theo lịch, ở staging, và kiểm chứng kết quả bằng chương trình thay vì chờ production tự kiểm tra hộ bạn:

#!/usr/bin/env bash
# test_failover.sh — trigger a scheduled Patroni switchover in staging and
# verify the new primary is accepting writes within an acceptable window.
set -euo pipefail

CLUSTER_NAME="staging-pg-cluster"
OLD_PRIMARY=$(patronictl -c /etc/patroni/patroni.yml list "$CLUSTER_NAME" \
  --format json | jq -r '.[] | select(.Role=="Leader") | .Member')

echo "Triggering switchover away from ${OLD_PRIMARY}..."
patronictl -c /etc/patroni/patroni.yml switchover "$CLUSTER_NAME" \
  --master "$OLD_PRIMARY" --force

start=$(date +%s)
new_primary=""
while [[ -z "$new_primary" ]]; do
  new_primary=$(patronictl -c /etc/patroni/patroni.yml list "$CLUSTER_NAME" \
    --format json | jq -r --arg old "$OLD_PRIMARY" \
    '.[] | select(.Role=="Leader" and .Member != $old) | .Member')
  [[ $(( $(date +%s) - start )) -gt 60 ]] && { echo "FAIL: no new leader after 60s"; exit 1; }
  sleep 2
done
elapsed=$(( $(date +%s) - start ))

psql -h "$new_primary" -d appdb -c "CREATE TABLE IF NOT EXISTS failover_probe(ts timestamptz);" \
  -c "INSERT INTO failover_probe VALUES (now());" \
  || { echo "FAIL: new primary ${new_primary} did not accept writes"; exit 1; }

echo "OK: switchover completed in ${elapsed}s, new primary ${new_primary} accepts writes"
# Alert if elapsed exceeds your RTO target, e.g.:
# [[ "$elapsed" -gt 30 ]] && curl -X POST "$SLACK_WEBHOOK_URL" -d "{\"text\":\"Failover took ${elapsed}s, exceeds RTO\"}"

Lên lịch chạy việc này hàng tháng ở staging (không bao giờ ở production, nơi một lần switchover ngoài kế hoạch là một sự cố thật, không phải một buổi diễn tập) làm được hai việc cùng lúc: chứng minh runbook và cấu hình Patroni thực sự hoạt động, và đo thời gian failover thật so với mục tiêu RTO thay vì một con số giả định.

Tự động tạo partition trước khi cần đến. ./12-partitioning-and-sharding.md đã bàn về lý do một bảng range-partition cần partition kế tiếp tồn tại trước khi dữ liệu của giai đoạn đó bắt đầu đến, và một script theo lịch hoặc extension pg_partman là cách chuẩn để đảm bảo điều đó. Điểm về automation đáng nhắc lại ở đây: đây không phải một cron job “có thì tốt,” mà là ngăn chặn một chế độ lỗi cứng — thiếu một partition tương lai nghĩa là các câu insert hoặc báo lỗi ngay lập tức hoặc âm thầm dồn vào một bucket DEFAULT, và cả hai kết cục đều tệ hơn năm phút cần để lên lịch cho hàm bảo trì của pg_partman hoặc một script nhỏ chạy CREATE TABLE ... PARTITION OF ... trước lịch một tháng.

Infrastructure as code cho tầng database trên cloud

Mọi thứ ở trên giả định DBA kiểm soát OS mà database chạy trên đó. Trong bối cảnh managed database — Amazon RDS, Aurora, Google Cloud SQL, Azure Database for PostgreSQL — không có OS nào để cấu hình cả: nhà cung cấp sở hữu instance, và công việc của DBA chuyển sang khai báo managed instance nên trông như thế nào (engine version, instance class, storage, parameter group, backup retention, vị trí network) dưới dạng code, để việc provision một môi trường mới là một diff đã review thay vì một chuỗi cú click console không ai ghi lại. ../devops/en/12-infrastructure-as-code.md đã bàn sâu về cơ chế chung của Terraform — state, plan/apply, module; phần riêng của PostgreSQL đơn giản là chính database trở thành một resource được khai báo thêm:

# rds_postgres.tf — a Terraform-managed RDS PostgreSQL instance
resource "aws_db_parameter_group" "pg16" {
  name   = "app-pg16-params"
  family = "postgres16"

  parameter {
    name  = "log_min_duration_statement"
    value = "1000"
  }
  parameter {
    name  = "shared_preload_libraries"
    value = "pg_stat_statements"
  }
}

resource "aws_db_instance" "app" {
  identifier     = "app-prod-db"
  engine         = "postgres"
  engine_version = "16.4"
  instance_class = "db.r6g.xlarge"

  allocated_storage     = 200
  max_allocated_storage = 1000   # storage autoscaling ceiling
  storage_encrypted     = true

  db_subnet_group_name   = aws_db_subnet_group.app.name
  vpc_security_group_ids = [aws_security_group.db.id]
  parameter_group_name   = aws_db_parameter_group.pg16.name

  multi_az                    = true
  backup_retention_period      = 14
  performance_insights_enabled = true

  lifecycle {
    prevent_destroy = true   # apply fails rather than dropping the production DB
  }
}

Sự dịch chuyển quan trọng so với chạy PostgreSQL trên một VM do DBA cấu hình bằng Ansible: parameter { name = "log_min_duration_statement" ... } thay cho việc tự template postgresql.conf, multi_az thay cho physical replication do Patroni quản lý, và backup_retention_period thay cho một cron job pg_basebackup — các khái niệm vận hành xuyên suốt bài này (cấu hình nhất quán, replication cho HA, backup retention, failover đã kiểm chứng) không biến mất, dịch vụ managed chỉ chuyển ai là người triển khai cơ chế, còn IaC vẫn giữ phần khai báo của những gì nên tồn tại trong version control dù thế nào đi nữa. prevent_destroy đáng được nhắc riêng: một terraform apply có dòng -/+ (destroy rồi tạo lại) nhắm vào một aws_db_instance production là dòng nguy hiểm nhất mà một DBA từng phải review trong một plan, và lifecycle guard này biến một sự kiện mất dữ liệu ngoài ý muốn thành một lần apply thất bại.

Bảng tham chiếu tác vụ automation

Tác vụTool thường dùngVì sao nên tự động hóa
Phân tích log tìm slow query / lỗiShell script grep/awk/sed, cronTần suất cao (liên tục), soi bằng less/grep tay không scale được quá một sự cố
Cài PostgreSQL + package OS nhất quánAnsible / Chef / PuppetMọi node mới phải khớp hạm đội chính xác; cài tay dễ lệch về package/version
Template hóa postgresql.conf / pg_hba.confAnsible (Jinja2) / Puppet / ChefRủi ro sai sót thủ công cao (gõ nhầm một setting bộ nhớ, sai CIDR trong một rule HBA); phải giữ giống hệt giữa primary/replica/DR
Kiểm tra backup (restore + kiểm tra số dòng/nội dung)Shell script + lịch cron/CICực kỳ quan trọng — một backup chưa kiểm chứng chỉ là giả thuyết; thảm họa thật hiếm khi xảy ra nên nếu không tự động thì việc kiểm tra tay sẽ không bao giờ được làm
Kiểm thử failover / switchoverShell script + patronictl, lên lịch ở stagingQuan trọng và hiếm gặp ở production, nên chỉ có cách diễn tập lặp lại, có kịch bản mới biết runbook có hoạt động không
Tạo partition trước cửa sổ retentionpg_partman hoặc script theo lịchTần suất cao (hàng tuần/tháng), chế độ lỗi cứng âm thầm nếu bỏ sót (insert lỗi hoặc dồn vào DEFAULT)
Provision managed database instance (RDS, Cloud SQL)Terraform / OpenTofuNhất quán giữa các môi trường, diff có thể review, prevent_destroy chặn sai sót thủ công thảm khốc
Việc keo dán ad-hoc: exporter tùy chỉnh, báo cáo lag tổng hợp, migration một lầnPython (psycopg2/psycopg3)Không vừa gọn một dòng shell hay một module CM; cần luồng điều khiển thật, xử lý lỗi, hoặc một API client

Best Practices

Bắt đầu mọi nỗ lực automation ở mức độ phức tạp mà bài toán thực sự cần, không phải mức độ trông ấn tượng. Một script grep/awk ba dòng chạy từ cron là câu trả lời đúng cho “cảnh báo tôi khi một query chậm,” và nhảy thẳng tới một nền tảng CM đầy đủ hay một dịch vụ Python tự chế cho cùng bài toán đó là over-engineering, thêm gánh nặng bảo trì mà không thêm an toàn. Chỉ nâng lên configuration management khi nỗi đau thực sự là tính nhất quán trên hạm đội — cùng một setting trong postgresql.conf cần giống hệt nhau giữa một primary và vài replica, hoặc một DR node cần dựng lại được từ định nghĩa thay vì từ trí nhớ ai đó về cách bản gốc từng được thiết lập.

Coi mọi file cấu hình mà một CM tool quản lý là nguồn sự thật duy nhất, và coi bất kỳ chỉnh sửa tay nào thực hiện trực tiếp trên server là drift cần bắt lấy, không phải điều được dung thứ. Giá trị của việc template hóa postgresql.confpg_hba.conf tan biến ngay khoảnh khắc ai đó SSH vào “chỉ lần này thôi” để tăng một setting dưới áp lực mà không đưa thay đổi đó ngược lại vào template — lần chạy CM kế tiếp hoặc âm thầm revert lại sửa chữa của họ (nếu tool tự áp dụng lại vô điều kiện) hoặc sửa chữa đó âm thầm lệch khỏi những gì trong git (nếu không), và dù kiểu nào thì file trên đĩa và file trong version control giờ đã bất đồng về sự thật. Chạy playbook với --check --diff trước khi apply vào production, y hệt cách bạn review một Terraform plan, và coi trọng một diff khác không trên một server không ai định đụng vào — đó là drift đang chỉ cho bạn biết cần nhìn vào đâu.

Dành lập trình thật (Python, phổ biến nhất) cho phần automation mà script và module CM thực sự không diễn đạt gọn gàng được: logic orchestration nhiều bước, các lệnh gọi tới nhiều API cần được đối chiếu với nhau, hay các kiểm tra monitoring có logic rẽ nhánh thật thay vì một ngưỡng pass/fail đơn giản. Viết một script ngay từ đầu, bằng bất kỳ ngôn ngữ nào phù hợp, luôn tốt hơn không tự động hóa một tác vụ lặp lại — mục đích của việc xây dựng năng lực hạ tầng rộng hơn với tư cách một DBA là có sẵn công cụ đúng kích cỡ cho từng tầng của bài toán, chứ không phải mặc định dùng công cụ mạnh nhất theo thói quen.

Cuối cùng, biến chính việc kiểm chứng thành một quá trình theo lịch, không người canh, thay vì một niềm tin. Một backup chưa từng được restore và một runbook failover chưa từng được thực thi, xét về mặt chức năng, đều là code chưa được test — và code chưa được test hỏng đúng lúc bạn không thể chịu đựng nổi, mà với một DBA thường nghĩa là ngay giữa sự cố thật mà automation lẽ ra phải giúp an toàn. Lên lịch cho việc kiểm tra restore backup và diễn tập failover ở staging như đã mô tả biến “chúng tôi nghĩ disaster recovery của mình hoạt động” thành “chúng tôi biết, vì nó đã chạy thành công thứ Ba tuần trước” — và biến một thảm họa thật, khi nó cuối cùng xảy ra, thành việc thực thi một quy trình đã được kiểm chứng thay vì lần thử nghiệm thật đầu tiên của một quy trình chưa từng được chứng minh.

Cuối cùng, giữ cho chính automation có thể quan sát và review được giống như bạn vẫn đòi hỏi với application code: đặt tên task và bước rõ ràng để output của một lần chạy nói cho bạn biết chuyện gì đã xảy ra, cảnh báo khi thất bại thay vì bắt ai đó phải để ý một status page xanh/đỏ, và giữ mọi playbook, script, module Terraform trong version control với cùng kỷ luật review như một schema migration — vì ở quy mô mà automation vận hành (nhiều server, không người canh, theo lịch), một sai sót trong chính automation là một sai sót được nhân bản ở mọi nơi nó chạy, ngay lập tức, đúng là chế độ lỗi mà toàn bộ ngành này tồn tại để ngăn chặn ngay từ đầu.

Tài liệu tham khảo

Xem thêm: ../devops/en/13-configuration-management.md, ../devops/en/12-infrastructure-as-code.md, ./13-replication-and-high-availability.md, ./14-backup-and-recovery.md, ./16-monitoring-logging-and-diagnostics.md, ./12-partitioning-and-sharding.md.

Part of the PostgreSQL DBA Roadmap knowledge base.

Overview

There is a specific moment that separates a DBA who is managing one database from one who is managing a fleet: the moment a task needs to be done identically, more than once, under time pressure. SSHing into a server and typing ALTER SYSTEM SET ... or hand-editing pg_hba.conf works fine the first time, on one box, when nothing is on fire. It stops working the second the same change needs to land on a primary, two replicas, and a DR standby without anyone forgetting a step, and it stops working entirely at 3 a.m. when a failover runbook exists only as a wiki page with twenty numbered steps that someone now has to execute perfectly from memory while paged and half awake.

That gap — between “I know how to do this” and “this will be done correctly, the same way, every time, by anyone, even under stress” — is what automation and infrastructure as code (IaC) close. Three properties matter more than any specific tool: consistency (every server that is supposed to look the same actually does, because it was built from the same definition rather than by hand); repeatability (a disaster recovery drill or a failover is a script or a playbook run, not a memory test); and auditability (the change that altered shared_buffers on every replica is a commit with an author, a timestamp, and a diff, not a shell history entry that scrolled off three servers ago). None of this is exotic DevOps ideology imported into the database world for its own sake — it is the direct, practical answer to the two failure modes that hurt DBAs the most: the config that quietly diverged between primary and replica six months ago and nobody noticed until a failover surfaced the difference, and the recovery procedure that was correct in someone’s head but wrong on paper (or nowhere at all) the one time it actually had to run.

This note covers automation and IaC specifically through a PostgreSQL DBA’s lens: where to start (plain scripts, before reaching for a full configuration management tool), how configuration management tools apply to PostgreSQL specifically, a real Ansible playbook that provisions and configures a PostgreSQL server, the broader infrastructure literacy a modern DBA needs beyond SQL, and how to automate the recurring operational tasks — backup verification, failover testing, partition creation — that are exactly the ones a tired or rushed human gets wrong.

Fundamentals

Automate the boring, repeated parts first

It is tempting to read “automation” and jump straight to a full configuration management platform, an orchestration layer, and a pipeline. In practice, most of the highest-value automation a PostgreSQL DBA writes is much smaller than that: a shell script that wraps a command you would otherwise type by hand, or an awk/sed/grep pipeline that turns a wall of log text into the one line that matters. This is worth doing first, before reaching for Ansible or Terraform, for a simple reason — a fifteen-line script that you understand completely, that runs from cron or your terminal, has a far better cost-to-benefit ratio for a task you do a few times a week than standing up a configuration management platform to do the same thing. Save the heavier tools for the problem they’re actually good at: keeping many servers in a consistent, declared state. Use scripts for the problem they’re good at: turning a manual, error-prone, repeated action into a one-line command.

The PostgreSQL log file and the output of diagnostic commands (psql, pg_stat_activity, pg_stat_statements) are text, and text is exactly what grep, awk, and sed exist to process. See ./16-monitoring-logging-and-diagnostics.md for the diagnostic tools and views these scripts wrap — this note is about turning a one-off diagnostic query you’d otherwise type into psql by hand into something that runs unattended and alerts you.

#!/usr/bin/env bash
# check_slow_queries.sh — grep the PostgreSQL log for statements over a threshold,
# extract duration and query text with awk, and alert if any exceed a hard limit.
set -euo pipefail

LOG_FILE="/var/log/postgresql/postgresql-16-main.log"
THRESHOLD_MS=5000   # alert on anything slower than 5 seconds
SINCE_MINUTES=15

# log_min_duration_statement must be set (e.g. to 1000) in postgresql.conf
# for "duration: ... ms  statement: ..." lines to appear at all.
recent_slow=$(
  find "$LOG_FILE" -newermt "-${SINCE_MINUTES} minutes" -printf '' 2>/dev/null || true
  grep --text 'duration:' "$LOG_FILE" \
    | awk -v threshold="$THRESHOLD_MS" '
        {
          for (i = 1; i <= NF; i++) {
            if ($i == "duration:") {
              dur = $(i + 1) + 0     # milliseconds, as a number
              if (dur >= threshold) print
            }
          }
        }'
)

if [[ -n "$recent_slow" ]]; then
  count=$(echo "$recent_slow" | wc -l)
  echo "ALERT: ${count} statement(s) over ${THRESHOLD_MS}ms in the last check:" >&2
  echo "$recent_slow" | tail -n 5 >&2
  # Wire this into your alerting channel instead of just stderr:
  # curl -s -X POST "$SLACK_WEBHOOK_URL" -d "{\"text\": \"${count} slow queries detected\"}"
  exit 1
fi

echo "OK: no statements over ${THRESHOLD_MS}ms"

This is the pragmatic starting point: a script anyone on the team can read top to bottom in thirty seconds, that does one job, that you can run by hand while debugging or drop into cron or a systemd timer once you trust it. The same pattern — grep to find the lines that matter, awk to pull fields out of them, sed to reshape them — covers an enormous fraction of routine DBA log triage: counting FATAL/ERROR lines per hour, extracting checkpoint timing (grep 'checkpoint complete'), or watching for could not receive data from client connection resets. Reach for a configuration management tool when the problem becomes “keep N servers in this state,” not before — a script that only you run, on one box, to answer one question does not need a control node, an inventory, or a YAML dialect.

Configuration management for PostgreSQL

Once the same task needs to happen consistently across a fleet — install PostgreSQL the same way on every node, keep postgresql.conf and pg_hba.conf in sync between primary and replicas, guarantee that a freshly rebuilt DR node ends up bit-for-bit configured like the node it’s replacing — plain scripts stop being the right tool, and configuration management (CM) takes over. ../devops/en/13-configuration-management.md covers the general mechanics of Ansible, Chef, Puppet, and Salt in depth — push vs. pull models, idempotency, the tool landscape — and that note is the place to go for how these tools work in general. This section stays narrowly focused on what changes when the thing being configured is a PostgreSQL server rather than a generic web tier.

Applied to PostgreSQL, a CM tool typically owns four responsibilities:

The tool landscape itself doesn’t change for PostgreSQL: Ansible remains the most common choice for DBA-authored automation because it’s agentless (no persistent agent process competing with PostgreSQL for resources on the database host) and its module set (postgresql_db, postgresql_user, postgresql_privs, postgresql_query in the community.postgresql collection) covers database-level objects directly, not just OS state. Puppet and Chef show up more often in shops that already standardized on them for the rest of the fleet and simply extend that same pull-based, continuously-converging model to the database tier.

A real Ansible playbook for PostgreSQL

The playbook below installs PostgreSQL from the PGDG repository, templates postgresql.conf from variables (so the same role produces a primary-sized config and a smaller replica/dev config just by changing variables), and ensures the service is running and enabled — the same “describe the destination, not the journey” model covered in ../devops/en/13-configuration-management.md, applied to a database instead of a web tier.

# postgresql.yml — provision and configure a PostgreSQL server
- name: Configure PostgreSQL server
  hosts: postgresql_servers
  become: true
  vars:
    pg_version: "16"
    pg_data_dir: "/var/lib/postgresql/{{ pg_version }}/main"
    pg_config_dir: "/etc/postgresql/{{ pg_version }}/main"
    pg_listen_addresses: "*"
    pg_max_connections: 200
    pg_shared_buffers: "4GB"
    pg_effective_cache_size: "12GB"
    pg_wal_level: "replica"
    pg_hba_rules:
      - { type: "host", database: "all", user: "app_user", address: "10.0.1.0/24", method: "scram-sha-256" }
      - { type: "host", database: "replication", user: "replicator", address: "10.0.2.0/24", method: "scram-sha-256" }

  tasks:
    - name: Add PGDG APT signing key
      ansible.builtin.apt_key:
        url: https://www.postgresql.org/media/keys/ACCC4CF8.asc
        state: present

    - name: Add PGDG APT repository
      ansible.builtin.apt_repository:
        repo: "deb http://apt.postgresql.org/pub/repos/apt {{ ansible_distribution_release }}-pgdg main"
        state: present
        filename: pgdg

    - name: Install PostgreSQL {{ pg_version }} and client tools
      ansible.builtin.apt:
        name:
          - "postgresql-{{ pg_version }}"
          - "postgresql-client-{{ pg_version }}"
          - "python3-psycopg2"   # required by Ansible's postgresql_* modules
        state: present
        update_cache: true

    - name: Template postgresql.conf from version-controlled source
      ansible.builtin.template:
        src: templates/postgresql.conf.j2
        dest: "{{ pg_config_dir }}/postgresql.conf"
        owner: postgres
        group: postgres
        mode: "0644"
      notify: Reload PostgreSQL

    - name: Template pg_hba.conf from version-controlled source
      ansible.builtin.template:
        src: templates/pg_hba.conf.j2
        dest: "{{ pg_config_dir }}/pg_hba.conf"
        owner: postgres
        group: postgres
        mode: "0640"
      notify: Reload PostgreSQL

    - name: Ensure PostgreSQL is running and enabled on boot
      ansible.builtin.service:
        name: "postgresql@{{ pg_version }}-main"
        state: started
        enabled: true

    - name: Wait for PostgreSQL to accept connections
      ansible.builtin.wait_for:
        port: 5432
        host: "{{ ansible_default_ipv4.address }}"
        timeout: 30

  handlers:
    - name: Reload PostgreSQL
      ansible.builtin.service:
        name: "postgresql@{{ pg_version }}-main"
        state: reloaded

The Jinja2 template it renders — the important part is that every value that legitimately differs between a beefy primary and a small replica is a variable, not a hand-typed number:

{# templates/postgresql.conf.j2 #}
listen_addresses = '{{ pg_listen_addresses }}'
port = 5432
max_connections = {{ pg_max_connections }}

shared_buffers = '{{ pg_shared_buffers }}'
effective_cache_size = '{{ pg_effective_cache_size }}'

wal_level = '{{ pg_wal_level }}'
max_wal_senders = 10
wal_keep_size = '1GB'
hot_standby = on

log_destination = 'stderr'
logging_collector = on
log_directory = 'log'
log_min_duration_statement = 1000   # ms — feeds the check_slow_queries.sh script above
log_line_prefix = '%m [%p] %q%u@%d '

And pg_hba.conf.j2, generated from the pg_hba_rules list rather than hand-maintained, so adding a new application or replica means adding one entry to the variable list, not remembering the exact column alignment of the file:

{# templates/pg_hba.conf.j2 #}
# TYPE  DATABASE  USER  ADDRESS  METHOD
local   all       postgres              peer
{% for rule in pg_hba_rules %}
{{ rule.type }}  {{ rule.database }}  {{ rule.user }}  {{ rule.address }}  {{ rule.method }}
{% endfor %}

Run it with ansible-playbook -i inventory/prod postgresql.yml --check --diff first to preview exactly what would change on each server — a diff of postgresql.conf and pg_hba.conf is far easier to review correctly than trusting that a hand-edit was applied identically to three replicas — then without --check to apply. Because the template module only reports changed when the rendered file actually differs from what’s on disk, a second run against a server that already converged reports zero changes, and the notify: Reload PostgreSQL handler only fires — reloading, never restarting, so existing connections survive — when a config file genuinely changed.

Key Concepts

Infrastructure literacy: the DBA skill set beyond SQL

A PostgreSQL DBA twenty years ago could plausibly spend an entire career deep in SQL, query plans, and backup tooling without ever touching a firewall rule. That is no longer true. The database now runs on infrastructure the DBA is expected to reason about directly — a cloud VM or Kubernetes pod, not a dedicated machine someone else racked and cabled — and three adjacent skills have become part of the job rather than someone else’s problem:

None of this replaces deep SQL and PostgreSQL internals knowledge — it sits alongside it. The practical framing is that a modern DBA is closer to an infrastructure engineer who specializes in PostgreSQL than to a pure SQL specialist who happens to have server access, and the automation habits in this note are exactly where that broader skill set gets applied day to day.

Automating routine DBA operations

The highest-leverage automation a DBA writes is not glamorous — it is turning the tasks that are supposed to happen regularly, and that are catastrophic when skipped or done wrong, into something that runs without a human remembering to do it.

Scripted backup verification. ./14-backup-and-recovery.md covers what a valid backup actually requires — a backup nobody has restored is a hypothesis, not a backup. Automating that verification means a scheduled job restores the latest backup into a scratch instance, runs pg_verifybackup (for pg_basebackup-taken backups) or a pg_restore --list sanity check (for pg_dump custom-format archives), and then runs a handful of application-level checks — row counts on critical tables, the most recent timestamp in an audit log — before declaring the backup good:

#!/usr/bin/env bash
# verify_backup.sh — restore last night's base backup into a scratch instance
# and confirm it actually comes up and holds recent data.
set -euo pipefail

BACKUP_DIR="/backups/basebackup/$(date +%F)"
SCRATCH_DATA_DIR="/var/lib/postgresql/16/scratch_verify"
SCRATCH_PORT=5433

rm -rf "$SCRATCH_DATA_DIR"
cp -a "$BACKUP_DIR" "$SCRATCH_DATA_DIR"

pg_verifybackup "$BACKUP_DIR" || { echo "FAIL: pg_verifybackup reported corruption"; exit 1; }

pg_ctl -D "$SCRATCH_DATA_DIR" -o "-p ${SCRATCH_PORT}" -l /tmp/verify_restore.log start
trap 'pg_ctl -D "$SCRATCH_DATA_DIR" stop -m immediate' EXIT

for i in $(seq 1 30); do
  pg_isready -p "$SCRATCH_PORT" && break
  sleep 2
done

row_count=$(psql -p "$SCRATCH_PORT" -d appdb -tAc "SELECT count(*) FROM orders;")
latest_row=$(psql -p "$SCRATCH_PORT" -d appdb -tAc "SELECT max(created_at) FROM orders;")

if [[ "$row_count" -lt 1000 ]]; then
  echo "FAIL: restored orders table has suspiciously few rows ($row_count)"; exit 1
fi

echo "OK: backup restored successfully — ${row_count} rows, latest order at ${latest_row}"

Run this nightly from cron or a CI schedule, alert on any non-zero exit, and the question “would our backup actually have saved us” stops being something you find out during a real incident.

Scripted failover testing. ./13-replication-and-high-availability.md covers Patroni-managed failover mechanics; the automation angle is that a failover runbook nobody has executed in six months is exactly the twenty-step-under-pressure scenario this note opened with. The fix is to deliberately trigger a failover on a schedule, in staging, and verify the outcome programmatically rather than waiting for production to test it for you:

#!/usr/bin/env bash
# test_failover.sh — trigger a scheduled Patroni switchover in staging and
# verify the new primary is accepting writes within an acceptable window.
set -euo pipefail

CLUSTER_NAME="staging-pg-cluster"
OLD_PRIMARY=$(patronictl -c /etc/patroni/patroni.yml list "$CLUSTER_NAME" \
  --format json | jq -r '.[] | select(.Role=="Leader") | .Member')

echo "Triggering switchover away from ${OLD_PRIMARY}..."
patronictl -c /etc/patroni/patroni.yml switchover "$CLUSTER_NAME" \
  --master "$OLD_PRIMARY" --force

start=$(date +%s)
new_primary=""
while [[ -z "$new_primary" ]]; do
  new_primary=$(patronictl -c /etc/patroni/patroni.yml list "$CLUSTER_NAME" \
    --format json | jq -r --arg old "$OLD_PRIMARY" \
    '.[] | select(.Role=="Leader" and .Member != $old) | .Member')
  [[ $(( $(date +%s) - start )) -gt 60 ]] && { echo "FAIL: no new leader after 60s"; exit 1; }
  sleep 2
done
elapsed=$(( $(date +%s) - start ))

psql -h "$new_primary" -d appdb -c "CREATE TABLE IF NOT EXISTS failover_probe(ts timestamptz);" \
  -c "INSERT INTO failover_probe VALUES (now());" \
  || { echo "FAIL: new primary ${new_primary} did not accept writes"; exit 1; }

echo "OK: switchover completed in ${elapsed}s, new primary ${new_primary} accepts writes"
# Alert if elapsed exceeds your RTO target, e.g.:
# [[ "$elapsed" -gt 30 ]] && curl -X POST "$SLACK_WEBHOOK_URL" -d "{\"text\":\"Failover took ${elapsed}s, exceeds RTO\"}"

Scheduling this monthly in staging (never in production, where an unplanned switchover is a real incident, not a drill) does two things at once: it proves the runbook and the Patroni configuration actually work, and it measures real failover time against your RTO target instead of an assumed one.

Automated partition creation ahead of need. ./12-partitioning-and-sharding.md already covers why a range-partitioned table needs its next partition to exist before data for that period starts arriving, and either a scheduled script or the pg_partman extension is the standard way to guarantee that. The automation point worth restating here: this is not a “nice to have” cron job, it’s a hard-failure-mode prevention — a missing future partition means inserts either error out immediately or silently pile into a DEFAULT catch-all, and both outcomes are worse than the five minutes it takes to schedule pg_partman’s maintenance function or a small script that runs CREATE TABLE ... PARTITION OF ... a month ahead of the calendar.

Infrastructure as code for the database layer in the cloud

Everything so far assumes the DBA controls the OS the database runs on. In managed-database contexts — Amazon RDS, Aurora, Google Cloud SQL, Azure Database for PostgreSQL — there is no OS to configure at all: the provider owns the instance, and the DBA’s job shifts to declaring what the managed instance should look like (engine version, instance class, storage, parameter group, backup retention, network placement) as code, so that provisioning a new environment is a reviewed diff instead of a sequence of console clicks nobody wrote down. ../devops/en/12-infrastructure-as-code.md covers Terraform’s general mechanics — state, plan/apply, modules — in depth; the PostgreSQL-specific piece is simply that the database itself becomes one more declared resource:

# rds_postgres.tf — a Terraform-managed RDS PostgreSQL instance
resource "aws_db_parameter_group" "pg16" {
  name   = "app-pg16-params"
  family = "postgres16"

  parameter {
    name  = "log_min_duration_statement"
    value = "1000"
  }
  parameter {
    name  = "shared_preload_libraries"
    value = "pg_stat_statements"
  }
}

resource "aws_db_instance" "app" {
  identifier     = "app-prod-db"
  engine         = "postgres"
  engine_version = "16.4"
  instance_class = "db.r6g.xlarge"

  allocated_storage     = 200
  max_allocated_storage = 1000   # storage autoscaling ceiling
  storage_encrypted     = true

  db_subnet_group_name   = aws_db_subnet_group.app.name
  vpc_security_group_ids = [aws_security_group.db.id]
  parameter_group_name   = aws_db_parameter_group.pg16.name

  multi_az                    = true
  backup_retention_period      = 14
  performance_insights_enabled = true

  lifecycle {
    prevent_destroy = true   # apply fails rather than dropping the production DB
  }
}

The important shift compared to running PostgreSQL on a VM the DBA configures with Ansible: parameter { name = "log_min_duration_statement" ... } replaces templating postgresql.conf by hand, multi_az replaces Patroni-managed physical replication, and backup_retention_period replaces a pg_basebackup cron job — the operational concepts from the rest of this note (consistent config, replication for HA, backup retention, verified failover) don’t disappear, the managed service just moves who implements the mechanics, while IaC keeps the declaration of what should exist in version control either way. prevent_destroy is worth calling out specifically: a terraform apply that includes a -/+ (destroy and recreate) line against a production aws_db_instance is the single most dangerous line a DBA will ever review in a plan, and the lifecycle guard turns an accidental data-loss event into a failed apply.

Automation task reference

TaskTool typically usedWhy automate it
Parsing logs for slow queries / errorsgrep/awk/sed shell script, cronHigh frequency (continuous), manual less/grep-ing doesn’t scale past one incident
Installing PostgreSQL + OS packages consistentlyAnsible / Chef / PuppetEvery new node must match the fleet exactly; manual installs drift on package/version choices
Templating postgresql.conf / pg_hba.confAnsible (Jinja2) / Puppet / ChefHigh risk of manual error (typo in a memory setting, wrong CIDR in an HBA rule); needs to stay identical across primary/replica/DR
Backup verification (restore + row-count/content check)Shell script + cron/CI scheduleCritical — an unverified backup is a hypothesis; low frequency of real disasters means manual testing never happens otherwise
Failover / switchover testingShell script + patronictl, scheduled in stagingCritical and rare in production, so the only way to know the runbook works is a staged, repeated drill
Partition creation ahead of retention windowpg_partman or a scheduled scriptHigh frequency (weekly/monthly), silent hard-failure mode if missed (inserts error or land in DEFAULT)
Provisioning managed database instances (RDS, Cloud SQL)Terraform / OpenTofuConsistency across environments, reviewable diffs, prevent_destroy guards against catastrophic manual mistakes
Ad-hoc glue: custom exporters, consolidated lag reports, one-off migrationsPython (psycopg2/psycopg3)Doesn’t fit a shell one-liner or a CM module; needs real control flow, error handling, or an API client

Best Practices

Start every automation effort at the level of complexity the problem actually needs, not the level that looks impressive. A three-line grep/awk script that runs from cron is the right answer for “alert me when a query is slow,” and reaching straight for a full CM platform or a bespoke Python service for that same problem is over-engineering that adds maintenance burden without adding safety. Move up to configuration management only once the actual pain is fleet consistency — the same postgresql.conf setting needing to be identical across a primary and several replicas, or a DR node needing to be rebuildable from definition rather than from someone’s memory of how the original was set up.

Treat every configuration file a CM tool manages as the single source of truth, and treat any manual edit made directly on a server as drift to be caught, not tolerated. The value of templating postgresql.conf and pg_hba.conf evaporates the moment someone SSHes in “just this once” to bump a setting under pressure and doesn’t feed the change back into the template — the next CM run either silently reverts their fix (if the tool re-applies unconditionally) or the fix silently diverges from what’s in git (if it doesn’t), and either way the file on disk and the file in version control now disagree about the truth. Run playbooks with --check --diff before applying against production, exactly as you would review a Terraform plan, and take a nonzero diff on a server nobody meant to touch seriously — that is drift telling you where to look.

Reserve real programming (Python, most commonly) for the automation that scripts and CM modules genuinely can’t express cleanly: multi-step orchestration logic, calls to multiple APIs that need to be reconciled, or monitoring checks with actual branching logic rather than a single pass/fail threshold. Writing a script in the first place, in whatever language fits, always beats not automating a repeated task at all — the point of building broader infrastructure literacy as a DBA is having the right-sized tool available for each layer of the problem, not defaulting to the most powerful one out of habit.

Make verification itself a scheduled, unattended process rather than a belief. A backup that has never been restored and a failover runbook that has never been executed are both, functionally, untested code — and untested code fails exactly when you can least afford it, which for a DBA usually means during the actual incident the automation was supposed to make safe. Scheduling the backup-restore check and the staging failover drill described above turns “we think our disaster recovery works” into “we know, because it ran successfully last Tuesday” — and turns a genuine disaster, when one eventually happens, into an execution of a tested procedure rather than the first real test of an unproven one.

Finally, keep the automation itself observable and reviewable the same way you’d insist on for application code: name tasks and steps clearly so a run’s output tells you what happened, alert on failures rather than requiring someone to notice a green/red status page, and keep every playbook, script, and Terraform module in version control with the same review discipline as a schema migration — because at the scale automation operates (many servers, unattended, on a schedule), a mistake in the automation itself is a mistake replicated everywhere it runs, instantly, which is exactly the failure mode the whole discipline exists to prevent in the first place.

References

See also: ../devops/en/13-configuration-management.md, ../devops/en/12-infrastructure-as-code.md, ./13-replication-and-high-availability.md, ./14-backup-and-recovery.md, ./16-monitoring-logging-and-diagnostics.md, ./12-partitioning-and-sharding.md.