← Back to DevBytes

Kubernetes Database Performance: Profiling and Optimization

Understanding Database Performance on Kubernetes

Running databases on Kubernetes introduces unique performance challenges and opportunities. Profiling a database in this context means systematically measuring, collecting, and analyzing resource usage and query behavior within the dynamic, containerized environment Kubernetes provides. Unlike traditional bare-metal or VM setups, Kubernetes adds layers such as Pod networking, ephemeral storage, CPU throttling, and orchestrated scheduling, all of which can directly impact database latency, throughput, and reliability.

Profiling is not a one-time task but an ongoing discipline. It involves capturing metrics from the Kubernetes control plane (via the Metrics API), the node level (cAdvisor), the database engine’s internal statistics, and application-level query patterns. The goal is to pinpoint bottlenecks—whether they stem from CPU contention, memory pressure, slow I/O, inefficient queries, or misconfigured Pod resources—and then apply targeted optimizations.

Why Database Performance Optimization Matters in Kubernetes

🚀 Deploy your AI agent in 10 minutes

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

Try it free →

Stateful workloads like databases are often the backbone of microservice architectures. A poorly performing database can cascade latency across dozens of services, degrade user experience, and increase cloud costs through overprovisioned resources. In Kubernetes, several factors amplify the need for careful optimization:

Key Performance Metrics to Profile

Effective profiling requires a holistic view across the stack. Focus on these metric categories:

Profiling Tools and Techniques

Combine Kubernetes-native tools with database-specific profilers for comprehensive visibility. The following techniques form a practical profiling stack.

1. Kubernetes Resource Metrics via kubectl and Prometheus

Start with the built-in Metrics API. Ensure the Metrics Server is installed in your cluster.

# Check current resource usage for a database Pod
kubectl top pod database-pod-name --containers

For historical analysis and alerting, use Prometheus with node-exporter and cAdvisor (often bundled in kubelet). A typical PromQL query to see CPU throttling of a database container:

# CPU throttling per second for a specific container
rate(container_cpu_cfs_throttled_periods_total{container="postgres"}[5m])

Memory pressure can be observed via:

# Memory usage vs limits
container_memory_working_set_bytes{container="postgres"} / container_spec_memory_limit_bytes{container="postgres"}

2. Database-Specific Profiling Extensions

Enable extensions or configuration that expose query performance without external agents.

PostgreSQL – pg_stat_statements

-- In your database, as superuser
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Then query top 10 slow queries by total time
SELECT queryid,
       LEFT(query, 100) AS query_preview,
       total_exec_time / 1000 AS total_sec,
       calls,
       mean_exec_time / 1000 AS mean_ms
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

MySQL – Performance Schema & Slow Query Log

-- Ensure performance_schema is enabled (usually on by default)
-- Enable slow query log in my.cnf ConfigMap:
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 2;  -- log queries > 2 seconds
-- Then use mysqldumpslow or pt-query-digest on the slow log file

MongoDB – Database Profiler

// In mongosh
db.setProfilingLevel(2)  // profile all operations
// Then inspect recent slow operations
db.system.profile.find({ millis: { $gt: 100 } }).sort({ ts: -1 }).limit(5).pretty()

3. Sidecar Profiling Containers

Run lightweight profiling agents as sidecars in the same Pod. This avoids cross-network latency and provides direct access to the database’s filesystem or socket. A common pattern uses a sidecar that periodically executes EXPLAIN ANALYZE on suspected queries or collects pg_stat_statements snapshots, then pushes metrics to a remote write endpoint.

# Example sidecar snippet in a Deployment YAML
containers:
- name: postgres
  image: postgres:15
  ...
- name: pg-profiler
  image: your-profiler-image:latest
  env:
  - name: PGHOST
    value: localhost
  - name: COLLECT_INTERVAL
    value: "30"
  volumeMounts:
  - name: shared-socket
    mountPath: /var/run/postgresql

Step-by-Step Guide: Profiling a PostgreSQL Database on Kubernetes

Let’s walk through a complete profiling session for a PostgreSQL StatefulSet deployed in production. We’ll gather metrics, identify a bottleneck, and optimize.

Step 1: Deploy PostgreSQL with Metrics Exposure

