Chapter 82 -- Capstone: shipping a well-behaved Go service (image, systemd, Kubernetes)
In one sentence
A well-behaved Linux service is small, unprivileged, resource-aware, observable, and stops gracefully. This chapter packages the HTTPS guide's Go server three ways, with every setting traced back to a chapter that explains why.
Why this matters
Most production incidents with containerised Go services come down to
packaging and runtime settings, not the code: running as root, no memory limit
hint for the GC, the wrong GOMAXPROCS, PID 1 with no signal handling, a
preStop hook that calls a binary the image doesn't have, or a 1 GB image full
of tools an attacker can use. Every one of those is a line in the files below.
The container image
# syntax=docker/dockerfile:1
# ---- build stage: full Go toolchain, never shipped ----
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
# CGO_ENABLED=0: a static binary that needs no libc in the final image.
# -trimpath and -ldflags="-s -w": reproducible paths, smaller binary.
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./https/httpsserver
# ---- final stage: no shell, no package manager, non-root ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER nonroot:nonroot
EXPOSE 8443
ENTRYPOINT ["/server", "-addr", ":8443", "-redirect", ""]
Built and run while writing this guide:
$ docker build -f Dockerfile -t gosvc:lab .
$ docker images gosvc:lab --format '{{.Size}}'
14.8MB
$ docker run -d --rm -p 18443:8443 --read-only --cap-drop=ALL gosvc:lab
$ curl -sk https://127.0.0.1:18443/readyz
ready
$ docker exec <id> sh
exec: "sh": executable file not found in $PATH <- nothing to exploit
| Line | Why | Chapter |
|---|---|---|
| multi-stage build | the toolchain never ships | 48 |
CGO_ENABLED=0 |
static binary, no libc, runs on distroless/static |
48 |
:nonroot + USER |
no root inside the container | 36, 71 |
--read-only, --cap-drop=ALL |
the process can't modify its image or gain privileges | 71 |
| no shell in the image | docker exec ... sh fails, and so does an attacker's |
52 |
The systemd unit (for VMs and bare metal)
# /etc/systemd/system/gosvc.service
[Unit]
Description=Go HTTPS service (gosvc)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# %d = the credentials directory: LoadCredential copies root-only key files there,
# readable by this service alone, even though it runs as an unprivileged dynamic user.
ExecStart=/opt/gosvc/server -addr :443 -redirect :80 -cert %d/tls.crt -key %d/tls.key
Restart=on-failure
RestartSec=2s
# Graceful shutdown: SIGTERM, then up to 30s to drain before SIGKILL.
KillSignal=SIGTERM
TimeoutStopSec=30s
# Identity: a throwaway user created at start, no shell, no home.
DynamicUser=yes
# Bind 80/443 without root: grant exactly one capability.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
# Resource limits (cgroup v2) -- and tell the Go runtime about them.
MemoryMax=512M
CPUQuota=200%
Environment=GOMEMLIMIT=460MiB
LimitNOFILE=65536
# Sandboxing: read-only OS, private /tmp, no access to /home, few syscalls.
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
SystemCallFilter=@system-service
SystemCallArchitectures=native
LoadCredential=tls.crt:/etc/gosvc/tls.crt
LoadCredential=tls.key:/etc/gosvc/tls.key
[Install]
WantedBy=multi-user.target
systemd-analyze verify passes this unit, and systemd-analyze security
rates its exposure 3.1 ("OK"). A default unit with no sandboxing scores
about 9.6 ("UNSAFE").
| Setting | Why | Chapter |
|---|---|---|
DynamicUser=yes + AmbientCapabilities=CAP_NET_BIND_SERVICE |
ports 80/443 without root | 36, 71 |
LoadCredential + %d |
root-only key files, readable by this service alone | 67 |
MemoryMax + GOMEMLIMIT (90%) |
kernel limit, and the GC told about it | 76 |
CPUQuota=200% |
Go 1.25+ sets GOMAXPROCS=2 from it | 75 |
LimitNOFILE=65536 |
room for connections | 78 |
KillSignal=SIGTERM, TimeoutStopSec=30s |
drain before SIGKILL | 74 |
ProtectSystem=strict, SystemCallFilter=@system-service |
read-only OS, restricted syscalls | 67 |
The Kubernetes Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: gosvc
spec:
replicas: 3
selector:
matchLabels: {app: gosvc}
template:
metadata:
labels: {app: gosvc}
spec:
terminationGracePeriodSeconds: 30 # > preStop sleep + drain time
securityContext:
runAsNonRoot: true
seccompProfile: {type: RuntimeDefault}
containers:
- name: server
image: registry.example.com/gosvc:1.4.2 # pin a version (or digest), never :latest
ports:
- containerPort: 8443
env:
- name: GOMEMLIMIT # ~90% of the 256Mi limit below (Chapter 76)
value: "230MiB"
resources:
requests: {cpu: "500m", memory: "256Mi"}
limits: {cpu: "2", memory: "256Mi"} # GOMAXPROCS follows the CPU limit (Go 1.25+)
readinessProbe: # "send me traffic?" fails while draining
httpGet: {path: /readyz, port: 8443, scheme: HTTPS}
periodSeconds: 2
failureThreshold: 2
livenessProbe: # "restart me?" never depends on other services
httpGet: {path: /healthz, port: 8443, scheme: HTTPS}
periodSeconds: 10
failureThreshold: 3
lifecycle:
preStop: # give endpoint removal time to propagate
sleep: {seconds: 5} # K8s 1.30+; distroless has no /bin/sleep to exec
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
This manifest was checked against the real Kubernetes API types
(k8s.io/api/apps/v1, strict decoding). Writing it turned up two bugs
worth knowing about:
- The first draft's
preStopran/server -sleep 5s, but distroless images have no/bin/sleepand the server has no such flag, so the hook would fail on every pod termination. Kubernetes 1.30+ has a built-insleepaction for exactly this case. - The first draft took
GOMEMLIMITfromlimits.memoryviaresourceFieldRef, which gives 100% of the limit, not the 90% Chapter 76 recommends.
| Setting | Why | Chapter |
|---|---|---|
readinessProbe on /readyz |
fails while draining, so no new traffic | 60 |
livenessProbe on /healthz |
never depends on other services | 60 |
preStop: sleep: 5 |
lets endpoint removal propagate before SIGTERM | 60, TCP/IP 57 |
terminationGracePeriodSeconds: 30 |
longer than preStop + drain | 74 |
limits.cpu: "2" |
GOMAXPROCS follows automatically (Go 1.25+) | 75 |
GOMEMLIMIT: 230MiB with limits.memory: 256Mi |
no surprise OOM kills | 76 |
runAsNonRoot, readOnlyRootFilesystem, drop: [ALL], seccompProfile |
least privilege | 71 |
| pinned image tag | reproducible rollbacks | 52 |
Exercises
- Deploy the manifest to
kind(Chapter 53), run a load generator against the Service, and do a rolling update. Count errors (target: zero). - Remove
preStopand repeat. How many requests fail during the rollout? - Lower
limits.memoryto 128Mi without changingGOMEMLIMIT. What happens, and what doeskubectl describe podsay? - Add
/debug/pprofon a separate, cluster-internal port and take a CPU profile withkubectl port-forward.
Check yourself
- Name three settings in these files that exist only because of the Go runtime, and the chapter that explains each.
- Why does the
preStophook usesleep:rather thanexec:? - What does
LoadCredentialsolve that file permissions alone can't, for aDynamicUserservice?
Further reading
- Go guide, Chapter 57 (shipping: Docker, systemd, graceful shutdown) and Chapter 67 (Go in containers and Kubernetes).
- Go plan Week 15: production hardening.
- HTTPS guide, Chapter 19, the server being shipped.