Of commits and containers — Using a Dockerfile to embed revision information in your Go application

Of commits and containers

Using a Dockerfile to embed revision information in your Go application

By jimmy@section411.com
By Jimmy Sawczuk
Published · 5 min. read

Guillaume Bolduc, Unsplash

When I’m debugging an issue with an application, one of the first questions I ask is what version of the application I’m looking at. If different versions of my app are deployed in multiple environments, it’s always worth checking to make sure the environment I’m looking at is running the code I think it is.

There are a few ways of accomplishing this. Sometimes you can SSH into your server and run git log -1, but if your application is running in a container that’s not always available. Another option is manually set the version in a file that becomes part of your build, but this is easy to overlook, surprisingly hard to automate, and often doesn’t give you the granularity you need.

Years ago, I wrote scm-status to make it easier to know what version my app was running. scm-status simply grabs any information from source control (either Git or Mercurial) that it can find in your current working directory and outputs it either to STDOUT or a file. As my build processes evolved, I mixed in go-binary, which pipes the output from scm-status and creates a .go file suitable for including in the compiled application. I tried to automate this as well as I could by writing Makefile targets that would generate the relevant REVISION.json and .go files before running a standard go build.

Recently, I’ve started working with Dockerized applications, and for a while have tried to figure out how to get version information in a Docker image without too much hassle. It turns out that while the process to get there is a little convoluted, I’m pretty happy with the solution I came up with.

First, find a place in your application where your revision information will live. For me, it was in the same package (main) as my binary. Once you know where that file will be, create a file called revision.go and paste the following:

1
2
3
4
5
6
7
package main

import "io"

// getRevision is a placeholder for a function which returns revision
// information. This file will be overwritten in the Docker environment.
func getRevision() (io.Reader, error) { return nil, nil }

Next, create a Dockerfile. Here’s an example for an app I’m working on now. Your file may vary slightly, but the gist should be similar.

 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
31
32
33
34
35
# PHASES 1 and 2
# Bring in both the go-binary and scm-status images, we'll need them during the
# build process.
FROM jimmysawczuk/go-binary AS go-binary
FROM jimmysawczuk/scm-status AS scm-status

# PHASE 3
# Build the app.
FROM golang:1.10.3 AS builder
WORKDIR /go/src/github.com/jimmysawczuk/jolabokaflod

# Bring in scm-status and go-binary from the earlier stages.
COPY --from=scm-status /usr/bin/scm-status /bin/scm-status
COPY --from=go-binary /usr/bin/go-binary /bin/go-binary

# Copy our code (and importantly, our .git folder) into the builder
COPY . .

# Run scm-status to get our REVISION.json file, then pipe it to go-binary
# to put the contents in a revision.go file. Note the flags for output file,
# the function name and the package name.
RUN scm-status | go-binary -out=cmd/server/revision.go -f getRevision -p main -readers

# Now that revision.go is created, we can build the app.
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o app github.com/jimmysawczuk/jolabokaflod/cmd/server

# PHASE 4
# Now that we have a binary with the revision information compiled in, all we need
# the final, trimmed down image.
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/
COPY --from=builder /go/src/github.com/jimmysawczuk/jolabokaflod/app ./server
EXPOSE 3000
ENTRYPOINT ["./server"]

Finally, wherever we want to see the revision, simply load it, unmarshal it however you prefer, and do whatever you want with it. Here’s an example:

 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
31
32
package main

import (
	"encoding/json"
	"net/http"
	"time"

	"github.com/jimmysawczuk/scm-status/scm"
)

func versionHandler(w http.ResponseWriter, r *http.Request) {
	// The only error we'd get here is if gzip decompression failed, so we'll ignore it.
	rdr, _ := getRevision()

	// Unmarshal the raw JSON into our object (again, ignoring errors. I know, I'm awful).
	target := scm.Snapshot{}
	json.NewDecoder(rdr).Decode(&target)

	// Construct and send our response.
	res := struct {
		Name     string    `json:"name"`
		Revision string    `json:"revision"`
		Date     time.Time `json:"date"`
	}{
		Name:     "jolabokaflod api",
		Revision: target.Hex.Short,
		Date:     target.Date,
	}
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(200)
	w.Write(res)
}

Overall I’m pretty happy with this solution, and I think it’s an improvement over the old Makefile-driven method. By putting this process in a Dockerfile, we don’t have to worry about dependencies or weird artifacts, and we only require the user to run a normal docker build rather than knowing to run a Makefile target.

It’s worth pointing out also that there’s no reason this has to be a Go application. For a non-Go application, we’d just output the results of scm-status to a file called REVISION.json, and either shim that data into a language-specific file or copy that file all the way through to our final container.


This article was originally published on Medium.