Skip to content

Commit dbe2a5a

Browse files
committed
Handle loadbalancing for multiple web hosts
1 parent 419a117 commit dbe2a5a

File tree

18 files changed

+367
-9
lines changed

18 files changed

+367
-9
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

@@ -101,7 +109,7 @@ def reboot
101109
execute *KAMAL.auditor.record("Rebooted proxy"), verbosity: :debug
102110
execute *KAMAL.registry.login
103111

104-
"Stopping and removing kamal-proxy on #{host}, if running..."
112+
info "Stopping and removing kamal-proxy on #{host}, if running..."
105113
execute *KAMAL.proxy.stop, raise_on_non_zero_exit: false
106114
execute *KAMAL.proxy.remove_container
107115
execute *KAMAL.proxy.ensure_apps_config_directory
@@ -122,6 +130,24 @@ def reboot
122130
end
123131
run_hook "post-proxy-reboot", hosts: host_list
124132
end
133+
134+
if KAMAL.config.proxy.load_balancing?
135+
lb_host = KAMAL.config.proxy.effective_loadbalancer
136+
run_hook "pre-loadbalancer-reboot", hosts: lb_host
137+
138+
on(lb_host) do |host|
139+
execute *KAMAL.auditor.record("Rebooted loadbalancer"), verbosity: :debug
140+
execute *KAMAL.registry.login
141+
142+
info "Stopping and removing load-balancer on #{host}, if running..."
143+
execute *KAMAL.loadbalancer.stop, raise_on_non_zero_exit: false
144+
execute *KAMAL.loadbalancer.remove_container
145+
146+
execute *KAMAL.loadbalancer.run
147+
end
148+
149+
run_hook "post-loadbalancer-reboot", hosts: lb_host
150+
end
125151
end
126152
end
127153
end
@@ -142,10 +168,10 @@ def upgrade
142168
execute *KAMAL.auditor.record("Rebooted proxy"), verbosity: :debug
143169
execute *KAMAL.registry.login
144170

145-
"Stopping and removing Traefik on #{host}, if running..."
171+
info "Stopping and removing Traefik on #{host}, if running..."
146172
execute *KAMAL.proxy.cleanup_traefik
147173

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

202234
desc "logs", "Show log lines from proxy on servers"
@@ -238,13 +270,80 @@ def remove
238270
end
239271
end
240272

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

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

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
@@ -97,6 +97,10 @@ def reset_image_version
9797
remove_file config.proxy_boot.image_version_file
9898
end
9999

100+
def loadbalancer
101+
@loadbalancer ||= Kamal::Commands::Loadbalancer.new(config, loadbalancer_config: KAMAL.loadbalancer_config)
102+
end
103+
100104
private
101105
def container_name
102106
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
@@ -30,6 +30,13 @@ proxy:
3030
hosts:
3131
- foo.example.com
3232
- bar.example.com
33+
34+
# Loadbalancer
35+
#
36+
# Specify a host to run the loadbalancer on. The loadbalancer will distribute requests
37+
# to all web hosts. If not specified but multiple web hosts are configured, the first
38+
# web host will be used as the loadbalancer host.
39+
loadbalancer: lb.example.com
3340

3441
# App port
3542
#
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)