Rapid prototyping of Go apps with ko

Assume you have an idea for a new application or service. You write some Go to test and validate the idea - it works on your machine! What’s the simplest way to turn the source code into an easily shareable artifact? The answer is ko:

ko makes building Go container images easy, fast, and secure by default.

Some years ago I wrote about the ideal Dockerfile for a minimal Go container image - with ko this is no longer necessary since it already incorporates many of the principles discussed in that post. ko knows how to build Go applications from source (also for multiple architectures and target platforms), package the artifact in an OCI container image and upload it to a container registry - oh, and it also has some integrations for deploying the result with Kubernetes. In this post I describe a workflow to use ko for fast, end-to-end prototyping on Kubernetes: from source code to deployment with one command!

Before we get started, make sure to install ko according to the documentation - I’m using version 0.15.1.

#  Build source code

Let’s assume we have a simple Go app like this:

1
2
$ go mod init my-go-app
go: creating new go.mod: module my-go-app

main.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

import (
    "fmt"
    "net/http"
    "time"
)

// Simple HTTP server that listens on port 8080 and returns the current time as HH:MM:SS
func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "It's %s.\n", time.Now().Format("15:04:05"))
	})
	http.ListenAndServe(":8080", nil)
}

With the Go toolchain we can run it locally like this:

1
2
3
$ go run main.go &
$ curl localhost:8080
It's 15:25:41.

A fully “Go-native” application (without Cgo or other external dependencies) is ideal for ko because it can rely on the standard Go build workflow.

#  Package as container image

As a Go programmer you are familiar with the go build command: it turns the application source code into an executable binary. The ko build command does the same, but instead of producing a binary, it produces a container image. One of the major benefits of OCI container images is that they are easily shareable via container registries. Thus, log in to your favorite registry to get started:

1
2
$ export KO_DOCKER_REPO=registry.example.com/jack
$ ko login registry.example.com --username jack --password hunter.2

Now let the magic begin:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ ko build my-go-app
2023/12/14 15:34:54 Using base cgr.dev/chainguard/static:latest@sha256:6ddc52909b55f716c3154a28efab0c7ffb7ef741aeb390a7a431e9f3001ec010 for my-go-app
2023/12/14 15:34:55 Building my-go-app for linux/amd64
2023/12/14 15:34:56 Publishing registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737:latest
2023/12/14 15:34:58 pushed blob: sha256:cde39bb985375cba3d3b80a0b928447709d4cfd21929e6b0b52a48680fc4106a
2023/12/14 15:34:58 pushed blob: sha256:250c06f7c38e52dc77e5c7586c3e40280dc7ff9bb9007c396e06d96736cf8542
2023/12/14 15:34:59 pushed blob: sha256:cea99c2397e0499381a4f347263688d68c29624fd2384b9a7626cb83d2814c18
2023/12/14 15:34:59 pushed blob: sha256:15cdb3441d5977fcfe316b866157555e0fb09ebe3f8bd3105d9b7a63dcc3c86f
2023/12/14 15:34:59 pushed blob: sha256:fcf8faa913eacf5240e9b089ab899671597a0bcf7a64caaa25242f1af4ebafa6
2023/12/14 15:34:59 pushed blob: sha256:2bad94c418370d034e1a4d09d58e15b0d2b6079233efb352bf89caa0eb9a7673
2023/12/14 15:34:59 registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737:sha256-16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f.sbom: digest: sha256:ccd2236c8965fa76928eda7c9b38a9c8395f473bf8d0a5e9ccc528fcbb8147b7 size: 373
2023/12/14 15:34:59 Published SBOM registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737:sha256-16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f.sbom
2023/12/14 15:34:59 registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737:latest: digest: sha256:16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f size: 1209
2023/12/14 15:34:59 Published registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737@sha256:16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f
registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737@sha256:16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f

ko build a binary from the source (line 3), wrapped it in a container image (line 2) and automatically uploaded it to ${KO_DOCKER_REGISTRY}/<APP_NAME>-<HASH_OF_GO_IMPORT_PATH> (line 4-10). ko also pushed two tags: latest and sha256-16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f.sbom - the first one contains the application, the second one contains the Software Bill of Materials (SBOM), i.e. all the versions and checksums of all dependencies that went into building the image (you can read more about that here). Because the container images built by ko are so small, all of this happens pretty fast as well (just a couple of seconds)!

Side note: in case you only want to use the image on your local machine and don’t want to upload it to a registry, you can pass the --local flag.

#  Deploy to Kubernetes

ko has already built the container image for us, now we need to deploy and run the application somewhere. Because ko build outputs a unique reference for each image it builds, we can use this to spin up a Deployment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
$ image=$(ko build .)
2023/12/14 15:49:41 Using base cgr.dev/chainguard/static:latest@sha256:6ddc52909b55f716c3154a28efab0c7ffb7ef741aeb390a7a431e9f3001ec010 for my-go-app
2023/12/14 15:49:42 Building my-go-app for linux/amd64
2023/12/14 15:49:42 Publishing registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737:latest
2023/12/14 15:49:43 existing manifest: latest@sha256:16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f
2023/12/14 15:49:43 existing manifest: sha256-16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f.sbom@sha256:ccd2236c8965fa76928eda7c9b38a9c8395f473bf8d0a5e9ccc528fcbb8147b7
2023/12/14 15:49:43 Published SBOM registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737:sha256-16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f.sbom
2023/12/14 15:49:43 Published registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737@sha256:16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f
$ echo "$image"
registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737@sha256:16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f

