Save each stage in multistage dockerfiles as a tarball (#244)

* resolve basenames in dockerfile to fix multistage bug

* WIP

* WIP

* Save dockerfile stages as tarballs

* added unit tests

* fix unit tests
This commit is contained in:
priyawadhwa 2018-07-19 11:27:49 -07:00 committed by GitHub
parent 697ad41bc5
commit eb6faa05a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 243 additions and 88 deletions

View File

@ -85,7 +85,7 @@ var RootCmd = &cobra.Command{
logrus.Error(err) logrus.Error(err)
os.Exit(1) os.Exit(1)
} }
ref, image, err := executor.DoBuild(executor.KanikoBuildArgs{ image, err := executor.DoBuild(executor.KanikoBuildArgs{
DockerfilePath: absouteDockerfilePath(), DockerfilePath: absouteDockerfilePath(),
SrcContext: srcContext, SrcContext: srcContext,
SnapshotMode: snapshotMode, SnapshotMode: snapshotMode,
@ -98,7 +98,7 @@ var RootCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
if err := executor.DoPush(ref, image, destinations, tarPath); err != nil { if err := executor.DoPush(image, destinations, tarPath); err != nil {
logrus.Error(err) logrus.Error(err)
os.Exit(1) os.Exit(1)
} }

View File

@ -1,10 +1,10 @@
FROM gcr.io/distroless/base:latest FROM gcr.io/distroless/base:latest as base
COPY . . COPY . .
FROM scratch as second FROM scratch as second
ENV foopath context/foo ENV foopath context/foo
COPY --from=0 $foopath context/b* /foo/ COPY --from=0 $foopath context/b* /foo/
FROM gcr.io/distroless/base:latest FROM base
ARG file ARG file
COPY --from=second /foo $file COPY --from=second /foo $file

View File

@ -40,6 +40,10 @@ const (
// for example, a tarball from a GCS bucket will be unpacked here // for example, a tarball from a GCS bucket will be unpacked here
BuildContextDir = "/kaniko/buildcontext/" BuildContextDir = "/kaniko/buildcontext/"
// KanikoIntermediateStagesDir is where we will store intermediate stages
// as tarballs in case they are needed later on
KanikoIntermediateStagesDir = "/kaniko/stages"
// Various snapshot modes: // Various snapshot modes:
SnapshotModeTime = "time" SnapshotModeTime = "time"
SnapshotModeFull = "full" SnapshotModeFull = "full"

View File

@ -18,20 +18,13 @@ package dockerfile
import ( import (
"bytes" "bytes"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/GoogleContainerTools/kaniko/pkg/constants" "github.com/GoogleContainerTools/kaniko/pkg/constants"
"github.com/GoogleContainerTools/kaniko/pkg/util" "github.com/GoogleContainerTools/kaniko/pkg/util"
"github.com/docker/docker/builder/dockerfile/instructions" "github.com/docker/docker/builder/dockerfile/instructions"
"github.com/docker/docker/builder/dockerfile/parser" "github.com/docker/docker/builder/dockerfile/parser"
"github.com/google/go-containerregistry/pkg/authn" "path/filepath"
"github.com/google/go-containerregistry/pkg/name" "strconv"
"github.com/google/go-containerregistry/pkg/v1" "strings"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/remote"
) )
// Parse parses the contents of a Dockerfile and returns a list of commands // Parse parses the contents of a Dockerfile and returns a list of commands
@ -89,29 +82,14 @@ func ParseCommands(cmdArray []string) ([]instructions.Command, error) {
// Dependencies returns a list of files in this stage that will be needed in later stages // Dependencies returns a list of files in this stage that will be needed in later stages
func Dependencies(index int, stages []instructions.Stage, buildArgs *BuildArgs) ([]string, error) { func Dependencies(index int, stages []instructions.Stage, buildArgs *BuildArgs) ([]string, error) {
var dependencies []string dependencies := []string{}
for stageIndex, stage := range stages { for stageIndex, stage := range stages {
if stageIndex <= index { if stageIndex <= index {
continue continue
} }
var sourceImage v1.Image sourceImage, err := util.RetrieveSourceImage(stageIndex, buildArgs.ReplacementEnvs(nil), stages)
if stage.BaseName == constants.NoBaseImage { if err != nil {
sourceImage = empty.Image return nil, err
} else {
// Initialize source image
ref, err := name.ParseReference(stage.BaseName, name.WeakValidation)
if err != nil {
return nil, err
}
auth, err := authn.DefaultKeychain.Resolve(ref.Context().Registry)
if err != nil {
return nil, err
}
sourceImage, err = remote.Image(ref, remote.WithAuth(auth), remote.WithTransport(http.DefaultTransport))
if err != nil {
return nil, err
}
} }
imageConfig, err := sourceImage.ConfigFile() imageConfig, err := sourceImage.ConfigFile()
if err != nil { if err != nil {

View File

@ -85,7 +85,7 @@ func Test_Dependencies(t *testing.T) {
helloPath, helloPath,
testDir, testDir,
}, },
nil, {},
} }
for index := range stages { for index := range stages {
@ -125,7 +125,7 @@ func Test_DependenciesWithArg(t *testing.T) {
helloPath, helloPath,
testDir, testDir,
}, },
nil, {},
} }
buildArgs := NewBuildArgs([]string{fmt.Sprintf("hienv=%s", helloPath)}) buildArgs := NewBuildArgs([]string{fmt.Sprintf("hienv=%s", helloPath)})

View File

@ -26,7 +26,6 @@ import (
"strconv" "strconv"
"github.com/GoogleContainerTools/kaniko/pkg/snapshot" "github.com/GoogleContainerTools/kaniko/pkg/snapshot"
"github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1"
@ -55,94 +54,75 @@ type KanikoBuildArgs struct {
Reproducible bool Reproducible bool
} }
func DoBuild(k KanikoBuildArgs) (name.Reference, v1.Image, error) { func DoBuild(k KanikoBuildArgs) (v1.Image, error) {
// Parse dockerfile and unpack base image to root // Parse dockerfile and unpack base image to root
d, err := ioutil.ReadFile(k.DockerfilePath) d, err := ioutil.ReadFile(k.DockerfilePath)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
stages, err := dockerfile.Parse(d) stages, err := dockerfile.Parse(d)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
dockerfile.ResolveStages(stages) dockerfile.ResolveStages(stages)
hasher, err := getHasher(k.SnapshotMode) hasher, err := getHasher(k.SnapshotMode)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
for index, stage := range stages { for index, stage := range stages {
baseImage, err := util.ResolveEnvironmentReplacement(stage.BaseName, k.Args, false)
if err != nil {
return nil, nil, err
}
finalStage := index == len(stages)-1 finalStage := index == len(stages)-1
// Unpack file system to root // Unpack file system to root
logrus.Infof("Unpacking filesystem of %s...", baseImage) sourceImage, err := util.RetrieveSourceImage(index, k.Args, stages)
var sourceImage v1.Image if err != nil {
var ref name.Reference return nil, err
if baseImage == constants.NoBaseImage {
logrus.Info("No base image, nothing to extract")
sourceImage = empty.Image
} else {
// Initialize source image
ref, err = name.ParseReference(baseImage, name.WeakValidation)
if err != nil {
return nil, nil, err
}
auth, err := authn.DefaultKeychain.Resolve(ref.Context().Registry)
if err != nil {
return nil, nil, err
}
sourceImage, err = remote.Image(ref, remote.WithAuth(auth), remote.WithTransport(http.DefaultTransport))
if err != nil {
return nil, nil, err
}
} }
if err := util.GetFSFromImage(sourceImage); err != nil { if err := util.GetFSFromImage(sourceImage); err != nil {
return nil, nil, err return nil, err
} }
l := snapshot.NewLayeredMap(hasher) l := snapshot.NewLayeredMap(hasher)
snapshotter := snapshot.NewSnapshotter(l, constants.RootDir) snapshotter := snapshot.NewSnapshotter(l, constants.RootDir)
// Take initial snapshot // Take initial snapshot
if err := snapshotter.Init(); err != nil { if err := snapshotter.Init(); err != nil {
return nil, nil, err return nil, err
} }
imageConfig, err := sourceImage.ConfigFile() imageConfig, err := sourceImage.ConfigFile()
if baseImage == constants.NoBaseImage { if sourceImage == empty.Image {
imageConfig.Config.Env = constants.ScratchEnvVars imageConfig.Config.Env = constants.ScratchEnvVars
} }
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
if err := resolveOnBuild(&stage, &imageConfig.Config); err != nil { if err := resolveOnBuild(&stage, &imageConfig.Config); err != nil {
return nil, nil, err return nil, err
} }
buildArgs := dockerfile.NewBuildArgs(k.Args) buildArgs := dockerfile.NewBuildArgs(k.Args)
for index, cmd := range stage.Commands { for index, cmd := range stage.Commands {
finalCmd := index == len(stage.Commands)-1 finalCmd := index == len(stage.Commands)-1
dockerCommand, err := commands.GetCommand(cmd, k.SrcContext) dockerCommand, err := commands.GetCommand(cmd, k.SrcContext)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
if dockerCommand == nil { if dockerCommand == nil {
continue continue
} }
if err := dockerCommand.ExecuteCommand(&imageConfig.Config, buildArgs); err != nil { if err := dockerCommand.ExecuteCommand(&imageConfig.Config, buildArgs); err != nil {
return nil, nil, err return nil, err
} }
if !finalStage || (k.SingleSnapshot && !finalCmd) { // Don't snapshot if it's not the final stage and not the final command
// Also don't snapshot if it's the final stage, not the final command, and single snapshot is set
if (!finalStage && !finalCmd) || (finalStage && !finalCmd && k.SingleSnapshot) {
continue continue
} }
// Now, we get the files to snapshot from this command and take the snapshot // Now, we get the files to snapshot from this command and take the snapshot
snapshotFiles := dockerCommand.FilesToSnapshot() snapshotFiles := dockerCommand.FilesToSnapshot()
if k.SingleSnapshot && finalCmd { if finalCmd {
snapshotFiles = nil snapshotFiles = nil
} }
contents, err := snapshotter.TakeSnapshot(snapshotFiles) contents, err := snapshotter.TakeSnapshot(snapshotFiles)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
util.MoveVolumeWhitelistToWhitelist() util.MoveVolumeWhitelistToWhitelist()
if contents == nil { if contents == nil {
@ -155,7 +135,7 @@ func DoBuild(k KanikoBuildArgs) (name.Reference, v1.Image, error) {
} }
layer, err := tarball.LayerFromOpener(opener) layer, err := tarball.LayerFromOpener(opener)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
sourceImage, err = mutate.Append(sourceImage, sourceImage, err = mutate.Append(sourceImage,
mutate.Addendum{ mutate.Addendum{
@ -167,36 +147,37 @@ func DoBuild(k KanikoBuildArgs) (name.Reference, v1.Image, error) {
}, },
) )
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
} }
sourceImage, err = mutate.Config(sourceImage, imageConfig.Config)
if err != nil {
return nil, err
}
if finalStage { if finalStage {
sourceImage, err = mutate.Config(sourceImage, imageConfig.Config)
if err != nil {
return nil, nil, err
}
if k.Reproducible { if k.Reproducible {
sourceImage, err = mutate.Canonical(sourceImage) sourceImage, err = mutate.Canonical(sourceImage)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
} }
return sourceImage, nil
return ref, sourceImage, nil }
if err := saveStageAsTarball(index, sourceImage); err != nil {
return nil, err
} }
if err := saveStageDependencies(index, stages, buildArgs.Clone()); err != nil { if err := saveStageDependencies(index, stages, buildArgs.Clone()); err != nil {
return nil, nil, err return nil, err
} }
// Delete the filesystem // Delete the filesystem
if err := util.DeleteFilesystem(); err != nil { if err := util.DeleteFilesystem(); err != nil {
return nil, nil, err return nil, err
} }
} }
return nil, nil, err return nil, err
} }
func DoPush(ref name.Reference, image v1.Image, destinations []string, tarPath string) error { func DoPush(image v1.Image, destinations []string, tarPath string) error {
// continue pushing unless an error occurs // continue pushing unless an error occurs
for _, destination := range destinations { for _, destination := range destinations {
// Push the image // Push the image
@ -263,6 +244,19 @@ func saveStageDependencies(index int, stages []instructions.Stage, buildArgs *do
return nil return nil
} }
func saveStageAsTarball(stageIndex int, image v1.Image) error {
destRef, err := name.NewTag("temp/tag", name.WeakValidation)
if err != nil {
return err
}
if err := os.MkdirAll(constants.KanikoIntermediateStagesDir, 0750); err != nil {
return err
}
tarPath := filepath.Join(constants.KanikoIntermediateStagesDir, strconv.Itoa(stageIndex))
logrus.Infof("Storing source image from stage %d at path %s", stageIndex, tarPath)
return tarball.WriteToFile(tarPath, destRef, image, nil)
}
func getHasher(snapshotMode string) (func(string) (string, error), error) { func getHasher(snapshotMode string) (func(string) (string, error), error) {
if snapshotMode == constants.SnapshotModeTime { if snapshotMode == constants.SnapshotModeTime {
logrus.Info("Only file modification time will be considered when snapshotting") logrus.Info("Only file modification time will be considered when snapshotting")

82
pkg/util/image_util.go Normal file
View File

@ -0,0 +1,82 @@
/*
Copyright 2018 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package util
import (
"github.com/GoogleContainerTools/kaniko/pkg/constants"
"github.com/docker/docker/builder/dockerfile/instructions"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/tarball"
"github.com/sirupsen/logrus"
"net/http"
"path/filepath"
"strconv"
)
var (
// For testing
retrieveRemoteImage = remoteImage
retrieveTarImage = tarballImage
)
// RetrieveSourceImage returns the base image of the stage at index
func RetrieveSourceImage(index int, buildArgs []string, stages []instructions.Stage) (v1.Image, error) {
currentStage := stages[index]
currentBaseName, err := ResolveEnvironmentReplacement(currentStage.BaseName, buildArgs, false)
if err != nil {
return nil, err
}
// First, check if the base image is a scratch image
if currentBaseName == constants.NoBaseImage {
logrus.Info("No base image, nothing to extract")
return empty.Image, nil
}
// Next, check if the base image of the current stage is built from a previous stage
// If so, retrieve the image from the stored tarball
for i, stage := range stages {
if i > index {
continue
}
if stage.Name == currentBaseName {
return retrieveTarImage(i)
}
}
// Otherwise, initialize image as usual
return retrieveRemoteImage(currentBaseName)
}
func tarballImage(index int) (v1.Image, error) {
tarPath := filepath.Join(constants.KanikoIntermediateStagesDir, strconv.Itoa(index))
logrus.Infof("Base image from previous stage %d found, using saved tar at path %s", index, tarPath)
return tarball.ImageFromPath(tarPath, nil)
}
func remoteImage(image string) (v1.Image, error) {
ref, err := name.ParseReference(image, name.WeakValidation)
if err != nil {
return nil, err
}
auth, err := authn.DefaultKeychain.Resolve(ref.Context().Registry)
if err != nil {
return nil, err
}
return remote.Image(ref, remote.WithAuth(auth), remote.WithTransport(http.DefaultTransport))
}

View File

@ -0,0 +1,97 @@
/*
Copyright 2018 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package util
import (
"bytes"
"github.com/GoogleContainerTools/kaniko/testutil"
"github.com/docker/docker/builder/dockerfile/instructions"
"github.com/docker/docker/builder/dockerfile/parser"
"github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"testing"
)
var (
dockerfile = `
FROM gcr.io/distroless/base:latest as base
COPY . .
FROM scratch as second
ENV foopath context/foo
COPY --from=0 $foopath context/b* /foo/
FROM base
ARG file
COPY --from=second /foo $file`
)
func Test_StandardImage(t *testing.T) {
stages, err := parse(dockerfile)
if err != nil {
t.Error(err)
}
original := retrieveRemoteImage
defer func() {
retrieveRemoteImage = original
}()
mock := func(image string) (v1.Image, error) {
return nil, nil
}
retrieveRemoteImage = mock
actual, err := RetrieveSourceImage(0, nil, stages)
testutil.CheckErrorAndDeepEqual(t, false, err, nil, actual)
}
func Test_ScratchImage(t *testing.T) {
stages, err := parse(dockerfile)
if err != nil {
t.Error(err)
}
actual, err := RetrieveSourceImage(1, nil, stages)
expected := empty.Image
testutil.CheckErrorAndDeepEqual(t, false, err, expected, actual)
}
func Test_TarImage(t *testing.T) {
stages, err := parse(dockerfile)
if err != nil {
t.Error(err)
}
original := retrieveTarImage
defer func() {
retrieveTarImage = original
}()
mock := func(index int) (v1.Image, error) {
return nil, nil
}
retrieveTarImage = mock
actual, err := RetrieveSourceImage(2, nil, stages)
testutil.CheckErrorAndDeepEqual(t, false, err, nil, actual)
}
// parse parses the contents of a Dockerfile and returns a list of commands
func parse(s string) ([]instructions.Stage, error) {
p, err := parser.Parse(bytes.NewReader([]byte(s)))
if err != nil {
return nil, err
}
stages, _, err := instructions.Parse(p.AST)
if err != nil {
return nil, err
}
return stages, err
}