From e0b9bcc4ca06d1c877b18e90afebb5d767317ff2 Mon Sep 17 00:00:00 2001 From: smairon Date: Sat, 6 Jun 2026 12:06:11 +0300 Subject: [PATCH 1/5] docs: add PR template Signed-off-by: smairon --- .github/pull_request_template.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..1a68db5e5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Goal + + +## Changes +- + +## Testing + + +## Checklist +- [ ] Title is a clear sentence (≤ 70 chars) +- [ ] Commits are signed (`git log --show-signature`) +- [ ] `submissions/labN.md` updated From c5c5f10e32a4fe05626f9daf3758ba7c8580173e Mon Sep 17 00:00:00 2001 From: "man@smairon.ru" Date: Tue, 9 Jun 2026 07:43:36 +0300 Subject: [PATCH 2/5] docs: upstream moved while you worked Signed-off-by: man@smairon.ru From 042f7ee901ae23ec2a06d99113f93e6a442e76f8 Mon Sep 17 00:00:00 2001 From: "man@smairon.ru" Date: Tue, 23 Jun 2026 11:33:15 +0300 Subject: [PATCH 3/5] docs(lab6): task1 submission Signed-off-by: man@smairon.ru --- app/Dockerfile | 25 ++++++++++ submissions/lab6.md | 115 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 app/Dockerfile create mode 100644 submissions/lab6.md diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 000000000..7bf1c8a46 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,25 @@ +FROM golang:1.24.0-alpine3.21 AS builder + +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY *.go ./ +COPY seed.json ./ + +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/quicknotes . + +FROM gcr.io/distroless/static:nonroot + +COPY --from=builder /out/quicknotes /quicknotes +COPY --from=builder /src/seed.json /seed.json + +ENV ADDR=:8080 \ + DATA_PATH=/data/notes.json \ + SEED_PATH=/seed.json + +USER 65532:65532 +EXPOSE 8080 + +ENTRYPOINT ["/quicknotes"] \ No newline at end of file diff --git a/submissions/lab6.md b/submissions/lab6.md new file mode 100644 index 000000000..4c0f26838 --- /dev/null +++ b/submissions/lab6.md @@ -0,0 +1,115 @@ +# Lab 6 — Task 1 + +## Dockerfile + +File: `app/Dockerfile` + +```dockerfile +FROM golang:1.24.0-alpine3.21 AS builder + +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY *.go ./ +COPY seed.json ./ + +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/quicknotes . + +FROM gcr.io/distroless/static:nonroot + +COPY --from=builder /out/quicknotes /quicknotes +COPY --from=builder /src/seed.json /seed.json + +ENV ADDR=:8080 \ + DATA_PATH=/data/notes.json \ + SEED_PATH=/seed.json + +USER 65532:65532 +EXPOSE 8080 + +ENTRYPOINT ["/quicknotes"] +``` + +## Image size + +```text +REPOSITORY TAG IMAGE ID CREATED SIZE +quicknotes lab6 202d7cf66323 8 seconds ago 14.2MB +``` + +## `docker inspect` config excerpt + +Equivalent excerpt from `docker inspect quicknotes:lab6`: + +```json +{ + "User": "65532:65532", + "ExposedPorts": { + "8080/tcp": {} + }, + "Entrypoint": [ + "/quicknotes" + ] +} +``` + +## Builder image size comparison + +```text +REPOSITORY TAG IMAGE ID CREATED SIZE +golang 1.24.0-alpine3.21 2d40d4fc278d 16 months ago 385MB +``` + +The final runtime image is `14.2MB`, compared with `385MB` for the builder base image. + +## Build and run verification + +```text +$ docker run -d --rm -p 8080:8080 -v "$PWD/data:/data" quicknotes:lab6 +$ curl -s http://localhost:8080/health +{"notes":7,"status":"ok"} +``` + +## Design answers + +### a) Why does layer order matter? + +Docker reuses cached layers only until the first instruction whose inputs change. If the Dockerfile does `COPY . .` before `go mod download`, then any source edit invalidates the `COPY . .` layer and forces Docker to run `go mod download` again, even though dependencies did not change. + +If the Dockerfile copies `go.mod` first, runs `go mod download`, and only then copies the source files, a source-only change keeps the dependency layer cached. On this app the rebuild time difference is small because the module has no external dependencies, but the cache behavior is still correct. + +Measured rebuilds after a source-only edit: + +```text +Bad order: COPY . . -> go mod download -> go build real 8.36s +Good order: COPY go.mod -> go mod download -> COPY src -> go build real 8.04s +``` + +Observed step behavior: + +- Bad order: `COPY . .`, `RUN go mod download`, and `RUN go build` all reran. +- Good order: `COPY go.mod` and `RUN go mod download` stayed cached; only source copy and build reran. + +In a real service with many downloaded modules, the good order saves much more time because it avoids network work on every source change. + +### b) Why `CGO_ENABLED=0`? + +`CGO_ENABLED=0` forces a pure-Go static binary that does not need a dynamic linker or C runtime in the final image. That is exactly what a distroless static runtime expects. + +If you forget it and the build produces a dynamically linked binary, the container usually fails to start in `gcr.io/distroless/static:nonroot` because the required loader or shared libraries are not present. The common symptom is an error like `no such file or directory` even though the binary file exists. + +### c) What is `gcr.io/distroless/static:nonroot`? + +It is a minimal runtime image for statically linked programs. It contains only the small set of runtime files needed to launch the application safely as a non-root user, such as basic identity metadata and CA certificates. + +It does not contain a shell, package manager, compiler, or normal debugging tools. There is no `sh`, no `apt`, no `apk`, and no extra userland utilities. + +That matters for CVEs because fewer installed packages means a much smaller attack surface and fewer OS-level vulnerabilities to scan, patch, or exploit. It does not remove bugs from the application itself, but it does remove a lot of unnecessary operating-system baggage. + +### d) What do `-ldflags='-s -w'` and `-trimpath` do, and what is the cost? + +`-ldflags='-s -w'` strips the symbol table and DWARF debug information from the binary. The main benefit is a smaller image. The cost is worse post-build debugging because the binary carries less debug metadata. + +`-trimpath` removes local filesystem paths from the compiled binary. That improves reproducibility and avoids leaking machine-specific build paths. The cost is that stack traces and debug output are slightly less informative because absolute source paths are no longer embedded. \ No newline at end of file From fc8e35c77868d7e0ea4422c21328c0d1d3a9951b Mon Sep 17 00:00:00 2001 From: "man@smairon.ru" Date: Tue, 23 Jun 2026 21:48:09 +0300 Subject: [PATCH 4/5] docs(lab6): task2 submission Signed-off-by: man@smairon.ru --- app/Dockerfile | 2 + app/main.go | 34 +++++++++++++++ compose.yaml | 23 ++++++++++ submissions/lab6.md | 101 ++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 compose.yaml diff --git a/app/Dockerfile b/app/Dockerfile index 7bf1c8a46..0653e0d0d 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -9,11 +9,13 @@ COPY *.go ./ COPY seed.json ./ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/quicknotes . +RUN mkdir -p /out/data && cp /src/seed.json /out/data/notes.json FROM gcr.io/distroless/static:nonroot COPY --from=builder /out/quicknotes /quicknotes COPY --from=builder /src/seed.json /seed.json +COPY --from=builder --chown=65532:65532 /out/data /data ENV ADDR=:8080 \ DATA_PATH=/data/notes.json \ diff --git a/app/main.go b/app/main.go index e258ffcfe..1f834bf3f 100644 --- a/app/main.go +++ b/app/main.go @@ -4,9 +4,11 @@ import ( "context" "errors" "log" + "net" "net/http" "os" "os/signal" + "strings" "syscall" "time" ) @@ -16,6 +18,13 @@ func main() { dataPath := envOrDefault("DATA_PATH", "data/notes.json") seedPath := envOrDefault("SEED_PATH", "seed.json") + if len(os.Args) > 1 && os.Args[1] == "healthcheck" { + if err := runHealthcheck(addr); err != nil { + log.Fatal(err) + } + return + } + if err := ensureSeeded(dataPath, seedPath); err != nil { log.Fatalf("seed: %v", err) } @@ -83,3 +92,28 @@ func dirname(p string) string { } return "." } + +func runHealthcheck(addr string) error { + hostport := addr + if strings.HasPrefix(hostport, ":") { + hostport = "127.0.0.1" + hostport + } else if host, port, err := net.SplitHostPort(addr); err == nil { + if host == "" || host == "0.0.0.0" || host == "::" { + host = "127.0.0.1" + } + hostport = net.JoinHostPort(host, port) + } + + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get("http://" + hostport + "/health") + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errors.New("health endpoint returned non-200 status") + } + + return nil +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..1e1909691 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,23 @@ +services: + quicknotes: + build: + context: ./app + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: /data/notes.json + SEED_PATH: /seed.json + volumes: + - quicknotes-data:/data + healthcheck: + test: ["CMD", "/quicknotes", "healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 3s + restart: unless-stopped + +volumes: + quicknotes-data: \ No newline at end of file diff --git a/submissions/lab6.md b/submissions/lab6.md index 4c0f26838..380d4ca9d 100644 --- a/submissions/lab6.md +++ b/submissions/lab6.md @@ -16,11 +16,13 @@ COPY *.go ./ COPY seed.json ./ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/quicknotes . +RUN mkdir -p /out/data && cp /src/seed.json /out/data/notes.json FROM gcr.io/distroless/static:nonroot COPY --from=builder /out/quicknotes /quicknotes COPY --from=builder /src/seed.json /seed.json +COPY --from=builder --chown=65532:65532 /out/data /data ENV ADDR=:8080 \ DATA_PATH=/data/notes.json \ @@ -36,7 +38,7 @@ ENTRYPOINT ["/quicknotes"] ```text REPOSITORY TAG IMAGE ID CREATED SIZE -quicknotes lab6 202d7cf66323 8 seconds ago 14.2MB +quicknotes lab6 01506b43afce About a minute ago 14.9MB ``` ## `docker inspect` config excerpt @@ -62,7 +64,7 @@ REPOSITORY TAG IMAGE ID CREATED SIZE golang 1.24.0-alpine3.21 2d40d4fc278d 16 months ago 385MB ``` -The final runtime image is `14.2MB`, compared with `385MB` for the builder base image. +The final runtime image is `14.9MB`, compared with `385MB` for the builder base image. ## Build and run verification @@ -112,4 +114,97 @@ That matters for CVEs because fewer installed packages means a much smaller atta `-ldflags='-s -w'` strips the symbol table and DWARF debug information from the binary. The main benefit is a smaller image. The cost is worse post-build debugging because the binary carries less debug metadata. -`-trimpath` removes local filesystem paths from the compiled binary. That improves reproducibility and avoids leaking machine-specific build paths. The cost is that stack traces and debug output are slightly less informative because absolute source paths are no longer embedded. \ No newline at end of file +`-trimpath` removes local filesystem paths from the compiled binary. That improves reproducibility and avoids leaking machine-specific build paths. The cost is that stack traces and debug output are slightly less informative because absolute source paths are no longer embedded. + +# Lab 6 - Task 2 + +## compose.yaml + +File: `compose.yaml` + +```yaml +services: + quicknotes: + build: + context: ./app + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: /data/notes.json + SEED_PATH: /seed.json + volumes: + - quicknotes-data:/data + healthcheck: + test: ["CMD", "/quicknotes", "healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 3s + restart: unless-stopped + +volumes: + quicknotes-data: +``` + +## Healthcheck verification + +```text +NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS +devops-intro-quicknotes-1 quicknotes:lab6 "/quicknotes" quicknotes 5 seconds ago Up 5 seconds (healthy) 0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp +``` + +## Persistence test output + +```text +$ docker compose up --build -d +[+] Running 4/4 + ✔ quicknotes:lab6 Built + ✔ Network devops-intro_default Created + ✔ Volume "devops-intro_quicknotes-data" Created + ✔ Container devops-intro-quicknotes-1 Started + +$ curl -s -X POST -H 'Content-Type: application/json' -d '{"title":"durable","body":"survive a restart"}' http://localhost:8080/notes +{"id":5,"title":"durable","body":"survive a restart","created_at":"2026-06-23T08:35:31.488789635Z"} + +$ curl -s http://localhost:8080/notes | grep durable +[{"id":1,"title":"Welcome to QuickNotes","body":"This is the project you'll containerize, deploy, monitor, and harden across all 10 labs.","created_at":"2026-01-15T10:00:00Z"},{"id":2,"title":"Read app/main.go first","body":"Start by understanding the entry point - env vars, signal handling, graceful shutdown.","created_at":"2026-01-15T10:05:00Z"},{"id":3,"title":"DevOps mantra","body":"If it hurts, do it more often.","created_at":"2026-01-15T10:10:00Z"},{"id":4,"title":"Endpoint cheat-sheet","body":"GET /notes GET /notes/{id} POST /notes DELETE /notes/{id} GET /health GET /metrics","created_at":"2026-01-15T10:15:00Z"},{"id":5,"title":"durable","body":"survive a restart","created_at":"2026-06-23T08:35:31.488789635Z"}] + +$ docker compose down +$ docker compose up -d + +$ curl -s http://localhost:8080/notes | grep durable +[{"id":1,"title":"Welcome to QuickNotes","body":"This is the project you'll containerize, deploy, monitor, and harden across all 10 labs.","created_at":"2026-01-15T10:00:00Z"},{"id":2,"title":"Read app/main.go first","body":"Start by understanding the entry point - env vars, signal handling, graceful shutdown.","created_at":"2026-01-15T10:05:00Z"},{"id":3,"title":"DevOps mantra","body":"If it hurts, do it more often.","created_at":"2026-01-15T10:10:00Z"},{"id":4,"title":"Endpoint cheat-sheet","body":"GET /notes GET /notes/{id} POST /notes DELETE /notes/{id} GET /health GET /metrics","created_at":"2026-01-15T10:15:00Z"},{"id":5,"title":"durable","body":"survive a restart","created_at":"2026-06-23T08:35:31.488789635Z"}] + +$ docker compose down -v +$ docker compose up -d + +$ curl -s http://localhost:8080/notes | grep durable +durable absent +``` + +## Design answers + +### e) Distroless has no shell. How do you healthcheck it? + +I used a binary that is already in the image: the QuickNotes executable itself. I added a `healthcheck` mode, and Compose runs it with exec form: + +```yaml +healthcheck: + test: ["CMD", "/quicknotes", "healthcheck"] +``` + +That helper performs an HTTP GET to `http://127.0.0.1:8080/health` and exits non-zero on failure. This works in distroless because it does not require `sh`, `curl`, `wget`, or any package manager. + +### f) Why does `volumes: [quicknotes-data:/data]` survive `docker compose down`? What destroys it? + +It survives `docker compose down` because named volumes are separate Docker objects from containers and networks. `down` removes the containers and the project network, but it leaves named volumes in place by default. + +The volume is destroyed by `docker compose down -v`, or by explicit volume removal such as `docker volume rm devops-intro_quicknotes-data` or a broader cleanup like `docker volume prune`. + +### g) What does `depends_on` without `condition: service_healthy` actually wait for? What bug can it cause? + +Without `condition: service_healthy`, `depends_on` only waits for the dependent container process to start, not for the application inside it to become ready. + +The bug is a startup race: a second service can start immediately after first, try to connect before it is ready to accept requests, and fail with connection errors even though Compose started containers in the declared order. \ No newline at end of file From ac212dcf2a7ffbb9a4161eb86006fb20966ab2c2 Mon Sep 17 00:00:00 2001 From: "man@smairon.ru" Date: Wed, 24 Jun 2026 07:39:04 +0300 Subject: [PATCH 5/5] docs(lab6): bonus submission Signed-off-by: man@smairon.ru --- .github/pull_request_template.md | 23 +++++-- compose.yaml | 7 ++ submissions/lab6.md | 113 ++++++++++++++++++++++++++++++- 3 files changed, 136 insertions(+), 7 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1a68db5e5..35d9fda87 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,13 +1,24 @@ ## Goal - +Complete Lab 6 for QuickNotes: containerize the app, run it with Compose and persistence, and apply the bonus hardening defaults. ## Changes -- +- Task 1: + Add a multi-stage distroless Dockerfile with a static stripped Go binary, nonroot runtime, and image size under 25 MB. +- Task 2: + Add `compose.yaml` with port publishing, named volume persistence, env vars, restart policy, and a distroless-compatible healthcheck. +- Bonus: + Harden the `quicknotes` service with `cap_drop: [ALL]`, `read_only: true`, `tmpfs: /tmp`, and `no-new-privileges`; document Docker and Trivy verification in `submissions/lab6.md`. ## Testing - +- `go test ./...` +- `docker build -t quicknotes:lab6 ./app` +- `docker run --rm -p 8080:8080 -v "$PWD/app/data:/data" quicknotes:lab6` and `curl /health` +- `docker compose up --build -d`, POST note, verify persistence across `down/up`, verify reset with `down -v` +- `docker inspect` checks for nonroot, dropped capabilities, read-only root, and `no-new-privileges` +- `docker compose exec quicknotes sh` fails as expected +- `trivy image --severity HIGH,CRITICAL quicknotes:lab6` ## Checklist -- [ ] Title is a clear sentence (≤ 70 chars) -- [ ] Commits are signed (`git log --show-signature`) -- [ ] `submissions/labN.md` updated +- [x] Title is a clear sentence (≤ 70 chars) +- [x] Commits are signed (`git log --show-signature`) +- [x] `submissions/labN.md` updated diff --git a/compose.yaml b/compose.yaml index 1e1909691..570829bf7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -11,12 +11,19 @@ services: SEED_PATH: /seed.json volumes: - quicknotes-data:/data + tmpfs: + - /tmp healthcheck: test: ["CMD", "/quicknotes", "healthcheck"] interval: 10s timeout: 3s retries: 3 start_period: 3s + cap_drop: + - ALL + read_only: true + security_opt: + - no-new-privileges:true restart: unless-stopped volumes: diff --git a/submissions/lab6.md b/submissions/lab6.md index 380d4ca9d..339d0a3cf 100644 --- a/submissions/lab6.md +++ b/submissions/lab6.md @@ -136,12 +136,19 @@ services: SEED_PATH: /seed.json volumes: - quicknotes-data:/data + tmpfs: + - /tmp healthcheck: test: ["CMD", "/quicknotes", "healthcheck"] interval: 10s timeout: 3s retries: 3 start_period: 3s + cap_drop: + - ALL + read_only: true + security_opt: + - no-new-privileges:true restart: unless-stopped volumes: @@ -207,4 +214,108 @@ The volume is destroyed by `docker compose down -v`, or by explicit volume remov Without `condition: service_healthy`, `depends_on` only waits for the dependent container process to start, not for the application inside it to become ready. -The bug is a startup race: a second service can start immediately after first, try to connect before it is ready to accept requests, and fail with connection errors even though Compose started containers in the declared order. \ No newline at end of file +The bug is a startup race: a second service can start immediately after first, try to connect before it is ready to accept requests, and fail with connection errors even though Compose started containers in the declared order. + +# Lab 6 - Bonus Task + +## Hardened `services.quicknotes` snippet + +```yaml +quicknotes: + build: + context: ./app + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: /data/notes.json + SEED_PATH: /seed.json + volumes: + - quicknotes-data:/data + tmpfs: + - /tmp + healthcheck: + test: ["CMD", "/quicknotes", "healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 3s + cap_drop: + - ALL + read_only: true + security_opt: + - no-new-privileges:true + restart: unless-stopped +``` + +## Verification outputs + +### 1) `USER nonroot` + +`Dockerfile` already uses `USER 65532:65532`. Runtime proof: + +```text +$ docker inspect quicknotes:lab6 --format '{{json .Config.User}}' +"65532:65532" +``` + +### 2) No shell available + +`Dockerfile` already uses the distroless runtime image `gcr.io/distroless/static:nonroot`. Proof: + +```text +$ docker compose exec -T quicknotes sh +OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH: unknown +``` + +### 3) Capabilities dropped + +```text +$ docker inspect devops-intro-quicknotes-1 --format '{{json .HostConfig.CapDrop}}' +["ALL"] +``` + +### 4) Read-only root filesystem + +Because the distroless image has no shell and no `touch` binary, I verified this at the Docker engine level instead of trying to run a fake write test command inside the container: + +```text +$ docker inspect devops-intro-quicknotes-1 --format '{{.HostConfig.ReadonlyRootfs}}' +true +``` + +The writable locations are only the named volume at `/data` and the explicit tmpfs mount at `/tmp`. + +### 5) `no-new-privileges` + +```text +$ docker inspect devops-intro-quicknotes-1 --format '{{json .HostConfig.SecurityOpt}}' +["no-new-privileges:true"] +``` + +## Trivy summary + +Command used: + +```text +$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.59.1 image --severity HIGH,CRITICAL --no-progress quicknotes:lab6 +``` + +Summary: + +```text +quicknotes:lab6 (debian 13.5) +============================= +Total: 0 (HIGH: 0, CRITICAL: 0) + +quicknotes (gobinary) +===================== +Total: 17 (HIGH: 16, CRITICAL: 1) +``` + +Interpretation: the distroless static base did its job on the OS side, with zero HIGH/CRITICAL base-image findings. The remaining findings come from the Go binary built with `go1.24.0`, so they are application-runtime issues in the bundled standard library rather than extra packages from the container image. + +## Most security per line of YAML + +If I had to pick one line, `cap_drop: [ALL]` gives the most security per line of Compose. It removes the default Linux capabilities that many containers do not actually need, which sharply reduces the blast radius of a compromise. `read_only: true` is a close second because it blocks a lot of persistence and tampering paths with one flag, but dropping capabilities is the stronger general hardening default for this app. The real takeaway is that these controls stack well: distroless, nonroot, dropped capabilities, read-only root, and `no-new-privileges` each cover a different failure mode. \ No newline at end of file