← Back to DevBytes

Kubernetes Database Bottleneck Detection and Resolution

Understanding Kubernetes Database Bottlenecks

A database bottleneck in a Kubernetes environment occurs when the database tier becomes the limiting factor in application performance, causing requests to queue, latency to spike, and throughput to plummet. Unlike traditional infrastructure, Kubernetes adds layers of orchestration complexity—pods, services, persistent volumes, and network policies—that can mask or amplify database performance issues. A bottleneck may manifest as slow query execution, connection saturation, I/O contention on persistent volumes, or CPU/memory exhaustion on the database pod itself.

These bottlenecks are particularly dangerous because they create cascading failures: a slow database causes application pods to hang, which triggers health check failures, leading Kubernetes to restart pods, which in turn floods the database with new connection storms. Understanding the anatomy of these bottlenecks is the first step toward building resilient data layers on Kubernetes.

Common Types of Database Bottlenecks in Kubernetes

Why Detecting and Resolving Database Bottlenecks Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Database bottlenecks directly impact end-user experience, revenue, and system reliability. In Kubernetes, the stakes are higher because the platform's self-healing mechanisms—like liveness probe restarts—can inadvertently worsen the situation. A database slowdown triggers a pod restart cascade, which generates a thundering herd of new connections, further overwhelming the database. This cycle can turn a minor performance hiccup into a full-blown outage.

From a developer's perspective, undetected bottlenecks also waste compute resources. Application pods sit idle waiting for query responses, consuming CPU and memory unnecessarily. On the business side, every millisecond of database latency compounds across microservice call chains, degrading features like checkout flows, search autocomplete, or real-time dashboards. Proactive detection and swift resolution are not optional—they are fundamental to operating stateful workloads on Kubernetes.

Detection Strategies and Practical Examples

Detection requires a multi-layered approach combining Kubernetes-native metrics, database-level observability, and application-side telemetry. Below are concrete techniques and code examples you can apply immediately.

1. Prometheus Metrics for Database Pods

Deploy a ServiceMonitor that scrapes database metrics. For PostgreSQL, use the postgres_exporter; for MySQL, use mysqld_exporter. Here is a complete example for PostgreSQL running in Kubernetes:

# postgres-exporter-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres-exporter
  namespace: monitoring
  labels:
    app: postgres-exporter
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres-exporter
  template:
    metadata:
      labels:
        app: postgres-exporter
    spec:
      containers:
        - name: exporter
          image: quay.io/prometheuscommunity/postgres-exporter:v0.15.0
          env:
            - name: DATA_SOURCE_NAME
              valueFrom:
                secretKeyRef:
                  name: db-credentials
                  key: dsn
          ports:
            - containerPort: 9187
              name: metrics
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 200m
              memory: 256Mi
---
apiVersion: v1
kind: Service
metadata:
  name: postgres-exporter
  namespace: monitoring
spec:
  selector:
    app: postgres-exporter
  ports:
    - port: 9187
      targetPort: 9187
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: postgres-exporter
  namespace: monitoring
spec:
  selector:
    matchLabels:
      app: postgres-exporter
  endpoints:
    - port: metrics
      interval: 15s
  namespaceSelector:
    matchNames:
      - monitoring

Once the exporter is running, you can query critical bottleneck indicators directly in Prometheus:

# Rate of deadlocks per second — high values indicate lock contention
rate(pg_stat_database_deadlocks{datname="mydb"}[5m])

# Active connections versus max connections — approaching limit is a red flag
pg_stat_database_numbackends{datname="mydb"}
/ 
pg_settings_max_connections{setting="max_connections"}

# Average query latency derived from pg_stat_statements
rate(pg_stat_statements_total_time_seconds{datname="mydb"}[5m])
/
rate(pg_stat_statements_calls{datname="mydb"}[5m])

# Disk I/O wait time from node-level metrics (requires node_exporter)
rate(node_disk_io_time_seconds_total{device="vdb"}[5m])

2. Grafana Dashboard for Quick Visualization

Create a dedicated dashboard panel that surfaces bottleneck signals at a glance. Below is a sample JSON snippet for a Grafana panel that alerts on connection saturation:

