Skip to content

Commit a1d1046

Browse files
committed
Handle loadbalancing for multiple web hosts
1 parent e217332 commit a1d1046

File tree

15 files changed

+364
-6
lines changed

15 files changed

+364
-6
lines changed

lib/kamal/cli/main.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ def deploy(boot_accessories: false)
4141

4242
invoke "kamal:cli:app:boot", [], invoke_options
4343

44+
if KAMAL.config.proxy.load_balancing?
45+
say "Updating loadbalancer configuration...", :magenta
46+
invoke "kamal:cli:proxy:loadbalancer", [ "deploy" ], invoke_options
47+
end
48+
4449
say "Prune old containers and images...", :magenta
4550
invoke "kamal:cli:prune:all", [], invoke_options
4651
end
@@ -70,6 +75,11 @@ def redeploy
7075
invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)
7176

7277
invoke "kamal:cli:app:boot", [], invoke_options
78+
79+
if KAMAL.config.proxy.load_balancing?
80+
say "Updating loadbalancer configuration...", :magenta
81+
invoke "kamal:cli:proxy:loadbalancer", [ "deploy" ], invoke_options
82+
end
7383
end
7484
end
7585

lib/kamal/cli/proxy.rb

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ def boot
1919
execute *KAMAL.proxy.ensure_apps_config_directory
2020
execute *KAMAL.proxy.start_or_run
2121
end
22+
23+
if KAMAL.config.proxy.load_balancing?
24+
on(KAMAL.config.proxy.effective_loadbalancer) do |host|
25+
info "Starting loadbalancer on #{host}..."
26+
execute *KAMAL.registry.login
27+
execute *KAMAL.loadbalancer.start_or_run
28+
end
29+
end
2230
end
2331
end
2432

@@ -114,7 +122,7 @@ def reboot
114122
execute *KAMAL.auditor.record("Rebooted proxy"), verbosity: :debug
115123
execute *KAMAL.registry.login
116124

117-
"Stopping and removing kamal-proxy on #{host}, if running..."
125+
info "Stopping and removing kamal-proxy on #{host}, if running..."
118126
execute *KAMAL.proxy.stop, raise_on_non_zero_exit: false
119127
execute *KAMAL.proxy.remove_container
120128
execute *KAMAL.proxy.ensure_apps_config_directory
@@ -123,6 +131,24 @@ def reboot
123131
end
124132
run_hook "post-proxy-reboot", hosts: host_list
125133
end
134+
135+
if KAMAL.config.proxy.load_balancing?
136+
lb_host = KAMAL.config.proxy.effective_loadbalancer
137+
run_hook "pre-loadbalancer-reboot", hosts: lb_host
138+
139+
on(lb_host) do |host|
140+
execute *KAMAL.auditor.record("Rebooted loadbalancer"), verbosity: :debug
141+
execute *KAMAL.registry.login
142+
143+
info "Stopping and removing load-balancer on #{host}, if running..."
144+
execute *KAMAL.loadbalancer.stop, raise_on_non_zero_exit: false
145+
execute *KAMAL.loadbalancer.remove_container
146+
147+
execute *KAMAL.loadbalancer.run
148+
end
149+
150+
run_hook "post-loadbalancer-reboot", hosts: lb_host
151+
end
126152
end
127153
end
128154
end
@@ -143,10 +169,10 @@ def upgrade
143169
execute *KAMAL.auditor.record("Rebooted proxy"), verbosity: :debug
144170
execute *KAMAL.registry.login
145171

146-
"Stopping and removing Traefik on #{host}, if running..."
172+
info "Stopping and removing Traefik on #{host}, if running..."
147173
execute *KAMAL.proxy.cleanup_traefik
148174

149-
"Stopping and removing kamal-proxy on #{host}, if running..."
175+
info "Stopping and removing kamal-proxy on #{host}, if running..."
150176
execute *KAMAL.proxy.stop, raise_on_non_zero_exit: false
151177
execute *KAMAL.proxy.remove_container
152178
execute *KAMAL.proxy.remove_image
@@ -198,6 +224,12 @@ def restart
198224
desc "details", "Show details about proxy container from servers"
199225
def details
200226
on(KAMAL.proxy_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.proxy.info), type: "Proxy" }
227+
228+
if KAMAL.config.proxy.load_balancing?
229+
on(KAMAL.config.proxy.effective_loadbalancer) do |host|
230+
puts_by_host host, capture_with_info(*KAMAL.proxy.loadbalancer.info), type: "Loadbalancer"
231+
end
232+
end
201233
end
202234

203235
desc "logs", "Show log lines from proxy on servers"
@@ -239,13 +271,80 @@ def remove
239271
end
240272
end
241273

