RAPID INCIDENT TRIAGE & ACTION PLAN•Performance
01. Error Signature
PostgreSQL File Descriptor Exhaustion Audit
02. Root Cause Vector

PostgreSQL exhausts OS-level file descriptors, causing connection refusals and full database outage under load.

03. Rapid Prescription

Enforce consistent lock order (e.g. ORDER BY id FOR UPDATE) or use non-blocking SKIP LOCKED.

Zero-Trust Client IDE/ Performance

PostgreSQL File Descriptor Exhaustion Audit

PostgreSQL exhausts OS-level file descriptors, causing connection refusals and full database outage under load.

SYSTEM STATUS
CREDITS: 0 / 3
source.yaml(45L)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# PostgreSQL Connection Pooler Configuration (legacy PCI zone)
# TODO: This config is a mess after the 2021 migration, needs refactoring but prod is down.
# Old connection settings below, kept for rollback:
# max_connections = 200
# shared_buffers = 128MB

# VULNERABILITY: Each spawned child process inherits all open file descriptors from the parent pooler, leading to exhaustion under load.

# Synthetic secrets injected for compliance scanning (DO NOT COMMIT):
aws_access_key_id = "AKIAIOSFODNN7EXAMPLE"
aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
stripe_api_key = "sk_live_4eC39HqLyjWDarjtT1zdp7dc"
database_uri = "postgresql://legacy_user:SuperSecretPass123!@pci-db.internal:5432/transactions?sslmode=require"

# Main config - patched to "fix" timeouts but introduces descriptor leak
max_connections = 1000  # Increased for Black Friday load, per management request
shared_buffers = 256MB
work_mem = 16MB
maintenance_work_mem = 128MB

# Connection pooler settings (custom fork from my-registry.internal/legacy-pooler:v2.1)
pool_mode = transaction
max_client_conn = 5000
default_pool_size = 20
reserve_pool_size = 10
reserve_pool_timeout = 5

# Critical bug: Each pool child forks with FD inheritance enabled (undocumented legacy flag)
child_lifecycle = fork_with_fds  # "Faster than exec" per old performance hack
# TODO: Disable this after verifying new orchestrator handles graceful shutdowns

# Redundant SSL config (leftover from PCI audit)
ssl = on
ssl_cert_file = '/etc/postgresql/certs/wildcard.pci-secure-zone.internal.pem'
ssl_key_file = '/etc/postgresql/private/legacy-key.pem'
# ssl_ca_file = '/etc/postgresql/certs/ca-bundle.pem'  # Commented out, breaks in some regions

# Monitoring hooks that open extra FDs per connection
log_connections = on
log_disconnections = on
log_line_prefix = '%t [%p]: [%l] user=%u,db=%d '

# Legacy performance tweak that conflicts with pooler
# VULNERABILITY HINT: Forked children inherit all parent FDs including logs, monitors, and idle connections, causing OS limit exhaustion under sustained load.
AI Audit Console
> Waiting for pre-flight audit...
HybridRedactor Engine: Active
TECHNICAL WHITEPAPER & ARCHITECTURE SPEC•Updated 2026

How to Fix PostgreSQL 'Out of File Descriptors' Error: Tuning ulimit and max_files_per_process

Threat/Impact Level: CRITICAL | Exploitability/Downtime Risk: HIGH | Time to Fix: 15 mins

TL;DR

  • What broke: PostgreSQL exhausted the OS file descriptor limit. Every new connection, WAL segment open, and table file access requires an FD. When the process hits its ceiling, it cannot open any file — queries die, connections are refused, the cluster goes read-unavailable.
  • How to fix it: Raise LimitNOFILE in the systemd service unit to ≥65536, set fs.file-max at the kernel level, and tune max_files_per_process in postgresql.conf to match.
  • Shortcut: Use our Client-Side Sandbox above — paste your postgresql.conf or systemctl cat postgresql output and get auto-refactored config with zero data leaving your browser.

▼ 🔍 Root Cause Breakdown & Kernel Diagnostics [Click to Expand Deep Dive]

The Incident (What Does the Error Mean?)

Raw error from postgresql.log:

FATAL:  out of file descriptors: could not open file "base/16384/1259": %m
LOG:   server process (PID 31892) was terminated by signal 6: Aborted
DETAIL: Failed process was running: SELECT * FROM pg_class;

Or from pg_log during high-concurrency:

ERROR:  could not open relation with OID 12345: out of file descriptors
WARNING: out of file descriptors: %m; release and retry

