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.
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