Go K8s PDB & HPA Production: 6 Key Configurations for Zero-Downtime Auto-Scaling
When Auto-Scaling Becomes a Disaster: K8s Elasticity's Darkest Hour
3 AM, a traffic surge triggers HPA scale-up. But new Pods need 15 seconds of cold start, and existing Pods are already being OOM Killed. Worse, PDB is not configured — 3 Pods are evicted simultaneously during scale-down, causing an immediate 503. The outage lasts 40 minutes, impacting 100K users.
This isn't an isolated case. Scaling-induced service disruptions, improper HPA metric selection, missing PDB configuration, and severe resource waste have become the four major pain points of K8s auto-scaling. PDB (PodDisruptionBudget) guarantees minimum available instances, while HPA (HorizontalPodAutoscaler) enables on-demand scaling. Together, they achieve true zero-downtime auto-scaling. This article walks you through 6 key configurations to build a production-grade K8s resilience system.
Core Concepts Reference
| Concept | Full Name | Purpose | Key Parameters |
|---|---|---|---|
| PDB | PodDisruptionBudget | Limit minimum available Pods during voluntary disruptions | minAvailable / maxUnavailable |
| HPA | HorizontalPodAutoscaler | Auto-scale Pod replicas based on metrics | Target CPU/memory, custom metrics |
| VPA | VerticalPodAutoscaler | Auto-adjust Pod resource requests/limits | minAllowed / maxAllowed |
| minAvailable | — | Minimum Pods that must remain available in PDB | Absolute value or percentage |
| maxUnavailable | — | Maximum Pods allowed unavailable in PDB | Absolute value or percentage |
| Target CPU | — | CPU utilization threshold that triggers HPA scale-up | Typically 60%-80% |
| Custom Metrics | — | HPA scaling based on business metrics | QPS, queue depth, etc. |
| Scaling Policy | — | HPA scale-up/down behavior control | scaleUp/scaleDown policies |
| Cold Start | — | Time from Pod start to ready | Impacts scale-up response speed |
Problem Analysis: 5 Challenges of K8s Auto-Scaling
Challenge 1: Scale-Up Delay Causes Overload. HPA detects a CPU spike and triggers scale-up, but new Pods take 10-30 seconds from scheduling to ready. During this time, traffic keeps pouring in and existing Pods may be overwhelmed.
Challenge 2: Scale-Down Causes Service Disruption. HPA randomly selects Pods to terminate during scale-down. Without PDB, too many Pods may be terminated simultaneously, causing a sudden drop in service capacity or even unavailability.
Challenge 3: Improper Metric Selection. Scaling based solely on CPU doesn't reflect real load. When a Go service has low CPU but high goroutine accumulation, HPA won't scale up, causing latency spikes.
Challenge 4: Cold Start Impact. Go applications need time to initialize connection pools and load configs. If readinessProbe is misconfigured, traffic hits new Pods before they're ready, causing request failures.
Challenge 5: Resource Fragmentation. After HPA scales up, Pods may be unevenly distributed. Scale-down may concentrate deletions on one node, causing unbalanced resource utilization and potentially triggering node-level cascading failures.
Configuration 1: PDB Minimum Availability Guarantee
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: api-service-pdb
namespace: production
spec:
minAvailable: 2
selector:
matchLabels:
app: api-service
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: gateway-pdb
namespace: production
spec:
maxUnavailable: 1
selector:
matchLabels:
app: gateway
PDB ensures at least 2 Pods remain available via minAvailable, or limits to 1 Pod unavailable via maxUnavailable. Key principle: minAvailable works for services with fixed replica counts, maxUnavailable for services with dynamic replica counts. PDB only protects against voluntary disruptions (node maintenance, scale-down), not involuntary disruptions (Pod crashes).
Configuration 2: HPA CPU/Memory Auto-Scaling
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-service-hpa
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-service
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
HPA triggers scaling at 70% CPU and 80% memory. scaleUp policy allows doubling replicas within 60 seconds, scaleDown policy reduces at most 10% every 60 seconds, and stabilizationWindowSeconds prevents scale-down flapping. Key: Set scale-down cooldown to 300 seconds to avoid frequent scaling from traffic fluctuations.
Configuration 3: HPA Custom Metric Scaling
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-service-custom-hpa
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-service
minReplicas: 3
maxReplicas: 30
metrics:
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: 1000
- type: Pods
pods:
metric:
name: goroutine_count
target:
type: AverageValue
averageValue: "5000"
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
Custom metrics are exposed to HPA via Prometheus Adapter. http_requests_per_second scales based on QPS, goroutine_count scales based on Go runtime goroutine count. Key: When Go services have low CPU but high goroutine accumulation, CPU-only metrics won't trigger scale-up — custom goroutine metrics are essential for production.
Go code for exposing custom metrics:
package main
import (
"net/http"
"runtime"
"sync/atomic"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
requestCounter atomic.Int64
goroutineGauge = prometheus.NewGaugeFunc(
prometheus.GaugeOpts{
Name: "goroutine_count",
Help: "Current number of goroutines",
},
func() float64 {
return float64(runtime.NumGoroutine())
},
)
httpRequestsPerSecond = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "http_requests_per_second",
Help: "HTTP requests per second",
},
[]string{"method", "path"},
)
)
func init() {
prometheus.MustRegister(goroutineGauge)
prometheus.MustRegister(httpRequestsPerSecond)
}
func metricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpRequestsPerSecond.WithLabelValues(r.Method, r.URL.Path).Inc()
next.ServeHTTP(w, r)
})
}
Configuration 4: Scaling Policies and Cooldown Periods
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-service-behavior-hpa
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-service
minReplicas: 3
maxReplicas: 50
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
behavior:
scaleUp:
stabilizationWindowSeconds: 0
selectPolicy: Max
policies:
- type: Percent
value: 100
periodSeconds: 60
- type: Pods
value: 4
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 600
selectPolicy: Min
policies:
- type: Percent
value: 5
periodSeconds: 120
- type: Pods
value: 1
periodSeconds: 120
Scale-up policy selectPolicy: Max chooses the most aggressive policy, ensuring fast response to load growth. Scale-down policy selectPolicy: Min chooses the most conservative policy, with a 600-second cooldown window preventing premature scale-down. Production rule: Scale up fast, scale down slow — better to spend extra resources than risk service disruption.
Configuration 5: Go Application Startup Optimization and Readiness Probes
package main
import (
"context"
"database/sql"
"fmt"
"net/http"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/redis/go-redis/v9"
)
type App struct {
db *sql.DB
redis *redis.Client
ready bool
}
func (a *App) Init(ctx context.Context) error {
var err error
a.db, err = sql.Open("mysql", "user:pass@tcp(mysql:3306)/db")
if err != nil {
return fmt.Errorf("open mysql: %w", err)
}
a.db.SetMaxOpenConns(50)
a.db.SetMaxIdleConns(10)
a.db.SetConnMaxLifetime(5 * time.Minute)
for i := 0; i < 10; i++ {
if err = a.db.PingContext(ctx); err == nil {
break
}
time.Sleep(time.Second)
}
if err != nil {
return fmt.Errorf("ping mysql after retries: %w", err)
}
a.redis = redis.NewClient(&redis.Options{
Addr: "redis:6379",
PoolSize: 50,
MinIdleConns: 10,
})
if err = a.redis.Ping(ctx).Err(); err != nil {
return fmt.Errorf("ping redis: %w", err)
}
a.ready = true
return nil
}
func (a *App) ReadinessHandler(w http.ResponseWriter, r *http.Request) {
if !a.ready {
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
if err := a.db.PingContext(r.Context()); err != nil {
http.Error(w, "db unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-service
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: api-service
template:
metadata:
labels:
app: api-service
spec:
terminationGracePeriodSeconds: 60
containers:
- name: api-service
image: api-service:latest
ports:
- containerPort: 8080
startupProbe:
httpGet:
path: /healthz
port: 8080
failureThreshold: 30
periodSeconds: 2
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
Key design: startupProbe gives slow-starting Pods enough initialization time (up to 60 seconds), readinessProbe checks dependency health, preStop hook gives Pods 10 seconds for graceful shutdown, terminationGracePeriodSeconds ensures in-flight requests complete after SIGTERM.
Configuration 6: End-to-End Resilience Testing
package main
import (
"context"
"fmt"
"os"
"time"
autoscalingv2 "k8s.io/api/autoscaling/v2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
)
type ResilienceTester struct {
clientset *kubernetes.Clientset
namespace string
}
func NewResilienceTester(kubeconfig, namespace string) (*ResilienceTester, error) {
config, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
if err != nil {
return nil, fmt.Errorf("build kubeconfig: %w", err)
}
cs, err := kubernetes.NewForConfig(config)
if err != nil {
return nil, fmt.Errorf("create clientset: %w", err)
}
return &ResilienceTester{clientset: cs, namespace: namespace}, nil
}
func (t *ResilienceTester) TestScaleUp(ctx context.Context, deployName string) error {
hpa, err := t.clientset.AutoscalingV2().HorizontalPodAutoscalers(t.namespace).Get(ctx, deployName+"-hpa", metav1.GetOptions{})
if err != nil {
return fmt.Errorf("get hpa: %w", err)
}
fmt.Printf("HPA %s: min=%d max=%d current=%d\n",
hpa.Name, *hpa.Spec.MinReplicas, hpa.Spec.MaxReplicas, hpa.Status.CurrentReplicas)
deploy, err := t.clientset.AppsV1().Deployments(t.namespace).Get(ctx, deployName, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("get deploy: %w", err)
}
fmt.Printf("Deployment %s: replicas=%d ready=%d available=%d\n",
deploy.Name, deploy.Status.Replicas, deploy.Status.ReadyReplicas, deploy.Status.AvailableReplicas)
return nil
}
func (t *ResilienceTester) TestPDB(ctx context.Context, pdbName string) error {
pdb, err := t.clientset.PolicyV1().PodDisruptionBudgets(t.namespace).Get(ctx, pdbName, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("get pdb: %w", err)
}
fmt.Printf("PDB %s: disruptionsAllowed=%d currentHealthy=%d desiredHealthy=%d\n",
pdb.Name, pdb.Status.DisruptionsAllowed, pdb.Status.CurrentHealthy, pdb.Status.DesiredHealthy)
return nil
}
func (t *ResilienceTester) RunFullTest(ctx context.Context) error {
fmt.Println("=== PDB Test ===")
if err := t.TestPDB(ctx, "api-service-pdb"); err != nil {
fmt.Fprintf(os.Stderr, "PDB test failed: %v\n", err)
}
fmt.Println("=== HPA Test ===")
if err := t.TestScaleUp(ctx, "api-service"); err != nil {
fmt.Fprintf(os.Stderr, "HPA test failed: %v\n", err)
}
fmt.Println("=== Scale Up Simulation ===")
scale, err := t.clientset.AppsV1().Deployments(t.namespace).GetScale(ctx, "api-service", metav1.GetOptions{})
if err != nil {
return fmt.Errorf("get scale: %w", err)
}
newScale := scale.DeepCopy()
newScale.Spec.Replicas = scale.Spec.Replicas * 2
_, err = t.clientset.AppsV1().Deployments(t.namespace).UpdateScale(ctx, "api-service", newScale, metav1.UpdateOptions{})
if err != nil {
return fmt.Errorf("update scale: %w", err)
}
fmt.Printf("Scaled from %d to %d replicas\n", scale.Spec.Replicas, newScale.Spec.Replicas)
time.Sleep(30 * time.Second)
return t.TestScaleUp(ctx, "api-service")
}
func main() {
kubeconfig := os.Getenv("KUBECONFIG")
if kubeconfig == "" {
kubeconfig = clientcmd.RecommendedHomeFile
}
tester, err := NewResilienceTester(kubeconfig, "production")
if err != nil {
fmt.Fprintf(os.Stderr, "init tester: %v\n", err)
os.Exit(1)
}
if err := tester.RunFullTest(context.Background()); err != nil {
fmt.Fprintf(os.Stderr, "test failed: %v\n", err)
os.Exit(1)
}
}
End-to-end testing verifies PDB protection, HPA scaling, and Deployment status. In production, run during low-traffic periods and observe whether scaling is smooth, PDB is effective, and Pods become healthy and ready.
5 Common Pitfalls
❌ Pitfall 1: Setting PDB minAvailable to 100% ✅ 100% means no voluntary disruptions are allowed — node maintenance becomes impossible. Set to 50%-66% to ensure at least half the Pods remain available.
❌ Pitfall 2: Setting HPA target CPU to 90% ✅ A 90% threshold means Pods are near their limit before scaling triggers — request latency will inevitably spike. Set to 60%-75% to leave a scaling buffer.
❌ Pitfall 3: Only configuring CPU metrics, ignoring memory and custom metrics ✅ Go services may have low CPU but high memory/goroutine usage. You must combine CPU + memory + business metrics to accurately reflect load.
❌ Pitfall 4: Using the same endpoint for readinessProbe and livenessProbe ✅ Readiness probe should check dependencies (DB/Redis), liveness probe should only check the process. Sharing an endpoint causes dependency flaps to restart Pods, worsening the failure.
❌ Pitfall 5: Ignoring preStop hook
✅ Without preStop, Pods are immediately removed from Service endpoints after SIGTERM — in-flight requests may be lost. sleep 10 gives Pods enough time to complete requests.
10 Error Troubleshooting
| Error Symptom | Possible Cause | Debug Command | Solution |
|---|---|---|---|
| HPA can't get CPU metrics | Metrics Server not installed | kubectl get pods -n kube-system | grep metrics |
Install Metrics Server |
| PDB DisruptionsAllowed=0 | minAvailable equals current replicas | kubectl get pdb -o yaml |
Lower minAvailable or increase replicas |
| HPA scale-up not triggering | Metrics below threshold | kubectl get hpa -o yaml |
Check current metric values and thresholds |
| Pod Pending after scale-up | Insufficient node resources | kubectl describe pod <pending-pod> |
Add nodes or reduce resource requests |
| Service 503 after scale-down | PDB not configured or too low | kubectl get pdb |
Configure PDB to guarantee minimum availability |
| Pod CrashLoopBackOff immediately after start | readinessProbe failing | kubectl logs <pod> |
Check dependency initialization and probe config |
| HPA scaling frequently | Scale-down cooldown too short | kubectl describe hpa |
Increase stabilizationWindowSeconds |
| Custom metrics unavailable | Prometheus Adapter not configured | kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1 |
Deploy Prometheus Adapter |
| Pods force-evicted during node maintenance | PDB not created | kubectl get pdb -A |
Create PDB for critical services |
| Scale-up too slow | scaleUp policy too conservative | kubectl describe hpa |
Adjust scaleUp policy to Percent:100 |
Advanced Optimization
1. Predictive Scaling. Based on historical traffic patterns, scale up before peak hours arrive. Combine KEDA's Cron trigger or a custom predictive controller for "resources ready before traffic."
2. Pod Topology Spread Constraints. Use topologySpreadConstraints to ensure scaled-up Pods are evenly distributed across availability zones, preventing single-AZ failures from causing service unavailability.
3. Priority and Preemption. Set high priority classes for critical services — when resources are scarce, critical services are prioritized while low-priority services can be preempted.
4. VPA and HPA Coordination. VPA adjusts resource requests, HPA adjusts replica counts. Recommend VPA in recommendation-only mode (mode: Off) to avoid conflicts with HPA.
5. FinOps Cost Optimization. Combine Spot instances with Cluster Autoscaler — non-critical services use Spot instances for cost savings, critical services use On-Demand instances for stability.
Comparison: HPA vs VPA vs KEDA vs Cluster Autoscaler
| Feature | HPA | VPA | KEDA | Cluster Autoscaler |
|---|---|---|---|---|
| Scaling Dimension | Horizontal (replicas) | Vertical (resources) | Horizontal (replicas) | Node count |
| Trigger Method | CPU/memory/custom metrics | Historical resource usage | Event-driven (multi-source) | Pod scheduling failure |
| Use Case | High load variance | Misconfigured resources | Event-driven/batch | Insufficient node resources |
| PDB Compatibility | ✅ Required | ⚠️ May conflict | ✅ Required | ✅ Required |
| Cold Start Impact | ⚠️ Affected | ✅ Not affected | ⚠️ Affected | ⚠️ Affected |
| Go Service Fit | ⚠️ Needs custom metrics | ✅ Auto-adjusts | ✅ Rich triggers | ✅ Transparent |
| Production Maturity | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Recommended Combo | HPA+PDB | VPA recommendation mode | KEDA+PDB | CA+HPA+PDB |
Summary
K8s auto-scaling isn't just about configuring an HPA — it's a four-part system: PDB for availability, HPA for elasticity, probes for readiness, and policies for pacing. The 6 key configurations — PDB minimum availability, HPA CPU/memory scaling, custom metric scaling, scaling policies and cooldown, Go startup optimization and probes, and end-to-end resilience testing — cover the complete production resilience chain. Remember: scale up fast, scale down slow, PDB is mandatory, probes must be separate — that's how you achieve true zero-downtime auto-scaling. In the future, AI-based predictive scaling and serverless elasticity will further reduce operational complexity.
Recommended Tools
- JSON Formatter — Format HPA/PDB YAML/JSON configs, quickly debug resource definition issues
- Hash Calculator — Calculate ConfigMap and Secret checksums, ensure scaling config data integrity
- cURL to Code — Convert cURL test commands to Go code, accelerate K8s API client development
Further Reading
Try these browser-local tools — no sign-up required →