From 7ed21b2e0741830087100fd711fc33701ffa1380 Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Mon, 18 Nov 2019 20:02:41 +0100 Subject: [PATCH] Autoremove multi-stage intermediate image(s) (#1279) * Autoremove multi-stage intermediate image(s) * Scripted test * Documentation * Make MiMa happy * Look for "LABEL ..." instead of comment to find id * Fix test --- build.sbt | 10 ++++ .../sbt/packager/docker/DockerPlugin.scala | 56 ++++++++++++++++++- .../typesafe/sbt/packager/docker/Keys.scala | 5 ++ .../sbt/packager/docker/dockerfile.scala | 7 +++ .../build.sbt | 7 +++ .../project/plugins.sbt | 1 + .../src/main/scala/Main.scala | 3 + .../test | 13 +++++ src/sbt-test/docker/file-permission/build.sbt | 14 +++-- src/sphinx/formats/docker.rst | 7 +++ 10 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 src/sbt-test/docker/autoremove-multi-stage-intermediate-images/build.sbt create mode 100644 src/sbt-test/docker/autoremove-multi-stage-intermediate-images/project/plugins.sbt create mode 100644 src/sbt-test/docker/autoremove-multi-stage-intermediate-images/src/main/scala/Main.scala create mode 100644 src/sbt-test/docker/autoremove-multi-stage-intermediate-images/test diff --git a/build.sbt b/build.sbt index 40b4d2218..24ae0e454 100644 --- a/build.sbt +++ b/build.sbt @@ -82,6 +82,16 @@ mimaBinaryIssueFilters ++= { ), ProblemFilters.exclude[ReversedMissingMethodProblem]( "com.typesafe.sbt.packager.graalvmnativeimage.GraalVMNativeImageKeys.com$typesafe$sbt$packager$graalvmnativeimage$GraalVMNativeImageKeys$_setter_$graalVMNativeImageGraalVersion_=" + ), + // added via #1279 + ProblemFilters.exclude[ReversedMissingMethodProblem]( + "com.typesafe.sbt.packager.docker.DockerKeys.com$typesafe$sbt$packager$docker$DockerKeys$_setter_$dockerAutoremoveMultiStageIntermediateImages_=" + ), + ProblemFilters.exclude[ReversedMissingMethodProblem]( + "com.typesafe.sbt.packager.docker.DockerKeys.dockerAutoremoveMultiStageIntermediateImages" + ), + ProblemFilters.exclude[DirectMissingMethodProblem]( + "com.typesafe.sbt.packager.docker.DockerPlugin.publishLocalDocker" ) ) } diff --git a/src/main/scala/com/typesafe/sbt/packager/docker/DockerPlugin.scala b/src/main/scala/com/typesafe/sbt/packager/docker/DockerPlugin.scala index 404a820fc..40c10f13a 100644 --- a/src/main/scala/com/typesafe/sbt/packager/docker/DockerPlugin.scala +++ b/src/main/scala/com/typesafe/sbt/packager/docker/DockerPlugin.scala @@ -1,6 +1,7 @@ package com.typesafe.sbt.packager.docker import java.io.File +import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean import sbt._ @@ -96,6 +97,7 @@ object DockerPlugin extends AutoPlugin { Option((version in Docker).value) ), dockerUpdateLatest := false, + dockerAutoremoveMultiStageIntermediateImages := true, dockerAliases := { val alias = dockerAlias.value if (dockerUpdateLatest.value) { @@ -134,6 +136,7 @@ object DockerPlugin extends AutoPlugin { val gidOpt = (daemonGroupGid in Docker).value val base = dockerBaseImage.value val addPerms = dockerAdditionalPermissions.value + val multiStageId = UUID.randomUUID().toString val generalCommands = makeFrom(base) +: makeMaintainer((maintainer in Docker).value).toSeq val stage0name = "stage0" @@ -141,6 +144,8 @@ object DockerPlugin extends AutoPlugin { case DockerPermissionStrategy.MultiStage => Seq( makeFromAs(base, stage0name), + makeLabel("snp-multi-stage" -> "intermediate"), + makeLabel("snp-multi-stage-id" -> multiStageId), makeWorkdir(dockerBaseDirectory), makeCopy(dockerBaseDirectory), makeUser("root"), @@ -190,7 +195,14 @@ object DockerPlugin extends AutoPlugin { packageName := packageName.value, publishLocal := { val log = streams.value.log - publishLocalDocker(stage.value, dockerBuildCommand.value, log) + publishLocalDocker( + stage.value, + dockerBuildCommand.value, + dockerExecCommand.value, + dockerPermissionStrategy.value, + dockerAutoremoveMultiStageIntermediateImages.value, + log + ) log.info( s"Built image ${dockerAlias.value.withTag(None).toString} with tags [${dockerAliases.value.flatMap(_.tag).mkString(", ")}]" ) @@ -242,6 +254,13 @@ object DockerPlugin extends AutoPlugin { ) ) + /** + * @param comment + * @return # comment + */ + private final def makeComment(comment: String): CmdLike = + Comment(comment) + /** * @param maintainer (optional) * @return LABEL MAINTAINER if defined @@ -497,12 +516,45 @@ object DockerPlugin extends AutoPlugin { override def buffer[T](f: => T): T = f } - def publishLocalDocker(context: File, buildCommand: Seq[String], log: Logger): Unit = { + def publishLocalDocker(context: File, + buildCommand: Seq[String], + execCommand: Seq[String], + strategy: DockerPermissionStrategy, + removeIntermediateImages: Boolean, + log: Logger): Unit = { log.debug("Executing Native " + buildCommand.mkString(" ")) log.debug("Working directory " + context.toString) val ret = sys.process.Process(buildCommand, context) ! publishLocalLogger(log) + if (removeIntermediateImages) { + val labelKey = "snp-multi-stage-id" + val labelCmd = s"LABEL ${labelKey}=" + strategy match { + case DockerPermissionStrategy.MultiStage => + IO.readLines(context / "Dockerfile") + .find(_.startsWith(labelCmd)) + .map(_.substring(labelCmd.size).stripPrefix("\"").stripSuffix("\"")) match { + // No matter if the build process succeeded or failed, we try to remove the intermediate images + case Some(id) => { + val label = s"${labelKey}=${id}" + log.info(s"""Removing intermediate image(s) (labeled "${label}") """) + val retImageClean = sys.process.Process( + execCommand ++ s"image prune -f --filter label=${label}".split(" ") + ) ! publishLocalLogger(log) + // FYI: "docker image prune" returns 0 (success) no matter if images were removed or not + if (retImageClean != 0) + log.err("Something went wrong while removing multi-stage intermediate image(s)") // no exception, just let the user know + } + case None => + log.info( + """Not removing multi-stage intermediate image(s) because id is missing in Dockerfile (Comment: "# id=...")""" + ) + } + case _ => // Intermediate images are not generated when using other strategies + } + } + if (ret != 0) throw new RuntimeException("Nonzero exit value: " + ret) } diff --git a/src/main/scala/com/typesafe/sbt/packager/docker/Keys.scala b/src/main/scala/com/typesafe/sbt/packager/docker/Keys.scala index 0c516a051..22a2eb9b2 100644 --- a/src/main/scala/com/typesafe/sbt/packager/docker/Keys.scala +++ b/src/main/scala/com/typesafe/sbt/packager/docker/Keys.scala @@ -26,6 +26,11 @@ trait DockerKeys { SettingKey[Seq[DockerAlias]]("dockerAliases", "Docker aliases for the built image") val dockerUpdateLatest = SettingKey[Boolean]("dockerUpdateLatest", "Set to update latest tag") + val dockerAutoremoveMultiStageIntermediateImages = + SettingKey[Boolean]( + "dockerAutoremoveMultiStageIntermediateImages", + "Automatically remove multi-stage intermediate images" + ) val dockerEntrypoint = SettingKey[Seq[String]]("dockerEntrypoint", "Entrypoint arguments passed in exec form") val dockerCmd = SettingKey[Seq[String]]( "dockerCmd", diff --git a/src/main/scala/com/typesafe/sbt/packager/docker/dockerfile.scala b/src/main/scala/com/typesafe/sbt/packager/docker/dockerfile.scala index 34cf84163..074e94439 100644 --- a/src/main/scala/com/typesafe/sbt/packager/docker/dockerfile.scala +++ b/src/main/scala/com/typesafe/sbt/packager/docker/dockerfile.scala @@ -79,6 +79,13 @@ case class CombinedCmd(cmd: String, arg: CmdLike) extends CmdLike { def makeContent: String = "%s %s\n" format (cmd, arg.makeContent) } +/** + * A comment + */ +case class Comment(comment: String) extends CmdLike { + def makeContent: String = "# %s\n" format (comment) +} + /** * A break in Dockerfile to express multi-stage build. * https://docs.docker.com/develop/develop-images/multistage-build/ diff --git a/src/sbt-test/docker/autoremove-multi-stage-intermediate-images/build.sbt b/src/sbt-test/docker/autoremove-multi-stage-intermediate-images/build.sbt new file mode 100644 index 000000000..5f859ffcb --- /dev/null +++ b/src/sbt-test/docker/autoremove-multi-stage-intermediate-images/build.sbt @@ -0,0 +1,7 @@ +enablePlugins(JavaAppPackaging) + +name := "docker-autoremove-multi-stage-intermediate-images-test" + +version := "0.1.0" + +maintainer := "Matthias Kurz " diff --git a/src/sbt-test/docker/autoremove-multi-stage-intermediate-images/project/plugins.sbt b/src/sbt-test/docker/autoremove-multi-stage-intermediate-images/project/plugins.sbt new file mode 100644 index 000000000..b53de154c --- /dev/null +++ b/src/sbt-test/docker/autoremove-multi-stage-intermediate-images/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % sys.props("project.version")) diff --git a/src/sbt-test/docker/autoremove-multi-stage-intermediate-images/src/main/scala/Main.scala b/src/sbt-test/docker/autoremove-multi-stage-intermediate-images/src/main/scala/Main.scala new file mode 100644 index 000000000..61471c658 --- /dev/null +++ b/src/sbt-test/docker/autoremove-multi-stage-intermediate-images/src/main/scala/Main.scala @@ -0,0 +1,3 @@ +object Main extends App { + println("Hello world") +} diff --git a/src/sbt-test/docker/autoremove-multi-stage-intermediate-images/test b/src/sbt-test/docker/autoremove-multi-stage-intermediate-images/test new file mode 100644 index 000000000..7fdf12493 --- /dev/null +++ b/src/sbt-test/docker/autoremove-multi-stage-intermediate-images/test @@ -0,0 +1,13 @@ +# First make sure we start clean +$ exec bash -c 'docker image prune -f --filter label=snp-multi-stage=intermediate' +# Generate the Docker image locally +> docker:publishLocal +# By default intermediate images will be removed +-$ exec bash -c 'docker images --filter label=snp-multi-stage=intermediate | grep -q ""' +# Now lets change the default so we keep those images +> set dockerAutoremoveMultiStageIntermediateImages := false +> docker:publishLocal +$ exec bash -c 'docker images --filter label=snp-multi-stage=intermediate | grep -q ""' +# Alright, now let's remove them by hand +$ exec bash -c 'docker image prune -f --filter label=snp-multi-stage=intermediate' +-$ exec bash -c 'docker images --filter label=snp-multi-stage=intermediate | grep -q ""' diff --git a/src/sbt-test/docker/file-permission/build.sbt b/src/sbt-test/docker/file-permission/build.sbt index cbea0ac2a..873d9aa33 100644 --- a/src/sbt-test/docker/file-permission/build.sbt +++ b/src/sbt-test/docker/file-permission/build.sbt @@ -13,9 +13,12 @@ lazy val root = (project in file(".")) checkDockerfileDefaults := { val dockerfile = IO.read((stagingDirectory in Docker).value / "Dockerfile") val lines = dockerfile.linesIterator.toList - assertEquals(lines, + assertEquals(lines.take(2), """FROM fabric8/java-centos-openjdk8-jdk as stage0 - |WORKDIR /opt/docker + |LABEL snp-multi-stage="intermediate"""".stripMargin.linesIterator.toList) + assert(lines(2).substring(0, 25) == "LABEL snp-multi-stage-id=") // random generated id is hard to test + assertEquals(lines.drop(3), + """WORKDIR /opt/docker |COPY opt /opt |USER root |RUN ["chmod", "-R", "u=rX,g=rX", "/opt/docker"] @@ -90,9 +93,12 @@ lazy val root = (project in file(".")) checkDockerfileWithWriteExecute := { val dockerfile = IO.read((stagingDirectory in Docker).value / "Dockerfile") val lines = dockerfile.linesIterator.toList - assertEquals(lines, + assertEquals(lines.take(2), """FROM fabric8/java-centos-openjdk8-jdk as stage0 - |WORKDIR /opt/docker + |LABEL snp-multi-stage="intermediate"""".stripMargin.linesIterator.toList) + assert(lines(2).substring(0, 25) == "LABEL snp-multi-stage-id=") // random generated id is hard to test + assertEquals(lines.drop(3), + """WORKDIR /opt/docker |COPY opt /opt |USER root |RUN ["chmod", "-R", "u=rwX,g=rwX", "/opt/docker"] diff --git a/src/sphinx/formats/docker.rst b/src/sphinx/formats/docker.rst index 4974fc745..08da886d5 100644 --- a/src/sphinx/formats/docker.rst +++ b/src/sphinx/formats/docker.rst @@ -182,6 +182,13 @@ Publishing Settings Overrides the default Docker rmi command. This may be used if force flags or other options need to be passed to the command ``docker rmi``. Defaults to ``Seq("[dockerExecCommand]", "rmi")`` and will be directly appended with the image name and tag. + ``dockerAutoremoveMultiStageIntermediateImages`` + If intermediate images should be automatically removed when ``MultiStage`` strategy is used. + Intermediate images usually aren't needed after packaging is finished and therefore defaults to ``true``. + All intermediate images are labeled ``snp-multi-stage=intermediate``. + If set to ``false`` and you want to remove all intermediate images at a later point, you can therefore do that by filtering for this label: + ``docker image prune -f --filter label=snp-multi-stage=intermediate`` + Tasks ----- The Docker plugin provides the following commands: