istio.io/istio@v0.0.0-20240520182934-d79c90f27776/tools/docker-builder/docker.go (about)

     1  // Copyright Istio Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package main
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"encoding/json"
    21  	"fmt"
    22  	"io"
    23  	"os"
    24  	"os/exec"
    25  	"path/filepath"
    26  	"regexp"
    27  	"strings"
    28  	"time"
    29  
    30  	"golang.org/x/sync/errgroup"
    31  
    32  	"istio.io/istio/pkg/log"
    33  	"istio.io/istio/pkg/ptr"
    34  	testenv "istio.io/istio/pkg/test/env"
    35  	"istio.io/istio/pkg/util/image"
    36  	"istio.io/istio/pkg/util/sets"
    37  )
    38  
    39  // RunDocker builds docker images using the `docker buildx bake` commands. Buildx is the
    40  // next-generation docker builder, and `bake` is an important part of that which allows us to
    41  // construct a big build plan which docker can execute in parallel. This provides order of magnitude
    42  // improves over a naive `docker build` flow when building many images.
    43  //
    44  // As an optimization, inputs to the Dockerfile are not built in the container. Rather, we build them
    45  // all outside and copy them into a staging folder that represents all the dependencies for the
    46  // Dockerfile. For example, we first build 'foo' to 'out/linux_amd64/foo'. Then, we copy foo to
    47  // 'out/linux_amd64/dockerx_build/build.docker.foo', along with all other dependencies for the foo
    48  // Dockerfile (as declared in docker.yaml). Creating this staging folder ensures that we do not copy
    49  // the entire source tree into the docker context (expensive) or need complex .dockerignore files.
    50  //
    51  // Once we have these staged folders, we just construct a docker bakefile and pass it to `buildx
    52  // bake` and let it do its work.
    53  func RunDocker(args Args) error {
    54  	requiresSplitBuild := len(args.Architectures) > 1 && (args.Save || !args.Push)
    55  	if !requiresSplitBuild {
    56  		log.Infof("building for architectures: %v", args.Architectures)
    57  		return runDocker(args)
    58  	}
    59  	// https://github.com/docker/buildx/issues/59 - load and save are not supported for multi-arch
    60  	// To workaround, we do a build per-arch, and suffix with the architecture
    61  	for _, arch := range args.Architectures {
    62  		args.Architectures = []string{arch}
    63  		args.suffix = ""
    64  		if arch != "linux/amd64" {
    65  			// For backwards compatibility, we do not suffix linux/amd64
    66  			_, arch, _ := strings.Cut(arch, "/")
    67  			args.suffix = "-" + arch
    68  		}
    69  		log.Infof("building for arch %v", arch)
    70  		if err := runDocker(args); err != nil {
    71  			return err
    72  		}
    73  	}
    74  	return nil
    75  }
    76  
    77  func runDocker(args Args) error {
    78  	tarFiles, err := ConstructBakeFile(args)
    79  	if err != nil {
    80  		return err
    81  	}
    82  
    83  	makeStart := time.Now()
    84  	for _, arch := range args.Architectures {
    85  		if err := RunMake(context.Background(), args, arch, args.PlanFor(arch).Targets()...); err != nil {
    86  			return err
    87  		}
    88  	}
    89  	if err := CopyInputs(args); err != nil {
    90  		return err
    91  	}
    92  	log.WithLabels("runtime", time.Since(makeStart)).Infof("make complete")
    93  
    94  	dockerStart := time.Now()
    95  	if err := RunBake(args); err != nil {
    96  		return err
    97  	}
    98  	if err := RunSave(args, tarFiles); err != nil {
    99  		return err
   100  	}
   101  	log.WithLabels("runtime", time.Since(dockerStart)).Infof("images complete")
   102  	return nil
   103  }
   104  
   105  func CopyInputs(a Args) error {
   106  	for _, target := range a.Targets {
   107  		for _, arch := range a.Architectures {
   108  			bp := a.PlanFor(arch).Find(target)
   109  			if bp == nil {
   110  				continue
   111  			}
   112  			args := bp.Dependencies()
   113  			args = append(args, filepath.Join(testenv.LocalOut, "dockerx_build", fmt.Sprintf("build.docker.%s", target)))
   114  			if err := RunCommand(a, "tools/docker-copy.sh", args...); err != nil {
   115  				return fmt.Errorf("copy: %v", err)
   116  			}
   117  		}
   118  	}
   119  	return nil
   120  }
   121  
   122  // RunSave handles the --save portion. Part of this is done by buildx natively - it will emit .tar
   123  // files. We need tar.gz though, so we have a bit more work to do
   124  func RunSave(a Args, files map[string]string) error {
   125  	if !a.Save {
   126  		return nil
   127  	}
   128  
   129  	root := filepath.Join(testenv.LocalOut, "release", "docker")
   130  	for name, alias := range files {
   131  		// Gzip the file
   132  		if err := VerboseCommand("gzip", "--fast", "--force", filepath.Join(root, name+".tar")).Run(); err != nil {
   133  			return err
   134  		}
   135  		// If it has an alias (ie pilot-debug -> pilot), copy it over. Copy after gzip to avoid double compute.
   136  		if alias != "" {
   137  			if err := Copy(filepath.Join(root, name+".tar.gz"), filepath.Join(root, alias+".tar.gz")); err != nil {
   138  				return err
   139  			}
   140  		}
   141  	}
   142  
   143  	return nil
   144  }
   145  
   146  func RunBake(args Args) error {
   147  	out := filepath.Join(testenv.LocalOut, "dockerx_build", "docker-bake.json")
   148  	_ = os.MkdirAll(filepath.Join(testenv.LocalOut, "release", "docker"), 0o755)
   149  	if err := createBuildxBuilderIfNeeded(args); err != nil {
   150  		return err
   151  	}
   152  	c := VerboseCommand("docker", "buildx", "bake", "-f", out, "all")
   153  	c.Stdout = os.Stdout
   154  	return c.Run()
   155  }
   156  
   157  // --save requires a custom builder. Automagically create it if needed
   158  func createBuildxBuilderIfNeeded(a Args) error {
   159  	if !a.Save {
   160  		return nil // default builder supports all but .save
   161  	}
   162  	if _, f := os.LookupEnv("CI"); !f {
   163  		// If we are not running in CI and the user is not using --save, assume the current
   164  		// builder is OK.
   165  		if !a.Save {
   166  			return nil
   167  		}
   168  		// --save is specified so verify if the current builder's driver is `docker-container` (needed to satisfy the export)
   169  		// This is typically used when running release-builder locally.
   170  		// Output an error message telling the user how to create a builder with the correct driver.
   171  		c := VerboseCommand("docker", "buildx", "inspect") // get current builder
   172  		out := new(bytes.Buffer)
   173  		c.Stdout = out
   174  		err := c.Run()
   175  		if err != nil {
   176  			return fmt.Errorf("command failed: %v", err)
   177  		}
   178  		matches := regexp.MustCompile(`Driver:\s+(.*)`).FindStringSubmatch(out.String())
   179  		if len(matches) == 0 || matches[1] != "docker-container" {
   180  			return fmt.Errorf("the docker buildx builder is not using the docker-container driver needed for .save.\n" +
   181  				"Create a new builder (ex: docker buildx create --driver-opt network=host,image=gcr.io/istio-testing/buildkit:v0.11.0" +
   182  				" --name container-builder --driver docker-container --buildkitd-flags=\"--debug\" --use)")
   183  		}
   184  		return nil
   185  	}
   186  	return exec.Command("sh", "-c", `
   187  export DOCKER_CLI_EXPERIMENTAL=enabled
   188  if ! docker buildx ls | grep -q container-builder; then
   189    docker buildx create --driver-opt network=host,image=gcr.io/istio-testing/buildkit:v0.11.0 --name container-builder --buildkitd-flags="--debug"
   190    # Pre-warm the builder. If it fails, fetch logs, but continue
   191    docker buildx inspect --bootstrap container-builder || docker logs buildx_buildkit_container-builder0 || true
   192  fi
   193  docker buildx use container-builder`).Run()
   194  }
   195  
   196  // ConstructBakeFile constructs a docker-bake.json to be passed to `docker buildx bake`.
   197  // This command is an extremely powerful command to build many images in parallel, but is pretty undocumented.
   198  // Most info can be found from the source at https://github.com/docker/buildx/blob/master/bake/bake.go.
   199  func ConstructBakeFile(a Args) (map[string]string, error) {
   200  	// Targets defines all images we are actually going to build
   201  	targets := map[string]Target{}
   202  	// Groups just bundles targets together to make them easier to work with
   203  	groups := map[string]Group{}
   204  
   205  	variants := sets.New(a.Variants...)
   206  	// hasDoubleDefault checks if we defined both DefaultVariant and PrimaryVariant. If we did, these
   207  	// are the same exact docker build, just requesting different tags. As an optimization, and to ensure
   208  	// byte-for-byte identical images, we will collapse these into a single build with multiple tags.
   209  	hasDoubleDefault := variants.Contains(DefaultVariant) && variants.Contains(PrimaryVariant)
   210  
   211  	allGroups := sets.New[string]()
   212  	// Tar files builds a mapping of tar file name (when used with --save) -> alias for that
   213  	// If the value is "", the tar file exists but has no aliases
   214  	tarFiles := map[string]string{}
   215  
   216  	allDestinations := sets.New[string]()
   217  	for _, variant := range a.Variants {
   218  		for _, target := range a.Targets {
   219  			// Just for Dockerfile, so do not worry about architecture
   220  			bp := a.PlanFor(a.Architectures[0]).Find(target)
   221  			if bp == nil {
   222  				continue
   223  			}
   224  			if variant == DefaultVariant && hasDoubleDefault {
   225  				// This will be process by the PrimaryVariant, skip it here
   226  				continue
   227  			}
   228  
   229  			// These images do not actually use distroless even when specified. So skip to avoid extra building
   230  			if strings.HasPrefix(target, "app_") && variant == DistrolessVariant {
   231  				continue
   232  			}
   233  			p := filepath.Join(testenv.LocalOut, "dockerx_build", fmt.Sprintf("build.docker.%s", target))
   234  			t := Target{
   235  				Context:    ptr.Of(p),
   236  				Dockerfile: ptr.Of(filepath.Base(bp.Dockerfile)),
   237  				Args:       createArgs(a, target, variant, ""),
   238  				Platforms:  a.Architectures,
   239  			}
   240  
   241  			t.Tags = append(t.Tags, extractTags(a, target, variant, hasDoubleDefault)...)
   242  			allDestinations.InsertAll(t.Tags...)
   243  
   244  			// See https://docs.docker.com/engine/reference/commandline/buildx_build/#output
   245  			if a.Push {
   246  				t.Outputs = []string{"type=registry"}
   247  			} else if a.Save {
   248  				n := target
   249  				if variant != "" && variant != DefaultVariant { // For default variant, we do not add it.
   250  					n += "-" + variant
   251  				}
   252  
   253  				tarFiles[n+a.suffix] = ""
   254  				if variant == PrimaryVariant && hasDoubleDefault {
   255  					tarFiles[n+a.suffix] = target + a.suffix
   256  				}
   257  				t.Outputs = []string{"type=docker,dest=" + filepath.Join(testenv.LocalOut, "release", "docker", n+a.suffix+".tar")}
   258  			} else {
   259  				t.Outputs = []string{"type=docker"}
   260  			}
   261  
   262  			if a.NoCache {
   263  				x := true
   264  				t.NoCache = &x
   265  			}
   266  
   267  			name := fmt.Sprintf("%s-%s", target, variant)
   268  			targets[name] = t
   269  			tgts := groups[variant].Targets
   270  			tgts = append(tgts, name)
   271  			groups[variant] = Group{tgts}
   272  
   273  			allGroups.Insert(variant)
   274  		}
   275  	}
   276  	groups["all"] = Group{sets.SortedList(allGroups)}
   277  	bf := BakeFile{
   278  		Target: targets,
   279  		Group:  groups,
   280  	}
   281  	out := filepath.Join(testenv.LocalOut, "dockerx_build", "docker-bake.json")
   282  	j, err := json.MarshalIndent(bf, "", "  ")
   283  	if err != nil {
   284  		return nil, err
   285  	}
   286  	_ = os.MkdirAll(filepath.Join(testenv.LocalOut, "dockerx_build"), 0o755)
   287  
   288  	if a.NoClobber {
   289  		e := errgroup.Group{}
   290  		for _, i := range sets.SortedList(allDestinations) {
   291  			if strings.HasSuffix(i, ":latest") { // Allow clobbering of latest - don't verify existence
   292  				continue
   293  			}
   294  			i := i
   295  			e.Go(func() error {
   296  				exists, err := image.Exists(i)
   297  				if err != nil {
   298  					return fmt.Errorf("failed to check image existence: %v", err)
   299  				}
   300  				if exists {
   301  					return fmt.Errorf("image %q already exists", i)
   302  				}
   303  				return nil
   304  			})
   305  		}
   306  		if err := e.Wait(); err != nil {
   307  			return nil, err
   308  		}
   309  	}
   310  
   311  	return tarFiles, os.WriteFile(out, j, 0o644)
   312  }
   313  
   314  func Copy(srcFile, dstFile string) error {
   315  	log.Debugf("Copy %v -> %v", srcFile, dstFile)
   316  	in, err := os.Open(srcFile)
   317  	if err != nil {
   318  		return err
   319  	}
   320  	defer in.Close()
   321  
   322  	out, err := os.Create(dstFile)
   323  	if err != nil {
   324  		return err
   325  	}
   326  
   327  	defer out.Close()
   328  
   329  	_, err = io.Copy(out, in)
   330  	if err != nil {
   331  		return err
   332  	}
   333  
   334  	return nil
   335  }