Asset paths
During deployments both the old and new containers will be active for a small period of time. There also may be lagging requests for older CSS and JS after the deployment. This can lead to 404s if a request for old assets hits a new container or visa-versa. This PR makes sure that both sets of assets are available throughout the deployment from before the new version of the app is booted. This can be configured by setting the asset path: ```yaml asset_path: "/rails/public/assets" ``` The process is: 1. We extract the assets out of the container, with docker run, docker cp, docker stop. Docker run sets the container command to "sleep" so this needs to be available in the container. 2. We create an asset volume directory on the host for the new version of the app on the host and copy the assets in there. 3. If there is a previous deployment we also copy the new assets into its asset volume and copy the older assets into the new asset volume. 4. We start the new container mapping the asset volume over the top of the container's asset path. This means the both the old and new versions have replaced the asset path with a volume containing both sets of assets and should be able to serve any request during the deployment. The older assets will continue to be available until the next deployment.
This commit is contained in:
@@ -10,12 +10,21 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
||||
on(KAMAL.hosts) do
|
||||
execute *KAMAL.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug
|
||||
execute *KAMAL.app.tag_current_as_latest
|
||||
|
||||
KAMAL.roles_on(host).each do |role|
|
||||
app = KAMAL.app(role: role)
|
||||
role_config = KAMAL.config.role(role)
|
||||
|
||||
if role_config.assets?
|
||||
execute *app.extract_assets
|
||||
old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
|
||||
execute *app.sync_asset_volumes(old_version: old_version)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
on(KAMAL.hosts, **KAMAL.boot_strategy) do |host|
|
||||
roles = KAMAL.roles_on(host)
|
||||
|
||||
roles.each do |role|
|
||||
KAMAL.roles_on(host).each do |role|
|
||||
app = KAMAL.app(role: role)
|
||||
auditor = KAMAL.auditor(role: role)
|
||||
role_config = KAMAL.config.role(role)
|
||||
@@ -27,13 +36,11 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
||||
execute *app.rename_container(version: version, new_version: tmp_version)
|
||||
end
|
||||
|
||||
execute *auditor.record("Booted app version #{version}"), verbosity: :debug
|
||||
|
||||
old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
|
||||
|
||||
if role_config.uses_cord?
|
||||
execute *app.tie_cord(role_config.cord_host_file)
|
||||
end
|
||||
execute *app.tie_cord(role_config.cord_host_file) if role_config.uses_cord?
|
||||
|
||||
execute *auditor.record("Booted app version #{version}"), verbosity: :debug
|
||||
|
||||
execute *app.run(hostname: "#{host}-#{SecureRandom.hex(6)}")
|
||||
|
||||
@@ -47,7 +54,10 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
||||
Kamal::Utils::HealthcheckPoller.wait_for_unhealthy(pause_after_ready: true) { capture_with_info(*app.status(version: old_version)) }
|
||||
end
|
||||
end
|
||||
|
||||
execute *app.stop(version: old_version), raise_on_non_zero_exit: false
|
||||
|
||||
execute *app.clean_up_assets if role_config.assets?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,6 +20,7 @@ class Kamal::Commands::App < Kamal::Commands::Base
|
||||
*role_config.health_check_args,
|
||||
*config.logging_args,
|
||||
*config.volume_args,
|
||||
*role_config.asset_volume_args,
|
||||
*role_config.label_args,
|
||||
*role_config.option_args,
|
||||
config.absolute_image,
|
||||
@@ -105,7 +106,7 @@ class Kamal::Commands::App < Kamal::Commands::Base
|
||||
def list_versions(*docker_args, statuses: nil)
|
||||
pipe \
|
||||
docker(:ps, *filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'),
|
||||
%(while read line; do echo ${line##{role_config.full_name}-}; done) # Extract SHA from "service-role-dest-SHA"
|
||||
%(while read line; do echo ${line##{role_config.container_prefix}-}; done) # Extract SHA from "service-role-dest-SHA"
|
||||
end
|
||||
|
||||
def list_containers
|
||||
@@ -153,7 +154,7 @@ class Kamal::Commands::App < Kamal::Commands::Base
|
||||
def cord(version:)
|
||||
pipe \
|
||||
docker(:inspect, "-f '{{ range .Mounts }}{{printf \"%s %s\n\" .Source .Destination}}{{ end }}'", container_name(version)),
|
||||
[:awk, "'$2 == \"#{role_config.cord_container_directory}\" {print $1}'"]
|
||||
[:awk, "'$2 == \"#{role_config.cord_volume.container_path}\" {print $1}'"]
|
||||
end
|
||||
|
||||
def tie_cord(cord)
|
||||
@@ -164,9 +165,43 @@ class Kamal::Commands::App < Kamal::Commands::Base
|
||||
remove_directory(cord)
|
||||
end
|
||||
|
||||
def extract_assets
|
||||
asset_container = "#{role_config.container_prefix}-assets"
|
||||
|
||||
combine \
|
||||
make_directory(role_config.asset_extracted_path),
|
||||
[*docker(:stop, "-t 1", asset_container, "2> /dev/null"), "|| true"],
|
||||
docker(:run, "--name", asset_container, "--detach", "--rm", config.latest_image, "sleep 1000000"),
|
||||
docker(:cp, "-L", "#{asset_container}:#{role_config.asset_path}/.", role_config.asset_extracted_path),
|
||||
docker(:stop, "-t 1", asset_container),
|
||||
by: "&&"
|
||||
end
|
||||
|
||||
def sync_asset_volumes(old_version: nil)
|
||||
new_extracted_path, new_volume_path = role_config.asset_extracted_path(config.version), role_config.asset_volume.host_path
|
||||
if old_version.present?
|
||||
old_extracted_path, old_volume_path = role_config.asset_extracted_path(old_version), role_config.asset_volume(old_version).host_path
|
||||
end
|
||||
|
||||
commands = [make_directory(new_volume_path), copy_contents(new_extracted_path, new_volume_path)]
|
||||
|
||||
if old_version.present?
|
||||
commands << copy_contents(new_extracted_path, old_volume_path, continue_on_error: true)
|
||||
commands << copy_contents(old_extracted_path, new_volume_path, continue_on_error: true)
|
||||
end
|
||||
|
||||
chain *commands
|
||||
end
|
||||
|
||||
def clean_up_assets
|
||||
chain \
|
||||
find_and_remove_older_siblings(role_config.asset_extracted_path),
|
||||
find_and_remove_older_siblings(role_config.asset_volume_path)
|
||||
end
|
||||
|
||||
private
|
||||
def container_name(version = nil)
|
||||
[ role_config.full_name, version || config.version ].compact.join("-")
|
||||
[ role_config.container_prefix, version || config.version ].compact.join("-")
|
||||
end
|
||||
|
||||
def filter_args(statuses: nil)
|
||||
@@ -186,4 +221,19 @@ class Kamal::Commands::App < Kamal::Commands::Base
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def find_and_remove_older_siblings(path)
|
||||
[
|
||||
:find,
|
||||
Pathname.new(path).dirname.to_s,
|
||||
"-maxdepth 1",
|
||||
"-name", "'#{role_config.container_prefix}-*'",
|
||||
"!", "-name", Pathname.new(path).basename.to_s,
|
||||
"-exec rm -rf \"{}\" +"
|
||||
]
|
||||
end
|
||||
|
||||
def copy_contents(source, destination, continue_on_error: false)
|
||||
[ :cp, "-rn", "#{source}/*", destination, *("|| true" if continue_on_error)]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -211,6 +211,10 @@ class Kamal::Configuration
|
||||
@run_id ||= SecureRandom.hex(16)
|
||||
end
|
||||
|
||||
def asset_path
|
||||
raw_config.asset_path
|
||||
end
|
||||
|
||||
private
|
||||
# Will raise ArgumentError if any required config keys are missing
|
||||
def ensure_required_keys_present
|
||||
|
||||
@@ -48,11 +48,15 @@ class Kamal::Configuration::Role
|
||||
argumentize "--env-file", host_env_file_path
|
||||
end
|
||||
|
||||
def asset_volume_args
|
||||
asset_volume&.docker_args
|
||||
end
|
||||
|
||||
def health_check_args(cord: true)
|
||||
if health_check_cmd.present?
|
||||
if cord && uses_cord?
|
||||
optionize({ "health-cmd" => health_check_cmd_with_cord, "health-interval" => health_check_interval })
|
||||
.concat(["--volume", "#{cord_host_directory}:#{cord_container_directory}"])
|
||||
.concat(cord_volume.docker_args)
|
||||
else
|
||||
optionize({ "health-cmd" => health_check_cmd, "health-interval" => health_check_interval })
|
||||
end
|
||||
@@ -74,15 +78,23 @@ class Kamal::Configuration::Role
|
||||
end
|
||||
|
||||
def uses_cord?
|
||||
running_traefik? && cord_container_directory.present? && health_check_cmd.present?
|
||||
running_traefik? && cord_volume && health_check_cmd.present?
|
||||
end
|
||||
|
||||
def cord_host_directory
|
||||
File.join config.run_directory_as_docker_volume, "cords", [full_name, config.run_id].join("-")
|
||||
File.join config.run_directory_as_docker_volume, "cords", [container_prefix, config.run_id].join("-")
|
||||
end
|
||||
|
||||
def cord_volume
|
||||
if (cord = health_check_options["cord"])
|
||||
@cord_volume ||= Kamal::Configuration::Volume.new \
|
||||
host_path: File.join(config.run_directory, "cords", [container_prefix, config.run_id].join("-")),
|
||||
container_path: cord
|
||||
end
|
||||
end
|
||||
|
||||
def cord_host_file
|
||||
File.join cord_host_directory, CORD_FILE
|
||||
File.join cord_volume.host_path, CORD_FILE
|
||||
end
|
||||
|
||||
def cord_container_directory
|
||||
@@ -90,7 +102,7 @@ class Kamal::Configuration::Role
|
||||
end
|
||||
|
||||
def cord_container_file
|
||||
File.join cord_container_directory, CORD_FILE
|
||||
File.join cord_volume.container_path, CORD_FILE
|
||||
end
|
||||
|
||||
|
||||
@@ -110,10 +122,37 @@ class Kamal::Configuration::Role
|
||||
name.web? || specializations["traefik"]
|
||||
end
|
||||
|
||||
def full_name
|
||||
def container_name(version = nil)
|
||||
[ container_prefix, version || config.version ].compact.join("-")
|
||||
end
|
||||
|
||||
def container_prefix
|
||||
[ config.service, name, config.destination ].compact.join("-")
|
||||
end
|
||||
|
||||
def asset_path
|
||||
specializations["asset_path"] || config.asset_path
|
||||
end
|
||||
|
||||
def assets?
|
||||
asset_path.present? && running_traefik?
|
||||
end
|
||||
|
||||
def asset_volume(version = nil)
|
||||
if assets?
|
||||
Kamal::Configuration::Volume.new \
|
||||
host_path: asset_volume_path(version), container_path: asset_path
|
||||
end
|
||||
end
|
||||
|
||||
def asset_extracted_path(version = nil)
|
||||
File.join config.run_directory, "assets", "extracted", container_name(version)
|
||||
end
|
||||
|
||||
def asset_volume_path(version = nil)
|
||||
File.join config.run_directory, "assets", "volumes", container_name(version)
|
||||
end
|
||||
|
||||
private
|
||||
attr_accessor :config
|
||||
|
||||
|
||||
22
lib/kamal/configuration/volume.rb
Normal file
22
lib/kamal/configuration/volume.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class Kamal::Configuration::Volume
|
||||
attr_reader :host_path, :container_path
|
||||
delegate :argumentize, to: Kamal::Utils
|
||||
|
||||
def initialize(host_path:, container_path:)
|
||||
@host_path = host_path
|
||||
@container_path = container_path
|
||||
end
|
||||
|
||||
def docker_args
|
||||
argumentize "--volume", "#{host_path_for_docker_volume}:#{container_path}"
|
||||
end
|
||||
|
||||
private
|
||||
def host_path_for_docker_volume
|
||||
if Pathname.new(host_path).absolute?
|
||||
host_path
|
||||
else
|
||||
File.join "$(pwd)", host_path
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user