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:
- Resource sharing and noisy neighbors: Pods compete for node-level CPU, memory, and I/O. A database Pod without properly tuned limits can be throttled or evicted, causing unpredictable performance.
- Storage abstraction: PersistentVolumeClaims (PVCs) abstract away physical disks. Without profiling, you may inadvertently use a storage class with low IOPS or high latency, crippling database throughput.
- Ephemeral networking: Pod IPs and Service endpoints change. Connection pooling and DNS caching become critical; misconfigurations cause spikes in connection establishment time.
- Elastic scaling: While Kubernetes excels at scaling stateless apps, databases require careful vertical scaling (more resources) or read-replica patterns. Profiling reveals when and how to scale effectively.
- Cost efficiency: Overprovisioned database Pods waste compute and storage. Underprovisioned ones cause outages. Continuous profiling ensures you right-size your deployments.
Key Performance Metrics to Profile
Effective profiling requires a holistic view across the stack. Focus on these metric categories:
- Kubernetes Container Metrics: CPU usage (cores/millicores), memory usage (RSS, working set), network bytes transmitted/received, and filesystem I/O (read/write throughput and IOPS). Collected via
kubectl topor Prometheus node-exporter/cAdvisor. - Database Internal Metrics: Active connections, transaction rate, query execution time percentiles, cache hit ratios (e.g., PostgreSQL shared buffers, MySQL InnoDB buffer pool), lock wait times, and replication lag for replica clusters.
- Query-Level Metrics: Slow query logs, aggregated query statistics (calls, total_time, rows returned), and execution plans. Tools like pg_stat_statements (PostgreSQL), MySQL Performance Schema, or MongoDB Database Profiler expose these.
- Storage Metrics: Volume latency and IOPS as reported by the CSI driver or cloud provider. High storage latency often masks itself as high database I/O wait time.
- Application Side: Connection pool utilization, transaction retries, and end-to-end response times from service meshes or APM tools.
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
- Embed profiling into CI/CD: Run performance regression tests for every schema change or configuration update. Use a temporary database Pod in a dedicated namespace, load production-like data, and measure query latency before merging.
- Set meaningful SLOs and alerting: Define Service Level Objectives for query latency (e.g., p99 < 50ms). Use Prometheus alerts on database metrics like connection exhaustion, replication lag > 10s, or disk space usage > 80%.
- Automate profiling snapshots: Use cron jobs or Kubernetes Jobs to periodically dump pg_stat_statements or slow query logs to object storage. This preserves historical data for trend analysis.
- Profile during non-peak hours: Schedule heavyweight profiling (e.g.,
VACUUM ANALYZE, updating statistics) during maintenance windows to avoid impacting live traffic. - Use Pod Disruption Budgets (PDBs): Ensure voluntary evictions don’t take down the database during profiling or maintenance. Set
maxUnavailable: 0for single-instance databases. - Monitor the profilers themselves: Ensure sidecar exporters don’t consume excessive CPU. Set resource limits on profiling containers.
- Iterate continuously: Profiling is not a “fix-and-forget” activity. Re-profile after any scaling event, schema change, traffic spike, or Kubernetes upgrade.
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.