$ kubectl create deployment my-deploy --image=$(ko build .) --port=8080
# (ko build output omitted)
deployment.apps/my-deploy created
$ kubectl get deploy
NAME                           READY   UP-TO-DATE   AVAILABLE   AGE
my-deploy                      1/1     1            1           9s
$ kubectl get pod
NAME                           READY   STATUS    RESTARTS   AGE
my-deploy-66f999bd78-zjmtx     1/1     Running   0          23s

Just like that we have a Deployment running in a Kubernetes cluster!

A similar approach can be used on OpenShift clusters:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ oc new-app --name my-app --image=$(ko build .)
--> Found container image cea99c2 (7 days old) from registry.cern.ch for "registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737@sha256:16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f"
    * An image stream tag will be created as "my-app:latest" that will track this image

--> Creating resources ...
    imagestream.image.openshift.io "my-app" created
    deployment.apps "my-app" created
--> Success
    Run 'oc status' to view your app.

$ oc get deploy
NAME                        READY   UP-TO-DATE   AVAILABLE   AGE
my-app                      1/1     1            1           8s
$ oc get pod
NAME                        READY   STATUS    RESTARTS   AGE
my-app-79ff6ffc9d-nnxwz     1/1     Running   0          14s

# optional: create `Service` and `Route` (Ingress) to make the application available externally
$ oc expose deploy/my-app --port=8080
service/my-app exposed
$ oc expose svc/my-app --port=8080
$ oc get route
NAME     HOST/PORT                             SERVICES  PORT
my-app   my-app-my-namespace.okd.example.com   my-app    8080
$ curl my-app-my-namespace.okd.example.com
It's 15:05:59

#  Rapid iteration

The previous commands show the power and reusability of ko, but they are not very useful for iterating because if we run the same command again, we get:

1
2
$ kubectl create deployment my-deploy --image=$(ko build .) --port=8080
error: failed to create deployment: deployments.apps "my-deploy" already exists

We could run kubectl delete deploy/my-deploy && kubectl create deploy ..., but there’s a better option: ko apply. It’s a wrapper around kubectl apply that detects references in the form of ko://<go-import-path>, builds the relevant image and substitutes the reference with the final path to the container image.

This means a YAML document like this (note the last line):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-go-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: my-go-app
  template:
    metadata:
      labels:
        app: my-go-app
    spec:
      containers:
      - name: app
        image: ko://my-go-app

can be deployed like this:

1
2
3
4
5
6
7
8
9
$ ko apply -f deploy.yaml
2023/12/14 17:45:33 Using base cgr.dev/chainguard/static:latest@sha256:6ddc52909b55f716c3154a28efab0c7ffb7ef741aeb390a7a431e9f3001ec010 for my-go-app
2023/12/14 17:45:33 Building my-go-app for linux/amd64
2023/12/14 17:45:35 Publishing registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737:latest
2023/12/14 17:45:38 existing manifest: latest@sha256:16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f
2023/12/14 17:45:38 existing manifest: sha256-16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f.sbom@sha256:ccd2236c8965fa76928eda7c9b38a9c8395f473bf8d0a5e9ccc528fcbb8147b7
2023/12/14 17:45:38 Published SBOM registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737:sha256-16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f.sbom
2023/12/14 17:45:38 Published registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737@sha256:16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f
deployment.apps/my-go-app created

Hint: instead of directly applying the manifests to the cluster, ko resolve can be used to print them to stdout.

Now we can update our source code, change the deployment and even add additional resources to the manifest, e.g. to expose the application externally:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# add a the bottom of deploy.yaml
---
apiVersion: v1
kind: Service
metadata:
  name: my-go-app
spec:
  type: ClusterIP
  selector:
    app: my-go-app
  ports:
  - port: 8080
    targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-go-app
spec:
  rules:
  - host: my-go-app.cluster.example.com
    http:
      paths:
      - backend:
          service:
            name: my-go-app
            port:
              number: 8080
        path: /
        pathType: Exact

And simply run the same command again to update everything:

1
2
3
4
5
6
7
8
9
$ ko apply -f deploy.yaml --sbom=none
2023/12/14 17:51:42 Using base cgr.dev/chainguard/static:latest@sha256:6ddc52909b55f716c3154a28efab0c7ffb7ef741aeb390a7a431e9f3001ec010 for my-go-app
2023/12/14 17:51:42 Building my-go-app for linux/amd64
2023/12/14 17:51:43 Publishing registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737:latest
2023/12/14 17:51:45 existing manifest: latest@sha256:16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f
2023/12/14 17:51:45 Published registry.example.com/jack/my-go-app-a9f6a17535407a54e1bf94b032dcd737@sha256:16a75df4106093942d012409deb68fe347d5b618295e9161edbb0a61c65e087f
deployment.apps/my-go-app configured
service/my-go-app created
ingress.networking.k8s.io/my-go-app created

This is really neat because it allows quickly iterating on application source code and deployment manifests:

  • add a new environment variable in the code → specify the right value in the Depoyment;
  • load configuration from a file → add a ConfigMap or Secret to the YAML manifests and mount them into the Deployment;
  • add a sidecar container for authentication / reverse proxying → update the Service definition;

I have found this to be an incredibly productive workflow and will surely be using it more often in the future. Check out all of ko’s configuration options and have a look at the frequently asked questions.

Happy building!