Best practice to deploy Golang with Docker

byGinkTue, 28 Feb 2023

1. Concept.

The standard image on Docker Hub for Go is named golang (docker pull golang). Using this image to run a Go program for interactive execution can lead to an image size of over 800MB. However, building Docker images from scratch by using compiled binaries that don't require multi-layered OS and environment support can result in much slimmer images.

This approach involves using a minimalist base image like alpine, busybox, or even scratch as the starting point and copying only the necessary files and dependencies into the image. This way, the resulting image will contain only the required components, making it very lightweight and efficient.

2. Let's get started.

The concept is quite interesting, isn't it? A smaller, faster-loading image with reduced memory consumption sounds ideal for any DevOps team. However, the reality isn't always straightforward. Our applications frequently require importing numerous packages and dependencies.

The good news is that Golang's build tool is excellent, offering numerous options to help us create a binary executable that can run on any Linux distribution. This means it can run in Docker without requiring any base images (OS) whatsoever.

2.1. Application with Golang native only.

This is the easiest case. All we need to do is a multistage Dockerfile like this:

FROM golang:latest AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
RUN go build -o main main.go

FROM scratch
WORKDIR /app
COPY --from=builder /app/main ./
CMD ["./main"]

This is because of Golang build tool, by default, will generate a binary without linking to any external library if the built-in packages already support.

Let's try with a classic hello-world app, and check the build result by ldd:

package main

import (
    "fmt"
)

func main() {
    fmt.Println("Hello, world!")
}
go build -o main main.go

ldd main
# => not a dynamic executable

2.2. Application with some packages that're not pure Golang

Back to the above example. Now we will add another standard library, which is net:

package main

import (
    "fmt"
    "net"
)

func main() {
    fmt.Println("Hello, world!")
    fmt.Println(net.LookupHost("google.com"))
}
go build -o main main.go

ldd main
# => linux-vdso.so.1 (0x00007ffce7702000)
# => libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f56f29b1000)
# => /lib64/ld-linux-x86-64.so.2 (0x00007f56f2be2000)

Oh, it's not static-linking anymore. What happened?

It turns out, some packages like net or os/user are not pure Golang. They need some C libraries to work.

How can we solve it? Hmm 🤔

Fortunately, the Go standard library has pure-Go versions of all the functions in net and os/user. Although these versions may have fewer features compared to the ones in libc, they are often sufficient for many use cases. By default, Go uses the libc calls, but you can choose to use the pure-Go versions instead by specifying the build tags netgo and osusergo.

go build -tags netgo -o main main.go
ldd main
# => not a dynamic executable

Great !!!

Or you can apply a restriction on CGO callouts, forcing the native Go implementations everywhere, with the environment variable CGO_ENABLED. This ENV is enabled by default but we can turn it off like this:

CGO_ENABLED=0 go build -o main main.go
ldd main
# => not a dynamic executable

Excellent !!!

2.3. Application that needs CGO support.

Disabled CGO support can help for above case just because there's another Golang version of net. Sometimes, CGO support is a must because our application rely on external C-libraries, or some packages we import just simply need that. What to do now?

Okay, Go has support for static-link for C-code also. This is hard and not 100% guarantee supported by the way. But it's an option and worth to give a try.

Let's take an example of go-sqlite3. A well known library that most of junior Golang developers will scratch the head, just because building binary (dynamic-link) in golang official image then run in Alpine. 💀

package main

import (
	"database/sql"
	"log"
	"os"
	"time"

	_ "github.com/mattn/go-sqlite3"
)

func main() {
	os.Remove("./foo.db")

	db, err := sql.Open("sqlite3", "./foo.db")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	sqlStmt := `
	create table foo (id integer not null primary key, name text);
	delete from foo;
	`
	_, err = db.Exec(sqlStmt)
	if err != nil {
		log.Printf("%q: %s\n", err, sqlStmt)
		return
	}
}

If we try to use the default build, it will be dynamic-link. Everything goes well until we run it on a different OS such as Alpine. The reason for this: Alpine is based on musl-libc instead of glibc which was used when building the binary.

go build -o main main.go
ldd main
# => linux-vdso.so.1 (0x00007ffe177a4000)
# => libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f7096dd9000)
# => /lib64/ld-linux-x86-64.so.2 (0x00007f709700a000)

But if we disable CGO, the generated binary simply can't even work.

CGO_ENABLED=0 go build -o main main.go
ldd main
# => not a dynamic executable
./main
# => "Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work. This is a stub": 
# => create table foo (id integer not null primary key, name text);
# => delete from foo;

So. Let's try another way, a flag to tell Go making static-link even with C-code -ldflags "-linkmode 'external' -extldflags '-static'".

go build -ldflags "-linkmode 'external' -extldflags '-static'" -o main main.go

You may see some warnings about the shared libraries from the glibc version but it will work smoothly.

Now back to the problem, why do we really need static link? Actually, if we build the binary by official golang image with dynamic link, then deploy inside a glibc based image like debian:bullseye-slim it still works just fine.

FROM golang:latest AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
RUN go build -o main main.go

FROM debian:bullseye-slim
WORKDIR /app
COPY --from=builder /app/main ./
CMD ["./main"]

But the final result based on debian still very large, the above simple app generates an 89 MB docker image. While the main binary (dynamic-link) is just around 6.5 MB. We want a small and efficient image that can run without relying on OS layer.

Modify the build step a bit to use static-link with alpine and we reduce the image size to 15MB.

FROM golang:latest AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
RUN go build -ldflags "-linkmode 'external' -extldflags '-static'" -o main main.go

FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/main ./
CMD ["./main"]

And if we replace alpine:latest by scratch, the image size will be now only 7.91 MB. Which is exactly equal to the static-link binary. Because the scratch base simply has nothing. Anyway, all the things we need is the application binary that can be run and deployed inside docker, not a bloated OS 👻


© 2016-2024  GinkCode.com