diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 000000000..84d0137ea --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,58 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.24-alpine AS builder + +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/quicknotes . + +RUN cat > /tmp/healthcheck.go <<'EOF' +package main + +import ( + "net/http" + "os" + "time" +) + +func main() { + client := http.Client{Timeout: 2 * time.Second} + resp, err := client.Get("http://127.0.0.1:8080/health") + if err != nil { + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + os.Exit(1) + } +} +EOF + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/healthcheck /tmp/healthcheck.go + +# Create an empty /data directory that will be copied into the runtime image +RUN mkdir -p /out/data + +FROM gcr.io/distroless/static:nonroot + +WORKDIR / + +COPY --from=builder /out/quicknotes /quicknotes +COPY --from=builder /out/healthcheck /healthcheck + +# Make /data exist in the image and be owned by distroless nonroot UID/GID 65532. +# When Docker creates a fresh named volume, it initializes it from this directory. +COPY --from=builder --chown=65532:65532 /out/data /data + +USER nonroot:nonroot + +EXPOSE 8080 + +ENTRYPOINT ["/quicknotes"] \ No newline at end of file diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..7dde0e154 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,60 @@ +services: + quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: "/data/notes.json" + SEED_PATH: "/seed.json" + volumes: + - quicknotes-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "/healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + + prometheus: + image: prom/prometheus:v3.11.3 + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro + command: + - "--config.file=/etc/prometheus/prometheus.yml" + depends_on: + quicknotes: + condition: service_healthy + restart: unless-stopped + + grafana: + image: grafana/grafana:13.0.3 + ports: + - "3000:3000" + environment: + GF_SECURITY_ADMIN_USER: "admin" + GF_SECURITY_ADMIN_PASSWORD: "lab8-admin-please-change" + GF_USERS_DEFAULT_THEME: "dark" + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + depends_on: + - prometheus + restart: unless-stopped + +volumes: + quicknotes-data: diff --git a/docs/runbook/high-error-rate.md b/docs/runbook/high-error-rate.md new file mode 100644 index 000000000..19edb4371 --- /dev/null +++ b/docs/runbook/high-error-rate.md @@ -0,0 +1,43 @@ +# Runbook: QuickNotes High Error Rate + +## What this alert means + +More than 5% of QuickNotes HTTP requests have returned 4xx or 5xx responses for at least 5 minutes. + +## Triage steps + +1. Open the QuickNotes Golden Signals dashboard in Grafana and check whether the error ratio is still above 5%. +2. Check whether traffic changed sharply at the same time. A real traffic spike plus errors may indicate a user-facing incident. +3. In Prometheus, inspect the request metric by status code to identify whether the errors are mostly 4xx or 5xx. +4. Check the QuickNotes container logs: + + docker compose logs quicknotes --tail=100 + +5. Confirm the service is reachable: + + curl -s http://localhost:8080/health + +6. Check whether recent deploy/config changes happened: + + git log --oneline -5 + docker compose ps + +## Mitigations + +1. Restart the QuickNotes service if it is returning 5xx responses or appears stuck: + + docker compose restart quicknotes + +2. Roll back the most recent configuration or image change if the issue started immediately after a deploy. +3. If malformed client traffic is causing a flood of 4xx responses, temporarily block or rate-limit the offending client/source if available. +4. If the data volume is suspected to be corrupt, stop the service and inspect `/data/notes.json` before deleting or replacing anything. + +## Post-incident + +After the incident is mitigated: + +1. Write a short postmortem using the Lecture 1 postmortem template. +2. Record the timeline: detection time, start time, mitigation time, and resolution time. +3. Identify whether users were affected and for how long. +4. Add or improve tests, alerts, dashboard panels, or validation so this failure is easier to detect next time. +5. Update this runbook if any step was missing or misleading. diff --git a/labs/lab1.md b/labs/lab1.md index bb4e226d9..eb319e50f 100644 --- a/labs/lab1.md +++ b/labs/lab1.md @@ -91,9 +91,20 @@ git config --global commit.gpgsign true git config --global tag.gpgsign true ``` -Tell the platform your SSH key is a **signing key**: -- GitHub: Settings → SSH and GPG keys → **New SSH key**, key type **Signing Key** -- GitLab: Profile → SSH Keys → tick "Usage type: Authentication & signing" +Now register the key on the platform. GitHub treats **Authentication** and **Signing** as *separate* roles for the same key, so you add it under both: + +- **Authentication Key** — lets you `clone` / `fetch` / `push` over SSH (`git@github.com:…`). If you cloned over HTTPS, or have never seen `ssh -T git@github.com` greet you by name, you don't have one configured yet — add it now or the `upstream` SSH remote will fail in Lab 2. +- **Signing Key** — gives your commits the **Verified** badge. + +- 🐙 GitHub: Settings → SSH and GPG keys → **New SSH key** → add the **same** `~/.ssh/id_ed25519.pub` **twice**, once with Key type **Authentication Key** and once with **Signing Key**. +- 🦊 GitLab: Profile → SSH Keys → a single key with **Usage type: Authentication & signing** covers both. + +Confirm authentication works before moving on: + +```bash +ssh -T git@github.com +# expect: Hi YOUR_USERNAME! You've successfully authenticated... +``` ### 1.4: Make a Signed Commit @@ -303,7 +314,8 @@ In `submissions/lab1.md`: ## Common Pitfalls - 🪤 **PR template doesn't auto-populate** — make sure the template is on `main` *before* opening the PR -- 🪤 **Commits show "Unverified"** — the SSH key must be added as a *Signing Key* on GitHub (not just an authentication key) +- 🪤 **Commits show "Unverified"** — the key must also be added as a **Signing Key** on GitHub; an Authentication Key alone won't verify commits (they're separate roles — see §1.3) +- 🪤 **`git@github.com: Permission denied (publickey)` on clone/fetch/push** — the *reverse* gap: your key is registered for signing but not as an **Authentication Key**. Add it as Authentication too (§1.3) and confirm with `ssh -T git@github.com`. Quick unblock for the *public* upstream: `git remote set-url upstream https://github.com/inno-devops-labs/DevOps-Intro.git` - 🪤 **`git push` rejected on `main`** — that's the bonus rule working as designed; push to `feature/lab1` instead - 🪤 **`gpg.format=ssh` ignored** — confirm Git ≥ 2.34: `git --version` - 🪤 **Pushed to the wrong branch** — `git switch feature/lab1` before `git push` diff --git a/labs/lab2.md b/labs/lab2.md index fca7b3f22..aae9acfb3 100644 --- a/labs/lab2.md +++ b/labs/lab2.md @@ -223,6 +223,7 @@ git bisect reset ## Common Pitfalls +- 🪤 **`git@github.com: Permission denied (publickey)` on `git fetch upstream`** — *not* a remote-config bug (the error is at the SSH layer, before Git reads the repo). Your key isn't registered for **authentication** on GitHub — and a **Signing Key** (Lab 1) does *not* count for auth, they're separate roles. Add the same `~/.ssh/id_ed25519.pub` as an **Authentication Key** (Lab 1 §1.3), verify with `ssh -T git@github.com`, then re-run. To unblock right now, the public upstream fetches over HTTPS with no key: `git remote set-url upstream https://github.com/inno-devops-labs/DevOps-Intro.git` - 🪤 **`reset --hard` without committing first** — your *uncommitted* edits really *are* gone (reflog only saves committed work). Always check `git status` first - 🪤 **`tag -v` says "no signature"** — you used `git tag NAME` instead of `git tag -a -s NAME -m "..."` - 🪤 **Rebase conflicts** — resolve, then `git rebase --continue`. Never `git rebase --skip` unless you know what you're skipping diff --git a/labs/lab3.md b/labs/lab3.md index 9f0970b20..87344cfb3 100644 --- a/labs/lab3.md +++ b/labs/lab3.md @@ -160,6 +160,23 @@ Tips: - GitLab: `parallel:matrix:` - Set `fail-fast: false` (GH) or equivalent so a single bad cell doesn't cancel the others — you want to *see* which combo broke +> ⚠️ **The matrix renames your checks — update branch protection (1.6) or your PR blocks forever.** A matrixed `test` job reports as `test (1.23)` and `test (1.24)`; the old required check named `test` will sit at *"Expected — Waiting for status to be reported"* indefinitely, even though every real check is green. Two fixes: +> +> 1. **Quick:** in the branch-protection rule, replace `vet`/`test` with the matrixed names (`vet (1.23)`, `vet (1.24)`, `test (1.23)`, `test (1.24)`). +> 2. **Robust (recommended):** add one aggregation job and require *only* it — then the matrix can change freely without touching protection settings: +> +> ```yaml +> ci-ok: +> if: always() +> needs: [vet, test, lint] +> runs-on: ubuntu-24.04 +> steps: +> - run: | +> test "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "false" +> ``` +> +> The `if: always()` matters — without it, a failed `needs` job *skips* `ci-ok`, and a skipped required check lets the PR through on some configurations. + ### 2.3: Skip docs-only changes Edit your trigger so the pipeline runs **only** when something in `app/` or your CI config itself changes. README edits should not burn 4 minutes of CI time. @@ -179,6 +196,8 @@ Capture wall-clock times from the CI UI for three scenarios: > 💡 To get a clean baseline, temporarily disable each optimization with a commit, take a screenshot of the run time, then restore. +> 🧪 **Expect the cache rows to be boring — that's the finding, not a failure.** QuickNotes has **zero third-party dependencies** (look at `app/go.mod` — no `require` block, no `go.sum`), so the module cache has nothing to store and total wall-clock barely moves with `cache: true` vs `cache: false`. Most of your 60–80 s is runner provisioning, checkout, and the Go toolchain download — none of which `setup-go`'s cache touches. Report what you measured and *explain why* (that's design question **f** in disguise). To see where caching *would* pay, compare the **per-step** durations (`setup-go`, `go test`) instead of job totals, and note which step a real dependency-heavy project would save on. + ### 2.5: Document In `submissions/lab3.md`: @@ -284,6 +303,8 @@ Answer in 4-6 sentences: - 🪤 **Forgot `working-directory` (or `cd app`) for Go commands** — Go modules live in `app/`, not the repo root; commands run from the root will fail with "no Go files" - 🪤 **`fail-fast: true` (the GH Actions default) in a matrix** — one fail cancels the others; you can't see *which* combo broke - 🪤 **Branch protection set on someone else's fork's `main`** — you can only protect *your* fork's `main`. The upstream course repo has its own protection +- 🪤 **PR stuck on "Expected — Waiting for status to be reported" after adding the matrix** — the matrix renamed `test` → `test (1.23)`/`test (1.24)`, but branch protection still requires the old `test` context, which will never report again. Update the required-check names or switch to the `ci-ok` aggregation job (see §2.2) +- 🪤 **"Caching didn't speed anything up"** — on a zero-dependency module that's the *correct* result, not a mistake (see §2.4); don't pad the timing table with numbers you didn't observe - 🪤 **`golangci-lint` version not pinned** — "latest" pulls a new release tomorrow that may flag your code with new rules. Pin `v2.5.0` exactly - 🪤 **GitLab CI: incorrect anchor syntax** (`<<: *name`) — GitLab is strict; use the in-platform CI Lint tool (`Project → CI/CD → Editor → Validate`) - 🪤 **Cache hits expire after 7 days of inactivity on GH** — that's expected; the cache key is what protects you against poisoning diff --git a/monitoring/grafana/dashboards/golden-signals.json b/monitoring/grafana/dashboards/golden-signals.json new file mode 100644 index 000000000..e079e08c4 --- /dev/null +++ b/monitoring/grafana/dashboards/golden-signals.json @@ -0,0 +1,237 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "p95 request duration if histogram exists; otherwise falls back to request rate as a proxy.", + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum(rate(quicknotes_http_request_duration_seconds_bucket[5m])) by (le)) or sum(rate(quicknotes_http_requests_total[5m]))", + "legendFormat": "p95 latency or request-rate proxy", + "range": true, + "refId": "A" + } + ], + "title": "Latency", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Total request rate.", + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(quicknotes_http_requests_total[1m]))", + "legendFormat": "requests/sec", + "range": true, + "refId": "A" + } + ], + "title": "Traffic", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Ratio of HTTP 4xx and 5xx responses to all responses.", + "fieldConfig": { + "defaults": { + "max": 1, + "min": 0, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(quicknotes_http_responses_by_code_total{code=~\"4..|5..\"}[5m])) / clamp_min(sum(rate(quicknotes_http_responses_by_code_total[5m])), 0.001)", + "legendFormat": "error ratio", + "range": true, + "refId": "A" + } + ], + "title": "Errors", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Current number of notes stored by QuickNotes.", + "fieldConfig": { + "defaults": { + "min": 0, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "quicknotes_notes_total", + "legendFormat": "notes", + "range": true, + "refId": "A" + } + ], + "title": "Saturation", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "10s", + "schemaVersion": 41, + "tags": [ + "quicknotes", + "golden-signals", + "lab8" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-30m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "QuickNotes Golden Signals", + "uid": "quicknotes-golden-signals", + "version": 1, + "weekStart": "" +} diff --git a/monitoring/grafana/provisioning/dashboards/dashboard.yml b/monitoring/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 000000000..642940411 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: "QuickNotes dashboards" + orgId: 1 + folder: "QuickNotes" + type: file + disableDeletion: false + allowUiUpdates: true + updateIntervalSeconds: 10 + options: + path: /var/lib/grafana/dashboards diff --git a/monitoring/grafana/provisioning/datasources/datasource.yml b/monitoring/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 000000000..0b304bc91 --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/monitoring/prometheus/alerts.yml b/monitoring/prometheus/alerts.yml new file mode 100644 index 000000000..59c758bab --- /dev/null +++ b/monitoring/prometheus/alerts.yml @@ -0,0 +1,17 @@ +groups: + - name: quicknotes-alerts + rules: + - alert: QuickNotesHighErrorRate + expr: | + ( + sum(rate(quicknotes_http_responses_by_code_total{code=~"4..|5.."}[5m])) + / + clamp_min(sum(rate(quicknotes_http_responses_by_code_total[5m])), 0.001) + ) > 0.05 + for: 5m + labels: + severity: page + annotations: + summary: "QuickNotes high HTTP error rate" + description: "More than 5% of QuickNotes HTTP requests have returned 4xx or 5xx responses for at least 5 minutes." + runbook_url: "docs/runbook/high-error-rate.md" \ No newline at end of file diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 000000000..8dc2755b2 --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,11 @@ +global: + scrape_interval: 15s + +rule_files: + - /etc/prometheus/alerts.yml + +scrape_configs: + - job_name: "quicknotes" + static_configs: + - targets: + - "quicknotes:8080" diff --git a/submissions/lab6.md b/submissions/lab6.md new file mode 100644 index 000000000..81bf46059 --- /dev/null +++ b/submissions/lab6.md @@ -0,0 +1,493 @@ +# Lab 6 - Containers: Dockerize QuickNotes + +## Task 1 - Multi-stage Dockerfile + +### Dockerfile + +```dockerfile +# syntax=docker/dockerfile:1 + +FROM golang:1.24-alpine AS builder + +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/quicknotes . + +RUN cat > /tmp/healthcheck.go <<'EOF' +package main + +import ( + "net/http" + "os" + "time" +) + +func main() { + client := http.Client{Timeout: 2 * time.Second} + resp, err := client.Get("http://127.0.0.1:8080/health") + if err != nil { + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + os.Exit(1) + } +} +EOF + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/healthcheck /tmp/healthcheck.go + +RUN mkdir -p /out/data + +FROM gcr.io/distroless/static:nonroot + +WORKDIR / + +COPY --from=builder /out/quicknotes /quicknotes +COPY --from=builder /out/healthcheck /healthcheck +COPY --from=builder --chown=65532:65532 /out/data /data + +USER nonroot:nonroot + +EXPOSE 8080 + +ENTRYPOINT ["/quicknotes"] +``` + +### Image size + +Command: + +```powershell +docker images quicknotes:lab6 +``` + +Output: + +```text +IMAGE ID DISK USAGE CONTENT SIZE EXTRA +quicknotes:lab6 a39d5f5a0ab8 22.7MB 5.71MB U +``` + +The final image size is **22.7 MB**, which is below the required 25 MB limit. + +### Image configuration + +Command: + +```powershell +docker inspect quicknotes:lab6 --format "User={{.Config.User}} Entrypoint={{json .Config.Entrypoint}} ExposedPorts={{json .Config.ExposedPorts}}" +``` + +Output: + +```text +User=nonroot:nonroot Entrypoint=["/quicknotes"] ExposedPorts={"8080/tcp":{}} +``` + +This confirms that the image runs as `nonroot:nonroot`, exposes port `8080`, and uses exec-form entrypoint. + +### Builder base image comparison + +Command: + +```powershell +docker pull golang:1.24-alpine +docker images golang:1.24-alpine +``` + +Output: + +```text +1.24-alpine: Pulling from library/golang +Digest: sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 +Status: Downloaded newer image for golang:1.24-alpine +docker.io/library/golang:1.24-alpine + +IMAGE ID DISK USAGE CONTENT SIZE EXTRA +golang:1.24-alpine 8bee1901f1e5 395MB 83.5MB +``` + +The builder image is **395 MB**, while the final runtime image is only **22.7 MB**. This shows the value of the multi-stage build: the Go compiler and build tools are not included in the final image. + +### Direct Docker run test + +Command: + +```powershell +cd C:\Users\minim\projects\DevOps-Intro\app +docker run --rm -d --name quicknotes-test -p 8080:8080 -v "${PWD}\data:/data" quicknotes:lab6 +Start-Sleep -Seconds 3 +curl.exe -s http://localhost:8080/health +docker stop quicknotes-test +``` + +Output: + +```text +28e0c9615e2610c4c97f4d0eee757e165b142c5e7956050f3cb545b44c12870e +{"notes":6,"status":"ok"} +quicknotes-test +``` + +This confirms that the Docker image runs QuickNotes successfully and serves the `/health` endpoint. + +### Design questions + +#### a) Why does layer order matter? + +Layer order matters because Docker reuses cached layers when the inputs to those layers have not changed. If the Dockerfile uses `COPY . .` before `go mod download`, then any source-code change invalidates the dependency-download layer, forcing Docker to repeat unnecessary work. + +The better strategy is: + +```dockerfile +COPY go.mod ./ +RUN go mod download +COPY . . +RUN go build ... +``` + +This allows Docker to cache `go mod download` as long as `go.mod` has not changed. In my cache-friendly build, the first build took about **25.4 seconds**, while a later cached Compose rebuild completed in about **1.9 seconds**. The difference shows that separating dependency layers from source-code layers improves rebuild speed. + +#### b) Why `CGO_ENABLED=0`? + +`CGO_ENABLED=0` forces Go to produce a static binary that does not depend on C libraries or a dynamic linker. This matters because `gcr.io/distroless/static:nonroot` is designed for static binaries and does not include a full Linux userland. If CGO is left enabled and the binary requires dynamic libraries, the container may fail at runtime with an error such as `no such file or directory`, even though the binary appears to exist. + +#### c) What is `gcr.io/distroless/static:nonroot`? + +`gcr.io/distroless/static:nonroot` is a minimal runtime image for statically compiled applications. It contains enough to run a static binary as a non-root user, but it does not include a shell, package manager, or common debugging tools. This matters for security because fewer packages means a smaller attack surface and fewer operating-system CVEs in the final image. + +#### d) What do `-ldflags="-s -w"` and `-trimpath` do? + +`-ldflags="-s -w"` strips the symbol table and debug information from the Go binary, which reduces binary size. `-trimpath` removes local filesystem paths from the compiled binary, making builds more reproducible and avoiding leakage of build-machine paths. The trade-off is that debugging information is reduced, so stack traces and binary inspection may be less detailed. + +--- + +## Task 2 - Compose, Healthcheck, and Persistent Volume + +### compose.yaml + +```yaml +services: + quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: "/data/notes.json" + SEED_PATH: "/seed.json" + volumes: + - quicknotes-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "/healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + +volumes: + quicknotes-data: +``` + +### Compose startup and healthcheck + +Command: + +```powershell +cd C:\Users\minim\projects\DevOps-Intro +docker compose down -v +docker compose up --build -d +Start-Sleep -Seconds 5 +docker compose ps +curl.exe -s http://localhost:8080/health +``` + +Output: + +```text +[+] up 4/4 + ✔ Image quicknotes:lab6 Built + ✔ Network devops-intro_default Created + ✔ Volume devops-intro_quicknotes-data Created + ✔ Container devops-intro-quicknotes-1 Started + +NAME IMAGE COMMAND SERVICE STATUS PORTS +devops-intro-quicknotes-1 quicknotes:lab6 "/quicknotes" quicknotes Up 5 seconds (healthy) 0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp + +{"notes":0,"status":"ok"} +``` + +This confirms that Compose starts the service, the healthcheck reports healthy, and the host can reach the container on port 8080. + +### Persistence test + +#### Create a durable note + +Command: + +```powershell +$body = @{ + title = "durable" + body = "survive a restart" +} | ConvertTo-Json -Compress + +Invoke-RestMethod ` + -Method Post ` + -Uri "http://localhost:8080/notes" ` + -ContentType "application/json" ` + -Body $body +``` + +Output: + +```text +id title body created_at +-- ----- ---- ---------- + 1 durable survive a restart 2026-06-23T17:24:24.43611536Z +``` + +#### Confirm the note exists + +Command: + +```powershell +Invoke-RestMethod http://localhost:8080/notes | ConvertTo-Json -Depth 5 +``` + +Output: + +```json +{ + "value": [ + { + "id": 1, + "title": "durable", + "body": "survive a restart", + "created_at": "2026-06-23T17:24:24.43611536Z" + } + ], + "Count": 1 +} +``` + +#### Confirm persistence after `docker compose down && docker compose up` + +Command: + +```powershell +docker compose down +docker compose up -d +Start-Sleep -Seconds 3 +(Invoke-RestMethod http://localhost:8080/notes | ConvertTo-Json -Depth 5) | Select-String durable +``` + +Output: + +```text +id title body created_at +-- ----- ---- ---------- + 2 durable survive a restart 2026-06-23T17:24:42.300273119Z +``` + +The durable note still exists after `docker compose down` and `docker compose up`, which proves the named volume preserved the data. + +#### Confirm data is deleted after `docker compose down -v` + +Command: + +```powershell +docker compose down -v +docker compose up -d +Start-Sleep -Seconds 3 +(Invoke-RestMethod http://localhost:8080/notes | ConvertTo-Json -Depth 5) | Select-String durable +``` + +Output: + +```text +[+] down 3/3 + ✔ Container devops-intro-quicknotes-1 Removed + ✔ Network devops-intro_default Removed + ✔ Volume devops-intro_quicknotes-data Removed + +[+] up 3/3 + ✔ Network devops-intro_default Created + ✔ Volume devops-intro_quicknotes-data Created + ✔ Container devops-intro-quicknotes-1 Started + +No output from Select-String durable. +``` + +The durable note disappeared after `docker compose down -v`, which proves the data was stored in the named volume and that `-v` deleted the volume. + +### Design questions + +#### e) Distroless has no shell. How do you healthcheck it? + +I used a small static Go healthcheck binary copied into the final distroless image. The Compose healthcheck runs it using exec form: `["CMD", "/healthcheck"]`. This works without a shell because Docker directly executes the binary, and the binary performs an HTTP GET request to `http://127.0.0.1:8080/health`. + +#### f) Why does `volumes: [quicknotes-data:/data]` survive `docker compose down`? + +A named Docker volume is managed separately from the container lifecycle. `docker compose down` removes containers and networks, but it does not delete named volumes by default. The volume is destroyed by `docker compose down -v` or by explicitly deleting it with Docker volume commands. + +#### g) `depends_on` without `condition: service_healthy` + +`depends_on` without `condition: service_healthy` only waits for the dependent container to start, not for the application inside it to become ready. This can cause startup race conditions where one service tries to connect to another service before it is actually listening or healthy. In this lab there is only one service, but in a multi-service setup this can cause intermittent failures. + +--- + +## Bonus Task - Security Defaults + +### Hardened quicknotes service block + +```yaml +quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: "/data/notes.json" + SEED_PATH: "/seed.json" + volumes: + - quicknotes-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "/healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true +``` + +### 1. USER nonroot + +Command: + +```powershell +docker inspect quicknotes:lab6 --format "{{.Config.User}}" +``` + +Output: + +```text +nonroot:nonroot +``` + +### 2. Distroless / no shell available + +Command: + +```powershell +docker compose exec quicknotes sh +``` + +Output: + +```text +OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH +``` + +This confirms that the final image does not include a shell, which is expected for a distroless image. + +### 3. Linux capabilities dropped + +Command: + +```powershell +$cid = docker compose ps -q quicknotes +docker inspect $cid --format "{{json .HostConfig.CapDrop}}" +``` + +Output: + +```text +["ALL"] +``` + +### 4. Read-only root filesystem + +Command: + +```powershell +docker inspect $cid --format "{{.HostConfig.ReadonlyRootfs}}" +``` + +Output: + +```text +true +``` + +### 5. no-new-privileges + +Command: + +```powershell +docker inspect $cid --format "{{json .HostConfig.SecurityOpt}}" +``` + +Output: + +```text +["no-new-privileges:true"] +``` + +### 6. Trivy scan + +Command: + +```powershell +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 +``` + +Output summary: + +```text +quicknotes:lab6 (debian 13.5) +============================= +Total: 0 (HIGH: 0, CRITICAL: 0) + +healthcheck (gobinary) +====================== +Total: 13 (HIGH: 13, CRITICAL: 0) + +quicknotes (gobinary) +===================== +Total: 13 (HIGH: 13, CRITICAL: 0) +``` + +The distroless OS layer had **0 HIGH** and **0 CRITICAL** vulnerabilities. Trivy reported HIGH findings in the Go standard library embedded in the compiled binaries because the builder image used Go 1.24, which was required by the lab. + +### Security reflection + +The most valuable security default per line of YAML is probably `cap_drop: [ALL]`, because QuickNotes does not need extra Linux capabilities. Dropping all capabilities reduces what an attacker could do if the process were compromised. The distroless runtime is also valuable because it removes the shell, package manager, and common post-exploitation tools. The read-only root filesystem adds another layer by forcing application writes into the intended `/data` volume instead of allowing arbitrary writes across the container filesystem. diff --git a/submissions/lab8.md b/submissions/lab8.md new file mode 100644 index 000000000..be4444d94 --- /dev/null +++ b/submissions/lab8.md @@ -0,0 +1,817 @@ +# Lab 8 - SRE & Monitoring: Golden Signals Dashboard + One Good Alert + +## Overview + +This lab extends the QuickNotes Docker Compose stack with Prometheus and Grafana. Prometheus scrapes QuickNotes metrics from inside the Compose network, Grafana provisions a Prometheus datasource and a four-panel golden signals dashboard, and Prometheus evaluates one alert rule for sustained high HTTP error rate. A runbook is included for the alert. + +--- + +# Task 1 - Prometheus + Grafana with a Provisioned Dashboard + +## File layout + +```text +monitoring/ +├── prometheus/ +│ ├── prometheus.yml +│ └── alerts.yml +└── grafana/ + ├── dashboards/ + │ └── golden-signals.json + └── provisioning/ + ├── datasources/ + │ └── datasource.yml + └── dashboards/ + └── dashboard.yml + +docs/ +└── runbook/ + └── high-error-rate.md + +submissions/ +└── lab8.md +``` + +--- + +## compose.yaml + +```yaml +services: + quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: "/data/notes.json" + SEED_PATH: "/seed.json" + volumes: + - quicknotes-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "/healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + + prometheus: + image: prom/prometheus:v3.11.3 + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro + command: + - "--config.file=/etc/prometheus/prometheus.yml" + depends_on: + quicknotes: + condition: service_healthy + restart: unless-stopped + + grafana: + image: grafana/grafana:13.0.3 + ports: + - "3000:3000" + environment: + GF_SECURITY_ADMIN_USER: "admin" + GF_SECURITY_ADMIN_PASSWORD: "lab8-admin-please-change" + GF_USERS_DEFAULT_THEME: "dark" + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + depends_on: + - prometheus + restart: unless-stopped + +volumes: + quicknotes-data: +``` + +--- + +## Prometheus config + +File: `monitoring/prometheus/prometheus.yml` + +```yaml +global: + scrape_interval: 15s + +rule_files: + - /etc/prometheus/alerts.yml + +scrape_configs: + - job_name: "quicknotes" + static_configs: + - targets: + - "quicknotes:8080" +``` + +This config sets the global scrape interval to 15 seconds and defines one scrape job for QuickNotes. The target is `quicknotes:8080`, using the Compose service name and internal container port. + +--- + +## Prometheus alert rule + +File: `monitoring/prometheus/alerts.yml` + +```yaml +groups: + - name: quicknotes-alerts + rules: + - alert: QuickNotesHighErrorRate + expr: | + ( + sum(rate(quicknotes_http_responses_by_code_total{code=~"4..|5.."}[5m])) + / + clamp_min(sum(rate(quicknotes_http_responses_by_code_total[5m])), 0.001) + ) > 0.05 + for: 5m + labels: + severity: page + annotations: + summary: "QuickNotes high HTTP error rate" + description: "More than 5% of QuickNotes HTTP requests have returned 4xx or 5xx responses for at least 5 minutes." + runbook_url: "docs/runbook/high-error-rate.md" +``` + +This alert fires only when the error ratio is above 5% for a sustained 5 minutes. It uses `quicknotes_http_responses_by_code_total` because the total request counter does not include status/code labels. + +--- + +## Grafana datasource provisioning + +File: `monitoring/grafana/provisioning/datasources/datasource.yml` + +```yaml +apiVersion: 1 + +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true +``` + +This provisions Prometheus as the default Grafana datasource using the Compose service name `prometheus`. + +--- + +## Grafana dashboard provider + +File: `monitoring/grafana/provisioning/dashboards/dashboard.yml` + +```yaml +apiVersion: 1 + +providers: + - name: "QuickNotes dashboards" + orgId: 1 + folder: "QuickNotes" + type: file + disableDeletion: false + allowUiUpdates: true + updateIntervalSeconds: 10 + options: + path: /var/lib/grafana/dashboards +``` + +This tells Grafana to load dashboards from `/var/lib/grafana/dashboards`, where the dashboard JSON is mounted. + +--- + +## Grafana dashboard JSON + +File: `monitoring/grafana/dashboards/golden-signals.json` + +```json +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "p95 request duration if histogram exists; otherwise falls back to request rate as a proxy.", + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum(rate(quicknotes_http_request_duration_seconds_bucket[5m])) by (le)) or sum(rate(quicknotes_http_requests_total[5m]))", + "legendFormat": "p95 latency or request-rate proxy", + "range": true, + "refId": "A" + } + ], + "title": "Latency", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Total request rate.", + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(quicknotes_http_requests_total[1m]))", + "legendFormat": "requests/sec", + "range": true, + "refId": "A" + } + ], + "title": "Traffic", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Ratio of HTTP 4xx and 5xx responses to all responses.", + "fieldConfig": { + "defaults": { + "max": 1, + "min": 0, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(quicknotes_http_responses_by_code_total{code=~\"4..|5..\"}[5m])) / clamp_min(sum(rate(quicknotes_http_responses_by_code_total[5m])), 0.001)", + "legendFormat": "error ratio", + "range": true, + "refId": "A" + } + ], + "title": "Errors", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Current number of notes stored by QuickNotes.", + "fieldConfig": { + "defaults": { + "min": 0, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "quicknotes_notes_total", + "legendFormat": "notes", + "range": true, + "refId": "A" + } + ], + "title": "Saturation", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "10s", + "schemaVersion": 41, + "tags": [ + "quicknotes", + "golden-signals", + "lab8" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-30m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "QuickNotes Golden Signals", + "uid": "quicknotes-golden-signals", + "version": 1, + "weekStart": "" +} +``` + +The dashboard has four golden signal panels: + +1. Latency +2. Traffic +3. Errors +4. Saturation + +--- + +## Stack startup + +Command: + +```powershell +docker compose down -v +docker compose up --build -d +Start-Sleep -Seconds 20 +docker compose ps +``` + +Expected services: + +```text +quicknotes +prometheus +grafana +``` + +--- + +## Prometheus target health + +Command: + +```powershell +$targets = Invoke-RestMethod http://localhost:9090/api/v1/targets +$targets.data.activeTargets.health +``` + +Output: + +```text +up +``` + +Raw target API also showed: + +```json +"health":"up" +``` + +This confirms that Prometheus successfully scrapes QuickNotes. + +--- + +## QuickNotes metrics check + +Command: + +```powershell +curl.exe -s http://localhost:8080/metrics | Select-String "quicknotes_http_requests_total" +``` + +Output: + +```text +# HELP quicknotes_http_requests_total All HTTP requests. +# TYPE quicknotes_http_requests_total counter +quicknotes_http_requests_total 1354 +``` + +The status-code-specific metric used for the alert was: + +```text +quicknotes_http_responses_by_code_total +``` + +The alert and dashboard error panel use this metric because it includes the `code` label. + +--- + +## Traffic generation + +Command: + +```powershell +1..200 | ForEach-Object { + curl.exe -s http://localhost:8080/health | Out-Null + + if ($_ % 5 -eq 0) { + curl.exe -s -X POST -H "Content-Type: application/json" --data "{bad json" http://localhost:8080/notes | Out-Null + } + + Start-Sleep -Milliseconds 100 +} +``` + +This generated healthy requests and malformed POST requests to make the dashboard show non-trivial traffic and errors. + +--- + +## Grafana dashboard screenshot + +Dashboard with generated traffic: + +https://gyazo.com/3a3a5d95e93616b63f5ef8f60e833cfb + +--- + +## Task 1 Design Questions + +### a) Pull vs push: what does Prometheus pulling mean? + +Prometheus uses a pull model, which means Prometheus initiates HTTP requests to scrape metrics from QuickNotes. Therefore, QuickNotes does not need to know where Prometheus is, but Prometheus must be able to reach QuickNotes. + +In this Compose stack, Prometheus reaches QuickNotes at `quicknotes:8080` using Docker Compose DNS. If Prometheus cannot reach QuickNotes, then the target becomes `down`, the `up` metric becomes `0`, and Prometheus stops receiving fresh application metrics. The application may still be running, but monitoring visibility is lost. + +### b) What query problems come from setting `scrape_interval` to `5s` or `5m`? + +A 5-second scrape interval increases metric volume and storage load. It also makes short-window queries more sensitive to noise and can produce unnecessarily spiky graphs. It may be useful for very fast systems, but for a small app like QuickNotes it is excessive. + +A 5-minute scrape interval is too slow for operational monitoring. Queries like `rate(metric[1m])` may not have enough samples, alerts take longer to detect real incidents, and dashboards feel stale. A 15-second interval is a practical middle ground for a lab service because it gives enough samples for rate calculations without generating too much data. + +### c) `rate()` vs `irate()` vs `delta()` for the Traffic panel + +The Traffic panel should use `rate()` because HTTP requests are represented by a counter. `rate(counter[window])` calculates the average per-second increase over the selected time window and handles counter resets. + +`irate()` uses only the last two samples, so it is more volatile and better for debugging very short spikes, not for a stable golden signals dashboard. `delta()` returns the raw difference over a time range, not a per-second rate, so it is less appropriate for requests-per-second traffic. + +### d) Why provision Grafana from files? + +Provisioning Grafana from files makes the dashboard and datasource reproducible. A fresh `docker compose up` can recreate the monitoring stack without manual clicking in the UI. It also means the dashboard JSON, datasource config, and provider config can be reviewed in pull requests, versioned in Git, and restored after a container or volume is deleted. + +Manual UI configuration is easy to lose and hard to audit. File provisioning treats observability configuration like code. + +--- + +# Task 2 - One Good Alert + Runbook + +## Alert rule definition + +File: `monitoring/prometheus/alerts.yml` + +```yaml +groups: + - name: quicknotes-alerts + rules: + - alert: QuickNotesHighErrorRate + expr: | + ( + sum(rate(quicknotes_http_responses_by_code_total{code=~"4..|5.."}[5m])) + / + clamp_min(sum(rate(quicknotes_http_responses_by_code_total[5m])), 0.001) + ) > 0.05 + for: 5m + labels: + severity: page + annotations: + summary: "QuickNotes high HTTP error rate" + description: "More than 5% of QuickNotes HTTP requests have returned 4xx or 5xx responses for at least 5 minutes." + runbook_url: "docs/runbook/high-error-rate.md" +``` + +The alert fires when the error ratio exceeds 5% for 5 minutes. It has `severity: page` and a runbook annotation. + +--- + +## Alert rule loaded + +Command: + +```powershell +curl.exe -s http://localhost:9090/api/v1/rules | Select-String "quicknotes_http_responses_by_code_total" +``` + +Relevant output: + +```text +"state":"pending" +"name":"QuickNotesHighErrorRate" +"query":"(sum(rate(quicknotes_http_responses_by_code_total{code=~\"4..|5..\"}[5m])) / clamp_min(sum(rate(quicknotes_http_responses_by_code_total[5m])), 0.001)) > 0.05" +"duration":300 +"labels":{"severity":"page"} +"runbook_url":"docs/runbook/high-error-rate.md" +"value":"3.089430894308943e-01" +"health":"ok" +``` + +This shows the rule loaded correctly, evaluated successfully, and entered pending state. + +--- + +## Alert trigger traffic + +Command: + +```powershell +$end = (Get-Date).AddMinutes(6) + +while ((Get-Date) -lt $end) { + curl.exe -s http://localhost:8080/health | Out-Null + curl.exe -s -X POST -H "Content-Type: application/json" --data "{bad json" http://localhost:8080/notes | Out-Null + Start-Sleep -Seconds 1 +} +``` + +This produced a sustained error ratio above 5% for more than 5 minutes. + +--- + +## Alert pending screenshot + +The alert entered Pending state after the error ratio exceeded 5%: + +https://gyazo.com/59a9f7162d2921e879226449096a7165 + +--- + +## Alert firing screenshot + +The alert entered Firing state after the sustained 5-minute error-rate breach: + +https://gyazo.com/ce7bbea0442daab07c1f015e18a3301c + +--- + +## Alert API output in firing state + +Command: + +```powershell +curl.exe -s "http://localhost:9090/api/v1/alerts" +``` + +Output: + +```json +{ + "status": "success", + "data": { + "alerts": [ + { + "labels": { + "alertname": "QuickNotesHighErrorRate", + "severity": "page" + }, + "annotations": { + "description": "More than 5% of QuickNotes HTTP requests have returned 4xx or 5xx responses for at least 5 minutes.", + "runbook_url": "docs/runbook/high-error-rate.md", + "summary": "QuickNotes high HTTP error rate" + }, + "state": "firing", + "activeAt": "2026-06-30T18:21:56.291370819Z", + "value": "4.605263157894737e-01" + } + ] + } +} +``` + +This confirms that the alert reached `firing`, carried the `severity: page` label, and linked to the runbook. + +--- + +# Runbook + +File: `docs/runbook/high-error-rate.md` + +```md +# Runbook: QuickNotes High Error Rate + +## What this alert means + +More than 5% of QuickNotes HTTP requests have returned 4xx or 5xx responses for at least 5 minutes. + +## Triage steps + +1. Open the QuickNotes Golden Signals dashboard in Grafana and check whether the error ratio is still above 5%. +2. Check whether traffic changed sharply at the same time. A real traffic spike plus errors may indicate a user-facing incident. +3. In Prometheus, inspect the request metric by status code to identify whether the errors are mostly 4xx or 5xx. +4. Check the QuickNotes container logs: + + docker compose logs quicknotes --tail=100 + +5. Confirm the service is reachable: + + curl -s http://localhost:8080/health + +6. Check whether recent deploy/config changes happened: + + git log --oneline -5 + docker compose ps + +## Mitigations + +1. Restart the QuickNotes service if it is returning 5xx responses or appears stuck: + + docker compose restart quicknotes + +2. Roll back the most recent configuration or image change if the issue started immediately after a deploy. +3. If malformed client traffic is causing a flood of 4xx responses, temporarily block or rate-limit the offending client/source if available. +4. If the data volume is suspected to be corrupt, stop the service and inspect `/data/notes.json` before deleting or replacing anything. + +## Post-incident + +After the incident is mitigated: + +1. Write a short postmortem using the Lecture 1 postmortem template. +2. Record the timeline: detection time, start time, mitigation time, and resolution time. +3. Identify whether users were affected and for how long. +4. Add or improve tests, alerts, dashboard panels, or validation so this failure is easier to detect next time. +5. Update this runbook if any step was missing or misleading. +``` + +--- + +## Task 2 Design Questions + +### e) Why “sustained for 5 minutes” instead of firing immediately on the first bad request? + +A single bad request is not necessarily an incident. It could be one malformed client request, a user typo, or a harmless probe. Firing immediately on the first error would create noisy alerts and train on-call engineers to ignore pages. + +A 5-minute sustained threshold filters out short bursts and focuses the alert on user-visible, persistent degradation. It is still fast enough to detect real incidents, but slow enough to avoid paging on one-off noise. + +### f) Symptom alerts vs cause alerts: what is a cause alert someone might write for QuickNotes, and why is it worse? + +A cause alert might be something like “CPU usage above 80%” or “container memory usage above 80%.” That is worse as a page because high CPU or memory does not always mean users are affected. The app could be busy but still serving requests successfully. + +The high error-rate alert is a symptom alert because it measures something users actually experience: failed requests. Cause metrics are useful for dashboards and investigation, but symptom alerts are better for paging because they are closer to real user impact. + +### g) What quantitative threshold would show the alert is too noisy? + +If this alert pages more than about **10% of the time when users are not actually affected**, I would consider it too noisy. In other words, if more than 1 in 10 pages turns out to be a false positive or non-actionable event, the alert threshold or duration should be adjusted. + +Another useful threshold is actionability: if fewer than 90% of pages require a real investigation or mitigation, the alert is probably causing alert fatigue. Paging should be reserved for problems that need human attention now. + +--- + +# Bonus Task - Synthetic Monitoring + +Bonus was not attempted. + +--- + +# Final Result + +The Lab 8 stack successfully runs: + +```text +QuickNotes +Prometheus +Grafana +``` + +Prometheus scrapes QuickNotes successfully: + +```text +up +``` + +Grafana provisions the QuickNotes Golden Signals dashboard from JSON and shows traffic: + +https://gyazo.com/3a3a5d95e93616b63f5ef8f60e833cfb + +The high-error-rate alert transitions through Pending and reaches Firing: + +```json +"state": "firing" +``` + +Firing screenshot: + +https://gyazo.com/ce7bbea0442daab07c1f015e18a3301c + +``` +```