04 - Container Image Building
📋 Jump to Takeaways🎁 What if your CI can't mount the Docker socket? How do you build and push container images with nothing but Go code and a registry endpoint?
Imports in code snippets are trimmed for brevity. See the full example linked at the end for complete, compilable source.
OCI Image Specification
The Open Container Initiative (OCI) image spec defines a standard format for container images. An image consists of a manifest (JSON describing the image), a config (runtime settings like entrypoint, env vars, working directory), and an ordered list of layers (filesystem changesets stored as compressed tar archives). Every registry (Docker Hub, GCR, ECR, GHCR) speaks this same protocol.
Understanding this structure means you can build, inspect, and push images entirely in Go without a Docker daemon running. This is critical for CI/CD pipelines running in containers themselves, serverless build systems, and custom deployment tooling.
go-containerregistry Library
Google's go-containerregistry library provides a pure-Go implementation for interacting with OCI images and registries. No Docker socket required.
go get github.com/google/go-containerregistryKey packages:
| Package | Purpose |
|---|---|
v1 |
Core image types and interfaces |
v1/empty |
Empty base image (scratch) |
v1/mutate |
Add layers, set config |
v1/tarball |
Create layers from tar archives |
v1/remote |
Push/pull from registries |
v1/remote/transport |
Auth and transport |
Creating Layers Programmatically
A layer is a tar.gz archive representing filesystem additions. You create one by writing files into a tar buffer:
package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"time"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/tarball"
)
func createLayer(files map[string][]byte) (v1.Layer, error) {
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
for name, content := range files {
header := &tar.Header{
Name: name,
Size: int64(len(content)),
Mode: 0755,
ModTime: time.Now(),
}
if err := tw.WriteHeader(header); err != nil {
return nil, err
}
if _, err := tw.Write(content); err != nil {
return nil, err
}
}
tw.Close()
gw.Close()
return tarball.LayerFromReader(&buf)
}This gives you full control. Add binaries, config files, certificates, or anything else directly into the image filesystem.
Mutating Base Images
The mutate package lets you take any base image and modify it. You can add layers, set the entrypoint, configure environment variables, and change the working directory:
import (
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/mutate"
v1 "github.com/google/go-containerregistry/pkg/v1"
)
func buildImage(binaryLayer v1.Layer) (v1.Image, error) {
// Start from scratch (empty image)
base := empty.Image
// Add the layer containing our binary
img, err := mutate.AppendLayers(base, binaryLayer)
if err != nil {
return nil, err
}
// Set runtime configuration
cfg, err := img.ConfigFile()
if err != nil {
return nil, err
}
cfg.Config.Entrypoint = []string{"/app/server"}
cfg.Config.Env = []string{
"PORT=8080",
"ENV=production",
}
cfg.Config.WorkingDir = "/app"
cfg.Config.ExposedPorts = map[string]struct{}{
"8080/tcp": {},
}
img, err = mutate.ConfigFile(img, cfg)
if err != nil {
return nil, err
}
return img, nil
}You can also start from a real base image pulled from a registry instead of empty.Image:
import "github.com/google/go-containerregistry/pkg/v1/remote"
ref, _ := name.ParseReference("gcr.io/distroless/static:nonroot")
base, _ := remote.Image(ref)
img, _ := mutate.AppendLayers(base, myLayer)Pushing to Registries with Authentication
The remote package handles pushing. Authentication uses the same credential helpers as Docker:
import (
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
)
func pushImage(img v1.Image, destination string) error {
ref, err := name.ParseReference(destination)
if err != nil {
return err
}
// Uses ~/.docker/config.json or credential helpers
return remote.Write(ref, img,
remote.WithAuthFromKeychain(authn.DefaultKeychain),
)
}For programmatic auth (CI/CD environments), provide credentials directly:
auth := &authn.Basic{
Username: "oauth2accesstoken",
Password: os.Getenv("GCR_TOKEN"),
}
err := remote.Write(ref, img, remote.WithAuth(auth))Image Scanning Concepts
Once you can build images in Go, you can also inspect them for vulnerabilities. The workflow:
- Extract layers: iterate over
img.Layers()and read the filesystem contents - Parse package manifests: find
dpkg/status,apk/installed, Gogo.sumfiles - Match against vulnerability databases: compare package versions against CVE feeds
Libraries like aquasecurity/trivy use this approach. Your custom tooling can hook into the build pipeline to reject images with critical CVEs before they reach a registry.
Scratch Image Builder
A program that builds minimal OCI container images entirely in Go, no Docker daemon required. It reads a pre-compiled static binary, constructs a tar layer, assembles a scratch-based image with entrypoint and environment config, and pushes the result to a container registry. The output is a 5-15 MB image with zero OS dependencies.
Input: A statically-compiled Go binary path and a destination registry reference (e.g., go run main.go myserver ghcr.io/myorg/myapp:v1.0.0)
Output: A pushed OCI image with digest confirmation, containing only your binary in a scratch container
Full source: examples/scratch-image-builder
Key Takeaways
- OCI images are manifests + config + ordered tar.gz layers, a simple format you can construct entirely in Go
go-containerregistryeliminates the Docker daemon dependency, enabling image builds in any environment- Creating layers is just writing tar archives; mutating images is composing layers with config changes
- Registry auth integrates with Docker credential helpers or accepts programmatic tokens for CI/CD
- Building from scratch produces minimal images with tiny attack surfaces, ideal for production Go services
- This approach enables custom build pipelines, image scanning, and registry management as native Go tooling
🎁 Your images are built and pushed. How do you tell Kubernetes to actually run them, all from Go code that creates deployments, watches pods, and rolls back failures?