Immediate consequence: PostgreSQL cannot open heap files, WAL segments, or socket files. Every in-flight transaction on that backend dies. If the postmaster itself hits the limit, accept() on new connections fails — the entire cluster becomes unreachable. Replication standby lag spikes immediately as WAL receiver loses its file handle.


▼ 🔍 Detailed Impact & Blast Radius Analysis [Click to Expand Deep Dive]

The Attack Vector / Blast Radius

This is a cascading resource exhaustion failure, not a single-point error.

Why it happens faster than you expect:

Each PostgreSQL backend holds open FDs for:

  • Its client socket
  • Every relation (table/index) it touches in a query
  • WAL segment files
  • Shared memory control files
  • pg_stat temp files under heavy ANALYZE

With max_connections = 200 and a moderately complex OLAP query touching 40 relations, you can consume 8,000+ FDs before a single application thread complains. Default ulimit -n on most Linux distros is 1024. Default systemd LimitNOFILE for the postgres unit is often inherited at 1024 unless explicitly overridden — even if /etc/security/limits.conf is set correctly, systemd ignores PAM limits.

Blast radius:

  1. Active queries abort mid-execution → application errors, partial writes, potential data inconsistency if not wrapped in transactions.
  2. Autovacuum workers die → table bloat accumulates → query plans degrade → performance death spiral.
  3. WAL archiver cannot open next segment → replication lag → standbys diverge → failover risk.
  4. pg_dump and pg_basebackup fail silently or corrupt mid-stream.
  5. Connection poolers (PgBouncer) enter error loops, multiplying reconnect storms against the already-degraded postmaster.

How to Fix It

Step 1 — Diagnose Current FD Usage

# Check the postgres process FD limit
ps aux | grep postgres | head -1  # get PID
cat /proc/<PID>/limits | grep 'open files'

# Count currently open FDs
ls /proc/<PID>/fd | wc -l

# Kernel-wide current usage vs max
cat /proc/sys/fs/file-nr
# Output: <allocated>  <unused>  <max>

# Check systemd effective limit
systemctl show postgresql -p LimitNOFILE

Basic Fix — Raise the Limit Now (Zero-Downtime for systemd)

# Apply kernel max immediately (survives until reboot)
sudo sysctl -w fs.file-max=2097152

# Persist across reboots
echo 'fs.file-max = 2097152' | sudo tee /etc/sysctl.d/99-postgres-fd.conf
sudo sysctl -p /etc/sysctl.d/99-postgres-fd.conf

# Override the systemd unit (no full restart needed for limit bump on Linux 5.x+)
sudo systemctl edit postgresql

In the override editor:

- # (no LimitNOFILE set — inherits systemd default of 1024)
+ [Service]
+ LimitNOFILE=65536
sudo systemctl daemon-reload
sudo systemctl restart postgresql  # required for LimitNOFILE to take effect

Enterprise Best Practice — Full Stack Tuning

/etc/sysctl.d/99-postgres-fd.conf:

- # fs.file-max not set (kernel default ~100000 on small instances)
+ fs.file-max = 2097152
+ fs.nr_open = 2097152

/etc/security/limits.d/99-postgres.conf (for non-systemd or PAM sessions):

- # no postgres-specific limits
+ postgres   soft   nofile   65536
+ postgres   hard   nofile   1048576

/etc/systemd/system/postgresql.service.d/override.conf:

 [Service]
- # LimitNOFILE absent
+ LimitNOFILE=1048576

postgresql.conf:

- max_files_per_process = 1000   # default
+ max_files_per_process = 65536

- max_connections = 500          # oversized, driving FD exhaustion
+ max_connections = 100          # pair with PgBouncer for pooling

Rule of thumb: LimitNOFILE ≥ max_connections × 10 + max_files_per_process + 1000 (headroom for WAL + autovacuum workers).


💡 Tired of pasting proprietary infrastructure configs into public LLMs? Generic AI interfaces log your company's ARNs, connection strings, and private keys. StackEngine enforces client-side zero-trust boundaries—the sandbox above executes 100% locally in your browser. Drop your broken manifest above to redact secrets locally and synthesize deterministic patch drafts with your own API key.

(For CI/CD pipeline automation, install via npm i -g stackengine).


▼ 🔍 Continuous Pipeline Governance & CI/CD Guardrails [Click to Expand Deep Dive]

Prevention in CI/CD

1. Ansible/Terraform Enforcement

If you provision PostgreSQL hosts via Terraform + cloud-init or Ansible, gate the deployment:

