Graceful Shutdown and Health Checks in Ruby

Khimananda Oli 9 min read Programming and Languages
Graceful Shutdown and Health Checks in Ruby

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.

SIGTERMfrom OrchestratorSignal TrapStop AcceptingDrain ActiveUpdate /readyClean ExitCode 0Timeout?Force SIGKILL/ready → 503LB Removes Pod/live → 200Process Still OK
Signal flow for graceful shutdown and health checks in Ruby: SIGTERM triggers connection draining while updating readiness probes to remove the instance from load balancers before exit.

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 TypePurposeFailure ActionRuby ImplementationCheck Dependencies?
LivenessIs the process alive and not deadlocked?Kill and restart containerIn-memory check, no I/ONever
ReadinessCan this instance serve traffic right now?Remove from service endpointsDB + cache + dependency checksAlways
StartupHas initialization completed?Delay other probes until successMigration check, warmup statusOnly 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.

Ruby Health Check Architecture/live (Liveness)In-Memory OnlyNo External I/OFail → Restart Pod/ready (Readiness)DB + Redis + DepsRespects Shutdown FlagFail → Remove from LB/startupMigration Complete?Cache Warmed?One-Time GateShared State: Concurrent::AtomicBoolean (Shutting Down Flag)Set by SIGTERM handler · Read by /ready · Never blocks /liveAnti-Pattern: /live checks DBDB latency → pod restart → cascade failureCorrect: /live returns 200 in <1msOnly /ready absorbs dependency failures
Health check endpoint separation for graceful shutdown and health checks in Ruby: liveness stays dependency-free while readiness reflects true serving capacity and shutdown state.

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.

# 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:

  1. Liveness probe depends on a slow dependency. Your /live endpoint 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.
  2. 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 generous failureThreshold.
  3. Readiness returns 200 during shutdown. The pod receives SIGTERM but 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.

Incorrect Configuration/live queries SELECT 1 FROM usersDB slow → timeout → pod killedNo startup probeRails boot > initialDelay → CrashLoop/ready ignores shutdown flagTraffic hits dying pod → 502 errorsPuma timeout = K8s grace periodNo margin → SIGKILL mid-requestNo preStop hookRace condition → 1-3% request lossCorrect Configuration/live returns head :ok (no I/O)Stable regardless of DB stateStartup probe: failureThreshold=30Allows 150s for migrations + warmup/ready checks AtomicBoolean first503 within 5s of SIGTERMPuma timeout (30s) < K8s grace (35s)5s margin for clean resource releasepreStop: sleep 5Endpoint propagation before drainOutcome: Zero dropped requests vs. intermittent 502s and restart storms
Side-by-side comparison of incorrect versus correct graceful shutdown and health checks in Ruby configurations, highlighting the five most common production failure modes.

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.

Frequently Asked Questions

Graceful shutdown allows a Ruby process to finish active requests and release resources before terminating. It prevents data corruption and dropped connections during deployments or restarts by trapping signals like SIGTERM and executing cleanup logic within frameworks like Puma, Sidekiq, or Rails.

Configure the on_worker_shutdown hook in puma.rb to close database connections and flush logs. Set prune_bundler to false and use the wait_for_less_busy_worker option. Ensure your process manager sends SIGTERM first, allowing Puma to drain requests before forcing termination with SIGKILL after a timeout.

Sidekiq requires quieting before stopping to prevent job loss. Send SIGUSR1 first to stop fetching new jobs, then SIGTERM after current jobs complete. In Kubernetes, configure preStop hooks to execute sidekiqctl quiet with adequate terminationGracePeriodSeconds to allow long-running background tasks to finish safely without interruption.

Expose a lightweight /health endpoint returning HTTP 200 when dependencies are reachable. Avoid heavy database queries; instead verify connection pool availability and cache connectivity. Use the rack-healthcheck gem or custom middleware that responds quickly to load balancer probes without triggering application-level business logic or expensive operations.

Liveness confirms the Ruby process runs and responds to basic requests, triggering container restarts on failure. Readiness verifies external dependencies like PostgreSQL and Redis are accessible before accepting traffic. Separate these endpoints so transient dependency failures do not cause unnecessary pod restarts while still removing unhealthy instances from service rotation.

Yes, aggressive timeouts may kill requests mid-shutdown. Increase timeout values during the draining phase or disable Rack::Timeout temporarily when receiving SIGTERM. Coordinate timeout settings with your reverse proxy and orchestration platform to ensure in-flight requests complete before the hard kill deadline expires during deployment cycles.

Start your server, send curl requests with artificial delays, then issue kill -SIGTERM pid. Monitor logs for cleanup messages and verify responses complete successfully. Use tools like siege or wrk to simulate concurrent traffic during shutdown testing to validate no requests drop and connections close properly under load.

Background threads, open file handles, or blocking I/O operations often prevent clean exits. Audit thread pools, message bus subscribers, and websocket connections for proper teardown handlers. Set explicit shutdown timeouts and use Thread.list to identify lingering threads that block the main process from completing its exit sequence reliably.

No, keep health endpoints unauthenticated for load balancer compatibility. Restrict access via network policies or VPC configuration instead. If sensitive information must be exposed, create separate authenticated diagnostic endpoints. Public health checks should return minimal status data to avoid leaking internal architecture details to unauthorized external scanners or attackers.

Passenger uses smart spawning to pre-warm workers before switching traffic. Configure passenger_pool_idle_time and max_instances appropriately. During deploys, use passenger-config restart-app with --rolling-restart to zero-downtime recycle workers. This maintains request handling capacity while updating code without dropping active connections or requiring full service interruption.

Set probe timeouts between two and five seconds with intervals of ten to fifteen seconds. Allow three consecutive failures before marking unhealthy. Match these values to your application p99 latency plus buffer. Overly aggressive thresholds cause flapping during garbage collection pauses or temporary resource contention in shared cloud environments.

Not directly, but eager loading increases memory footprint which impacts restart time. Preload critical classes at boot to reduce lazy-loading overhead during request draining. Balance startup speed against shutdown responsiveness by profiling class loading patterns and adjusting config.eager_load_paths to optimize both cold start latency and graceful termination behavior.

Use service mesh sidecars or orchestrator lifecycle hooks to sequence termination. Drain ingress traffic first, then signal application shutdown, finally close egress connections. Implement distributed tracing correlation IDs to track request completion across services. Stagger shutdown windows to prevent cascading failures when dependent services terminate simultaneously during maintenance windows.

Yes, use okcomputer or rack-health for standardized endpoints. These gems provide configurable dependency checks, caching, and response formatting compatible with Kubernetes and AWS ALB. They integrate with Rails initializers and support custom check classes for verifying specific infrastructure components without writing repetitive boilerplate health verification code.

All in-progress requests abort immediately, potentially corrupting writes or leaving partial transactions. Prevent this by setting terminationGracePeriodSeconds longer than your maximum request duration plus cleanup time. Monitor shutdown metrics to tune grace periods. Accept that SIGKILL is unrecoverable and design idempotent operations to tolerate forced terminations safely.