Use the Postgres container image with built-in metrics exporter or deploy a separate postgres_exporter as a sidecar. Below is a simplified StatefulSet manifest that enables pg_stat_statements and sets resource requests/limits for initial profiling.

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: pg-db
spec:
  serviceName: pg-db
  replicas: 1
  selector:
    matchLabels:
      app: pg-db
  template:
    metadata:
      labels:
        app: pg-db
    spec:
      containers:
      - name: postgres
        image: postgres:15.4
        ports:
        - containerPort: 5432
          name: postgres
        env:
        - name: POSTGRES_PASSWORD
          valueFrom:
            secretKeyRef:
              name: pg-secret
              key: password
        - name: POSTGRES_DB
          value: appdb
        resources:
          requests:
            cpu: "500m"
            memory: "1Gi"
          limits:
            cpu: "2"
            memory: "4Gi"
        volumeMounts:
        - name: pgdata
          mountPath: /var/lib/postgresql/data
        - name: pg-conf
          mountPath: /etc/postgresql/postgresql.conf
          subPath: postgresql.conf
      - name: exporter
        image: prometheuscommunity/postgres-exporter:v0.14.0
        env:
        - name: DATA_SOURCE_NAME
          value: "postgresql://postgres:$(POSTGRES_PASSWORD)@localhost:5432/appdb?sslmode=disable"
        envFrom:
        - secretRef:
            name: pg-secret
        ports:
        - containerPort: 9187
          name: metrics
      volumes:
      - name: pg-conf
        configMap:
          name: pg-config
  volumeClaimTemplates:
  - metadata:
      name: pgdata
    spec:
      accessModes: [ "ReadWriteOnce" ]
      storageClassName: "fast-ssd"
      resources:
        requests:
          storage: 100Gi

The ConfigMap pg-config enables pg_stat_statements and sets initial tuning parameters:

apiVersion: v1
kind: ConfigMap
metadata:
  name: pg-config
data:
  postgresql.conf: |
    shared_preload_libraries = 'pg_stat_statements'
    pg_stat_statements.track = all
    max_connections = 200
    shared_buffers = '1GB'
    effective_cache_size = '3GB'
    work_mem = '64MB'
    maintenance_work_mem = '256MB'

Step 2: Collect Baseline Metrics

After the StatefulSet is running and receiving production traffic, capture a baseline for 24 hours using Prometheus and Grafana or a simple script.

# Get Pod resource usage every 10 seconds for 1 hour
for i in {1..360}; do
  kubectl top pod pg-db-0 --containers >> baseline-resources.txt
  sleep 10
done

Meanwhile, connect to the database and take snapshots of pg_stat_statements every 5 minutes to capture query evolution.

-- Save to a file via cron or script
COPY (SELECT * FROM pg_stat_statements) TO '/tmp/stat_snapshot.csv' CSV HEADER;

Step 3: Analyze the Collected Data

From the resource baseline, you notice CPU usage regularly spikes to 1.8 cores (90% of limit) and memory usage grows to 3.5 GiB (near limit). The Prometheus query for throttling reveals:

rate(container_cpu_cfs_throttled_periods_total{container="postgres"}[5m]) > 0.1

This shows throttling is happening during those spikes, meaning the CPU limit is too low for peak periods. Next, examining the pg_stat_statements snapshots, you find the top query by total time:

SELECT queryid, query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
WHERE total_exec_time > 60000  -- 60 seconds total
ORDER BY total_exec_time DESC
LIMIT 1;

-- Output shows a query like:
-- SELECT * FROM orders WHERE status = $1 ORDER BY created_at DESC;
-- with 5000 calls, total_exec_time = 120000 ms (120 seconds)

Step 4: Identify Root Cause

The slow query runs frequently, scanning the entire orders table. EXPLAIN ANALYZE confirms a Seq Scan. No index on (status, created_at). The high CPU and memory usage correlate with this query, as PostgreSQL tries to sort large result sets in memory or spills to disk.

Step 5: Apply Optimization

First, create an appropriate index:

CREATE INDEX CONCURRENTLY idx_orders_status_created ON orders (status, created_at DESC);

After the index is built, the query switches to an Index Scan, reducing execution time from 24 ms average per call to 0.5 ms. CPU usage drops by 40%, memory stabilizes. The throttling disappears because the workload now fits within the CPU limit.

Optimization Strategies for Kubernetes-Hosted Databases

Profiling reveals bottlenecks; optimization resolves them. Apply these strategies iteratively based on profiling data.

1. Tune Database Configuration Inside Kubernetes

Use a ConfigMap to inject optimized database parameters. Key settings for PostgreSQL:

shared_buffers: 25% of Pod memory limit (e.g., 1GB out of 4Gi)
effective_cache_size: 75% of Pod memory limit
work_mem: start with 64MB, increase if no swap pressure
maintenance_work_mem: 256MB for index creation
max_wal_size: 10GB (to control checkpoint I/O spikes)

For MySQL in InnoDB:

innodb_buffer_pool_size: 70% of memory limit
innodb_log_file_size: 512M or larger for write-heavy workloads
innodb_flush_method: O_DIRECT (avoids double caching with OS page cache)
max_connections: limit based on connection pooler capacity

2. Resource Limits and Requests Tuning

Set requests to the average observed usage and limits to a safe peak. Avoid CPU limits that are too low (causing throttling) or too high (wasting node capacity). For databases, memory limits are critical—exceeding them triggers OOMKill. Use kubectl top and Prometheus data to find the 95th percentile usage over a week, then set:

resources:
  requests:
    cpu: "800m"
    memory: "2Gi"
  limits:
    cpu: "2"
    memory: "4Gi"

3. Storage Optimization

Profile volume performance using Prometheus metrics from the CSI driver or by running fio inside an init container. If latency is high, switch to a storage class with higher IOPS (e.g., AWS io2, Azure Ultra SSD). Ensure the filesystem mount options are optimized (noatime, nodiratime). Use a separate PVC for Write-Ahead Log (WAL) in PostgreSQL to isolate sequential writes.

# Example: separate WAL volume in PostgreSQL
volumeMounts:
- name: pgdata
  mountPath: /var/lib/postgresql/data
- name: pgwal
  mountPath: /var/lib/postgresql/data/pg_wal

4. Connection Pooling

Databases on Kubernetes often suffer from connection overhead due to many microservices. Place a connection pooler like PgBouncer or ProxySQL as a sidecar or a separate Deployment. This reduces the number of active database connections, lowering memory and CPU. Example PgBouncer sidecar:

containers:
- name: pgbouncer
  image: edoburu/pgbouncer:1.19
  ports:
  - containerPort: 6432
  env:
  - name: DB_HOST
    value: localhost
  - name: DB_USER
    value: postgres
  volumeMounts:
  - name: pgbouncer-conf
    mountPath: /etc/pgbouncer

Tune the pool size based on profiling: monitor reserve_pool wait events to detect pool saturation.

5. Node Affinity and Anti-Affinity

Pin database Pods to nodes with specific hardware (e.g., high-memory instances, local NVMe SSDs) using nodeSelectors or nodeAffinity. Avoid co-locating multiple database instances on the same node to prevent I/O contention. Use podAntiAffinity:

affinity:
  podAntiAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
    - weight: 100
      podAffinityTerm:
        labelSelector:
          matchExpressions:
          - key: app
            operator: In
            values:
            - pg-db
        topologyKey: kubernetes.io/hostname

6. Read Replicas and Horizontal Scaling

For read-heavy workloads, deploy read replicas using a StatefulSet with replication enabled. Use PostgreSQL streaming replication or MySQL Group Replication. Profile replication lag via pg_stat_replication or SHOW SLAVE STATUS. Distribute read traffic through a Service with selector for replicas.

# Example Service for read replicas
apiVersion: v1
kind: Service
metadata:
  name: pg-read
spec:
  selector:
    app: pg-db
    role: replica
  ports:
  - port: 5432

Best Practices for Ongoing Performance Management

Conclusion

Profiling and optimizing database performance on Kubernetes requires a fusion of container orchestration insights and deep database expertise. By systematically collecting metrics from the Kubernetes control plane, the database engine, and the storage layer, you transform guesswork into data-driven decisions. The practical techniques outlined—enabling pg_stat_statements, deploying Prometheus exporters, running sidecar profilers, and tuning resource limits—empower teams to uncover hidden bottlenecks like CPU throttling, inefficient queries, or storage latency.

Optimization then becomes a natural progression: create missing indexes, adjust shared_buffers, add connection poolers, isolate WAL traffic, or upgrade storage classes. The best practices of embedding profiling into CI/CD, setting SLOs, and continuously revisiting metrics ensure your stateful workloads remain fast, reliable, and cost-effective. In the dynamic, multi-tenant world of Kubernetes, a well-profiled database isn’t just a performance win—it’s a foundation for resilient, scalable applications.

🚀 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