building containers with code

mush together tarballs into a new container image

SEAN K.H. LIAO

building containers with code

mush together tarballs into a new container image

building containers

docker build requires a docker daemon, kaniko needs to run inside a container, and buildah also needs some sort of containing.

But what if all I want to do is insert a layer with a few files, maybe from a tarball? Bazel's rules_docker, jib, ko, and crane all seem to be able to do some version of this.

Using go-containerregistry, you can do it too. For example, you could download upstream tarballs, pull down a base image, and append the tarballs as layers, optionally fixing the path locations and updating some new env vars.

example

Here's the builder I use for creating a combined gcloud, go, ko container image for building and deploying to Cloud Run with a single stage:

  1// kobuilder builds the container image used in the cloudbuild ci process.
  2// The only dependencies are that authentication is available by default.
  3package main
  4
  5import (
  6        "archive/tar"
  7        "compress/gzip"
  8        "encoding/json"
  9        "flag"
 10        "fmt"
 11        "io"
 12        "net/http"
 13        "os"
 14        "path"
 15        "strings"
 16
 17        "github.com/google/go-containerregistry/pkg/authn"
 18        "github.com/google/go-containerregistry/pkg/name"
 19        v1 "github.com/google/go-containerregistry/pkg/v1"
 20        "github.com/google/go-containerregistry/pkg/v1/mutate"
 21        "github.com/google/go-containerregistry/pkg/v1/remote"
 22        "github.com/google/go-containerregistry/pkg/v1/tarball"
 23)
 24
 25var logV bool
 26
 27func log(a ...interface{}) {
 28        if logV {
 29                fmt.Fprintln(os.Stderr, a...)
 30        }
 31}
 32
 33func main() {
 34        var baseImgName, targetImgName string
 35        flag.StringVar(&baseImgName, "base", "gcr.io/google.com/cloudsdktool/cloud-sdk:alpine", "base image name")
 36        flag.StringVar(&targetImgName, "target", "us-central1-docker.pkg.dev/com-seankhliao/run/ko:latest", "target image name")
 37        flag.BoolVar(&logV, "v", false, "log progress")
 38        flag.Parse()
 39
 40        err := run(baseImgName, targetImgName)
 41        if err != nil {
 42                fmt.Fprintf(os.Stderr, "%v\n", err)
 43                os.Exit(1)
 44        }
 45}
 46
 47func run(baseImgName, targetImgName string) error {
 48        koURL, err := latestKo()
 49        if err != nil {
 50                return fmt.Errorf("get latest ko: %w", err)
 51        }
 52        log("got ko url", koURL)
 53
 54        goURL, err := latestGo()
 55        if err != nil {
 56                return fmt.Errorf("get latest go: %w", err)
 57        }
 58        log("got go url", goURL)
 59
 60        baseRef, err := name.ParseReference(baseImgName)
 61        if err != nil {
 62                return fmt.Errorf("parse base img=%s: %w", baseImgName, err)
 63        }
 64        log("got base ref", baseRef)
 65
 66        img, err := remote.Image(baseRef)
 67        if err != nil {
 68                return fmt.Errorf("get base: %w", err)
 69        }
 70        log("got base img")
 71
 72        koLayer, err := tarToLayer(koURL, "/usr/local/bin", "ko")
 73        if err != nil {
 74                return fmt.Errorf("get ko layer: %w", err)
 75        }
 76        log("got ko layer")
 77
 78        goLayer, err := tarToLayer(goURL, "/usr/local", "")
 79        if err != nil {
 80                return fmt.Errorf("get go layer: %w", err)
 81        }
 82        log("got go layer")
 83
 84        img, err = mutate.AppendLayers(img, goLayer, koLayer)
 85        if err != nil {
 86                return fmt.Errorf("append layers: %w", err)
 87        }
 88        log("appended layers")
 89
 90        configFile, err := img.ConfigFile()
 91        if err != nil {
 92                return fmt.Errorf("get config file")
 93        }
 94        log("got config file")
 95        configFile.Config.WorkingDir = "/worspace"
 96        for i, e := range configFile.Config.Env {
 97                if strings.HasPrefix(e, "PATH=") {
 98                        configFile.Config.Env[i] = "PATH=/usr/local/go/bin:" + e[5:]
 99                }
100        }
101        configFile.Config.Env = append(
102                configFile.Config.Env,
103                "CGO_ENABLED=0",
104                "GOFLAGS=-trimpath",
105        )
106
107        img, err = mutate.ConfigFile(img, configFile)
108        if err != nil {
109                return fmt.Errorf("set config file: %w", err)
110        }
111        log("mutated config file")
112
113        targetRef, err := name.ParseReference(targetImgName)
114        if err != nil {
115                return fmt.Errorf("parse target img=%s: %w", targetImgName, err)
116        }
117        log("got target ref", targetRef)
118
119        err = remote.Write(targetRef, img, remote.WithAuthFromKeychain(authn.DefaultKeychain))
120        if err != nil {
121                return fmt.Errorf("write target img=%s: %w", targetImgName, err)
122        }
123        log("wrote target img")
124
125        return nil
126}
127
128// turns a remote tarball to an image layer
129// download is the download url.
130// root is the target root within the image for the extracted tarball.
131// only limits the output to a single file matching the name.
132func tarToLayer(download, root, only string) (v1.Layer, error) {
133        res, err := http.Get(download)
134        if err != nil {
135                return nil, fmt.Errorf("GET %s: %w", download, err)
136        }
137        defer res.Body.Close()
138        if res.StatusCode != 200 {
139                return nil, fmt.Errorf("GET %s: %s", download, res.Status)
140        }
141        log("got tar response")
142
143        gr, err := gzip.NewReader(res.Body)
144        if err != nil {
145                return nil, fmt.Errorf("gzip reader %s: %w", download, err)
146        }
147
148        tr := tar.NewReader(gr)
149        pr, pw := io.Pipe()
150        tw := tar.NewWriter(pw)
151
152        go func() {
153                log("started writing tar")
154                defer log("finished writing tar")
155                defer pw.Close()
156                defer tw.Close()
157                for th, err := tr.Next(); err == nil; th, err = tr.Next() {
158                        if only != "" && th.Name != only {
159                                continue
160                        }
161                        th.Name = path.Join(root, th.Name)
162                        err = tw.WriteHeader(th)
163                        if err != nil {
164                                panic("write header: " + err.Error())
165                        }
166                        _, err = io.Copy(tw, tr)
167                        if err != nil {
168                                panic("copy body: " + err.Error())
169                        }
170                }
171        }()
172
173        return tarball.LayerFromReader(pr, tarball.WithEstargz)
174}
175
176func latestKo() (string, error) {
177        res, err := http.Get("https://api.github.com/repos/google/ko/releases")
178        if err != nil {
179                return "", fmt.Errorf("GET google/ko: %w", err)
180        }
181        defer res.Body.Close()
182        if res.StatusCode != 200 {
183                return "", fmt.Errorf("GET google/ko: %s", res.Status)
184        }
185        b, err := io.ReadAll(res.Body)
186        if err != nil {
187                return "", fmt.Errorf("read google/ko: %w", err)
188        }
189        var ghrel ghRel
190        err = json.Unmarshal(b, &ghrel)
191        if err != nil {
192                return "", fmt.Errorf("unmarshal google/ko: %w", err)
193        }
194        for _, asset := range ghrel[0].Assets {
195                if strings.Contains(asset.Name, "Linux") && strings.Contains(asset.Name, "x86_64") {
196                        return asset.Browser_download_url, nil
197                }
198        }
199        return "", fmt.Errorf("no file found in google/ko")
200}
201
202type ghRel []struct {
203        Tag_name string
204        Assets   []struct {
205                Name                 string
206                Browser_download_url string
207        }
208}
209
210func latestGo() (string, error) {
211        res, err := http.Get("https://go.dev/dl/?mode=json")
212        if err != nil {
213                return "", fmt.Errorf("GET go.dev/dl: %w", err)
214        }
215        defer res.Body.Close()
216        if res.StatusCode != 200 {
217                return "", fmt.Errorf("GET go.dev/dl: %s", res.Status)
218        }
219        b, err := io.ReadAll(res.Body)
220        if err != nil {
221                return "", fmt.Errorf("read go.dev/dl: %w", err)
222        }
223        var godl goDL
224        err = json.Unmarshal(b, &godl)
225        if err != nil {
226                return "", fmt.Errorf("unamarshal go.dev/dl: %w", err)
227        }
228        for _, file := range godl[0].Files {
229                if file.Os == "linux" && file.Arch == "amd64" && file.Kind == "archive" {
230                        return "https://go.dev/dl/" + file.Filename, nil
231                }
232        }
233        return "", fmt.Errorf("no file found in go.dev/dl")
234}
235
236type goDL []struct {
237        Version string
238        Stable  bool
239        Files   []struct {
240                Filename string
241                Os       string
242                Arch     string
243                Version  string
244                Sha256   string
245                Size     int
246                Kind     string
247        }
248}
249