
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Dropped requests during deploys and false-positive restart loops are the two most common reliability failures I see in Ruby production environments. Implementing graceful shutdown and health checks in Ruby correctly requires coordinating OS signals, web server configuration, and orchestrator probes so they work as a unified system rather than isolated features. This guide covers the exact Puma configuration, signal handling patterns, and Kubernetes probe tuning needed to achieve true zero-downtime operations.
How do you handle SIGTERM for graceful shutdown and health checks in Ruby?
Ruby does not gracefully shut down by default. When Kubernetes or systemd sends SIGTERM, an unprepared Ruby process exits immediately, severing active TCP connections and returning 502 errors to clients. You must explicitly trap this signal and coordinate with your web server's built-in shutdown mechanism.
Trapping signals safely in Puma
Puma handles SIGTERM natively when configured correctly, but many teams override this behavior accidentally with custom signal traps. The correct approach is to let Puma manage the shutdown sequence while using hooks to update your application-level health state. In your puma.rb configuration:
# config/puma.rb
workers Integer(ENV.fetch("WEB_CONCURRENCY", 2))
threads_count = Integer(ENV.fetch("RAILS_MAX_THREADS", 5))
threads threads_count, threads_count
preload_app!
# Critical: Set timeout to match or exceed K8s terminationGracePeriodSeconds
shutdown_timeout 30
on_worker_boot do
# Re-establish DB connections after fork
ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
end
before_fork do
# Disconnect parent DB connection to prevent shared socket issues
ActiveRecord::Base.connection_pool.disconnect! if defined?(ActiveRecord)
end The shutdown_timeout directive is the single most misconfigured parameter. It defines how long Puma waits for in-flight requests to complete before forcing worker termination. If your Kubernetes terminationGracePeriodSeconds is 30 seconds and your Puma timeout is also 30, you have zero margin for cleanup overhead. Always set Puma's timeout to at least 5 seconds less than the orchestrator's grace period.
Application-level signal coordination
For background job processors like Sidekiq or custom daemons running in the same container, you need explicit signal handling that coordinates with Puma's shutdown. A common mistake I encounter in audits is trapping SIGTERM globally and calling exit, which kills Puma before it finishes draining. Instead, use an atomic flag:
# lib/graceful_shutdown.rb
module GracefulShutdown
@shutting_down = Concurrent::AtomicBoolean.new(false)
def self.shutting_down?
@shutting_down.true?
end
def self.register_signal_handlers!
trap("TERM") do
@shutting_down.make_true
Rails.logger.info("SIGTERM received, initiating graceful shutdown")
# Let Puma handle its own shutdown; notify background workers
BackgroundWorkerRegistry.drain_all(timeout: 25)
end
end
end
# config/initializers/graceful_shutdown.rb
GracefulShutdown.register_signal_handlers! if Rails.env.production? This pattern ensures your health check endpoints can query GracefulShutdown.shutting_down? to immediately return 503 on /ready while allowing Puma to continue serving existing requests. For more on structuring observability around these events, see structured logging best practices to capture shutdown timing metrics.
What is the difference between liveness and readiness probes in Ruby?
Confusing liveness and readiness is the primary cause of restart loops in Ruby deployments. These probes serve fundamentally different purposes, and implementing them identically guarantees operational pain.
| Probe Type | Purpose | Failure Action | Ruby Implementation | Check Dependencies? |
|---|---|---|---|---|
| Liveness | Is the process alive and not deadlocked? | Kill and restart container | In-memory check, no I/O | Never |
| Readiness | Can this instance serve traffic right now? | Remove from service endpoints | DB + cache + dependency checks | Always |
| Startup | Has initialization completed? | Delay other probes until success | Migration check, warmup status | Only during boot |
Liveness: Keep it trivial
Your liveness endpoint must never touch the database, Redis, or any external service. If PostgreSQL is slow, a liveness check that queries it will timeout, causing Kubernetes to kill your pod — exactly when you need it most. A correct liveness implementation:
# app/controllers/health_controller.rb
class HealthController < ApplicationController
skip_before_action :authenticate_user!, only: [:live, :ready]
def live
# Pure in-memory check. If this fails, the process is broken.
head :ok
rescue StandardError => e
Rails.logger.error("Liveness check failed: #{e.message}")
head :internal_server_error
end
end Readiness: Reflect actual serving capacity
Readiness determines whether traffic reaches your pod. During shutdown, this endpoint must return 503 immediately upon receiving SIGTERM. During normal operation, it should verify critical dependencies with timeouts:
def ready
return head(:service_unavailable) if GracefulShutdown.shutting_down?
checks = {
database: check_database,
redis: check_redis
}
if checks.values.all? { |c| c[:status] == "ok" }
render json: { status: "ready", checks: checks }, status: :ok
else
render json: { status: "unready", checks: checks }, status: :service_unavailable
end
end
private
def check_database
ActiveRecord::Base.connection.execute("SELECT 1")
{ status: "ok" }
rescue StandardError => e
{ status: "error", message: e.message }
end Each dependency check must have its own timeout (typically 2–3 seconds). A hung database connection should make the pod unready, not trigger a liveness restart. Understanding how these probes interact with broader monitoring is covered in the four golden signals of monitoring.
How do you configure Kubernetes probes for Ruby applications?
Even perfect Ruby code fails with misconfigured probes. The timing relationships between your application's shutdown behavior and Kubernetes probe parameters determine whether deploys are truly zero-downtime.
Recommended probe configuration
# k8s/deployment.yaml (relevant excerpt)
spec:
terminationGracePeriodSeconds: 35
containers:
- name: app
ports:
- containerPort: 3000
startupProbe:
httpGet:
path: /startup
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 30 # Allow up to 150s for migrations/warmup
livenessProbe:
httpGet:
path: /live
port: 3000
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 3000
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
successThreshold: 1 Key relationships: terminationGracePeriodSeconds (35s) exceeds Puma's shutdown_timeout (30s) by 5 seconds for cleanup overhead. The readiness probe's periodSeconds (5s) ensures the pod is removed from endpoints quickly after SIGTERM. The liveness timeoutSeconds (2s) is short because the endpoint performs no I/O. For teams managing multiple environments, managing multiple environments in IaC helps keep these values consistent across staging and production.
The preStop hook safety net
There is a race condition between Kubernetes removing a pod from endpoints and your application receiving SIGTERM. Traffic can arrive during this window. A preStop hook adds a deliberate delay:
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"] This 5-second sleep gives the kube-proxy and ingress controller time to propagate endpoint removal before your app stops accepting connections. Without this, approximately 1–3% of requests during rolling deploys will hit pods that have already begun shutting down. Increase terminationGracePeriodSeconds accordingly (to 40s in this example).
Why are my Ruby pods stuck in CrashLoopBackOff after adding health checks?
This is almost always caused by one of three issues, ranked by frequency in my incident reviews:
- Liveness probe depends on a slow dependency. Your
/liveendpoint queries PostgreSQL or Redis. During high load or partial outages, the probe times out repeatedly, triggering restarts that worsen the underlying problem. Fix: make liveness purely in-memory. - Startup probe missing for slow-booting apps. Rails applications with extensive initializers or pending migrations take longer than the liveness
initialDelaySeconds. Kubernetes kills the pod before it finishes starting. Fix: add a dedicated startup probe with a generousfailureThreshold. - Readiness returns 200 during shutdown. The pod receives
SIGTERMbut continues reporting ready, so traffic keeps flowing to a dying process. Requests fail with connection resets. Fix: check the shutdown flag atomically in your readiness handler as shown above.
Debugging these issues systematically requires understanding the full probe lifecycle. My article on debugging CrashLoopBackOff in Kubernetes provides a structured diagnostic workflow specifically for Ruby workloads.
Deploy Reliable Ruby Applications with Confidence
Getting graceful shutdown and health checks in Ruby right eliminates an entire category of production incidents that masquerade as random failures. The implementation is straightforward once you understand the contract between your application, your web server, and your orchestrator: liveness proves the process exists, readiness proves it can serve, and shutdown coordination ensures neither lies during transitions. Audit your current configuration against the patterns in this guide, fix the timing relationships first, and test with actual rolling deploys under load — synthetic tests rarely expose the race conditions that matter. If your team needs help validating these patterns against your specific infrastructure or compliance requirements, reach out to discuss your deployment architecture.