{
  "panels": [
    {
      "title": "Database Connection Saturation %",
      "type": "stat",
      "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
      "targets": [
        {
          "expr": "(pg_stat_database_numbackends{datname=\"mydb\"} / pg_settings_max_connections) * 100",
          "legendFormat": "Connection %",
          "refId": "A"
        }
      ],
      "fieldConfig": {
        "defaults": {
          "thresholds": {
            "steps": [
              { "color": "green", "value": 0 },
              { "color": "orange", "value": 60 },
              { "color": "red", "value": 85 }
            ]
          }
        }
      }
    },
    {
      "title": "Query Latency (ms)",
      "type": "timeseries",
      "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
      "targets": [
        {
          "expr": "(rate(pg_stat_statements_total_time_seconds[5m]) / rate(pg_stat_statements_calls[5m])) * 1000",
          "legendFormat": "Avg Query Latency ms",
          "refId": "B"
        }
      ]
    }
  ]
}

3. Kubectl-Based On-the-Spot Checks

Sometimes you need immediate answers without waiting for metrics to aggregate. Use these kubectl commands to inspect database pods directly:

# Check database pod resource usage in real time
kubectl top pod -l app=postgres-db --containers

# Examine database pod logs for slow queries or connection errors
kubectl logs -l app=postgres-db --tail=100 | grep -i "duration\|timeout\|connection"

# Describe persistent volumes to spot I/O issues
kubectl describe pvc postgres-data-pvc | grep -A 5 "Capacity\|StorageClass\|Used"

# Check node disk pressure — a node-level bottleneck
kubectl describe node db-node-1 | grep -i "disk\|pressure"

4. Application-Side Tracing with OpenTelemetry

Inject tracing into your application code to measure exact database call durations. Here's a Node.js example using OpenTelemetry with automatic PostgreSQL instrumentation:

// tracing.js — initialize OpenTelemetry for database tracing
const opentelemetry = require('@opentelemetry/sdk-node');
const { PostgreSQLInstrumentation } = require('@opentelemetry/instrumentation-pg');
const { ConsoleSpanExporter } = require('@opentelemetry/sdk-trace-base');
const { Resource } = require('@opentelemetry/resources');

const postgresInstrumentation = new PostgreSQLInstrumentation({
  enhancedDatabaseReporting: true,
  // Capture query parameters to identify slow patterns
  requireParentSpan: false,
});

const sdk = new opentelemetry.NodeSDK({
  resource: new Resource({
    'service.name': 'user-service',
    'deployment.environment': 'production',
  }),
  traceExporter: new ConsoleSpanExporter(),
  instrumentations: [postgresInstrumentation],
});

sdk.start();

// Gracefully shut down on SIGTERM
process.on('SIGTERM', () => {
  sdk.shutdown()
    .then(() => console.log('Tracing terminated'))
    .catch((err) => console.error('Error shutting down tracing', err))
    .finally(() => process.exit(0));
});

Export these traces to Jaeger or Tempo and query for spans with db.statement tags where duration exceeds a threshold. This gives you precise query-level attribution—you'll know exactly which application code path generated the slow query.

Resolution Techniques with Code Examples

Once you've identified a bottleneck, resolution depends on the root cause. Below are targeted fixes for the most common scenarios.

1. Connection Pooling and Throttling

If connection saturation is the culprit, implement server-side pooling with PgBouncer or ProxySQL. Here's a PgBouncer deployment in Kubernetes configured for transaction-level pooling:

# pgbouncer-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pgbouncer
  namespace: production
spec:
  replicas: 2
  selector:
    matchLabels:
      app: pgbouncer
  template:
    metadata:
      labels:
        app: pgbouncer
    spec:
      containers:
        - name: pgbouncer
          image: edoburu/pgbouncer:1.19.0
          ports:
            - containerPort: 6432
          env:
            - name: DB_USER
              valueFrom:
                secretKeyRef:
                  name: db-credentials
                  key: username
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: db-credentials
                  key: password
            - name: DB_HOST
              value: "postgres-primary-service"
            - name: DB_PORT
              value: "5432"
            - name: DB_NAME
              value: "mydb"
          resources:
            requests:
              cpu: 250m
              memory: 256Mi
            limits:
              cpu: 500m
              memory: 512Mi
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: pgbouncer-config
  namespace: production
data:
  pgbouncer.ini: |
    [pgbouncer]
    listen_addr = 0.0.0.0
    listen_port = 6432
    auth_type = md5
    auth_file = /etc/pgbouncer/userlist.txt
    pool_mode = transaction
    max_client_conn = 500
    default_pool_size = 25
    reserve_pool_size = 5
    reserve_pool_timeout = 3
    log_connections = 1
    log_disconnections = 1
    log_pooler_errors = 1
    stats_period = 60
    server_idle_timeout = 300
    query_wait_timeout = 120