274+
desc "loadbalancer STATUS", "Manage the load balancer"
275+
def loadbalancer(status)
276+
case status
277+
when "info"
278+
if KAMAL.config.proxy.load_balancing?
279+
on(KAMAL.config.proxy.effective_loadbalancer) do |host|
280+
puts "Loadbalancer status on #{host}:"
281+
puts capture_with_info(*KAMAL.loadbalancer.info)
282+
end
283+
else
284+
puts "Load balancing is not configured"
285+
end
286+
when "start"
287+
if KAMAL.config.proxy.load_balancing?
288+
on(KAMAL.config.proxy.effective_loadbalancer) do |host|
289+
execute *KAMAL.registry.login
290+
execute *KAMAL.loadbalancer.start_or_run
291+
end
292+
else
293+
puts "Load balancing is not configured"
294+
end
295+
when "stop"
296+
if KAMAL.config.proxy.load_balancing?
297+
on(KAMAL.config.proxy.effective_loadbalancer) do |host|
298+
execute *KAMAL.loadbalancer.stop, raise_on_non_zero_exit: false
299+
end
300+
else
301+
puts "Load balancing is not configured"
302+
end
303+
when "logs"
304+
if KAMAL.config.proxy.load_balancing?
305+
on(KAMAL.config.proxy.effective_loadbalancer) do |host|
306+
puts_by_host host, capture(*KAMAL.loadbalancer.logs(timestamps: true)), type: "Loadbalancer"
307+
end
308+
else
309+
puts "Load balancing is not configured"
310+
end
311+
when "deploy"
312+
if KAMAL.config.proxy.load_balancing?
313+
targets = []
314+
KAMAL.config.roles.each do |role|
315+
next unless role.running_proxy?
316+
317+
role.hosts.each do |host|
318+
targets << host
319+
end
320+
end
321+
322+
on(KAMAL.config.proxy.effective_loadbalancer) do |host|
323+
info "Deploying to loadbalancer on #{host} with targets: #{targets.join(', ')}"
324+
execute *KAMAL.loadbalancer.deploy(targets: targets)
325+
end
326+
else
327+
puts "Load balancing is not configured"
328+
end
329+
else
330+
puts "Unknown loadbalancer subcommand: #{status}. Available: info, start, stop, logs, deploy"
331+
end
332+
end
333+
242334
desc "remove_container", "Remove proxy container from servers", hide: true
243335
def remove_container
244336
with_lock do
245337
on(KAMAL.proxy_hosts) do
246338
execute *KAMAL.auditor.record("Removed proxy container"), verbosity: :debug
247339
execute *KAMAL.proxy.remove_container
248340
end
341+
342+
if KAMAL.config.proxy.load_balancing?
343+
on(KAMAL.config.proxy.effective_loadbalancer) do
344+
execute *KAMAL.auditor.record("Removed loadbalancer container"), verbosity: :debug
345+
execute *KAMAL.loadbalancer.remove_container
346+
end
347+
end
249348
end
250349
end
251350

@@ -256,6 +355,13 @@ def remove_image
256355
execute *KAMAL.auditor.record("Removed proxy image"), verbosity: :debug
257356
execute *KAMAL.proxy.remove_image
258357
end
358+
359+
if KAMAL.config.proxy.load_balancing?
360+
on(KAMAL.config.proxy.effective_loadbalancer) do
361+
execute *KAMAL.auditor.record("Removed loadbalancer image"), verbosity: :debug
362+
execute *KAMAL.loadbalancer.remove_image
363+
end
364+
end
259365
end
260366
end
261367

lib/kamal/commander.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@ def proxy
113113
@commands[:proxy] ||= Kamal::Commands::Proxy.new(config)
114114
end
115115

116+
def loadbalancer_config
117+
@loadbalancer_config ||= Kamal::Configuration::Loadbalancer.new(config: config, proxy_config: config.proxy.proxy_config)
118+
end
119+
120+
def loadbalancer
121+
@commands[:loadbalancer] ||= Kamal::Commands::Loadbalancer.new(config, loadbalancer_config: loadbalancer_config)
122+
end
123+
116124
def prune
117125
@commands[:prune] ||= Kamal::Commands::Prune.new(config)
118126
end

