Skip to main content

Go Dockerfile

· 7 min read

The things I've collected to write my best Dockerfile. Appreciate any comments mentioning I could do it better and more optimal.

Preface

TLDR:

FROM golang:1.23.1-alpine AS builder

WORKDIR /app

ENV CGO_ENABLED=0
ENV GOOS=linux

COPY go.mod go.mod
COPY go.sum go.sum
RUN --mount=type=cache,target=/go/pkg/mod/ go mod download -x

COPY . .

FROM builder AS dev

RUN --mount=type=cache,target=/go/pkg/mod/ --mount=type=cache,target="/root/.cache/go-build" go install github.com/go-delve/delve/cmd/dlv@v1.23.0

RUN --mount=type=cache,target=/go/pkg/mod/ --mount=type=cache,target="/root/.cache/go-build" go build -gcflags=all="-N -l" -o server ./cmd/server

CMD ["dlv", "--listen=:40000", "--continue", "--headless=true", "--api-version=2", "--accept-multiclient", "exec", "server"]

FROM builder AS prod

RUN --mount=type=cache,target=/go/pkg/mod/ --mount=type=cache,target="/root/.cache/go-build" go build -ldflags "-s -w" -o server ./cmd/server

FROM alpine:3.13

RUN addgroup -g 1001 appgroup && adduser -D -G appgroup -u 1001 appuser

WORKDIR /app

USER 1001

COPY --from=prod /app/server server

CMD ["/app/server"]

Link: https://github.com/treenq/treenq/blob/98e6d8dd5f5756fe5df561913e10515784ef7163/Dockerfile

Now let's breakdown what's happening here and why.

note

I use colima and all the things described here work well. However, all the docs referenses will go to docker. I think they did a great job to push the industry standart. Also you have to turn on buildx to make these features work. It requires buildx installation and setting a feature flag DOCKER_BUILDKIT=1.

Base image

First, we need a base image in order to build the app. We want the base being less as possible, for that purpose I use alpine:

FROM golang:1.23.1-alpine AS builder

You also can consider distroless, it's very suitable for interpreted languages like python/nodejs, but alpine works well for Go.

Dependencies

The next step we prepare a surface to build the image, all the necessary dependencies we install there.

If you need specific timezones, certificates, github private repo creds, the service modules, whatever, we do it right here.

COPY go.mod go.mod
COPY go.sum go.sum
RUN --mount=type=cache,target=/go/pkg/mod/ go mod download -x

We must copy only desegnated dependencies definition and right after download them. If you don't know why and what is docker layers please refer to Docker basics and get back.

-x Flag is kinda adds verbosity showing what go mod download executes.

The interesting details here is --mount=type=cache. You can find more in the reference

If you update a dependency in your go.mod and rebuild it, then this statement will not download all the packages from scratch, it creates a designated mount and hold them in one place.

Unfortunately, most of the CI systems create a new image for a new job and it doesn't work in CI, but helps a lot for local testing. For instance, I run e2e tests locally and it saves a couple of minutes every try.

And only then we copy the rest of the codebase:

COPY . .
note

Don't forget to check your dockerignore to skip copying useless files.

Targets

You must have heard about multi-stage images. And you have built the dockerfiles with 2 stages, first to build a binary and the next to run it in a blank environment.

But I will convince you to have 4 stages.

Docker has an amazing feature: targets.

The targets allow to run a specified stage of the image.

Let's have a look how it plays out in case of e2e tests.

We have a regular docker compose setup with a database or whatever dependencies you have to run your tests. Im a big fan of a debugger and a big hater of infinite print statements. Don't get me wrong, logging is an awesome tool, but not the forgotten prints in a production build.

FROM builder AS dev

RUN --mount=type=cache,target=/go/pkg/mod/ --mount=type=cache,target="/root/.cache/go-build" go install github.com/go-delve/delve/cmd/dlv@v1.23.0

