Files

331 lines
12 KiB
Markdown
Raw Permalink Normal View History

2026-07-16 10:13:46 +03:30
# Lesson 10 — Docker, docker-compose & Course Wrap-up
> **New Go concepts in this lesson:** none — this lesson is entirely about
> Docker/containerization, which is language-agnostic. If you've followed
> the Go Basics lessons and Lessons 19, you already know everything Go
> needs for this course.
This is the last lesson — we'll containerize the whole app (API + MySQL +
Redis) so it runs with one command, then do a full review of everything
you've built.
## Part A — Docker basics playground
A minimal example first, so the concepts aren't tangled up with our full
project.
```bash
mkdir ~/go-playground/docker-demo && cd ~/go-playground/docker-demo
go mod init docker-demo
```
**`main.go`**
```go
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello from inside docker")
})
http.ListenAndServe(":8080", nil)
}
```
**`Dockerfile`**
```dockerfile
# ---- Stage 1: build ----
FROM golang:1.26 AS builder
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 produces a statically-linked binary - no C libraries
# needed, which lets us run it on a tiny base image in stage 2.
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/server .
# ---- Stage 2: run ----
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /app/bin/server .
EXPOSE 8080
CMD ["./server"]
```
Build and run it:
```bash
docker build -t docker-demo .
docker run -p 8080:8080 docker-demo
curl http://localhost:8080
```
Line by line:
- **Multi-stage build** — two `FROM` lines means two separate images are
involved. The first (`builder`) has the full Go toolchain (~800MB+) and
compiles your binary. The second (`alpine`) is a tiny (~7MB) Linux
image that only receives the *finished binary*, not the compiler,
source code, or build tools. Your final shipped image ends up small
with a much smaller attack surface — no compiler sitting around in
production.
- `FROM golang:1.26 AS builder``AS builder` names this stage so we can
reference it later with `--from=builder`.
- `WORKDIR /app` — sets the working directory inside the image for all
subsequent commands, same idea as `cd`.
- `COPY go.mod ./` then `RUN go mod download` **before** `COPY . .` — this
ordering is deliberate and matters for build speed. Docker caches each
layer; if `go.mod` hasn't changed, Docker reuses the cached
`go mod download` layer instead of re-downloading every dependency on
every code change. If we copied all the source first, any code edit
would invalidate the cache and force a full re-download every build.
- `CGO_ENABLED=0 GOOS=linux go build -o /app/bin/server .`
`CGO_ENABLED=0` disables cgo (Go code calling C code), forcing a fully
static binary with no dynamic library dependencies — this is what lets
it run on the minimal `alpine` image without missing shared libraries.
`GOOS=linux` ensures we cross-compile for Linux even if you're building
this on macOS/Windows.
- `COPY --from=builder /app/bin/server .` — the actual multi-stage magic:
pull just one file out of the *first* image into the *second*,
discarding everything else from the builder stage.
- `EXPOSE 8080` — documentation for humans/tools about which port the
container listens on; doesn't actually publish the port by itself
(that's `-p` on `docker run`).
- `CMD ["./server"]` — the command that runs when the container starts.
Now let's connect it to something else via **docker-compose**, so you see
multi-container orchestration before we do it for real:
**`docker-compose.yml`**
```yaml
services:
app:
build: .
ports:
- "8080:8080"
depends_on:
- redis
environment:
REDIS_ADDR: redis:6379
redis:
image: redis:8
ports:
- "6379:6379"
```
```bash
docker compose up --build
```
- `build: .` — build the image from the `Dockerfile` in the current
directory, instead of pulling a pre-built image.
- `depends_on: [redis]` — tells compose to start `redis` before `app`.
Note: this only controls *startup order*, not "wait until Redis is
actually ready to accept connections" — a fast-starting app can still
race ahead of a slow-starting dependency.
- `environment: REDIS_ADDR: redis:6379` — the key insight for compose
networking: **service names become hostnames**. Inside the compose
network, the `app` container can reach Redis at the hostname `redis`
(not `127.0.0.1`!), because compose sets up internal DNS that resolves
service names to the right container's IP. This is exactly why our app
reads `REDIS_ADDR` from config instead of hardcoding
`127.0.0.1:6379` — it needs to be different in Docker vs. local dev.
## Part B — containerize the full project
**`Dockerfile`** at the project root (same multi-stage pattern, adjusted
for our module path):
```dockerfile
FROM golang:1.26 AS builder
WORKDIR /app
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/server ./cmd/api
FROM alpine:3.20
# ca-certificates is needed for outbound HTTPS calls - our Google OAuth
# token exchange and userinfo requests both need this to verify certs.
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/bin/server .
EXPOSE 8080
CMD ["./server"]
```
- `COPY go.mod go.sum* ./` — the `*` after `go.sum` means "copy it if it
exists, don't fail if it doesn't" (useful before you've run
`go mod tidy` the very first time).
- `./cmd/api` in the build command — points at our actual entrypoint
package from Lesson 1's project layout, not the project root.
- `RUN apk add --no-cache ca-certificates` — Alpine's minimal base
doesn't include root CA certificates by default. Without this, any
outbound HTTPS call our app makes (Google's token/userinfo endpoints)
would fail with a certificate verification error.
**`docker-compose.yml`** — the full stack: our app, MySQL, and Redis:
```yaml
services:
app:
build: .
ports:
- "8080:8080"
depends_on:
- mysql
- redis
environment:
PORT: 8080
ENV: development
DB_HOST: mysql
DB_PORT: 3306
DB_USER: root
DB_PASSWORD: devpass
DB_NAME: go_simple_api
REDIS_ADDR: redis:6379
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
GOOGLE_REDIRECT_URL: http://localhost:8080/auth/google/callback
ALLOWED_ORIGINS: http://localhost:3000
mysql:
image: mysql:9
environment:
MYSQL_ROOT_PASSWORD: devpass
MYSQL_DATABASE: go_simple_api
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:8
ports:
- "6379:6379"
volumes:
mysql_data:
```
- `DB_HOST: mysql` / `REDIS_ADDR: redis:6379` — using compose service
names as hostnames, exactly as explained in Part A. This is *why* we
built `config.go` to read these from env vars back in Lesson 3/6
instead of hardcoding `127.0.0.1` — the same code now works both
locally and inside compose, just by changing environment variables.
- `${GOOGLE_CLIENT_ID}` / `${GOOGLE_CLIENT_SECRET}` — compose substitutes
these from your shell environment or a `.env` file sitting next to
`docker-compose.yml` (compose auto-loads a file literally named `.env`
in the same directory).
- `volumes: mysql_data:/var/lib/mysql` — without this, MySQL's data
directory lives *inside* the container's writable layer, destroyed when
the container is removed (`docker compose down`). A **named volume**
persists that data on the host, independent of the container's
lifecycle.
- About the `depends_on` startup-order caveat: MySQL can take a few
seconds to become ready even after its container "starts." Our
`database.NewMySQL` already calls `db.PingContext` with a timeout and
returns an error if it fails — so if you hit a race on
`docker compose up`, the cleanest fix is either restarting just the
`app` service, or adding a small retry loop around the ping in
`NewMySQL`. Treat that as an optional improvement rather than something
required for this course.
**Try the whole stack:**
```bash
docker compose up --build
```
```bash
curl -X POST http://localhost:8080/register \
-H "Content-Type: application/json" \
-d '{"email":"hamid@example.com","password":"secret123"}'
curl -c cookies.txt -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"email":"hamid@example.com","password":"secret123"}'
curl -b cookies.txt http://localhost:8080/me
```
Stop everything cleanly:
```bash
docker compose down # stops and removes containers, keeps the volume
docker compose down -v # also wipes the mysql_data volume
```
---
## Course review — what you actually built
| Concept | Where you learned it | Where it lives now |
|---|---|---|
| chi routing, graceful shutdown | Lesson 1 | `router/`, `cmd/api/main.go` |
| Structured JSON logging (`slog`) | Lesson 2 | `logging/`, `middleware/request_logger.go` |
| MySQL connection pooling | Lesson 3 | `database/mysql.go` |
| Repository pattern, pointers | Lesson 4 | `models/user_repository.go` |
| bcrypt, JSON request handling | Lesson 5 | `handlers/auth.go` |
| Server-side sessions (scs + Redis) | Lesson 6 | `session/`, login/logout/me |
| OAuth2 (Google login) | Lesson 7 | `oauth/`, `handlers/oauth_google.go` |
| Context values, auth middleware | Lesson 8 | `middleware/require_auth.go` |
| Rate limiting, CORS, cookie security | Lesson 9 | `router.go`, `session.go`, `config.go` |
| Docker & docker-compose | Lesson 10 | `Dockerfile`, `docker-compose.yml` |
## Core Go ideas that came up repeatedly — make sure these are solid
- **Pointers (`*`/`&`)** — sharing state (`*sql.DB`, `*scs.SessionManager`)
vs. copying values; writing into caller variables (`rows.Scan`,
`res.LastInsertId``b.ID`).
- **Interfaces implicitly satisfied** — `*chi.Mux` and our custom handlers
all satisfy `http.Handler` just by having the right method, no explicit
"implements" keyword.
- **Closures and the three-layer middleware pattern** —
`func(deps) func(http.Handler) http.Handler`, seen in `RequestLogger`
and `RequireAuth`.
- **`context.Context`** — carrying request-scoped values (request ID,
current user) and deadlines (timeouts) through a call chain without
threading extra parameters everywhere.
- **Error wrapping (`%w`) and sentinel errors** — `ErrUserNotFound`,
`errors.Is`, giving callers a stable way to distinguish error *kinds*
without string-matching messages.
- **Dependency injection via structs** —
`AuthHandler{userRepo, sessions, logger}` instead of global variables,
making every handler's dependencies explicit and testable.
## Reasonable next steps, if you want to keep going
- **Testing** — table-driven tests, `httptest` (you touched this in
Lesson 5's Part A) for handlers, and mocking the repository via an
interface instead of a concrete `*sql.DB`-backed struct.
- **A real migration tool** (e.g. `golang-migrate`) instead of
`CREATE TABLE IF NOT EXISTS` on every boot — versioned, reversible
schema changes.
- **CSRF tokens**, if you ever add a same-origin HTML form frontend, as
flagged in Lesson 9.
- **Refresh tokens / remember-me**, since right now a session simply
expires after 24 hours with no renewal path.
- **Structured error responses with error codes**, so a frontend can
branch on `"error_code": "invalid_credentials"` instead of parsing
message strings.
- **Observability**: running Grafana Alloy to tail this container's JSON
stdout logs and ship them to Loki is a natural next step, since Lesson
2 already gives you the right log shape for it.
That's the course. You went from an empty folder to a real, containerized
Go API with password auth, Google OAuth, Redis-backed sessions, rate
limiting, and structured logging — and along the way, picked up the core
Go idioms (pointers, interfaces, closures, contexts, error handling) that
show up in essentially every real-world Go codebase.