Files
bonfire/postgres/postgres-tune.sh
T
2026-07-04 08:47:38 +01:00

506 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# PostgreSQL Configuration Tuner
# Based on PGTune algorithm (https://pgtune.leopard.in.ua)
# Detects system resources and calculates optimal settings
# Outputs ENV vars that can be sourced before running envsubst
# Constants
readonly KB=1024
readonly MB=$((1024 * KB))
readonly GB=$((1024 * MB))
readonly TB=$((1024 * GB))
# Database types
readonly DB_TYPE_WEB="web"
readonly DB_TYPE_OLTP="oltp"
readonly DB_TYPE_DW="dw"
readonly DB_TYPE_DESKTOP="desktop"
readonly DB_TYPE_MIXED="mixed"
# Storage types
readonly STORAGE_SSD="ssd"
readonly STORAGE_HDD="hdd"
readonly STORAGE_SAN="san"
# Auto-detect or use environment variables
# NOTE: default profile is `mixed` (not `oltp`): Bonfire is OLTP plus a tail of analytics-shaped
# queries (many-join boundarised feeds/threads), which is exactly PGTune's mixed workload. It also
# right-sizes max_connections (100 vs oltp's 300 — Bonfire's Ecto pool is the pooler, ~25-50 conns;
# an oversized value wastes RAM headroom and shrinks the computed work_mem, which caused measured
# multi-TB temp-file spills in prod).
PG_DB_TYPE="${PG_DB_TYPE:-${DB_TYPE:-mixed}}"
PG_STORAGE_TYPE="${PG_STORAGE_TYPE:-${DISK_STORAGE_TYPE:-ssd}}"
PG_DB_VERSION="${PG_DB_VERSION:-17}"
# System resources detection
detect_total_memory_kb() {
if [[ -n "${AVAILABLE_RAM_GB:-}" ]]; then
echo $((AVAILABLE_RAM_GB * GB / KB))
elif [[ -n "${AVAILABLE_RAM_MB:-}" ]]; then
echo $((AVAILABLE_RAM_MB * MB / KB))
elif [[ -r /sys/fs/cgroup/memory.max && "$(cat /sys/fs/cgroup/memory.max)" != "max" ]]; then
# cgroup v2: an explicit container memory limit is the deliberate budget signal
echo $(($(cat /sys/fs/cgroup/memory.max) / KB))
elif [[ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]] &&
[[ "$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes)" -lt $((1 << 60)) ]]; then
# cgroup v1 (its "no limit" sentinel is a huge number rather than "max")
echo $(($(cat /sys/fs/cgroup/memory/memory.limit_in_bytes) / KB))
elif [[ -f /proc/meminfo ]]; then
# Calculate based on total available memory: claiming a fraction as co-hosted services need the rest. Sized for our standard stack (app + postgres + search + proxy). Override with PG_RAM_PERCENT or AVAILABLE_RAM_MB.
awk -v pct="${PG_RAM_PERCENT:-40}" '/MemTotal/ {print int($2 * pct / 100)}' /proc/meminfo
elif command -v sysctl &> /dev/null; then
# macOS/BSD
local mem_bytes
mem_bytes=$(sysctl -n hw.memsize 2>/dev/null || sysctl -n hw.physmem 2>/dev/null)
echo $((mem_bytes * ${PG_RAM_PERCENT:-40} / 100 / KB))
else
# Fallback: assume 4GB
echo $((4 * GB / KB))
fi
}
detect_cpu_count() {
if [[ -n "${NUM_CPU:-}" ]]; then
echo "$NUM_CPU"
elif command -v nproc &> /dev/null; then
nproc
elif [[ -f /proc/cpuinfo ]]; then
grep -c ^processor /proc/cpuinfo
elif command -v sysctl &> /dev/null; then
sysctl -n hw.ncpu 2>/dev/null || echo "2"
else
echo "2"
fi
}
# Get system resources
TOTAL_MEMORY_KB=$(detect_total_memory_kb)
CPU_COUNT=$(detect_cpu_count)
# Helper functions
to_mb() {
echo $(($1 / KB))
}
to_gb() {
echo $(($1 / MB))
}
# Print detected/configured values to stderr so they don't interfere with the export output
{
echo "=========================================="
echo "PostgreSQL Configuration Detection"
echo "=========================================="
echo "System Resources:"
echo " Total Memory: $(to_mb $TOTAL_MEMORY_KB) MB ($(to_gb $TOTAL_MEMORY_KB) GB)"
echo " CPU Cores: ${CPU_COUNT}"
echo ""
echo "Configuration:"
echo " Database Type: ${PG_DB_TYPE}"
echo " Storage Type: ${PG_STORAGE_TYPE}"
echo " PostgreSQL Version: ${PG_DB_VERSION}"
echo "=========================================="
echo ""
} >&2
# Calculate max_connections
calc_max_connections() {
if [[ -n "${PG_MAX_CONNECTIONS:-}" ]]; then
echo "$PG_MAX_CONNECTIONS"
return
fi
case "$PG_DB_TYPE" in
"$DB_TYPE_WEB") echo 200 ;;
"$DB_TYPE_OLTP") echo 300 ;;
"$DB_TYPE_DW") echo 40 ;;
"$DB_TYPE_DESKTOP") echo 20 ;;
"$DB_TYPE_MIXED") echo 100 ;;
*) echo 100 ;;
esac
}
# Calculate shared_buffers
calc_shared_buffers() {
if [[ -n "${PG_SHARED_BUFFERS_MB:-}" ]]; then
echo $((PG_SHARED_BUFFERS_MB * MB / KB))
return
fi
local shared_buffers_kb
case "$PG_DB_TYPE" in
"$DB_TYPE_WEB"|"$DB_TYPE_OLTP"|"$DB_TYPE_DW"|"$DB_TYPE_MIXED")
shared_buffers_kb=$((TOTAL_MEMORY_KB / 4))
;;
"$DB_TYPE_DESKTOP")
shared_buffers_kb=$((TOTAL_MEMORY_KB / 16))
;;
*)
shared_buffers_kb=$((TOTAL_MEMORY_KB / 4))
;;
esac
echo "$shared_buffers_kb"
}
# Calculate effective_cache_size
calc_effective_cache_size() {
if [[ -n "${PG_EFFECTIVE_CACHE_SIZE_MB:-}" ]]; then
echo $((PG_EFFECTIVE_CACHE_SIZE_MB * MB / KB))
return
fi
case "$PG_DB_TYPE" in
"$DB_TYPE_WEB"|"$DB_TYPE_OLTP"|"$DB_TYPE_DW"|"$DB_TYPE_MIXED")
echo $((TOTAL_MEMORY_KB * 3 / 4))
;;
"$DB_TYPE_DESKTOP")
echo $((TOTAL_MEMORY_KB / 4))
;;
*)
echo $((TOTAL_MEMORY_KB * 3 / 4))
;;
esac
}
# Calculate maintenance_work_mem
calc_maintenance_work_mem() {
if [[ -n "${PG_MAINTENANCE_WORK_MEM_MB:-}" ]]; then
echo $((PG_MAINTENANCE_WORK_MEM_MB * MB / KB))
return
fi
local maint_mem_kb
case "$PG_DB_TYPE" in
"$DB_TYPE_DW")
maint_mem_kb=$((TOTAL_MEMORY_KB / 8))
;;
*)
maint_mem_kb=$((TOTAL_MEMORY_KB / 16))
;;
esac
# Cap at 2GB
local max_maint_mem=$((2 * GB / KB))
if [[ $maint_mem_kb -gt $max_maint_mem ]]; then
maint_mem_kb=$max_maint_mem
fi
echo "$maint_mem_kb"
}
# Calculate checkpoint settings
calc_checkpoint_settings() {
local min_wal_kb max_wal_kb
if [[ -n "${PG_MIN_WAL_SIZE_MB:-}" ]]; then
min_wal_kb=$((PG_MIN_WAL_SIZE_MB * MB / KB))
else
case "$PG_DB_TYPE" in
"$DB_TYPE_WEB"|"$DB_TYPE_MIXED") min_wal_kb=$((1024 * MB / KB)) ;;
"$DB_TYPE_OLTP") min_wal_kb=$((2048 * MB / KB)) ;;
"$DB_TYPE_DW") min_wal_kb=$((4096 * MB / KB)) ;;
"$DB_TYPE_DESKTOP") min_wal_kb=$((100 * MB / KB)) ;;
*) min_wal_kb=$((1024 * MB / KB)) ;;
esac
fi
if [[ -n "${PG_MAX_WAL_SIZE_MB:-}" ]]; then
max_wal_kb=$((PG_MAX_WAL_SIZE_MB * MB / KB))
else
case "$PG_DB_TYPE" in
"$DB_TYPE_WEB"|"$DB_TYPE_MIXED") max_wal_kb=$((4096 * MB / KB)) ;;
"$DB_TYPE_OLTP") max_wal_kb=$((8192 * MB / KB)) ;;
"$DB_TYPE_DW") max_wal_kb=$((16384 * MB / KB)) ;;
"$DB_TYPE_DESKTOP") max_wal_kb=$((2048 * MB / KB)) ;;
*) max_wal_kb=$((4096 * MB / KB)) ;;
esac
fi
echo "$min_wal_kb $max_wal_kb"
}
# Calculate wal_buffers
calc_wal_buffers() {
if [[ -n "${PG_WAL_BUFFERS_MB:-}" ]]; then
echo $((PG_WAL_BUFFERS_MB * MB / KB))
return
fi
local shared_buffers_kb=$1
local wal_buffers_kb=$((shared_buffers_kb * 3 / 100))
local max_wal_buffer=$((16 * MB / KB))
if [[ $wal_buffers_kb -gt $max_wal_buffer ]]; then
wal_buffers_kb=$max_wal_buffer
fi
# Round up to 16MB if close
local near_max=$((14 * MB / KB))
if [[ $wal_buffers_kb -gt $near_max && $wal_buffers_kb -lt $max_wal_buffer ]]; then
wal_buffers_kb=$max_wal_buffer
fi
# Minimum 32KB
if [[ $wal_buffers_kb -lt 32 ]]; then
wal_buffers_kb=32
fi
echo "$wal_buffers_kb"
}
# Calculate default_statistics_target
calc_default_statistics_target() {
if [[ -n "${PG_DEFAULT_STATISTICS_TARGET:-}" ]]; then
echo "$PG_DEFAULT_STATISTICS_TARGET"
return
fi
case "$PG_DB_TYPE" in
"$DB_TYPE_DW") echo 500 ;;
*) echo 100 ;;
esac
}
# Calculate random_page_cost
calc_random_page_cost() {
if [[ -n "${PG_RANDOM_PAGE_COST:-}" ]]; then
echo "$PG_RANDOM_PAGE_COST"
return
fi
case "$PG_STORAGE_TYPE" in
"$STORAGE_HDD") echo "4" ;;
"$STORAGE_SSD"|"$STORAGE_SAN") echo "1.1" ;;
*) echo "1.1" ;;
esac
}
# Calculate effective_io_concurrency
calc_effective_io_concurrency() {
if [[ -n "${PG_EFFECTIVE_IO_CONCURRENCY:-}" ]]; then
echo "$PG_EFFECTIVE_IO_CONCURRENCY"
return
fi
case "$PG_STORAGE_TYPE" in
"$STORAGE_HDD") echo 2 ;;
"$STORAGE_SSD") echo 200 ;;
"$STORAGE_SAN") echo 300 ;;
*) echo 200 ;;
esac
}
# Calculate parallel workers settings
# Echoes 4 space-separated values: max_worker_processes max_parallel_workers_per_gather max_parallel_workers max_parallel_maintenance_workers
# (single source of truth — generate_env_vars reads these instead of duplicating the logic)
calc_parallel_settings() {
local max_worker_processes max_parallel_workers_per_gather max_parallel_workers max_parallel_maintenance_workers
if [[ $CPU_COUNT -ge 4 ]]; then
local workers_per_gather=$((CPU_COUNT / 2))
# Cap at 4 for non-DW workloads
if [[ "$PG_DB_TYPE" != "$DB_TYPE_DW" && $workers_per_gather -gt 4 ]]; then
workers_per_gather=4
fi
local parallel_maint_workers=$((CPU_COUNT / 2))
if [[ $parallel_maint_workers -gt 4 ]]; then
parallel_maint_workers=4
fi
max_worker_processes=${PG_MAX_WORKER_PROCESSES:-$CPU_COUNT}
max_parallel_workers_per_gather=${PG_MAX_PARALLEL_WORKERS_PER_GATHER:-$workers_per_gather}
max_parallel_workers=${PG_MAX_PARALLEL_WORKERS:-$CPU_COUNT}
max_parallel_maintenance_workers=${PG_MAX_PARALLEL_MAINTENANCE_WORKERS:-$parallel_maint_workers}
else
max_worker_processes=${PG_MAX_WORKER_PROCESSES:-8}
max_parallel_workers_per_gather=${PG_MAX_PARALLEL_WORKERS_PER_GATHER:-2}
max_parallel_workers=${PG_MAX_PARALLEL_WORKERS:-8}
max_parallel_maintenance_workers=${PG_MAX_PARALLEL_MAINTENANCE_WORKERS:-2}
fi
echo "$max_worker_processes $max_parallel_workers_per_gather $max_parallel_workers $max_parallel_maintenance_workers"
}
# Calculate work_mem
calc_work_mem() {
if [[ -n "${PG_WORK_MEM_KB:-}" ]]; then
echo "$PG_WORK_MEM_KB"
return
fi
local shared_buffers_kb=$1
local max_connections=$2
# the actual computed worker count, passed in by generate_env_vars so the divisor stays
# consistent with the exported max_worker_processes (was previously assumed to be 8)
local max_worker_processes=${3:-${PG_MAX_WORKER_PROCESSES:-8}}
local work_mem_kb=$(( (TOTAL_MEMORY_KB - shared_buffers_kb) / ((max_connections + max_worker_processes) * 3) ))
case "$PG_DB_TYPE" in
"$DB_TYPE_DW"|"$DB_TYPE_MIXED"|"$DB_TYPE_DESKTOP")
work_mem_kb=$((work_mem_kb / 2))
;;
esac
# Floor for web/oltp/mixed app workloads: below ~16MB, many-join queries (sorts + hashes)
# constantly spill to disk-based algorithms writing temp files (measured multi-TB on a busy
# instance at 16.6MB with an oversized max_connections divisor). Other profiles keep 64KB.
local min_work_mem=64
case "$PG_DB_TYPE" in
"$DB_TYPE_WEB"|"$DB_TYPE_OLTP"|"$DB_TYPE_MIXED")
min_work_mem=$((16 * MB / KB))
;;
esac
if [[ $work_mem_kb -lt $min_work_mem ]]; then
work_mem_kb=$min_work_mem
fi
echo "$work_mem_kb"
}
# Calculate huge_pages
calc_huge_pages() {
if [[ -n "${PG_HUGE_PAGES:-}" ]]; then
echo "$PG_HUGE_PAGES"
return
fi
# Enable for systems with >= 32GB RAM
if [[ $TOTAL_MEMORY_KB -ge $((32 * GB / KB)) ]]; then
echo "try"
else
echo "off"
fi
}
# Calculate checkpoint_completion_target
calc_checkpoint_completion_target() {
echo "${PG_CHECKPOINT_COMPLETION_TARGET:-0.9}"
}
# Calculate autovacuum_max_workers (~1/3 of cores, min 3 which is also the PG default)
calc_autovacuum_max_workers() {
if [[ -n "${PG_AUTOVACUUM_MAX_WORKERS:-}" ]]; then
echo "$PG_AUTOVACUUM_MAX_WORKERS"
return
fi
local workers=$((CPU_COUNT / 3))
if [[ $workers -lt 3 ]]; then
workers=3
fi
echo "$workers"
}
# WAL compression: lz4 is cheaper than the pglz default but needs PG >= 15
calc_wal_compression() {
if [[ -n "${PG_WAL_COMPRESSION:-}" ]]; then
echo "$PG_WAL_COMPRESSION"
return
fi
if [[ "${PG_DB_VERSION%%.*}" -ge 15 ]]; then
echo "lz4"
else
echo "off"
fi
}
# TOAST compression for large values (post content etc.): lz4 needs PG >= 14
calc_toast_compression() {
if [[ -n "${PG_TOAST_COMPRESSION:-}" ]]; then
echo "$PG_TOAST_COMPRESSION"
return
fi
if [[ "${PG_DB_VERSION%%.*}" -ge 14 ]]; then
echo "lz4"
else
echo "pglz"
fi
}
# Main generation function - output as ENV vars
generate_env_vars() {
local max_connections shared_buffers_kb effective_cache_kb maint_work_mem_kb
local min_wal_kb max_wal_kb wal_buffers_kb work_mem_kb
local default_stats_target random_page_cost effective_io_concurrency
local huge_pages checkpoint_target
max_connections=$(calc_max_connections)
shared_buffers_kb=$(calc_shared_buffers)
effective_cache_kb=$(calc_effective_cache_size)
maint_work_mem_kb=$(calc_maintenance_work_mem)
read -r min_wal_kb max_wal_kb <<< "$(calc_checkpoint_settings)"
wal_buffers_kb=$(calc_wal_buffers "$shared_buffers_kb")
default_stats_target=$(calc_default_statistics_target)
random_page_cost=$(calc_random_page_cost)
effective_io_concurrency=$(calc_effective_io_concurrency)
huge_pages=$(calc_huge_pages)
checkpoint_target=$(calc_checkpoint_completion_target)
# Parallel settings (single source: calc_parallel_settings), needed BEFORE work_mem
# since the worker count is part of its divisor
local max_worker_processes max_parallel_workers_per_gather max_parallel_workers max_parallel_maintenance_workers
read -r max_worker_processes max_parallel_workers_per_gather max_parallel_workers max_parallel_maintenance_workers <<< "$(calc_parallel_settings)"
work_mem_kb=$(calc_work_mem "$shared_buffers_kb" "$max_connections" "$max_worker_processes")
local autovacuum_max_workers wal_compression toast_compression
autovacuum_max_workers=$(calc_autovacuum_max_workers)
wal_compression=$(calc_wal_compression)
toast_compression=$(calc_toast_compression)
# Output ENV vars (only if not already set)
cat << EOF
# PostgreSQL configuration calculated for: $PG_DB_TYPE workload, ${CPU_COUNT} CPUs, $(to_mb $TOTAL_MEMORY_KB)MB RAM, $PG_STORAGE_TYPE storage
export PG_MAX_CONNECTIONS=\${PG_MAX_CONNECTIONS:-$max_connections}
export PG_SHARED_BUFFERS_MB=\${PG_SHARED_BUFFERS_MB:-$(to_mb "$shared_buffers_kb")}
export PG_EFFECTIVE_CACHE_SIZE_MB=\${PG_EFFECTIVE_CACHE_SIZE_MB:-$(to_mb "$effective_cache_kb")}
export PG_MAINTENANCE_WORK_MEM_MB=\${PG_MAINTENANCE_WORK_MEM_MB:-$(to_mb "$maint_work_mem_kb")}
export PG_WORK_MEM_KB=\${PG_WORK_MEM_KB:-$work_mem_kb}
export PG_HUGE_PAGES=\${PG_HUGE_PAGES:-$huge_pages}
export PG_WAL_BUFFERS_MB=\${PG_WAL_BUFFERS_MB:-$(to_mb "$wal_buffers_kb")}
export PG_MIN_WAL_SIZE_MB=\${PG_MIN_WAL_SIZE_MB:-$(to_mb "$min_wal_kb")}
export PG_MAX_WAL_SIZE_MB=\${PG_MAX_WAL_SIZE_MB:-$(to_mb "$max_wal_kb")}
export PG_CHECKPOINT_COMPLETION_TARGET=\${PG_CHECKPOINT_COMPLETION_TARGET:-$checkpoint_target}
export PG_DEFAULT_STATISTICS_TARGET=\${PG_DEFAULT_STATISTICS_TARGET:-$default_stats_target}
export PG_RANDOM_PAGE_COST=\${PG_RANDOM_PAGE_COST:-$random_page_cost}
export PG_EFFECTIVE_IO_CONCURRENCY=\${PG_EFFECTIVE_IO_CONCURRENCY:-$effective_io_concurrency}
export PG_MAX_WORKER_PROCESSES=\${PG_MAX_WORKER_PROCESSES:-$max_worker_processes}
export PG_MAX_PARALLEL_WORKERS_PER_GATHER=\${PG_MAX_PARALLEL_WORKERS_PER_GATHER:-$max_parallel_workers_per_gather}
export PG_MAX_PARALLEL_WORKERS=\${PG_MAX_PARALLEL_WORKERS:-$max_parallel_workers}
export PG_MAX_PARALLEL_MAINTENANCE_WORKERS=\${PG_MAX_PARALLEL_MAINTENANCE_WORKERS:-$max_parallel_maintenance_workers}
# JIT: a per-execution LLVM compile tax on complex-but-fast app queries; off unless doing long analytics scans
export PG_JIT=\${PG_JIT:-off}
# observability: query stats extension (restart-required to change), I/O timing, full query texts in pg_stat_activity
export PG_PRELOAD_LIBS=\${PG_PRELOAD_LIBS:-pg_stat_statements}
export PG_TRACK_IO_TIMING=\${PG_TRACK_IO_TIMING:-on}
export PG_TRACK_ACTIVITY_QUERY_SIZE=\${PG_TRACK_ACTIVITY_QUERY_SIZE:-16384}
# autovacuum tuned for soft-delete/append app workloads (default 20% thresholds never trigger on big tables);
# per-table thresholds for the hottest tables also ship as an app migration
export PG_AUTOVACUUM_VACUUM_SCALE_FACTOR=\${PG_AUTOVACUUM_VACUUM_SCALE_FACTOR:-0.05}
export PG_AUTOVACUUM_VACUUM_INSERT_SCALE_FACTOR=\${PG_AUTOVACUUM_VACUUM_INSERT_SCALE_FACTOR:-0.05}
export PG_AUTOVACUUM_ANALYZE_SCALE_FACTOR=\${PG_AUTOVACUUM_ANALYZE_SCALE_FACTOR:-0.02}
export PG_AUTOVACUUM_VACUUM_COST_LIMIT=\${PG_AUTOVACUUM_VACUUM_COST_LIMIT:-1000}
export PG_AUTOVACUUM_MAX_WORKERS=\${PG_AUTOVACUUM_MAX_WORKERS:-$autovacuum_max_workers}
export PG_LOG_AUTOVACUUM_MIN_DURATION=\${PG_LOG_AUTOVACUUM_MIN_DURATION:-0}
# cheaper compression codecs (version-gated: wal lz4 needs PG15+, toast lz4 PG14+)
export PG_WAL_COMPRESSION=\${PG_WAL_COMPRESSION:-$wal_compression}
export PG_TOAST_COMPRESSION=\${PG_TOAST_COMPRESSION:-$toast_compression}
# diagnostics logging: slow statements (server-side; the app logs its own from 100ms), lock waits, temp-file spills
export PG_LOG_MIN_DURATION_STATEMENT=\${PG_LOG_MIN_DURATION_STATEMENT:-2000}
export PG_LOG_LOCK_WAITS=\${PG_LOG_LOCK_WAITS:-on}
export PG_LOG_TEMP_FILES=\${PG_LOG_TEMP_FILES:-0}
EOF
}
# Run generation
generate_env_vars