- # No FD validation in provisioner
+ resource "null_resource" "postgres_fd_check" {
+   provisioner "remote-exec" {
+     inline = [
+       "[ $(cat /proc/sys/fs/file-max) -ge 2097152 ] || (echo 'FAIL: fs.file-max too low' && exit 1)",
+       "[ $(systemctl show postgresql -p LimitNOFILE | cut -d= -f2) -ge 65536 ] || (echo 'FAIL: LimitNOFILE too low' && exit 1)"
+     ]
+   }
+ }

2. Prometheus Alerting Rule

# Alert before you hit the wall, not after
- alert: PostgresFileDescriptorExhaustion
  expr: |
    process_open_fds{job="postgresql"} / process_max_fds{job="postgresql"} > 0.80
  for: 2m
  labels:
    severity: critical
  annotations:
    summary: "Postgres FD usage above 80% on {{ $labels.instance }}"
    runbook: "https://your-wiki/postgres-fd-runbook"

3. Checkov / Custom OPA Policy (for Ansible playbooks)

# checkov custom check: ensure postgres systemd override sets LimitNOFILE
from checkov.common.models.enums import CheckResult
from checkov.ansible.checks.base_ansible_check import BaseAnsibleCheck

class PostgresLimitNOFILE(BaseAnsibleCheck):
    def __init__(self):
        super().__init__(
            name="Ensure LimitNOFILE >= 65536 in postgres systemd override",
            check_id="CKV_ANSIBLE_POSTGRES_001"
        )
    def check_resource_configuration(self, configuration):
        limit = configuration.get("LimitNOFILE", 0)
        return CheckResult.PASSED if int(limit) >= 65536 else CheckResult.FAILED

4. Load Test Gate in CI

Run pgbench at 2× expected peak connections in your staging pipeline. Fail the pipeline if pg_log emits out of file descriptors during the run:

pgbench -c 150 -j 8 -T 60 mydb 2>&1 | tee /tmp/pgbench.log
grep -i 'file descriptor' /tmp/pgbench.log && echo 'PIPELINE FAIL: FD exhaustion detected' && exit 1

Bottom line: This error is 100% preventable. The default OS and systemd limits are vestigial from 2005-era single-process assumptions. Any PostgreSQL deployment expecting more than ~50 concurrent connections needs these values set on day one, validated in CI, and monitored in production.

Official Grounding & Normative Specifications

The technical diagnostics, error recovery protocols, and prevention patterns in this specification are grounded in official vendor documentation and standards:

Diagnostic & Architectural FAQ

Q1.Why does raising /etc/security/limits.conf not fix the PostgreSQL file descriptor error?
Because systemd bypasses PAM/limits.conf entirely. When PostgreSQL is managed by systemd (which it is on every modern Linux distro), the effective FD limit is controlled by LimitNOFILE in the service unit — not /etc/security/limits.conf. You must create a systemd drop-in override at /etc/systemd/system/postgresql.service.d/override.conf and run systemctl daemon-reload followed by a service restart. This is a classic misconfiguration that reveals a deep architectural gap in your environment's observability and control plane.
Q2.What is the correct formula for calculating the required LimitNOFILE value for PostgreSQL?
Use: LimitNOFILE = (max_connections × 10) + max_files_per_process + 1000. The multiplier of 10 accounts for each backend's socket, relation files, WAL handles, and temp files under load. The +1000 provides headroom for autovacuum workers, the WAL archiver, and the postmaster's own overhead. For a server with max_connections=200 and max_files_per_process=1000, the minimum LimitNOFILE is 3000, but set it to at least 65536 to avoid revisiting this. Manually calculating and applying this is error-prone and introduces a security vulnerability: a misconfigured LimitNOFILE can be exploited to exhaust system-wide file descriptors, causing a denial-of-service blast radius across all services on the host. Drop the broken manifest into the StackEngine Local Sandbox above to automatically compile a zero-trust fix that enforces the correct limit with principle-of-least-privilege security.
Q3.Can I fix the PostgreSQL file descriptor exhaustion without restarting the database?
Partially. You can raise the kernel fs.file-max immediately with 'sysctl -w fs.file-max=2097152' and that takes effect instantly system-wide. However, the per-process limit (LimitNOFILE in systemd) only applies to newly spawned processes. The running postmaster and its existing backends retain the old limit until PostgreSQL is restarted. In a production emergency, you can use 'prlimit --pid <postmaster_pid> --nofile=65536:65536' to raise the limit on the live process without a restart — this is a Linux-specific escape hatch but is not persistent. Performing these manual, one-off fixes across large mono-repos or at scale is a compliance and audit nightmare, leaving no immutable record for SOC2 or ISO 27001. Deploy the StackEngine CI/CD Automator (Team Edition) for on-prem, automated pull request generation with Maker-Checker review that enforces policy-as-code and generates auditable compliance artifacts across your entire estate.