---
apiVersion: v1
kind: Service
metadata:
  name: pgbouncer-service
  namespace: production
spec:
  selector:
    app: pgbouncer
  ports:
    - port: 6432
      targetPort: 6432

On the application side, enforce connection limits at the client library level as well:

// Node.js pg pool configuration with strict limits
const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.PGBOUNCER_HOST || 'pgbouncer-service',
  port: 6432,
  database: 'mydb',
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 20,               // Maximum connections per pod
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
  statement_timeout: 10000,  // Abort queries exceeding 10s
  query_timeout: 10000,
});

// Graceful shutdown handler to drain connections
process.on('SIGTERM', async () => {
  console.log('Draining database connections...');
  await pool.end();
  console.log('All connections closed');
  process.exit(0);
});

2. Query Optimization and Indexing

When query contention is the root cause, you need database-level visibility. Use PostgreSQL's pg_stat_statements extension to find slow queries, then create targeted indexes. Enable the extension if not already active:

-- Connect to your database and enable pg_stat_statements
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Find the top 10 slowest queries by average execution time
SELECT
  queryid,
  LEFT(query, 200) AS truncated_query,
  calls,
  mean_exec_time::numeric(10,2) AS avg_ms,
  total_exec_time::numeric(10,2) AS total_ms,
  rows
FROM pg_stat_statements
WHERE calls > 100
ORDER BY mean_exec_time DESC
LIMIT 10;

Once you identify a slow query, analyze its execution plan and add an index:

-- Example: slow query on orders table by customer_id and created_at
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC;

-- If the plan shows a sequential scan, create a composite index
CREATE INDEX CONCURRENTLY idx_orders_customer_created
ON orders (customer_id, created_at DESC);

-- Verify the index is being used
SELECT
  schemaname,
  tablename,
  indexname,
  idx_scan,
  idx_tup_read
FROM pg_stat_user_indexes
WHERE indexname = 'idx_orders_customer_created';

For applications that cannot tolerate downtime during index creation, use CREATE INDEX CONCURRENTLY which builds the index without locking the table for writes. Monitor the progress with:

-- Check concurrent index creation progress
SELECT
  p.pid,
  p.query,
  now() - p.query_start AS duration,
  p.state
FROM pg_stat_activity p
WHERE p.query LIKE '%CONCURRENTLY%'
  AND p.state = 'active';

3. Resource Limit Tuning

Database pods suffering from CPU starvation or memory pressure need adjusted resource limits. Use vertical pod autoscaling recommendations or manual tuning:

# postgres-statefulset-patch.yaml — adjust resource limits
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres-primary
spec:
  template:
    spec:
      containers:
        - name: postgres
          resources:
            requests:
              cpu: "2"
              memory: "4Gi"
            limits:
              cpu: "4"
              memory: "8Gi"
          env:
            - name: shared_buffers
              value: "2GB"
            - name: effective_cache_size
              value: "6GB"
            - name: maintenance_work_mem
              value: "512MB"
            - name: work_mem
              value: "64MB"

Validate that the node has enough allocatable resources. If not, consider node affinity rules to place the database pod on a dedicated node with sufficient capacity:

# Node affinity for database workloads
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres-primary
spec:
  template:
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: workload-type
                    operator: In
                    values:
                      - database
      tolerations:
        - key: "database"
          operator: "Equal"
          value: "true"
          effect: "NoSchedule"

4. Persistent Volume I/O Optimization

When disk I/O is the bottleneck, you have several Kubernetes-native options. First, identify the current storage class and its performance characteristics:

# List storage classes and their provisioners
kubectl get storageclass --all-namespaces -o wide

# Check PVC details for the database
kubectl get pvc postgres-data-pvc -o yaml | grep -A 10 "storageClassName\|volumeName"

If you're on a cloud provider, migrate to a higher-IOPS storage class. Here's an example of creating a new PVC with premium storage and migrating data:

# premium-storage-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-data-premium
  namespace: production
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: premium-ssd-iops-5000
  resources:
    requests:
      storage: 100Gi
