diff --git a/README.md b/README.md index 29a6b268..4a3f689d 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,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..45311e08 100644 --- a/lib/mrsk/commands/builder.rb +++ b/lib/mrsk/commands/builder.rb @@ -7,11 +7,11 @@ class Mrsk::Commands::Builder < Mrsk::Commands::Base def target case - when config.builder && config.builder["multiarch"] == false + when !config.builder.multiarch? native - when config.builder && config.builder["local"] && config.builder["remote"] + when config.builder.local? && config.builder.remote? multiarch_remote - when config.builder && config.builder["remote"] + when config.builder.remote? native_remote else multiarch diff --git a/lib/mrsk/commands/builder/base.rb b/lib/mrsk/commands/builder/base.rb index 32dcba4f..2e697311 100644 --- a/lib/mrsk/commands/builder/base.rb +++ b/lib/mrsk/commands/builder/base.rb @@ -13,11 +13,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,39 +26,30 @@ class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base [ "-t", config.absolute_image, "-t", config.latest_image ] end + def build_cache + if config.builder.cache? + ["--cache-to", config.builder.cache_to, + "--cache-from", config.builder.cache_from] + end + end + def build_labels argumentize "--label", { service: config.service } end def build_args - argumentize "--build-arg", args, sensitive: true + argumentize "--build-arg", config.builder.args, sensitive: true end def build_secrets - argumentize "--secret", secrets.collect { |secret| [ "id", secret ] } + argumentize "--secret", config.builder.secrets.collect { |secret| [ "id", secret ] } end def build_dockerfile - if Pathname.new(File.expand_path(dockerfile)).exist? - argumentize "--file", dockerfile + if Pathname.new(File.expand_path(config.builder.dockerfile)).exist? + argumentize "--file", config.builder.dockerfile else - raise BuilderError, "Missing #{dockerfile}" + raise BuilderError, "Missing #{config.builder.dockerfile}" 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"]) || "." - end end diff --git a/lib/mrsk/commands/builder/multiarch/remote.rb b/lib/mrsk/commands/builder/multiarch/remote.rb index 5ca10048..2bc566a9 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(config.builder.local_arch), "--platform", "linux/#{config.builder.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(config.builder.remote_arch), "--platform", "linux/#{config.builder.remote_arch}" end def create_contexts combine \ - create_context(local["arch"], local["host"]), - create_context(remote["arch"], remote["host"]) + create_context(config.builder.local_arch, config.builder.local_host), + create_context(config.builder.remote_arch, config.builder.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(config.builder.local_arch), + remove_context(config.builder.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..709c7878 100644 --- a/lib/mrsk/commands/builder/native.rb +++ b/lib/mrsk/commands/builder/native.rb @@ -1,17 +1,32 @@ class Mrsk::Commands::Builder::Native < Mrsk::Commands::Builder::Base def create - # No-op on native + # No-op on native without cache + + if config.builder.cache? + docker :buildx, :create, "--use", "--driver=docker-container" + end end def remove - # No-op on native + # No-op on native without cache + + if config.builder.cache? + docker :buildx, :rm, builder_name + end end def push - combine \ - docker(:build, *build_options, build_context), - docker(:push, config.absolute_image), - docker(:push, config.latest_image) + if config.builder.cache? + docker :buildx, :build, + "--push", + *build_options, + build_context + else + combine \ + docker(:build, *build_options, build_context), + docker(:push, config.absolute_image), + docker(:push, config.latest_image) + end end def info diff --git a/lib/mrsk/commands/builder/native/remote.rb b/lib/mrsk/commands/builder/native/remote.rb index f461c589..31c9e0c3 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}-#{config.builder.remote_arch}" end def platform - "linux/#{arch}" + "linux/#{config.builder.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} #{config.builder.remote_arch} native host'", "--docker", "'host=#{config.builder.remote_host}'" end def remove_context diff --git a/lib/mrsk/configuration.rb b/lib/mrsk/configuration.rb index e78e45cb..05fd004a 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 @@ -182,7 +182,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 @@ -197,6 +197,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..0cf3a21a --- /dev/null +++ b/lib/mrsk/configuration/builder.rb @@ -0,0 +1,113 @@ +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 cache? + !!@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 cache? + case @options["cache"]["type"] + when 'gha' + "type=gha" + when 'registry' + [ + "type=registry", + "ref=#{@server}/#{cache_image}" + ].compact.join(",") + end + end + end + + def cache_to + if cache? + case @options["cache"]["type"] + when "gha" + [ + "type=gha", + @options["cache"]&.fetch("options", nil), + ].compact.join(",") + when "registry" + [ + "type=registry", + @options["cache"]&.fetch("options", nil), + "ref=#{@server}/#{cache_image}" + ].compact.join(",") + 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 current_branch + `git rev-parse --abbrev-ref HEAD`.strip + end +end \ No newline at end of file diff --git a/test/configuration/builder_test.rb b/test/configuration/builder_test.rb new file mode 100644 index 00000000..9b17ea7f --- /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 "cache?" do + assert_equal false, @config.builder.cache? + 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 \ No newline at end of file diff --git a/test/configuration_test.rb b/test/configuration_test.rb index 20e9bc28..e9feda94 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -266,6 +266,6 @@ 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 end