diff --git a/README.md b/README.md index 5c676489..f148a5f2 100644 --- a/README.md +++ b/README.md @@ -473,6 +473,37 @@ builder: context: ".." ``` +### Using multistage builder cache + +Docker multistage build cache can singlehandedly speed up your builds by a lot. Currently MRSK only supports using the GHA cache or the Registry cache: + +```yaml +# Using GHA cache +builder: + cache: + type: gha + +# Using Registry cache +builder: + cache: + type: registry + +# Using Registry cache with different cache image +builder: + cache: + type: registry + # default image name is -build-cache + image: application-cache-image + +# Using Registry cache with additinonal cache-to options +builder: + cache: + type: registry + options: mode=max,image-manifest=true,oci-mediatypes=true +``` + +For further insights into build cache optimization, check out documentation on Docker's official website: https://docs.docker.com/build/cache/. + ### Using build secrets for new images Some images need a secret passed in during build time, like a GITHUB_TOKEN, to give access to private gem repositories. This can be done by having the secret in ENV, then referencing it in the builder configuration: diff --git a/lib/mrsk/commands/builder.rb b/lib/mrsk/commands/builder.rb index 58ca626e..074890ce 100644 --- a/lib/mrsk/commands/builder.rb +++ b/lib/mrsk/commands/builder.rb @@ -7,11 +7,13 @@ class Mrsk::Commands::Builder < Mrsk::Commands::Base def target case - when config.builder && config.builder["multiarch"] == false + when !config.builder.multiarch? && !config.builder.cached? native - when config.builder && config.builder["local"] && config.builder["remote"] + when !config.builder.multiarch? && config.builder.cached? + native_cached + when config.builder.local? && config.builder.remote? multiarch_remote - when config.builder && config.builder["remote"] + when config.builder.remote? native_remote else multiarch @@ -22,6 +24,10 @@ class Mrsk::Commands::Builder < Mrsk::Commands::Base @native ||= Mrsk::Commands::Builder::Native.new(config) end + def native_cached + @native ||= Mrsk::Commands::Builder::Native::Cached.new(config) + end + def native_remote @native ||= Mrsk::Commands::Builder::Native::Remote.new(config) end diff --git a/lib/mrsk/commands/builder/base.rb b/lib/mrsk/commands/builder/base.rb index 32dcba4f..b45cee4d 100644 --- a/lib/mrsk/commands/builder/base.rb +++ b/lib/mrsk/commands/builder/base.rb @@ -3,6 +3,7 @@ class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base class BuilderError < StandardError; end delegate :argumentize, to: Mrsk::Utils + delegate :args, :secrets, :dockerfile, :local_arch, :local_host, :remote_arch, :remote_host, :cache_from, :cache_to, to: :builder_config def clean docker :image, :rm, "--force", config.absolute_image @@ -13,11 +14,11 @@ class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base end def build_options - [ *build_tags, *build_labels, *build_args, *build_secrets, *build_dockerfile ] + [ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile ] end def build_context - context + config.builder.context end @@ -26,6 +27,13 @@ class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base [ "-t", config.absolute_image, "-t", config.latest_image ] end + def build_cache + if cache_to && cache_from + ["--cache-to", cache_to, + "--cache-from", cache_from] + end + end + def build_labels argumentize "--label", { service: config.service } end @@ -46,19 +54,7 @@ class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base end end - def args - (config.builder && config.builder["args"]) || {} - end - - def secrets - (config.builder && config.builder["secrets"]) || [] - end - - def dockerfile - (config.builder && config.builder["dockerfile"]) || "Dockerfile" - end - - def context - (config.builder && config.builder["context"]) || "." + def builder_config + config.builder end end diff --git a/lib/mrsk/commands/builder/multiarch/remote.rb b/lib/mrsk/commands/builder/multiarch/remote.rb index 5ca10048..9e27b2f5 100644 --- a/lib/mrsk/commands/builder/multiarch/remote.rb +++ b/lib/mrsk/commands/builder/multiarch/remote.rb @@ -22,17 +22,17 @@ class Mrsk::Commands::Builder::Multiarch::Remote < Mrsk::Commands::Builder::Mult end def create_local_buildx - docker :buildx, :create, "--name", builder_name, builder_name_with_arch(local["arch"]), "--platform", "linux/#{local["arch"]}" + docker :buildx, :create, "--name", builder_name, builder_name_with_arch(local_arch), "--platform", "linux/#{local_arch}" end def append_remote_buildx - docker :buildx, :create, "--append", "--name", builder_name, builder_name_with_arch(remote["arch"]), "--platform", "linux/#{remote["arch"]}" + docker :buildx, :create, "--append", "--name", builder_name, builder_name_with_arch(remote_arch), "--platform", "linux/#{remote_arch}" end def create_contexts combine \ - create_context(local["arch"], local["host"]), - create_context(remote["arch"], remote["host"]) + create_context(local_arch, local_host), + create_context(remote_arch, remote_host) end def create_context(arch, host) @@ -41,19 +41,11 @@ class Mrsk::Commands::Builder::Multiarch::Remote < Mrsk::Commands::Builder::Mult def remove_contexts combine \ - remove_context(local["arch"]), - remove_context(remote["arch"]) + remove_context(local_arch), + remove_context(remote_arch) end def remove_context(arch) docker :context, :rm, builder_name_with_arch(arch) end - - def local - config.builder["local"] - end - - def remote - config.builder["remote"] - end end diff --git a/lib/mrsk/commands/builder/native.rb b/lib/mrsk/commands/builder/native.rb index 05d5534f..e1b6880b 100644 --- a/lib/mrsk/commands/builder/native.rb +++ b/lib/mrsk/commands/builder/native.rb @@ -1,10 +1,10 @@ class Mrsk::Commands::Builder::Native < Mrsk::Commands::Builder::Base def create - # No-op on native + # No-op on native without cache end def remove - # No-op on native + # No-op on native without cache end def push diff --git a/lib/mrsk/commands/builder/native/cached.rb b/lib/mrsk/commands/builder/native/cached.rb new file mode 100644 index 00000000..f586203f --- /dev/null +++ b/lib/mrsk/commands/builder/native/cached.rb @@ -0,0 +1,16 @@ +class Mrsk::Commands::Builder::Native::Cached < Mrsk::Commands::Builder::Native + def create + docker :buildx, :create, "--use", "--driver=docker-container" + end + + def remove + docker :buildx, :rm, builder_name + end + + def push + docker :buildx, :build, + "--push", + *build_options, + build_context + end +end diff --git a/lib/mrsk/commands/builder/native/remote.rb b/lib/mrsk/commands/builder/native/remote.rb index f461c589..2fc00e0f 100644 --- a/lib/mrsk/commands/builder/native/remote.rb +++ b/lib/mrsk/commands/builder/native/remote.rb @@ -28,29 +28,21 @@ class Mrsk::Commands::Builder::Native::Remote < Mrsk::Commands::Builder::Native private - def arch - config.builder["remote"]["arch"] - end - - def host - config.builder["remote"]["host"] - end - def builder_name "mrsk-#{config.service}-native-remote" end def builder_name_with_arch - "#{builder_name}-#{arch}" + "#{builder_name}-#{remote_arch}" end def platform - "linux/#{arch}" + "linux/#{remote_arch}" end def create_context docker :context, :create, - builder_name_with_arch, "--description", "'#{builder_name} #{arch} native host'", "--docker", "'host=#{host}'" + builder_name_with_arch, "--description", "'#{builder_name} #{remote_arch} native host'", "--docker", "'host=#{remote_host}'" end def remove_context diff --git a/lib/mrsk/configuration.rb b/lib/mrsk/configuration.rb index 00be1f47..2b00a699 100644 --- a/lib/mrsk/configuration.rb +++ b/lib/mrsk/configuration.rb @@ -6,7 +6,7 @@ require "erb" require "net/ssh/proxy/jump" class Mrsk::Configuration - delegate :service, :image, :servers, :env, :labels, :registry, :builder, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true + delegate :service, :image, :servers, :env, :labels, :registry, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Mrsk::Utils attr_accessor :destination @@ -186,7 +186,7 @@ class Mrsk::Configuration env_args: env_args, volume_args: volume_args, ssh_options: ssh_options, - builder: raw_config.builder, + builder: builder.to_h, accessories: raw_config.accessories, logging: logging_args, healthcheck: healthcheck @@ -201,6 +201,10 @@ class Mrsk::Configuration raw_config.hooks_path || ".mrsk/hooks" end + def builder + Mrsk::Configuration::Builder.new(config: self) + end + private # Will raise ArgumentError if any required config keys are missing def ensure_required_keys_present diff --git a/lib/mrsk/configuration/builder.rb b/lib/mrsk/configuration/builder.rb new file mode 100644 index 00000000..46c12cb4 --- /dev/null +++ b/lib/mrsk/configuration/builder.rb @@ -0,0 +1,114 @@ +class Mrsk::Configuration::Builder + def initialize(config:) + @options = config.raw_config.builder || {} + @image = config.image + @server = config.registry["server"] + + valid? + end + + def to_h + @options + end + + def multiarch? + @options["multiarch"] != false + end + + def local? + !!@options["local"] + end + + def remote? + !!@options["remote"] + end + + def cached? + !!@options["cache"] + end + + def args + @options["args"] || {} + end + + def secrets + @options["secrets"] || [] + end + + def dockerfile + @options["dockerfile"] || "Dockerfile" + end + + def context + @options["context"] || "." + end + + def local_arch + @options["local"]["arch"] if local? + end + + def local_host + @options["local"]["host"] if local? + end + + def remote_arch + @options["remote"]["arch"] if remote? + end + + def remote_host + @options["remote"]["host"] if remote? + end + + def cache_from + if cached? + case @options["cache"]["type"] + when "gha" + cache_from_config_for_gha + when "registry" + cache_from_config_for_registry + end + end + end + + def cache_to + if cached? + case @options["cache"]["type"] + when "gha" + cache_to_config_for_gha + when "registry" + cache_to_config_for_registry + end + end + end + + private + def valid? + if @options["local"] && !@options["remote"] + raise ArgumentError, "You must specify both local and remote builder config for remote multiarch builds" + end + + if @options["cache"] && @options["cache"]["type"] + raise ArgumentError, "Invalid cache type: #{@options["cache"]["type"]}" unless ["gha", "registry"].include?(@options["cache"]["type"]) + end + end + + def cache_image + @options["cache"]&.fetch("image", nil) || "#{@image}-build-cache" + end + + def cache_from_config_for_gha + "type=gha" + end + + def cache_from_config_for_registry + [ "type=registry", "ref=#{@server}/#{cache_image}" ].compact.join(",") + end + + def cache_to_config_for_gha + [ "type=gha", @options["cache"]&.fetch("options", nil)].compact.join(",") + end + + def cache_to_config_for_registry + [ "type=registry", @options["cache"]&.fetch("options", nil), "ref=#{@server}/#{cache_image}" ].compact.join(",") + end +end diff --git a/test/commands/builder_test.rb b/test/commands/builder_test.rb index 57b3a24d..f385d127 100644 --- a/test/commands/builder_test.rb +++ b/test/commands/builder_test.rb @@ -6,10 +6,10 @@ class CommandsBuilderTest < ActiveSupport::TestCase end test "target multiarch by default" do - builder = new_builder_command + builder = new_builder_command(builder: { "cache" => { "type" => "gha" }}) assert_equal "multiarch", builder.name assert_equal \ - "docker buildx build --push --platform linux/amd64,linux/arm64 --builder mrsk-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .", + "docker buildx build --push --platform linux/amd64,linux/arm64 --builder mrsk-app-multiarch -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", builder.push.join(" ") end @@ -21,19 +21,27 @@ class CommandsBuilderTest < ActiveSupport::TestCase builder.push.join(" ") end + test "target native cached when multiarch is off and cache is set" do + builder = new_builder_command(builder: { "multiarch" => false, "cache" => { "type" => "gha" }}) + assert_equal "native/cached", builder.name + assert_equal \ + "docker buildx build --push -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", + builder.push.join(" ") + end + test "target multiarch remote when local and remote is set" do - builder = new_builder_command(builder: { "local" => { }, "remote" => { } }) + builder = new_builder_command(builder: { "local" => { }, "remote" => { }, "cache" => { "type" => "gha" } }) assert_equal "multiarch/remote", builder.name assert_equal \ - "docker buildx build --push --platform linux/amd64,linux/arm64 --builder mrsk-app-multiarch-remote -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .", + "docker buildx build --push --platform linux/amd64,linux/arm64 --builder mrsk-app-multiarch-remote -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", builder.push.join(" ") end test "target native remote when only remote is set" do - builder = new_builder_command(builder: { "remote" => { "arch" => "amd64" } }) + builder = new_builder_command(builder: { "remote" => { "arch" => "amd64" }, "cache" => { "type" => "gha" } }) assert_equal "native/remote", builder.name assert_equal \ - "docker buildx build --push --platform linux/amd64 --builder mrsk-app-native-remote -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .", + "docker buildx build --push --platform linux/amd64 --builder mrsk-app-native-remote -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", builder.push.join(" ") end diff --git a/test/configuration/builder_test.rb b/test/configuration/builder_test.rb new file mode 100644 index 00000000..1b355187 --- /dev/null +++ b/test/configuration/builder_test.rb @@ -0,0 +1,151 @@ +require "test_helper" + +class ConfigurationBuilderTest < ActiveSupport::TestCase + setup do + @deploy = { + service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, + servers: [ "1.1.1.1" ] + } + + @config = Mrsk::Configuration.new(@deploy) + + @deploy_with_builder_option = { + service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, + servers: [ "1.1.1.1" ], + builder: {} + } + + @config_with_builder_option = Mrsk::Configuration.new(@deploy_with_builder_option) + end + + test "multiarch?" do + assert_equal true, @config.builder.multiarch? + end + + test "setting multiarch to false" do + @deploy_with_builder_option[:builder] = { "multiarch" => false } + + assert_equal false, @config_with_builder_option.builder.multiarch? + end + + test "local?" do + assert_equal false, @config.builder.local? + end + + test "remote?" do + assert_equal false, @config.builder.remote? + end + + test "remote_arch" do + assert_nil @config.builder.remote_arch + end + + test "remote_host" do + assert_nil @config.builder.remote_host + end + + test "remote config is missing when local is specified" do + @deploy_with_builder_option[:builder] = { "local" => { "arch" => "arm64", "host" => "unix:///Users/<%= `whoami`.strip %>/.docker/run/docker.sock" } } + + assert_raises(ArgumentError) do + @config_with_builder_option.builder + end + end + + test "setting both local and remote configs" do + @deploy_with_builder_option[:builder] = { + "local" => { "arch" => "arm64", "host" => "unix:///Users/<%= `whoami`.strip %>/.docker/run/docker.sock" }, + "remote" => { "arch" => "amd64", "host" => "ssh://root@192.168.0.1" } + } + + assert_equal true, @config_with_builder_option.builder.local? + assert_equal true, @config_with_builder_option.builder.remote? + + assert_equal "amd64", @config_with_builder_option.builder.remote_arch + assert_equal "ssh://root@192.168.0.1", @config_with_builder_option.builder.remote_host + + assert_equal "arm64", @config_with_builder_option.builder.local_arch + assert_equal "unix:///Users/<%= `whoami`.strip %>/.docker/run/docker.sock", @config_with_builder_option.builder.local_host + end + + test "cached?" do + assert_equal false, @config.builder.cached? + end + + test "invalid cache type specified" do + @deploy_with_builder_option[:builder] = { "cache" => { "type" => "invalid" } } + + assert_raises(ArgumentError) do + @config_with_builder_option.builder + end + end + + test "cache_from" do + assert_nil @config.builder.cache_from + end + + test "cache_to" do + assert_nil @config.builder.cache_to + end + + test "setting gha cache" do + @deploy_with_builder_option[:builder] = { "cache" => { "type" => "gha", "options" => "mode=max" } } + + assert_equal "type=gha", @config_with_builder_option.builder.cache_from + assert_equal "type=gha,mode=max", @config_with_builder_option.builder.cache_to + end + + test "setting registry cache" do + @deploy_with_builder_option[:builder] = { "cache" => { "type" => "registry", "options" => "mode=max,image-manifest=true,oci-mediatypes=true" } } + + assert_equal "type=registry,ref=/dhh/app-build-cache", @config_with_builder_option.builder.cache_from + assert_equal "type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=/dhh/app-build-cache", @config_with_builder_option.builder.cache_to + end + + test "setting registry cache with image" do + @deploy_with_builder_option[:builder] = { "cache" => { "type" => "registry", "image" => "mrsk", "options" => "mode=max" } } + + assert_equal "type=registry,ref=/mrsk", @config_with_builder_option.builder.cache_from + assert_equal "type=registry,mode=max,ref=/mrsk", @config_with_builder_option.builder.cache_to + end + + test "args" do + assert_equal({}, @config.builder.args) + end + + test "setting args" do + @deploy_with_builder_option[:builder] = { "args" => { "key" => "value" } } + + assert_equal({ "key" => "value" }, @config_with_builder_option.builder.args) + end + + test "secrets" do + assert_equal [], @config.builder.secrets + end + + test "setting secrets" do + @deploy_with_builder_option[:builder] = { "secrets" => ["GITHUB_TOKEN"] } + + assert_equal ["GITHUB_TOKEN"], @config_with_builder_option.builder.secrets + end + + test "dockerfile" do + assert_equal "Dockerfile", @config.builder.dockerfile + end + + test "setting dockerfile" do + @deploy_with_builder_option[:builder] = { "dockerfile" => "Dockerfile.dev" } + + assert_equal "Dockerfile.dev", @config_with_builder_option.builder.dockerfile + end + + test "context" do + assert_equal ".", @config.builder.context + end + + test "setting context" do + @deploy_with_builder_option[:builder] = { "context" => ".." } + + assert_equal "..", @config_with_builder_option.builder.context + end +end diff --git a/test/configuration_test.rb b/test/configuration_test.rb index b5ed8f93..606bb389 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -266,7 +266,7 @@ class ConfigurationTest < ActiveSupport::TestCase end test "to_h" do - assert_equal({ :roles=>["web"], :hosts=>["1.1.1.1", "1.1.1.2"], :primary_host=>"1.1.1.1", :version=>"missing", :repository=>"dhh/app", :absolute_image=>"dhh/app:missing", :service_with_version=>"app-missing", :env_args=>["-e", "REDIS_URL=\"redis://x/y\""], :ssh_options=>{:user=>"root", :auth_methods=>["publickey"]}, :volume_args=>["--volume", "/local/path:/container/path"], :logging=>["--log-opt", "max-size=\"10m\""], :healthcheck=>{"path"=>"/up", "port"=>3000, "max_attempts" => 7 }}, @config.to_h) + assert_equal({ :roles=>["web"], :hosts=>["1.1.1.1", "1.1.1.2"], :primary_host=>"1.1.1.1", :version=>"missing", :repository=>"dhh/app", :absolute_image=>"dhh/app:missing", :service_with_version=>"app-missing", :env_args=>["-e", "REDIS_URL=\"redis://x/y\""], :ssh_options=>{:user=>"root", :auth_methods=>["publickey"]}, :volume_args=>["--volume", "/local/path:/container/path"], :builder=>{}, :logging=>["--log-opt", "max-size=\"10m\""], :healthcheck=>{"path"=>"/up", "port"=>3000, "max_attempts" => 7 }}, @config.to_h) end test "min version is lower" do