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 }