RUN --mount=type=cache,target=/go/pkg/mod/ --mount=type=cache,target="/root/.cache/go-build" go build -gcflags=all="-N -l" -o server ./cmd/server

CMD ["dlv", "--listen=:40000", "--continue", "--headless=true", "--api-version=2", "--accept-multiclient", "exec", "server"]

On installing delve don't forget to specify cache mount, it has its own dependencies and it will help to speed the build up. Moreover, if you don't mount the cache it won't discover the installeed dependencies in a previous step.

The mount flag might look unclear, as an argument you only put a path where to mount, you can't control the source of directory, it's managed docker buildkit.

Then we build a binary with a couple important gcflags, where -l disables inlining and -N removes optimisations. It matters because otherwise debugger won't be able to show some variables or navigate into some functions. You can read more here.

As the last statement we run dlv.

And that's what we have in a docker compose:

server:
build:
context: .
dockerfile: Dockerfile
target: dev
ports:
- '8000:8000'
- '40000:40000'
security_opt:
- seccomp:unconfined
cap_add:
- SYS_PTRACE

Here we specify target field, clear and simple. You also can pass it as a cli argument to an image.

Ports has 2 elements, a regular app HTTP port and a DAP (Debug Adapter Protocol) port that delve exposes.

Next we add secuty_opt since the default Seccomp profile restricts the ptrace system call.

note

Seccomp is a Linux kernel feature used to restrict the system calls that a process can make. By default, Docker applies a restrictive Seccomp profile to limit potentially dangerous system calls, improving container security. Read more here and here.

When you specify seccomp:unconfined, it removes the Seccomp restrictions, allowing the container to make all system calls. This config allows running ptrace syscall in the container, delve uses it to set breakpoints, observe memory, etc.

But it's not enough. We have to not only remove a restriction, but explicitly give a permission, that's why we have cap_add statement: to add a capability for that syscall.

Build prod

The prod build is quite simple and well known:

FROM builder AS prod

RUN --mount=type=cache,target=/go/pkg/mod/ --mount=type=cache,target="/root/.cache/go-build" go build -ldflags "-s -w" -o server ./cmd/server

We still use mount cache, we just put different build flags, in this case ldflags to achieve exactly the opposite we did in order to build a debug target. -s and -w stand for skipping debug info, read more here.

Run prod

There is no lots of new things for you, I want to focus on a small important thing: a user.

RUN addgroup -g 1001 appgroup && adduser -D -G appgroup -u 1001 appuser

USER 1001

There is so many information around on this security topic and I keep seeing zero attention to a user inside the image.

Shortly speaking - less ability less chance to make a mistake or open a vulnerability. Docker has a good blog post to cover why it matters.

Target dependenciy graph

Now about the main concern of so many stages.

Why would I build all the stages for releasing my go app?

You will not, if you turned on buildkit it will behave as a smarter and build only the necessary dependencies, it means it builds the dependency graph and builds only necessary part, so your production CI will never install delve to waste your time. The documentation explains it very well.

A few caveats on debugging remote DAP.

If you start a debugging process as is you will find your breakpoints Rejected. It happens because your DAP communicates breakpoints state with DAP server using the client paths, and your client IDE is located on your machine, while the Go binary was built inside an image, another host machine.

You can find the fix in the official doc.

First, I would connect to dlv dlv connect localhost:40000 and test path substitution, for instance config substitute-path /path/in/docker /local/path where /path/in/docker is just your WORKDIR statement and /local/path is your local dir (input pwd in the project folder). After that you can try list main.main and make sure it lists you a main function without an error.

Eventually I have the following config to configure remote debugger:

{
"type": "go",
"name": "debug remote service",
"mode": "remote",
"request": "attach",
"port": 40000,
"substitutePath": [
{
"from": "${env:HOME}/projects/project-name",
"to": "/app"
},
{
"from": "${env:HOME}/go/pkg/mod/",
"to": "/go/pkg/mod/"
}
]
}

Conclusion

Sorry, have nothing to say. Appreciate if you leave things better than you found it.