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:
// kobuilder builds the container image used in the cloudbuild ci process.
// The only dependencies are that authentication is available by default.
package main
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/tarball"
)
var logV bool
func log(a ...interface{}) {
if logV {
fmt.Fprintln(os.Stderr, a...)
}
}
func main() {
var baseImgName, targetImgName string
flag.StringVar(&baseImgName, "base", "gcr.io/google.com/cloudsdktool/cloud-sdk:alpine", "base image name")
flag.StringVar(&targetImgName, "target", "us-central1-docker.pkg.dev/com-seankhliao/run/ko:latest", "target image name")
flag.BoolVar(&logV, "v", false, "log progress")
flag.Parse()
err := run(baseImgName, targetImgName)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
func run(baseImgName, targetImgName string) error {
koURL, err := latestKo()
if err != nil {
return fmt.Errorf("get latest ko: %w", err)
}
log("got ko url", koURL)
goURL, err := latestGo()
if err != nil {
return fmt.Errorf("get latest go: %w", err)
}
log("got go url", goURL)
baseRef, err := name.ParseReference(baseImgName)
if err != nil {
return fmt.Errorf("parse base img=%s: %w", baseImgName, err)
}
log("got base ref", baseRef)
img, err := remote.Image(baseRef)
if err != nil {
return fmt.Errorf("get base: %w", err)
}
log("got base img")
koLayer, err := tarToLayer(koURL, "/usr/local/bin", "ko")
if err != nil {
return fmt.Errorf("get ko layer: %w", err)
}
log("got ko layer")
goLayer, err := tarToLayer(goURL, "/usr/local", "")
if err != nil {
return fmt.Errorf("get go layer: %w", err)
}
log("got go layer")
img, err = mutate.AppendLayers(img, goLayer, koLayer)
if err != nil {
return fmt.Errorf("append layers: %w", err)
}
log("appended layers")
configFile, err := img.ConfigFile()
if err != nil {
return fmt.Errorf("get config file")
}
log("got config file")
configFile.Config.WorkingDir = "/worspace"
for i, e := range configFile.Config.Env {
if strings.HasPrefix(e, "PATH=") {
configFile.Config.Env[i] = "PATH=/usr/local/go/bin:" + e[5:]
}
}
configFile.Config.Env = append(
configFile.Config.Env,
"CGO_ENABLED=0",
"GOFLAGS=-trimpath",
)
img, err = mutate.ConfigFile(img, configFile)
if err != nil {
return fmt.Errorf("set config file: %w", err)
}
log("mutated config file")
targetRef, err := name.ParseReference(targetImgName)
if err != nil {
return fmt.Errorf("parse target img=%s: %w", targetImgName, err)
}
log("got target ref", targetRef)
err = remote.Write(targetRef, img, remote.WithAuthFromKeychain(authn.DefaultKeychain))
if err != nil {
return fmt.Errorf("write target img=%s: %w", targetImgName, err)
}
log("wrote target img")
return nil
}
// turns a remote tarball to an image layer
// download is the download url.
// root is the target root within the image for the extracted tarball.
// only limits the output to a single file matching the name.
func tarToLayer(download, root, only string) (v1.Layer, error) {
res, err := http.Get(download)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", download, err)
}
defer res.Body.Close()
if res.StatusCode != 200 {
return nil, fmt.Errorf("GET %s: %s", download, res.Status)
}
log("got tar response")
gr, err := gzip.NewReader(res.Body)
if err != nil {
return nil, fmt.Errorf("gzip reader %s: %w", download, err)
}
tr := tar.NewReader(gr)
pr, pw := io.Pipe()
tw := tar.NewWriter(pw)
go func() {
log("started writing tar")
defer log("finished writing tar")
defer pw.Close()
defer tw.Close()
for th, err := tr.Next(); err == nil; th, err = tr.Next() {
if only != "" && th.Name != only {
continue
}
th.Name = path.Join(root, th.Name)
err = tw.WriteHeader(th)
if err != nil {
panic("write header: " + err.Error())
}
_, err = io.Copy(tw, tr)
if err != nil {
panic("copy body: " + err.Error())
}
}
}()
return tarball.LayerFromReader(pr, tarball.WithEstargz)
}
func latestKo() (string, error) {
res, err := http.Get("https://api.github.com/repos/google/ko/releases")
if err != nil {
return "", fmt.Errorf("GET google/ko: %w", err)
}
defer res.Body.Close()
if res.StatusCode != 200 {
return "", fmt.Errorf("GET google/ko: %s", res.Status)
}
b, err := io.ReadAll(res.Body)
if err != nil {
return "", fmt.Errorf("read google/ko: %w", err)
}
var ghrel ghRel
err = json.Unmarshal(b, &ghrel)
if err != nil {
return "", fmt.Errorf("unmarshal google/ko: %w", err)
}
for _, asset := range ghrel[0].Assets {
if strings.Contains(asset.Name, "Linux") && strings.Contains(asset.Name, "x86_64") {
return asset.Browser_download_url, nil
}
}
return "", fmt.Errorf("no file found in google/ko")
}
type ghRel []struct {
Tag_name string
Assets []struct {
Name string
Browser_download_url string
}
}
func latestGo() (string, error) {
res, err := http.Get("https://go.dev/dl/?mode=json")
if err != nil {
return "", fmt.Errorf("GET go.dev/dl: %w", err)
}
defer res.Body.Close()
if res.StatusCode != 200 {
return "", fmt.Errorf("GET go.dev/dl: %s", res.Status)
}
b, err := io.ReadAll(res.Body)
if err != nil {
return "", fmt.Errorf("read go.dev/dl: %w", err)
}
var godl goDL
err = json.Unmarshal(b, &godl)
if err != nil {
return "", fmt.Errorf("unamarshal go.dev/dl: %w", err)
}
for _, file := range godl[0].Files {
if file.Os == "linux" && file.Arch == "amd64" && file.Kind == "archive" {
return "https://go.dev/dl/" + file.Filename, nil
}
}
return "", fmt.Errorf("no file found in go.dev/dl")
}
type goDL []struct {
Version string
Stable bool
Files []struct {
Filename string
Os string
Arch string
Version string
Sha256 string
Size int
Kind string
}
}