---
# Job to copy data from old PVC to new PVC
apiVersion: batch/v1
kind: Job
metadata:
  name: pg-data-migration
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrator
          image: alpine:3.19
          command: ["/bin/sh", "-c"]
          args:
            - |
              apk add --no-cache rsync &&
              rsync -av --progress /mnt/old/ /mnt/new/ &&
              echo "Migration complete"
          volumeMounts:
            - name: old-data
              mountPath: /mnt/old
            - name: new-data
              mountPath: /mnt/new
      volumes:
        - name: old-data
          persistentVolumeClaim:
            claimName: postgres-data-pvc
        - name: new-data
          persistentVolumeClaim:
            claimName: postgres-data-premium

Additionally, tune the database's disk-related configuration to align with the new storage performance:

-- PostgreSQL settings for high-IOPS SSD storage
ALTER SYSTEM SET random_page_cost = 1.1;        -- Lower for SSD
ALTER SYSTEM SET effective_io_concurrency = 200; -- Parallel I/O for SSD
ALTER SYSTEM SET wal_writer_delay = 20;          -- More frequent WAL writes
ALTER SYSTEM SET checkpoint_timeout = 15min;     -- Less frequent checkpoints
SELECT pg_reload_conf();

5. Network Latency Mitigation

If database pods and application pods are on different nodes, network latency can become a bottleneck. Use pod affinity to co-locate related services, or enable direct pod-to-pod communication bypassing service proxies:

# Pod affinity to keep application and database on same node
apiVersion: apps/v1
kind: Deployment
metadata:
  name: user-service
spec:
  template:
    spec:
      affinity:
        podAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchLabels:
                  app: postgres-db
              topologyKey: "kubernetes.io/hostname"

For advanced scenarios, consider using Istio's DestinationRule with connection pool settings to limit concurrent connections and implement circuit breaking:

# istio-destination-rule.yaml
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: postgres-db-circuit-breaker
spec:
  host: postgres-primary-service.production.svc.cluster.local
  trafficPolicy:
    connectionPool:
      tcp:
        maxConnections: 100
        connectTimeout: 3s
        tcpKeepalive:
          time: 7200s
          interval: 75s
    outlierDetection:
      consecutive5xxErrors: 5
      interval: 30s
      baseEjectionTime: 60s
      maxEjectionPercent: 50

6. Read Replica Scaling

For read-heavy workloads, deploy read replicas and route SELECT queries to them. Here's a Kubernetes-native approach using a separate service for replicas:

# postgres-replica-statefulset.yaml (simplified)
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres-replica
spec:
  replicas: 2
  selector:
    matchLabels:
      app: postgres-replica
  serviceName: postgres-replica-service
  template:
    metadata:
      labels:
        app: postgres-replica
    spec:
      containers:
        - name: postgres
          image: postgres:16
          env:
            - name: POSTGRES_REPLICATION_MODE
              value: "slave"
            - name: POSTGRES_MASTER_SERVICE_HOST
              value: "postgres-primary-service"
---
apiVersion: v1
kind: Service
metadata:
  name: postgres-replica-service
spec:
  selector:
    app: postgres-replica
  ports:
    - port: 5432

On the application side, configure read/write splitting:

// TypeORM example with read/write splitting
import { DataSource } from 'typeorm';

const AppDataSource = new DataSource({
  type: 'postgres',
  replication: {
    master: {
      host: process.env.DB_PRIMARY_HOST || 'postgres-primary-service',
      port: 5432,
      username: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      database: 'mydb',
    },
    slaves: [{
      host: process.env.DB_REPLICA_HOST || 'postgres-replica-service',
      port: 5432,
      username: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      database: 'mydb',
    }],
  },
  entities: ['./entities/*.ts'],
  // SELECT queries automatically route to replicas
});

Best Practices for Sustained Database Performance on Kubernetes

Detection and resolution are not one-time activities. Embed these practices into your development and operations workflow to prevent bottlenecks from re-emerging.

1. Implement Progressive Resource Scaling

Use Horizontal Pod Autoscaling (HPA) for stateless application pods, but combine it with careful database connection limits. Never allow HPA to scale application pods beyond what your connection pool can handle. Set maxConnections in PgBouncer or your pool library to a value derived from your database's actual capacity, then set application HPA maxReplicas accordingly:

# HPA for application deployment — capped by database capacity
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: user-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: user-service
  minReplicas: 2
  maxReplicas: 20  # Must not exceed (pgbouncer_max_client_conn / pool_max_per_pod)
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

2. Establish Database SLOs and Alerts

Define Service Level Objectives for database performance and create Prometheus alert rules that fire before users notice degradation:

