57.7 Project 7: Go with MySQL and MongoDB in a tiny distroless image
Why: the problem
Suppose we are at Ola and the platform team writes a rides API in Go because it is fast and uses little memory. They want the container to be as small and as safe as possible, because hundreds of copies run on every server. A normal golang image is over 800 MB and has a shell, a compiler and many tools an attacker could use. Go can build one static binary that needs nothing else, not even a C library. So we use a multi-stage build: compile in golang:1.23, then copy only the binary into gcr.io/distroless/static, which has no shell and no package manager. The final image is about 12 MB. We run the same image against MySQL and against MongoDB, exactly like the Node and Python projects, and we learn how to debug a container that has no shell.
What: every term in plain English
- Go module — A Go project with a go.mod file that lists its dependencies.
- Static binary — One file with everything inside. CGO_ENABLED=0 makes Go build it without the C library.
- Multi-stage build — A Dockerfile with two or more FROM lines. Only the last stage becomes the image.
- Distroless — Google base images with only the app runtime. No shell, no apt, no busybox.
- nonroot — A user (id 65532) that distroless images already have.
- go-sql-driver/mysql — The standard MySQL driver for Go's database/sql package.
- mongo-go-driver — The official MongoDB driver for Go.
- Debug container — docker debug or a sidecar busybox container that shares the network or PID of the app.
How: step by step
- Write main.go with a small store interface. One file for MySQL, one for Mongo.
- Write a two-stage Dockerfile. golang:1.23 builder, distroless runtime.
- Build and compare sizes. 800 MB builder vs 12 MB image.
- Run with MySQL. compose.mysql.yaml
- Run with MongoDB. compose.mongo.yaml
- Debug without a shell. busybox sidecar sharing the network.
- Clean up. down -v and remove images.
Architecture
Folder layout
go-rides/
main.go
store_mysql.go
store_mongo.go
go.mod
go.sum
sql/init.sql
Dockerfile
.dockerignore
.env
compose.mysql.yaml
compose.mongo.yaml
Example 1: The Go code with a store interface
Goal. The Go code with a store interface.
go.mod
module ola/rides
go 1.23
require (
github.com/go-sql-driver/mysql v1.8.1
go.mongodb.org/mongo-driver v1.17.1
)
main.go
package main
import (
"encoding/json"
"log"
"net/http"
"os"
)
type Ride struct {
ID string `json:"id"`
From string `json:"from"`
To string `json:"to"`
}
type Store interface {
Ping() error
List() ([]Ride, error)
Add(r Ride) (Ride, error)
}
func main() {
var s Store
var err error
if os.Getenv("DB_KIND") == "mongo" {
s, err = NewMongo(os.Getenv("MONGO_URL"))
} else {
s, err = NewMySQL(os.Getenv("MYSQL_DSN"))
}
if err != nil {
log.Fatal(err)
}
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
if err := s.Ping(); err != nil {
http.Error(w, err.Error(), 503)
return
}
w.Write([]byte("ok"))
})
http.HandleFunc("/rides", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var in Ride
if json.NewDecoder(r.Body).Decode(&in) != nil || in.From == "" {
http.Error(w, "from and to are required", 400)
return
}
out, err := s.Add(in)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(201)
json.NewEncoder(w).Encode(out)
return
}
list, err := s.List()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
json.NewEncoder(w).Encode(list)
})
log.Println("rides api on :8080 db=" + os.Getenv("DB_KIND"))
log.Fatal(http.ListenAndServe(":8080", nil))
}
store_mysql.go
package main
import (
"database/sql"
"fmt"
"time"
_ "github.com/go-sql-driver/mysql"
)
type MySQL struct{ db *sql.DB }
func NewMySQL(dsn string) (*MySQL, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
for i := 0; i != 15; i++ {
if db.Ping() == nil {
break
}
time.Sleep(2 * time.Second)
}
return &MySQL{db}, db.Ping()
}
func (m *MySQL) Ping() error { return m.db.Ping() }
func (m *MySQL) List() ([]Ride, error) {
rows, err := m.db.Query("SELECT id, src, dst FROM rides ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
out := []Ride{}
for rows.Next() {
var id int
var r Ride
rows.Scan(&id, &r.From, &r.To)
r.ID = fmt.Sprint(id)
out = append(out, r)
}
return out, nil
}
func (m *MySQL) Add(r Ride) (Ride, error) {
res, err := m.db.Exec("INSERT INTO rides (src, dst) VALUES (?, ?)", r.From, r.To)
if err != nil {
return r, err
}
id, _ := res.LastInsertId()
r.ID = fmt.Sprint(id)
return r, nil
}
store_mongo.go
package main
import (
"context"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
type Mongo struct{ c *mongo.Collection; cl *mongo.Client }
func NewMongo(url string) (*Mongo, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cl, err := mongo.Connect(ctx, options.Client().ApplyURI(url))
if err != nil {
return nil, err
}
return &Mongo{cl.Database("ola").Collection("rides"), cl}, cl.Ping(ctx, nil)
}
func (m *Mongo) Ping() error { return m.cl.Ping(context.Background(), nil) }
func (m *Mongo) List() ([]Ride, error) {
cur, err := m.c.Find(context.Background(), bson.M{})
if err != nil {
return nil, err
}
out := []Ride{}
err = cur.All(context.Background(), &out)
return out, err
}
func (m *Mongo) Add(r Ride) (Ride, error) {
_, err := m.c.InsertOne(context.Background(), bson.M{"from": r.From, "to": r.To})
return r, err
}
What each line does
type Store interface— Both database files give the same three functions, so main.go does not care which one is used.DB_KIND— Chooses MySQL or Mongo at start-up.for i := 0; i != 15— Retries the MySQL ping for 30 seconds while the database starts.? placeholders— Safe parameters, no SQL injection.context.WithTimeout— Stops the Mongo connect after 30 seconds instead of hanging.json tags— Turn the Go struct fields into lower-case JSON keys.
Green output
$ docker run --rm -v "$PWD":/src -w /src golang:1.23 go mod tidy
go: downloading github.com/go-sql-driver/mysql v1.8.1
go: downloading go.mongodb.org/mongo-driver v1.17.1
$ ls
Dockerfile go.mod go.sum main.go store_mongo.go store_mysql.go
Expected result. The module and go.sum are ready, created by a throwaway Go container, so Go is not needed on the laptop.
Example 2: A two-stage Dockerfile to a 12 MB distroless image
Goal. A two-stage Dockerfile to a 12 MB distroless image.
Dockerfile
# Stage 1: build
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/rides .
# Stage 2: run
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/rides /rides
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/rides"]
.dockerignore
.git
.env
*.md
What each line does
AS build— Names the first stage so the second can copy from it.COPY go.mod go.sum first— Dependencies are cached in a layer. Changing main.go does not download them again.CGO_ENABLED=0— No C library, so the binary runs on an image with nothing in it.-ldflags="-s -w"— Removes debug symbols and makes the binary smaller.distroless/static:nonroot— Has only CA certificates, time zones and a nonroot user.ENTRYPOINT ["/rides"]— Exec form. There is no shell, so shell form would fail.
Green output
$ docker build -t go-rides:1.0 .
=> [build 6/6] RUN CGO_ENABLED=0 GOOS=linux go build -trimpath ...
=> [stage-1 2/2] COPY --from=build /out/rides /rides
=> naming to docker.io/library/go-rides:1.0
$ docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -E 'go-rides|golang'
go-rides:1.0 12.4MB
golang:1.23 823MB
$ docker run --rm --entrypoint sh go-rides:1.0
docker: Error response from daemon: ... exec: "sh": executable file not found in $PATH
Expected result. The image is 12 MB and has no shell at all, which is what we want for security.
Example 3: Run with MySQL, then with MongoDB
Goal. Run with MySQL, then with MongoDB.
sql/init.sql
CREATE TABLE rides (
id INT AUTO_INCREMENT PRIMARY KEY,
src VARCHAR(100) NOT NULL,
dst VARCHAR(100) NOT NULL
);
INSERT INTO rides (src, dst) VALUES ('Koregaon Park', 'Pune Airport');
compose.mysql.yaml
services:
api:
build: .
image: go-rides:1.0
ports: ["8080:8080"]
environment:
DB_KIND: mysql
MYSQL_DSN: rides:${DB_PASSWORD}@tcp(db:3306)/rides?parseTime=true
depends_on:
db: { condition: service_healthy }
read_only: true
cap_drop: [ALL]
db:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: ${ROOT_PASSWORD}
MYSQL_DATABASE: rides
MYSQL_USER: rides
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- db-data:/var/lib/mysql
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${ROOT_PASSWORD}"]
interval: 5s
retries: 20
volumes:
db-data:
compose.mongo.yaml
services:
api:
image: go-rides:1.0
ports: ["8080:8080"]
environment:
DB_KIND: mongo
MONGO_URL: mongodb://root:${MONGO_PASSWORD}@mongo:27017/?authSource=admin
depends_on:
mongo: { condition: service_healthy }
read_only: true
cap_drop: [ALL]
mongo:
image: mongo:7
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
volumes: [mongo-data:/data/db]
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
retries: 20
volumes:
mongo-data:
.env
ROOT_PASSWORD=change-me-root
DB_PASSWORD=change-me-app
MONGO_PASSWORD=change-me-mongo
What each line does
MYSQL_DSN— Go MySQL connection string: user:password@tcp(host:port)/database.read_only: true— The container root filesystem cannot be written. The Go app writes nothing to disk.cap_drop: [ALL]— Removes all Linux capabilities. Port 8080 needs none.No healthcheck on api— Distroless has no wget or curl. We check health from outside, or use a debug sidecar.image: go-rides:1.0 only in mongo file— Reuses the image built by the MySQL file, no second build.
Green output
$ docker compose -f compose.mysql.yaml -p rides-mysql up -d --wait
Container rides-mysql-db-1 Healthy
Container rides-mysql-api-1 Started
$ curl -s localhost:8080/rides
[{"id":"1","from":"Koregaon Park","to":"Pune Airport"}]
$ docker compose -p rides-mysql down
$ docker compose -f compose.mongo.yaml -p rides-mongo up -d --wait
$ curl -s -X POST localhost:8080/rides -d '{"from":"Baner","to":"Hinjewadi"}'
{"id":"","from":"Baner","to":"Hinjewadi"}
$ docker stats --no-stream --format '{{.Name}} {{.MemUsage}}' rides-mongo-api-1
rides-mongo-api-1 6.1MiB / 7.6GiB
Expected result. One 12 MB image serves both databases and uses only about 6 MB of memory.
Example 4: Debug a container that has no shell
Goal. Debug a container that has no shell.
debug commands
# 1. Logs always work
docker compose -p rides-mongo logs api
# 2. A busybox sidecar on the same network namespace
docker run --rm -it --network container:rides-mongo-api-1 busybox:1.36 \
wget -qO- http://localhost:8080/healthz
# 3. Docker Desktop / Docker 4.33+ : docker debug adds a toolbox shell
docker debug rides-mongo-api-1
# 4. See the files inside the image without running it
docker create --name peek go-rides:1.0
docker export peek | tar -tv | head
docker rm peek
What each line does
--network container:NAME— The busybox container shares the network of the app, so localhost is the app.busybox wget— Gives us a tiny HTTP client to call the health URL from inside.docker debug— Attaches a temporary shell with tools to any container, even distroless (Docker Desktop Pro feature).docker export | tar -tv— Lists the files of a container filesystem without running it.
Green output
$ docker run --rm --network container:rides-mongo-api-1 busybox:1.36 wget -qO- http://localhost:8080/healthz
ok
$ docker create --name peek go-rides:1.0 && docker export peek | tar -tv | grep rides
-rwxr-xr-x 0/0 8126464 2026-10-25 10:40 rides
Expected result. We can test and inspect the app with no shell inside the image.
Every command, step by step (plain docker and Compose)
-
Make folders
mkdir -p go-rides/sql && cd go-ridesWhat it does: Creates the folders. Add every file from the examples.
(no output) -
Create go.sum
docker run --rm -v "$PWD":/src -w /src golang:1.23 go mod tidyWhat it does: Downloads modules and writes go.sum using a throwaway container.
go: downloading github.com/go-sql-driver/mysql v1.8.1 -
Build
docker build -t go-rides:1.0 .What it does: Runs both stages. Only the second becomes the image.
naming to docker.io/library/go-rides:1.0 -
Compare sizes
docker image ls go-ridesWhat it does: Shows the tiny final image.
go-rides 1.0 12.4MB -
Check the user
docker image inspect go-rides:1.0 --format '{{.Config.User}}'What it does: The image runs as nonroot.
nonroot:nonroot -
Start MySQL stack
docker compose -f compose.mysql.yaml -p rides-mysql up -d --waitWhat it does: MySQL first, then the API.
Container rides-mysql-api-1 Started -
Test
curl -s localhost:8080/ridesWhat it does: Reads the seed ride.
[{"id":"1","from":"Koregaon Park","to":"Pune Airport"}] -
Add a ride
curl -s -X POST localhost:8080/rides -d '{"from":"Wakad","to":"Kharadi"}'What it does: Writes a row to MySQL.
{"id":"2","from":"Wakad","to":"Kharadi"} -
Switch to Mongo
docker compose -p rides-mysql down docker compose -f compose.mongo.yaml -p rides-mongo up -d --waitWhat it does: Same image, other database.
Container rides-mongo-api-1 Started -
Health from a sidecar
docker run --rm --network container:rides-mongo-api-1 busybox:1.36 wget -qO- http://localhost:8080/healthzWhat it does: Checks health from inside with no shell in the app.
ok -
Clean up
docker compose -f compose.mongo.yaml -p rides-mongo down -v docker compose -f compose.mysql.yaml -p rides-mysql down -v docker image rm go-rides:1.0What it does: Removes stacks, volumes and the image.
Untagged: go-rides:1.0
When it is red 1
exec /rides: no such file or directory
Cause. The binary was built with CGO on, so it needs the C library that distroless/static does not have.
Fix, step by step
- Build with CGO_ENABLED=0.
- Or use gcr.io/distroless/base-debian12, which has glibc.
- Rebuild and run again.
When it is red 2
exec: "sh": executable file not found in $PATH
Cause. CMD or ENTRYPOINT uses shell form, or you tried docker exec sh. Distroless has no shell.
Fix, step by step
- Use exec form: ENTRYPOINT ["/rides"].
- Debug with a busybox sidecar or docker debug instead of exec sh.
When it is red 3
dial tcp 172.21.0.2:3306: connect: connection refused
Cause. MySQL was not ready yet.
Fix, step by step
- Keep the retry loop in NewMySQL.
- Use depends_on with condition: service_healthy.
When it is red 4
x509: certificate signed by unknown authority
Cause. The app calls an HTTPS service, but the image has no CA certificates (scratch image).
Fix, step by step
- Use distroless/static, which includes CA certificates.
- Or copy /etc/ssl/certs/ca-certificates.crt from the build stage into scratch.
When it is red 5
go: go.mod requires go >= 1.23 (running go 1.21)
Cause. The builder image is older than the go line in go.mod.
Fix, step by step
- Use FROM golang:1.23 or newer.
- Or lower the go line in go.mod.
Practice tasks
Easy Task 1: Build
Why: Start.
What to do: Build go-rides:1.0 and print its size.
- Use a VM or laptop with Docker and Compose.
- Create the go-rides folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker build -t go-rides:1.0 .
docker image ls go-ridesEasy Task 2: MySQL
Why: Basics.
What to do: Start the MySQL stack and add a ride.
- Use a VM or laptop with Docker and Compose.
- Create the go-rides folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.mysql.yaml -p rides-mysql up -d --wait
curl -s -X POST localhost:8080/rides -d '{"from":"A","to":"B"}'Easy Task 3: Mongo
Why: Same image.
What to do: Run the Mongo stack.
- Use a VM or laptop with Docker and Compose.
- Create the go-rides folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.mongo.yaml -p rides-mongo up -d --wait
curl -s localhost:8080/ridesMedium Task 4: No shell
Why: Security.
What to do: Prove there is no shell in the image.
- Use a VM or laptop with Docker and Compose.
- Create the go-rides folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker run --rm --entrypoint sh go-rides:1.0Medium Task 5: Sidecar
Why: Debug.
What to do: Call /healthz from a busybox sidecar.
- Use a VM or laptop with Docker and Compose.
- Create the go-rides folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker run --rm --network container:rides-mongo-api-1 busybox:1.36 wget -qO- http://localhost:8080/healthzMedium Task 6: Memory
Why: Efficiency.
What to do: Read the memory use of the API.
- Use a VM or laptop with Docker and Compose.
- Create the go-rides folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker stats --no-stream rides-mongo-api-1Medium Task 7: Scratch
Why: Even smaller.
What to do: Change the runtime stage to FROM scratch and copy CA certs.
- Use a VM or laptop with Docker and Compose.
- Create the go-rides folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/rides /rides
USER 65532:65532
ENTRYPOINT ["/rides"]Hard Task 8: Version flag
Why: Build args.
What to do: Bake a version string with ldflags.
- Use a VM or laptop with Docker and Compose.
- Create the go-rides folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o /out/rides .
docker build --build-arg VERSION=1.1 -t go-rides:1.1 .Hard Task 9: Scan
Why: Security.
What to do: Scan both images and compare CVE counts.
- Use a VM or laptop with Docker and Compose.
- Create the go-rides folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker scout cves go-rides:1.0
docker scout cves golang:1.23 | tail -3Easy Task 10: Clean
Why: Habit.
What to do: Remove everything.
- Use a VM or laptop with Docker and Compose.
- Create the go-rides folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.mongo.yaml -p rides-mongo down -v
docker compose -f compose.mysql.yaml -p rides-mysql down -v
docker image rm go-rides:1.0Homework: try at home
Add a /version endpoint whose value is set at build time with --build-arg VERSION=1.1 and -ldflags "-X main.version=...", rebuild, and show the new value without changing the code.
Recap
- Go builds one static binary with CGO_ENABLED=0.
- Multi-stage builds keep the 800 MB compiler out of the final image.
- Distroless images have no shell, so use exec form and sidecars to debug.
- One interface, two store files, one image for MySQL and MongoDB.
- read_only and cap_drop make the container even safer.
- Retry the database in code as well as in Compose.
FAQ
Q. Scratch or distroless?
Distroless/static is scratch plus CA certificates, time zones and a nonroot user. Use it unless you need the very last kilobytes.
Q. Can I use Alpine for Go?
Yes, but you get a shell and apk you do not need. Distroless is smaller and safer.
Q. How do I add a health check without curl?
Add a tiny flag to the Go binary, like /rides -health, and use it in HEALTHCHECK with exec form.
Q. Why does the Mongo id come back empty?
Our Add function does not read InsertedID. A practice task fixes it.
Q. Does the image work on ARM Macs and Graviton?
Build with docker buildx build --platform linux/amd64,linux/arm64 for both.
Q. Is 12 MB normal?
Yes. Most Go API images are 10 to 25 MB with distroless.
Interview questions
-
How do you make the smallest possible image for a Go service?
Model answer: Multi-stage build, CGO_ENABLED=0, -ldflags "-s -w", and a distroless/static or scratch runtime stage.
-
How do you debug a distroless container in production?
Model answer: Logs first, then an ephemeral debug container sharing its network or PID namespace (docker debug, kubectl debug).
-
Why is a shell-less image more secure?
Model answer: An attacker who gets in cannot run shell commands or install tools; the attack surface is much smaller.
-
What is the difference between exec form and shell form?
Model answer: Exec form runs the binary directly as PID 1 and gets signals; shell form wraps it in /bin/sh -c, which needs a shell.
-
How do you handle CA certificates in a scratch image?
Model answer: Copy ca-certificates.crt from the build stage, or use distroless/static which includes them.
Quick quiz
Q1. Why CGO_ENABLED=0?
It builds a static binary that does not need the C library, so it runs on distroless/static or scratch.
Q2. What is inside a distroless image?
Only the app runtime pieces: CA certificates, time zones, a nonroot user. No shell or package manager.
Q3. How do you check health of a distroless container?
From outside, or with a sidecar like busybox sharing its network.
Q4. Why copy go.mod and go.sum before the code?
So the module download layer is cached when only code changes.
Q5. What does read_only: true do?
Makes the container root filesystem read-only.
Samajla ka?
Revision card
Interview card
Comparison card