lib/kamal/commands/loadbalancer.rb

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
class Kamal::Commands::Loadbalancer < Kamal::Commands::Base
2+
delegate :argumentize, :optionize, to: Kamal::Utils
3+
4+
attr_reader :loadbalancer_config
5+
6+
def initialize(config, loadbalancer_config: nil)
7+
super(config)
8+
@loadbalancer_config = loadbalancer_config
9+
end
10+
11+
def run
12+
pipe \
13+
[ :echo, proxy_image ],
14+
xargs(docker(:run,
15+
"--name", container_name,
16+
"--network", "kamal",
17+
"--detach",
18+
"--restart", "unless-stopped",
19+
"--publish", "80:80",
20+
"--publish", "443:443",
21+
"--label", "org.opencontainers.image.title=kamal-loadbalancer",
22+
"--volume", "kamal-loadbalancer-config:/home/kamal-loadbalancer/.config/kamal-loadbalancer"))
23+
end
24+
25+
def start
26+
docker :container, :start, container_name
27+
end
28+
29+
def stop(name: container_name)
30+
docker :container, :stop, name
31+
end
32+
33+
def start_or_run
34+
combine start, run, by: "||"
35+
end
36+
37+
def deploy(targets: [])
38+
target_args = targets.map { |t| "#{t}:80" }
39+
40+
hosts = loadbalancer_config.hosts
41+
42+
options = []
43+
options << "--target=#{target_args.join(',')}"
44+
options << "--host=#{hosts.join(',')}"
45+
options << "--tls" if loadbalancer_config.ssl?
46+
47+
docker :exec, container_name, "kamal-proxy", "deploy", loadbalancer_config.config.service, *options
48+
end
49+
50+
def info
51+
docker :ps, "--filter", "name=^#{container_name}$"
52+
end
53+
54+
def version
55+
pipe \
56+
docker(:inspect, container_name, "--format '{{.Config.Image}}'"),
57+
[ :cut, "-d:", "-f2" ]
58+
end
59+
60+
def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil)
61+
pipe \
62+
docker(:logs, container_name, ("--since #{since}" if since), ("--tail #{lines}" if lines), ("--timestamps" if timestamps), "2>&1"),
63+
("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep)
64+
end
65+
66+
def follow_logs(host:, timestamps: true, grep: nil, grep_options: nil)
67+
run_over_ssh pipe(
68+
docker(:logs, container_name, ("--timestamps" if timestamps), "--tail", "10", "--follow", "2>&1"),
69+
(%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep)
70+
).join(" "), host: host
71+
end
72+
73+
def remove_container
74+
docker :container, :prune, "--force", "--filter", "label=org.opencontainers.image.title=kamal-loadbalancer"
75+
end
76+
77+
def remove_image
78+
docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=kamal-loadbalancer"
79+
end
80+
81+
def ensure_directory
82+
make_directory loadbalancer_config.directory
83+
end
84+
85+
def remove_directory
86+
super(loadbalancer_config.directory)
87+
end
88+
89+
private
90+
def proxy_image
91+
[
92+
loadbalancer_config.config.proxy_boot.image_default,
93+
Kamal::Configuration::Proxy::Boot::MINIMUM_VERSION
94+
].join(":")
95+
end
96+
97+
def container_name
98+
loadbalancer_config.container_name
99+
end
100+
end

lib/kamal/commands/proxy.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ def reset_run_command
105105
remove_file config.proxy_boot.run_command_file
106106
end
107107

108+
def loadbalancer
109+
@loadbalancer ||= Kamal::Commands::Loadbalancer.new(config, loadbalancer_config: KAMAL.loadbalancer_config)
110+
end
111+
108112
private
109113
def container_name
110114
config.proxy_boot.container_name

lib/kamal/configuration.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ def proxy_roles
145145
roles.select(&:running_proxy?)
146146
end
147147

148+
def load_balancing?
149+
proxy&.load_balancing?
150+
end
151+
148152
def proxy_role_names
149153
proxy_roles.flat_map(&:name)
150154
end

lib/kamal/configuration/docs/proxy.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ proxy:
2525
hosts:
2626
- foo.example.com
2727
- bar.example.com
28+
29+
# Loadbalancer
30+
#
31+
# Specify a host to run the loadbalancer on. The loadbalancer will distribute requests
32+
# to all web hosts. If not specified but multiple web hosts are configured, the first
33+
# web host will be used as the loadbalancer host.
34+
loadbalancer: lb.example.com
2835

2936
# App port
3037
#
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
class Kamal::Configuration::Loadbalancer < Kamal::Configuration::Proxy
2+
CONTAINER_NAME = "load-balancer".freeze
3+
4+
def self.validation_config_key
5+
"proxy"
6+
end
7+
8+
def initialize(config:, proxy_config:)
9+
super(config: config, proxy_config: proxy_config)
10+
end
11+
12+
def deploy_options
13+
opts = super
14+
15+
opts[:host] = hosts if hosts.present?
16+
opts[:tls] = proxy_config["ssl"].presence
17+
18+
opts
19+
end
20+
21+
def directory
22+
File.join config.run_directory, "loadbalancer"
23+
end
24+
25+
def container_name
26+
CONTAINER_NAME
27+
end
28+
end

0 commit comments

Comments
 (0)