# prometheus-alerts-db.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: database-slo-alerts
  namespace: monitoring
spec:
  groups:
    - name: database.rules
      rules:
        - alert: DatabaseConnectionSaturation
          expr: (pg_stat_database_numbackends / pg_settings_max_connections) > 0.80
          for: 5m
          labels:
            severity: warning
          annotations:
            summary: "Database connections above 80%"
            description: "{{ $labels.datname }} has {{ $value | humanizePercentage }} connections used."

        - alert: DatabaseHighLatency
          expr: (rate(pg_stat_statements_total_time_seconds[5m]) / rate(pg_stat_statements_calls[5m])) > 0.5
          for: 3m
          labels:
            severity: critical
          annotations:
            summary: "Query latency exceeds 500ms"
            description: "Average query time is {{ $value | humanizeDuration }}."

        - alert: DatabaseDeadlockRate
          expr: rate(pg_stat_database_deadlocks[5m]) > 0.01
          for: 2m
          labels:
            severity: warning
          annotations:
            summary: "Deadlocks detected"
            description: "Deadlock rate is {{ $value }} per second in {{ $labels.datname }}."

3. Run Regular Load Tests Against Staging

Create a dedicated Kubernetes Job that simulates production traffic patterns against your staging database. Use tools like pgbench or custom scripts:

# pgbench-loadtest-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: pgbench-loadtest
  namespace: staging
spec:
  ttlSecondsAfterFinished: 3600
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: pgbench
          image: postgres:16
          command: ["/bin/bash", "-c"]
          args:
            - |
              set -e
              echo "Initializing test data..."
              pgbench -h postgres-primary-service -U benchuser -d benchdb -i -s 100
              echo "Running load test: 50 clients for 10 minutes..."
              pgbench -h postgres-primary-service -U benchuser -d benchdb \
                -c 50 -j 4 -T 600 -P 30 --progress=30 \
                -f <(echo "SELECT * FROM orders WHERE customer_id = random() * 1000;") \
                > /results/pgbench-output.txt
              echo "Load test complete."
          volumeMounts:
            - name: results
              mountPath: /results
      volumes:
        - name: results
          emptyDir: {}

4. Adopt Database-First Graceful Shutdowns

Always drain database connections before application pods terminate. Kubernetes sends SIGTERM and waits for terminationGracePeriodSeconds before SIGKILL. Use that window to close connections cleanly:

# Deployment with proper pre-stop hook and graceful period
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-service
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: app
          lifecycle:
            preStop:
              exec:
                command:
                  - "/bin/sh"
                  - "-c"
                  - |
                    echo "Draining connections before shutdown..."
                    curl -X POST http://localhost:8080/drain
                    sleep 5
                    echo "Connections drained."

5. Maintain a Database Runbook

Document resolution procedures in a runbook stored as a ConfigMap or in your Git repository. When an alert fires, the on-call engineer follows a proven sequence rather than improvising:

# Example runbook entries (stored as documentation)
1. Connection Saturation (>80%):
   - Check: kubectl logs -l app=pgbouncer | grep "closing because:"
   - Action: Temporarily increase default_pool_size in pgbouncer config
   - Action: Identify connection-leaking application pods via pg_stat_activity
   - Action: Roll back recent deployments that changed connection pooling

2. High Query Latency (>500ms):
   - Check: SELECT * FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 5;
   - Action: Run EXPLAIN ANALYZE on the top query
   - Action: Create missing index or rewrite query
   - Action: If sudden spike, check for recent schema changes or data volume growth

3. I/O Saturation:
   - Check: kubectl top node | grep db-node
   - Check: iostat -x 1 10 inside the database pod
   - Action: Verify storage class provisioned IOPS
   - Action: Consider PVC migration to faster storage class

Conclusion

Detecting and resolving database bottlenecks in Kubernetes is a continuous discipline that spans monitoring configuration, application architecture, infrastructure tuning, and operational procedures. The techniques covered in this tutorial—from Prometheus metric collection and Grafana visualization to connection pooling with PgBouncer, query optimization, storage class migration, and pod affinity rules—form a comprehensive toolkit. The key insight is that database performance on Kubernetes is not solely a DBA concern; it requires developers to instrument their code, platform engineers to configure appropriate resource limits and network policies, and operators to maintain alert rules and runbooks. By embedding these practices into your daily workflow, you transform database bottlenecks from crisis-inducing surprises into manageable, predictable events that your team can resolve before users ever notice.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles