From 8ea1a98e85071c437d7f2cc726bf5c2b31c695ea Mon Sep 17 00:00:00 2001 From: Martijn Date: Thu, 12 Mar 2026 10:11:09 +0100 Subject: [PATCH] Add support for review apps or scale-to-zero configurations --- lib/kamal/configuration.rb | 4 ++++ lib/kamal/configuration/docs/proxy.yml | 12 ++++++++++ lib/kamal/configuration/proxy.rb | 6 +++++ lib/kamal/configuration/proxy/run.rb | 5 +++++ lib/kamal/configuration/validator/proxy.rb | 10 +++++++++ test/commands/app_test.rb | 8 +++++++ test/commands/proxy_test.rb | 14 ++++++++++++ test/configuration/proxy_test.rb | 26 ++++++++++++++++++++++ 8 files changed, 85 insertions(+) diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index ee30e59dc..544876790 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -120,6 +120,10 @@ def roles servers.roles end + def any_role_use_proxy_idle? + roles.any? { |role| role.proxy&.idle? } + end + def role(name) roles.detect { |r| r.name == name.to_s } end diff --git a/lib/kamal/configuration/docs/proxy.yml b/lib/kamal/configuration/docs/proxy.yml index 87e428e3c..301b5bf35 100644 --- a/lib/kamal/configuration/docs/proxy.yml +++ b/lib/kamal/configuration/docs/proxy.yml @@ -148,6 +148,17 @@ proxy: - X-Request-ID - X-Request-Start + # Idle + # + # Stop containers after a period of inactivity and wake them up on the next request. + # + # This requires the proxy to have access to the Docker socket. + # + # Defaults to disabled: + idle: + timeout: 300 # Stop containers after 5 minutes of no requests + wake_timeout: 30 # Maximum time to wait for containers to wake up + # Run configuration # # These options are used when booting the proxy container. @@ -158,6 +169,7 @@ proxy: metrics_port: 9090 # Port for Prometheus metrics debug: true # Debug logging (default: false) log_max_size: "30m" # Maximum log file size (default: "10m") + docker_socket: true # Mount the Docker socket (default: false, or true if idle is used) publish: false # Publish ports to the host (default: true) bind_ips: # List of IPs to bind to when publishing ports - 0.0.0.0 diff --git a/lib/kamal/configuration/proxy.rb b/lib/kamal/configuration/proxy.rb index 3f2d83fad..29537e7c8 100644 --- a/lib/kamal/configuration/proxy.rb +++ b/lib/kamal/configuration/proxy.rb @@ -21,6 +21,10 @@ def app_port proxy_config.fetch("app_port", 80) end + def idle? + proxy_config.dig("idle", "timeout").present? + end + def ssl? proxy_config.fetch("ssl", false) end @@ -90,6 +94,8 @@ def deploy_options "tls-redirect": proxy_config.dig("ssl_redirect"), "log-request-header": proxy_config.dig("logging", "request_headers") || DEFAULT_LOG_REQUEST_HEADERS, "log-response-header": proxy_config.dig("logging", "response_headers"), + "idle-timeout": seconds_duration(proxy_config.dig("idle", "timeout")), + "idle-wake-timeout": seconds_duration(proxy_config.dig("idle", "wake_timeout")), "error-pages": error_pages }.compact end diff --git a/lib/kamal/configuration/proxy/run.rb b/lib/kamal/configuration/proxy/run.rb index 969321c32..725dc041b 100644 --- a/lib/kamal/configuration/proxy/run.rb +++ b/lib/kamal/configuration/proxy/run.rb @@ -17,6 +17,10 @@ def debug? run_config.fetch("debug", nil) end + def docker_socket? + run_config.fetch("docker_socket", config.any_role_use_proxy_idle?) + end + def publish? run_config.fetch("publish", true) end @@ -94,6 +98,7 @@ def run_command_options def docker_options_args [ *apps_volume_args, + *("--volume=/var/run/docker.sock:/var/run/docker.sock" if docker_socket?), *publish_args, *logging_args, *("--expose=#{metrics_port}" if metrics_port.present?), diff --git a/lib/kamal/configuration/validator/proxy.rb b/lib/kamal/configuration/validator/proxy.rb index 5dc3fd46b..bef4495a2 100644 --- a/lib/kamal/configuration/validator/proxy.rb +++ b/lib/kamal/configuration/validator/proxy.rb @@ -21,6 +21,16 @@ def validate! end end + if config["idle"].present? + if config["idle"]["timeout"].present? && !config["idle"]["timeout"].is_a?(Integer) + error "Idle timeout must be an integer (seconds)" + end + + if config["idle"]["wake_timeout"].present? && !config["idle"]["wake_timeout"].is_a?(Integer) + error "Idle wake timeout must be an integer (seconds)" + end + end + if run_config = config["run"] if run_config["bind_ips"].present? ensure_valid_bind_ips(config["bind_ips"]) diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index 08f3fba26..61ad34ce5 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -150,6 +150,14 @@ class CommandsAppTest < ActiveSupport::TestCase new_command.deploy(target: "172.1.0.2").join(" ") end + test "deploy with idle" do + @config[:proxy] = { "idle" => { "timeout" => 300, "wake_timeout" => 30 } } + + assert_equal \ + "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"172.1.0.2:80\" --deploy-timeout=\"30s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\" --idle-timeout=\"300s\" --idle-wake-timeout=\"30s\"", + new_command.deploy(target: "172.1.0.2").join(" ") + end + test "remove" do assert_equal \ "docker exec kamal-proxy kamal-proxy remove app-web", diff --git a/test/commands/proxy_test.rb b/test/commands/proxy_test.rb index 77e9a53ac..e09eddd86 100644 --- a/test/commands/proxy_test.rb +++ b/test/commands/proxy_test.rb @@ -221,6 +221,20 @@ class CommandsProxyTest < ActiveSupport::TestCase new_command.run.join(" ") end + test "docker socket mount when idle configured" do + @config[:proxy] = { "idle" => { "timeout" => 300 }, "run" => { "version" => "v0.9.2" } } + assert_equal \ + "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config --volume=/var/run/docker.sock:/var/run/docker.sock --publish 80:80 --publish 443:443 --log-opt max-size=10m basecamp/kamal-proxy:v0.9.2 kamal-proxy run", + new_command.run.join(" ") + end + + test "explicit docker socket mount config" do + @config[:proxy] = { "run" => { "docker_socket" => true } } + assert_equal \ + "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config --volume=/var/run/docker.sock:/var/run/docker.sock --publish 80:80 --publish 443:443 --log-opt max-size=10m basecamp/kamal-proxy:v0.9.2 kamal-proxy run", + new_command.run.join(" ") + end + private def new_command Kamal::Commands::Proxy.new(Kamal::Configuration.new(@config, version: "123"), host: "1.1.1.1") diff --git a/test/configuration/proxy_test.rb b/test/configuration/proxy_test.rb index e0b328f3b..fbeacce5b 100644 --- a/test/configuration/proxy_test.rb +++ b/test/configuration/proxy_test.rb @@ -105,6 +105,32 @@ class ConfigurationProxyTest < ActiveSupport::TestCase end end + test "idle configuration" do + @deploy[:proxy] = { + "host" => "example.com", + "idle" => { + "timeout" => 300, + "wake_timeout" => 30 + } + } + + proxy = config.proxy + assert_equal true, proxy.idle? + assert_equal "300s", proxy.deploy_options[:"idle-timeout"] + assert_equal "30s", proxy.deploy_options[:"idle-wake-timeout"] + end + + test "idle validation" do + @deploy[:proxy] = { + "host" => "example.com", + "idle" => { + "timeout" => "300" + } + } + + assert_raises(Kamal::ConfigurationError) { config.proxy } + end + private def config Kamal::Configuration.new(@deploy)