Everything we have built so far has a go-live date. What comes afterwards does not: it is the day-to-day of whoever keeps the platform alive, and it is exactly what no tutorial covers. Tutorials end when the pod reaches Running. Reality starts there.
Reality is three in the morning on a Saturday with an alert on your phone. It is the meeting where somebody from finance asks why the cloud bill has gone up 60 % in four months. It is the developer who wants to deploy at six on a Friday afternoon. It is the awkward conversation about whether you can promise 99.99 % availability or only 99.9 %. It is the cluster upgrade that has to happen before the version goes out of support, and that nobody wants to touch because it works.
This lesson closes the module with that part: incident management, runbooks, the error budget as a decision-making tool, change management, capacity planning, costs and a maintenance calendar. And it also closes the whole Rutas Norte journey.
Compliance warning. The incident management procedures described here affect service continuity and, when an incident involves improper access to, loss of, or exposure of personal data, they trigger notification obligations with strict legal deadlines. The severity classification, the communication channels and the postmortem templates must be reviewed by the organisation's compliance officer, who must also determine in which cases an incident constitutes a notifiable security breach.
Contents
- Incident management
- Runbooks
- SLIs, SLOs and the error budget
- Change management
- Capacity planning for the May bank-holiday weekend
- Costs
- Maintenance calendar
- Team maturity
- Incident management
1.1. Severity levels
Severity is not determined by how alarming the problem sounds, but by the impact on the user and on the business. Defining it in advance avoids the "is this serious?" debate exactly when there is no time to have it.
| Sev | Definition | Examples at Rutas Norte | Response | Communication |
|---|---|---|---|---|
| 1 | Service down or unusable for most users; or data loss/exposure | www.rutasnorte.example not responding; bookings-postgres with no primary; no ticket can be bought at all |
Immediate, 24×7. Whoever is needed gets woken up | Customers + the board, every 30 min |
| 2 | Critical functionality degraded, or partially down | Payments fail on 30 % of attempts; p95 latency above 2 s; pre blocked in the middle of the bank-holiday release |
Immediate during extended hours (07:00-24:00) | Internal + the board |
| 3 | Non-critical functionality affected; no direct impact on sales | occupancy-reports fails two nights running; Grafana unreachable; one PostgreSQL replica down with the other two healthy |
Next working day | Internal |
| 4 | Nuisance or latent risk; no current impact | A certificate expiring in 20 days; disk at 70 %; a noisy alert that needs tuning | Scheduled as a task | None |
Two rules that prevent a lot of damage:
- When in doubt, raise the severity. Lowering it once you understand the problem is cheap. Discovering two hours in that a sev 1 was being treated as a sev 3 is expensive.
- Anyone can declare an incident. No permission and no hierarchy needed. A false positive costs twenty minutes; a real incident left undeclared costs hours.
1.2. The roles during an incident
In severity 1 and 2 incidents, three roles are explicitly separated. In small teams one person can hold two, but command and hands-on work are never combined.
| Role | What they do | What they do NOT do |
|---|---|---|
| Incident commander | Coordinates, decides, keeps the timeline, decides when to escalate and when to declare it resolved | Does not touch the keyboard. Does not diagnose |
| Communications | Informs the business, the board and customers; updates the status page; filters interruptions | Does not make technical decisions |
| Responders | Diagnose and apply mitigations, telling the commander everything they do | Do not talk to the business, do not decide the communications |
Why separating them is not bureaucracy but a lesson learned the hard way. The person debugging cannot at the same time answer on three channels, talk to the board and keep the timeline: if they try, they do all four badly. With nobody deciding, you end up with three people applying contradictory mitigations to the same system — one restarts pods, another does a rollout undo, a third scales by hand — which is a real and frequent pattern. With no dedicated communications, either nobody gets informed, or the responders spend the incident answering "is it fixed yet?" instead of fixing it. And the commander, with no hands on the keyboard, keeps perspective: they are the one who notices that everybody has spent forty minutes chasing a hypothesis that leads nowhere.
1.3. The script for the first ten minutes
graph TB
A[Alert or report] --> B{Real impact<br/>on users?}
B -->|No| C[Sev 3 or 4<br/>scheduled task]
B -->|Yes| D[Declare an incident<br/>open a dedicated channel]
D --> E[Assign a commander<br/>whoever declared it, if nobody else]
E --> F[Min 0-2: scope<br/>what is failing, for whom, since when?]
F --> G[Min 2-4: what has changed?<br/>deployments, config, infrastructure]
G --> H[Min 4-7: MITIGATE<br/>do not diagnose yet]
H --> I[Min 7-10: communicate<br/>status + next update]
I --> J{Mitigated?}
J -->|Yes| K[Now yes: diagnose<br/>calmly]
J -->|No| L[Escalate: more people,<br/>the provider, higher severity]
Minutes 0-2. Scope. What is failing, for whom and since when. The answers get written in the channel:
curl -s http://alertmanager.rutas-norte.example/api/v2/alerts \
| jq -r '.[] | select(.status.state=="active")
| "\(.labels.alertname)\t\(.labels.severity)\t\(.startsAt)"' | sort -k3
curl -sG http://prometheus.rutas-norte.example/api/v1/query --data-urlencode 'query=
sum(rate(rutasnorte_requests_total{code=~"5.."}[5m]))
/ sum(rate(rutasnorte_requests_total[5m]))' | jq -r '.data.result[0].value[1]'Minutes 2-4. What has changed? Between 70 and 80 % of incidents are caused by a recent change. It is the highest-yield question in the whole process.
git -C manifests log --since='6 hours ago' --oneline -- overlays/pro
argocd app history bookings-api-pro | tail -5
kubectl get events -A --sort-by=.lastTimestamp | tail -30
kubectl -n rutas-norte-pro get pods --sort-by=.status.startTime | tail -10Minutes 4-7. Mitigate, do not diagnose. This is the point where most teams go wrong. The natural impulse is to understand what is happening. The correct goal is to make it stop hurting, and to understand afterwards.
| Mitigation | When | Command |
|---|---|---|
| Roll back the last deployment | There was a deployment in the last few hours | git revert + argocd app sync |
| Abort the canary | There is a Rollout in progress |
kubectl argo rollouts abort bookings-api |
| Switch off a flag | The failure is in one specific feature | A change in the flag service |
| Scale manually | Saturation is the cause | kubectl scale --replicas=N |
| Restart the affected component | Memory leak or corrupt state | kubectl rollout restart deploy/X |
| Degrade deliberately | An external dependency is failing | A degraded-mode flag |
Minutes 7-10. Communicate. A short, honest message, with the next update committed to:
[SEV 2] Intermittent failures when confirming a booking
Started: 11:42 · Detected: 11:47 · Status: mitigating
Impact: roughly 25 % of purchase attempts are failing.
Timetable search is working normally.
Action: rolling back the bookings-api 2.8.1 deployment (11:38).
Next update: 12:15
Commander: Marta · Communications: IkerWhat makes this message useful: it states the impact in user terms, not infrastructure terms; it says what is being done; and it commits to a specific time for the next update, which stops interruptions at the source.
1.4. Communicating with the business
| Audience | What they need | What they do NOT need |
|---|---|---|
| The board | Sales impact, estimated time, whether data is at risk | CrashLoopBackOff, PromQL, pod names |
| Customer support | What to tell the customer and what alternative to offer | Technical detail |
| Customers | That the problem is known and being worked on | The technical cause |
| The technical team | Everything | — |
Rules: communicate before they ask; say what you know and explicitly what you do not; never promise a resolution time you cannot guarantee (promise the next update, not the resolution); and give bad news early.
1.5. The blameless postmortem
It is held within 48 hours of every severity 1 and 2 incident, and of any incident with something to learn from.
Blameless means something very specific: you start from the position that everybody acted reasonably with the information and the tools they had at the time. If somebody ran a destructive command, the question is not why they ran it, but why the system made such a command easy to run by mistake. If somebody did not see the alert, the question is not why they missed it, but why the alert was not visible.
The reason is not kindness, it is effectiveness: as soon as blame is on the table, people stop telling you what actually happened, and without that there is no analysis to be had.
The template:
# Postmortem — INC-2026-041
## Summary
One sentence: what happened, who it affected, how long it lasted.
## Impact
- Duration: 11:42 – 12:31 (49 min)
- Users affected: ~2,400 failed purchase attempts
- Estimated financial impact: ~€11,000 in uncompleted sales
- Data: no loss and no exposure ← if there were, this triggers the legal protocol
## Timeline (exact times)
- 11:38 · bookings-api 2.8.1 deployed (approved promotion)
- 11:42 · 500 errors start on POST /bookings
- 11:47 · BookingsApiHighErrorRate fires (for: 5m)
- 11:49 · Marta declares SEV 2 and takes command
- 11:53 · The 11:38 deployment is identified as the suspect
- 11:58 · git revert + forced sync
- 12:06 · Error rate back to normal
- 12:31 · Declared resolved after 25 min of observation
## Root cause
Version 2.8.1 added a query on `pricing_rules` with no index on
(line, time_slot). At production volume, every booking confirmation
did a sequential scan of 1.2 M rows, exhausting the connection
pool and causing cascading 500 errors.
## Why it was not caught earlier
1. `pre` has 40,000 rows in `pricing_rules`; production has 1.2 M.
The query was instantaneous in pre.
2. The canary analysis was skipped: the promotion was done with
`promote --full` to get in before the bank-holiday freeze.
3. There is no alert on slow PostgreSQL queries.
## What went well
- The alert fired 5 min after the problem started.
- The Git rollback took 8 min from decision to service restored.
- The separation of roles worked: nobody interrupted the responders.
## Follow-up actions
| # | Action | Type | Owner | Date |
|---|---|---|---|---|
| 1 | Index on pricing_rules(line, time_slot) | Corrective | Ane | 06/08 |
| 2 | Populate `pre` with a representative production volume (anonymised) | Preventive | Iker | 20/08 |
| 3 | Forbid `promote --full` in pro without the commander's authorisation | Preventive | Marta | 13/08 |
| 4 | Alert on pg_stat_statements: queries > 1 s | Detection | Ane | 20/08 |
| 5 | "API latency through the roof" runbook | Response | Jon | 27/08 |Four criteria for actions to be worth anything: a named owner (not "the team"), a specific date, prioritised (three that get done beat twelve that do not), and reviewed in the weekly meeting until they are closed.
Action number 2 in this example is the most valuable and the most expensive: the underlying problem was not the query, it was that pre did not resemble production. That kind of action is what prevents whole families of future incidents.
- Runbooks
A runbook is the written procedure for a specific symptom. Its real value is that it lets somebody who is not an expert in that component respond correctly at three in the morning.
2.1. The template
# [Name of the symptom]
**Alert:** AlertName · **Severity:** N · **Updated:** date
## Symptom
What you observe, as whoever receives the alert sees it.
## Impact
What it means for the user and for the business. It determines urgency.
## Checks
Exact commands to confirm the problem and bound its scope.
## Mitigation
What to do NOW to make it stop hurting. Ordered from safest to most aggressive.
## Resolution
How to fix it properly, without rushing, after mitigating.
## Escalation
If it is not mitigated in X minutes: who to call and with what information.Rules for a useful runbook: commands that can be copied and run as they stand, not descriptions; mitigation before diagnosis; linked from the annotation of the alert that fires it; and reviewed after every use, because an out-of-date runbook is worse than none.
2.2. Runbook: bookings-postgres disk nearly full
# bookings-postgres disk nearly full
**Alert:** PostgresDiskHigh · **Severity:** 2 (critical if >95 %) · **Upd.:** 2026-07-30
## Symptom
The data or WAL volume of bookings-postgres is above 85 % usage.
## Impact
At 100 %, PostgreSQL STOPS ACCEPTING WRITES. No tickets can be
bought. Immediate sev 1. Going from 85 % to 100 % can take hours or minutes
depending on the load.
## Checks
kubectl -n rutas-norte-pro exec bookings-postgres-1 -- df -h /var/lib/postgresql/data /var/lib/postgresql/wal
# Data or WAL? It is the question that decides everything else.
# If it is WAL: is archiving failing?
kubectl -n rutas-norte-pro exec bookings-postgres-1 -- psql -U postgres -tc \
"SELECT last_archived_wal, last_failed_wal, last_failed_time FROM pg_stat_archiver;"
# If it is WAL: is an abandoned replication slot retaining segments?
kubectl -n rutas-norte-pro exec bookings-postgres-1 -- psql -U postgres -tc \
"SELECT slot_name, active, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained FROM pg_replication_slots;"
# If it is data: what is taking up the space?
kubectl -n rutas-norte-pro exec bookings-postgres-1 -- psql -U postgres bookings -tc \
"SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) FROM pg_catalog.pg_statio_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 10;"
## Mitigation
1. **Expand the volume** (the safest; the rutasnorte-fast class
supports online expansion, see 05-05):
kubectl -n rutas-norte-pro patch pvc bookings-postgres-1 \
-p '{"spec":{"resources":{"requests":{"storage":"300Gi"}}}}'
kubectl -n rutas-norte-pro get pvc bookings-postgres-1 -w
2. **If it is an abandoned slot** (active=false and several GB retained):
confirm with the team that the replica no longer exists and drop it:
kubectl -n rutas-norte-pro exec bookings-postgres-1 -- psql -U postgres -c \
"SELECT pg_drop_replication_slot('slot_name');"
⚠ Dropping an ACTIVE slot breaks that replica. Verify first.
3. **If archiving is failing**: check the store credentials and
connectivity. Until it archives, WAL piles up without end.
4. **NEVER** delete files from pg_wal by hand. It corrupts the cluster.
## Resolution
- Data: review retention of historical tables; partition `bookings`
by date; VACUUM FULL in a maintenance window if there is bloat.
- WAL: repair the archiving; tune max_wal_size if the spike is normal.
- Review the growth trend and plan capacity for 6 months ahead.
## Escalation
If it does not drop below 90 % in 20 min, or if it goes above 95 %: raise to SEV 1,
notify the on-call commander and prepare the restore from 11-02 in case
the cluster goes read-only.2.3. Runbook: API latency through the roof
# bookings-api latency through the roof
**Alert:** BookingsApiHighLatency (p95 > 500 ms, 10 min) · **Sev:** 2 · **Upd.:** 2026-08-06
## Symptom
The 95th percentile of bookings-api latency is above 500 ms in a
sustained way. SLO threshold: 300 ms.
## Impact
Buying tickets feels slow; conversion falls. Above
2 s, customers give up and web-store starts timing out.
## Checks
# 1. Is it the whole API or one specific route?
curl -sG http://prometheus.rutas-norte.example/api/v1/query --data-urlencode 'query=
histogram_quantile(0.95, sum by (route, le) (rate(rutasnorte_request_duration_seconds_bucket[5m])))' \
| jq -r '.data.result[] | "\(.metric.route)\t\(.value[1])"' | sort -k2 -rn
# 2. Is it load (more requests) or degradation (same requests, slower)?
curl -sG http://prometheus.rutas-norte.example/api/v1/query --data-urlencode 'query=
sum(rate(rutasnorte_requests_total[5m]))'
# 3. Is the HPA scaling or is it at the ceiling?
kubectl -n rutas-norte-pro get hpa bookings-api
# 4. CPU throttling or memory pressure?
kubectl -n rutas-norte-pro top pods -l app.kubernetes.io/name=bookings-api
# 5. Is it the database?
kubectl -n rutas-norte-pro exec bookings-postgres-1 -- psql -U postgres bookings -tc \
"SELECT substr(query,1,60), calls, round(mean_exec_time::numeric,1) AS ms
FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;"
# 6. Are the pooler's connections exhausted?
kubectl -n rutas-norte-pro logs deploy/bookings-postgres-pooler-rw --tail=50 | grep -i "pool\|wait"
# 7. Is it the external payment gateway?
curl -sG http://prometheus.rutas-norte.example/api/v1/query --data-urlencode 'query=
histogram_quantile(0.95, sum by (le) (rate(rutasnorte_payment_duration_seconds_bucket[5m])))'
## Mitigation
| Finding | Action |
|---|---|
| HPA at maxReplicas | Raise maxReplicas temporarily and check there are nodes |
| Recent deployment | Roll back (see the failed-deployment runbook) |
| A new slow query | Switch off the flag for the feature involved |
| Connection pool exhausted | Raise the Pooler's default_pool_size (headroom up to ~150) |
| Payment gateway slow | Enable degraded mode: booking without immediate payment |
| CPU throttling | Check that no limits.cpu has been introduced (see 11-01) |
## Resolution
Fix the specific cause: a missing index, an N+1 query, pool
sizing, badly calculated CPU requests. Add the case to the k6
load test suite so that it does not come back.
## Escalation
If p95 > 2 s for more than 10 min: raise to SEV 1. If the cause is the
external gateway: contact the provider and enable degraded mode.2.4. Runbook: a deployment that has gone wrong
# Failed bookings-api deployment
**Trigger:** an alert after a deployment, or an aborted Rollout · **Sev:** 2 · **Upd.:** 2026-08-06
## Symptom
After a deployment: 5xx errors, high latency, pods restarting or an
Argo Rollouts AnalysisRun in Failed.
## Impact
Depends on how far the deployment got. With the canary aborted, the impact is
already contained. With promote --full, it affects 100 % of users.
## Checks
kubectl argo rollouts get rollout bookings-api -n rutas-norte-pro
git -C manifests log -3 --oneline -- overlays/pro
argocd app history bookings-api-pro | tail -5
kubectl -n rutas-norte-pro logs -l app.kubernetes.io/name=bookings-api \
--tail=100 --since=15m | grep -i error | head -30
## Mitigation (ordered by speed)
1. **Flag** (5 s) — if the change is behind a flag, switch it off.
2. **Abort the Rollout** (10 s) — if it is still in progress:
kubectl argo rollouts abort bookings-api -n rutas-norte-pro
3. **Revert in Git** (3-4 min) — ALWAYS, even if 1 or 2 was done,
because selfHeal would put the bad version straight back:
cd manifests
git revert --no-edit $(git log -1 --format=%H -- overlays/pro)
git push origin main
argocd app sync bookings-api-pro
argocd app wait bookings-api-pro --health --timeout 300
4. ⚠ **If the deployment included a NON-compatible schema migration**,
do NOT roll back without asking. It can corrupt data. Escalate immediately.
## Verification afterwards
Run layers 1-9 of the verification from 11-01. Do not declare it resolved
until 15 min with no alerts.
## Resolution
A postmortem is mandatory. Key questions: why did `pre` not catch it?
why did the canary not catch it? was any gate skipped?
## Escalation
If the problem persists after rolling back, the deployment was NOT the cause:
go back to the latency runbook or open a general investigation (07-06).2.5. Runbook: a lost node
# Lost or NotReady node
**Alert:** NodeNotReady (NotReady > 5 min) · **Sev:** 3, or 2 if several · **Upd.:** 2026-07-15
## Symptom
One or more nodes NotReady or gone from the cluster.
## Impact
Usually none: the pods reschedule themselves. It is SEV 2 if several
go down at once, if it affects a bookings-postgres instance, or if the
cluster runs out of capacity to reschedule.
## Checks
kubectl get nodes -o wide
kubectl describe node <node> | grep -A15 Conditions
kubectl get pods -A -o wide --field-selector spec.nodeName=<node>
kubectl get pods -A --field-selector status.phase=Pending # is there anywhere to reschedule?
kubectl -n rutas-norte-pro get cluster bookings-postgres # does it affect the state?
## Mitigation
1. **A single node, the rest healthy**: usually nothing needs doing. Karpenter
provisions a new one in 1-3 min. Check with `kubectl get nodes -w` that
no pods are left Pending.
2. **Pods Pending for lack of capacity**: check that Karpenter can
scale (node class limits, cloud quotas, VPC IPs)
with `kubectl -n karpenter logs deploy/karpenter --tail=50`.
3. **A PDB is blocking eviction** (see the HPA/PDB consistency in 11-01):
`kubectl -n rutas-norte-pro get pdb`.
4. **It was a bookings-postgres instance**: verify that the operator
promoted and that the replica is being rebuilt.
5. **NotReady and not being replaced**: force the drain and delete it:
kubectl drain <node> --ignore-daemonsets --delete-emptydir-data --force
kubectl delete node <node>
## Resolution
Identify whether it was a spot instance interruption (normal and expected,
see 10-06), hardware failure, kubelet resource exhaustion or a network
problem. If it recurs in the same zone, suspect the zone and consider
excluding it temporarily.
## Escalation
If 3 or more nodes go down in 10 min, or if a whole zone disappears: SEV 2,
notify the commander, and assess the failover procedure from 11-05.2.6. Linking runbooks from the alerts
A runbook is only useful if it turns up at the moment of the alert. The runbook annotation on the rules from 07-04 is what achieves that:
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: rutas-norte-alerts
namespace: rutas-norte-pro
spec:
groups:
- name: bookings-api
rules:
- alert: BookingsApiHighLatency
expr: |
histogram_quantile(0.95, sum by (le) (
rate(rutasnorte_request_duration_seconds_bucket[5m]))) > 0.5
for: 10m
labels: { severity: "2", team: development, service: bookings-api }
annotations:
summary: "bookings-api p95 latency above 500 ms"
description: "current p95: {{ $value | humanizeDuration }}"
runbook: "https://wiki.rutasnorte.example/runbooks/api-high-latency"
dashboard: "https://grafana.rutas-norte.example/d/bookings-api"
- alert: PostgresDiskHigh
expr: |
(1 - kubelet_volume_stats_available_bytes{persistentvolumeclaim=~"bookings-postgres.*"}
/ kubelet_volume_stats_capacity_bytes{persistentvolumeclaim=~"bookings-postgres.*"}) > 0.85
for: 5m
labels: { severity: "2", team: platform }
annotations:
summary: "Volume {{ $labels.persistentvolumeclaim }} at {{ $value | humanizePercentage }}"
runbook: "https://wiki.rutasnorte.example/runbooks/postgres-disk-full"And one simple policy that keeps the quality up: every alert that wakes somebody up must have a runbook. If it does not, either you write one or the alert should not be waking anybody.
- SLIs, SLOs and the error budget
In 07-04 we saw what they are. Here they are going to be used to make decisions, which is the only thing that distinguishes them from decoration on a dashboard.
3.1. The Rutas Norte indicators
An SLI measures what the user experiences, not what the infrastructure does. "CPU at 80 %" is not an SLI; "99.7 % of searches respond in under 400 ms" is.
| SLI | Precise definition | Why this one |
|---|---|---|
| Purchase availability | % of POST /bookings with a non-5xx response |
It is the transaction that generates revenue |
| Search latency | % of GET /schedules answered in < 400 ms |
It is the first thing the user does; if it is slow, they leave |
| Website availability | % of requests to www with a 2xx or 3xx response |
No website, no sales |
| Report freshness | % of days with occupancy-reports finished before 07:00 |
Operations plans the fleet with them |
# Purchase availability SLI, 30-day window
sum(rate(rutasnorte_requests_total{route="/bookings", method="POST", code!~"5.."}[30d]))
/
sum(rate(rutasnorte_requests_total{route="/bookings", method="POST"}[30d]))3.2. Setting the target on business criteria
The classic mistake is choosing the number for aesthetic reasons: "99.99 % sounds serious". Each extra nine multiplies the cost roughly tenfold.
| SLO | Downtime per month | Relative cost | What it demands |
|---|---|---|---|
| 99.0 % | 7 h 18 min | × 1 | One cluster, good practice |
| 99.5 % | 3 h 39 min | × 2 | Multi-zone high availability |
| 99.9 % | 43 min | × 4 | On-call, runbooks, safe deployments |
| 99.95 % | 21 min | × 8 | Active 24×7 on-call, regional redundancy |
| 99.99 % | 4 min | × 20 | Multi-region active-active, total automation |
The three questions that set the number: what does each minute of downtime cost? (at Rutas Norte, about €90/min on a normal day and about €900/min during the May bank-holiday weekend); what does the user perceive? (nobody notices three minutes a month, everybody notices seven hours); and what does the competition do and what has been promised contractually?
The Rutas Norte SLOs:
| SLI | SLO | Window | Justification |
|---|---|---|---|
| Purchase availability | 99.9 % | 30 rolling days | 43 min/month ≈ €3,900 on a normal day. Acceptable. The jump to 99.95 % would demand active 24×7 on-call, which costs more than it saves |
| Search latency | 99.0 % < 400 ms | 30 rolling days | 1 % of slow searches does not move conversion measurably |
| Website availability | 99.9 % | 30 rolling days | Same as purchases: no website, no sales |
| Report freshness | 95 % of days | 90 days | Operations tolerates the occasional delay |
Note that none of them is 100 %. A 100 % SLO means any change is unacceptable, and that paralyses the product.
3.3. The error budget as a decision-making tool
Error budget = 100 % − SLO. With a 99.9 % SLO over 30 days, the budget is 43 minutes and 12 seconds of unavailability per month.
And here is the idea that changes everything: that budget is a resource you can spend deliberately. Having part of it consumed is not a failure; it is what it exists for.
# Budget consumed in the 30-day window (0 = untouched, 1 = exhausted)
(1 - (
sum(rate(rutasnorte_requests_total{route="/bookings",method="POST",code!~"5.."}[30d]))
/ sum(rate(rutasnorte_requests_total{route="/bookings",method="POST"}[30d]))
)) / (1 - 0.999)The Rutas Norte policy, approved by the board and by the team:
| Budget consumed | What you can do | Who decides |
|---|---|---|
| < 50 % | Normal pace. You can take on more risk: faster canaries, infrastructure changes | The team |
| 50-75 % | Normal pace, but every high-risk deployment requires a full canary with no promote --full |
The team |
| 75-100 % | Warning: fixes and low-risk features only. Priority to the outstanding postmortem actions | Platform |
| > 100 % (exhausted) | Feature freeze. The team spends 100 % of its time on reliability until the budget recovers. No exceptions without board approval | The board |
What is valuable about this policy is that it turns an argument about opinions into a decision based on a number. When product asks for one more feature and platform says the system is fragile, the conversation stops being "I reckon that…" and becomes "the budget is at 130 %, and this is what we agreed to do in that case".
And it works in both directions, which is what makes it fair: if the budget is at 20 % halfway through the month, platform cannot block a deployment on grounds of risk. The system is being more reliable than promised, and that means you can go faster.
Alerting on the budget: you alert on the burn rate, not on an absolute threshold. The expression combines a long window and a short one to avoid false positives, and it fires when the pace would exhaust thirty days' budget in two.
- alert: ErrorBudgetBurnFast
expr: |
(1 - (sum(rate(rutasnorte_requests_total{route="/bookings",code!~"5.."}[1h]))
/ sum(rate(rutasnorte_requests_total{route="/bookings"}[1h])))) > 14.4 * 0.001
and
(1 - (sum(rate(rutasnorte_requests_total{route="/bookings",code!~"5.."}[5m]))
/ sum(rate(rutasnorte_requests_total{route="/bookings"}[5m])))) > 14.4 * 0.001
for: 2m
labels: { severity: "2", team: platform }
annotations:
summary: "Fast burn of the purchase error budget"
runbook: "https://wiki.rutasnorte.example/runbooks/error-budget"
- Change management
4.1. Windows and freezes
| Type of change | When it is allowed | Approval |
|---|---|---|
| Low-risk fix | Any working day, 09:00-16:00 | Code review |
| Feature with a canary | Monday to Thursday, 09:00-15:00 | The promotion gate from 11-03 |
| Schema migration | Tuesday or Wednesday, 10:00-12:00 | Platform + development |
| Infrastructure change | Tuesday or Wednesday, 09:00-12:00 | Platform + 48 h notice |
| Cluster upgrade | Scheduled quarterly window | A written plan + a rehearsal in nonpro |
| Incident fix | Always, no window | The incident commander |
Scheduled freezes:
| Period | Duration | What is frozen |
|---|---|---|
| May bank holiday | From 29 April 18:00 to 5 May 09:00 | Everything except sev 1 and 2 fixes |
| Easter week | From the Friday before to the Monday after | Ditto |
| August (weeks 32-33) | 2 weeks | Infrastructure changes; the rest with approval |
| Christmas | 22 Dec to 7 Jan | Everything except critical fixes |
A freeze is not "no work happens": it is "production does not change". During the May bank-holiday freeze the team does what it cannot do the rest of the year: write runbooks, improve tests, review noisy alerts, pay down technical debt that does not touch production.
4.2. Why deploying on Friday afternoon is a bad idea
It is not superstition, it is the arithmetic of detection time and response capacity. Problems do not appear instantly: memory leaks, table bloat, connection exhaustion and disks filling up take hours or days, and a deployment at 18:00 on Friday can fail at 04:00 on Saturday. At the weekend there are fewer people and they are less available: the 12-minute time to restore from 11-03 is measured in working hours, and on a Saturday morning it is easily triple that. Weekend traffic is different: at Rutas Norte more leisure tickets are sold on a Saturday than on any weekday, so that is when a failure hurts most. Nobody is watching, because whoever deployed has gone home. And there is a perverse effect: knowing all of this, whoever deploys at 18:00 on a Friday does it in a hurry and skips checks, so they can leave.
The Rutas Norte rule: the last deployment to production is Thursday at 15:00, and Friday is spent watching what was deployed during the week. The exception is incident fixes, which have no window because the cost of not making them is higher.
- Capacity planning for the May bank-holiday weekend
5.1. The starting numbers
| Metric | Normal day | May bank holiday (forecast) | Factor |
|---|---|---|---|
Requests/s to bookings-api (mean) |
85 | 850 | ×10 |
| Peak requests/s | 240 | 2,400 | ×10 |
| Bookings/day | 3,100 | 24,000 | ×7.7 |
| Queries/s to PostgreSQL | 320 | 3,400 | ×10.6 |
| Outbound bandwidth | 45 Mbps | 480 Mbps | ×10.7 |
The peak is not evenly spread: it concentrates between 10:00 and 13:00 on the Wednesday and Thursday before the long weekend, when people buy their return ticket.
5.2. The calculation, component by component
bookings-api. From the load test in 09-06: one replica with 200m of CPU sustains around 70 requests/s with a p95 of 180 ms. For 2,400 requests/s:
2400 / 70 = 34.3 replicas needed
+ 20 % safety margin = 41 replicas
current maxReplicas: 40 → raise to 55And you have to verify that the arithmetic adds up along the whole chain, including the PostgreSQL connections, which were the bottleneck in 11-02:
41 × 200m CPU = 8.2 vCPU of requests + web-store (0.75) + workers + system ≈ 14 vCPU
4 vCPU nodes with ~3.2 usable → 5 nodes minimum, 8 with margin
Karpenter: the node class limit must allow at least 12
41 replicas × 20 connections = 820 requested
PgBouncer max_client_conn 1000 ✔ (18 % margin)
default_pool_size 40 + reserve 10 = 50 real against 197 available ✔The namespace ResourceQuota (03-04): with 41 API replicas plus everything else, the current 20 vCPU quota falls short. It has to go up to 32 before the bank holiday, or the HPA will ask for pods the quota will reject: a silent and baffling failure.
PostgreSQL: 3,400 queries/s against the usual 320. The load test showed that the primary holds up to 4,200 queries/s before degrading, with the report reads already diverted to replicas. Enough, but with no margin for a new badly indexed query, which is exactly what happened in incident INC-2026-041.
5.3. The load test that validates it
// tests/may-bank-holiday-load.js — run in rutas-norte-pre
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
const failureRate = new Rate('business_failures');
const purchaseDuration = new Trend('full_purchase_duration');
export const options = {
scenarios: {
// A realistic profile: ramp up, a 20-min plateau and ramp down.
may_bank_holiday: {
executor: 'ramping-arrival-rate',
startRate: 85,
timeUnit: '1s',
preAllocatedVUs: 300,
maxVUs: 2000,
stages: [
{ target: 85, duration: '2m' }, // baseline
{ target: 850, duration: '5m' }, // ramp
{ target: 850, duration: '20m' }, // sustained plateau
{ target: 2400, duration: '3m' }, // peak
{ target: 2400, duration: '5m' }, // sustained peak
{ target: 85, duration: '5m' }, // ramp down
],
},
},
thresholds: {
'http_req_duration{type:search}': ['p(95)<400'],
'http_req_duration{type:booking}': ['p(95)<800'],
'http_req_failed': ['rate<0.005'],
'business_failures': ['rate<0.01'],
},
};
const BASE = __ENV.BASE_URL || 'https://api-pre.rutasnorte.example';
export default function () {
group('full purchase flow', () => {
const search = http.get(`${BASE}/schedules?line=BIL-SAN&date=2026-05-01`,
{ tags: { type: 'search' } });
check(search, { 'search 200': (r) => r.status === 200 });
sleep(Math.random() * 3 + 1); // the user looks at the results
if (Math.random() < 0.12) { // only 12 % book: the real funnel
const start = Date.now();
const booking = http.post(`${BASE}/bookings`,
JSON.stringify({ line: 'BIL-SAN', date: '2026-05-01', seats: 2 }),
{ headers: { 'Content-Type': 'application/json',
'Idempotency-Key': `k6-${__VU}-${__ITER}` },
tags: { type: 'booking' } });
purchaseDuration.add(Date.now() - start);
failureRate.add(!check(booking, { 'booking 201': (r) => r.status === 201 }));
}
});
}Results of the test on 15 April 2026:
| Metric | Target | Result | Verdict |
|---|---|---|---|
| Search p95 | < 400 ms | 287 ms | ✔ |
| Booking p95 | < 800 ms | 612 ms | ✔ |
| HTTP error rate | < 0.5 % | 0.08 % | ✔ |
| Replicas reached | < 55 | 47 | ✔ |
| HPA reaction time | < 2 min | 95 s | ✔ |
| PostgreSQL peak CPU | < 80 % | 71 % | ✔ |
| PgBouncer queue wait | 0 | 0 | ✔ |
| Nodes provisioned by Karpenter | — | 11 (in 3 min) | ⚠ see note |
The note: Karpenter took three minutes to provision the peak's nodes. During that time there were pods in Pending and the p95 rose to 1.1 s. Action taken: over-provision with low-priority pods (a negative PriorityClass, from 06-05) that Karpenter evicts instantly when real load arrives, leaving nodes already warm.
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata: { name: capacity-filler }
value: -10 # negative priority: any real workload evicts them
globalDefault: false
description: "Reserves warm capacity. Evicted when real load arrives."
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: capacity-filler, namespace: platform }
spec:
replicas: 6 # 6 in bank-holiday week; 2 the rest of the year
selector: { matchLabels: { app: capacity-filler } }
template:
metadata: { labels: { app: capacity-filler } }
spec:
priorityClassName: capacity-filler
terminationGracePeriodSeconds: 0
containers:
- name: pause
image: registry.k8s.io/pause:3.9
resources: { requests: { cpu: "1", memory: 2Gi } }5.4. The bank-holiday preparation checklist
| # | Action | When |
|---|---|---|
| 1 | Full load test in pre with the bank-holiday profile |
3 weeks before |
| 2 | Raise the HPAs' maxReplicas according to the calculation |
2 weeks before |
| 3 | Increase the namespace ResourceQuota | 2 weeks before |
| 4 | Check Karpenter limits and cloud quotas | 2 weeks before |
| 5 | Raise capacity-filler from 2 to 6 replicas |
1 week before |
| 6 | Review and test every runbook | 1 week before |
| 7 | Confirm the on-call rota with names and phone numbers | 1 week before |
| 8 | Backup restore tested | 1 week before |
| 9 | Change freeze | 29 April 18:00 |
| 10 | Daily metrics review during the long weekend | Daily |
| 11 | Undo 2, 3 and 5 | 1 week after |
Point 11 is systematically forgotten and it is the reason next year's bill starts 20 % higher.
- Costs
6.1. Why the bill goes through the roof
| Cause | Mechanism | Typical magnitude |
|---|---|---|
Oversized requests |
1 vCPU is reserved and 80m is used. You pay for what is reserved | 30-50 % waste |
| Environments nobody switches off | dev and pre at full capacity 168 h/week while used for 40 |
60 % of the non-production cost |
| Autoscaling with no ceiling | The maxReplicas set "just in case" gets reached during an attack or a loop |
Inexplicable bill spikes |
| Orphaned resources | PVs from deleted pods, Service load balancers half-removed, unassigned elastic IPs | 5-15 %, growing over time |
| Log and metric retention | Keeping everything for 30 days "just in case" | It often exceeds the compute cost |
| Cross-zone traffic | Pods spread out talking to each other; every byte between zones is billed | 3-8 %, invisible until you measure it |
| Oversized volumes | A 500 GB volume with 40 GB used costs the same as a full one | Variable |
| Everything on demand | Not using spot instances for tolerant workloads | Up to 70 % overspend on those workloads |
And one underlying cause that encompasses them all: nobody sees the bill for what they deploy. A developer who puts requests: 2 instead of 200m gets no signal at all.
6.2. Visibility with OpenCost
helm install opencost opencost/opencost -n platform \
--set opencost.prometheus.internal.enabled=true \
--set opencost.prometheus.internal.serviceName=prometheus-operated \
--set opencost.prometheus.internal.namespaceName=monitoring# Cost per namespace over the last 7 days
curl -sG http://opencost.platform:9003/allocation/compute \
-d window=7d -d aggregate=namespace -d accumulate=true \
| jq -r '.data[0] | to_entries[] | "\(.key)\t\(.value.totalCost | . * 100 | round / 100) €"' \
| sort -k2 -rnrutas-norte-pro 1842.30 €
rutas-norte-pre 612.45 €
monitoring 384.10 €
rutas-norte-dev 298.72 €
platform 156.90 €
argocd 41.20 €The fact that monitoring costs more than rutas-norte-dev always comes as a surprise, and it is normal: Prometheus with long retention and many series is expensive.
| Tool | Model | When |
|---|---|---|
| OpenCost | Open source, CNCF | To start with. It covers the essentials |
| Kubecost | Commercial, with a free tier | Reports, budgets, cost alerts, recommendations |
| Provider tooling | Included | Good for the overall bill; bad for per-pod detail |
6.3. Allocation by team and environment
This is where the labels from 02-07 stop being a convention and become a management instrument.
# Cost per team
curl -sG http://opencost.platform:9003/allocation/compute \
-d window=30d -d aggregate=label:rutasnorte.example%2Fteam -d accumulate=true \
| jq -r '.data[0] | to_entries[] | "\(.key)\t\(.value.totalCost | round) €"'__unallocated__ is the quality indicator for your labelling: €341 that nobody can explain. The Kyverno policy from 11-05 that requires the rutasnorte.example/team label exists precisely to drive that number to zero.
Informational allocation works better than real internal billing. A monthly report per team, compared with the previous month, changes behaviour without the bureaucracy of actually charging. It is enough for somebody to see that their service went from 300 to 700 euros.
6.4. The levers, ordered by return
| # | Lever | Typical saving | Effort | Risk |
|---|---|---|---|---|
| 1 | Right-sizing using the VPA recommendations (09-02) | 25-40 % | Low | Low |
| 2 | Switching non-production off at night and at weekends | 60 % of dev+pre |
Low | None |
| 3 | Spot instances for tolerant workloads | Up to 70 % on those workloads | Medium | Low with a PDB |
| 4 | Orphaned resources: volumes, load balancers, IPs | 5-15 % | Low, one-off | None |
| 5 | Log and metric retention | 20-40 % of observability | Low | Medium: less history |
| 6 | Oversized volumes | Variable | Medium | Low |
| 7 | Committed-use discounts for 1-3 years | 20-40 % of the stable base | Low | A financial commitment |
Lever 1: right-sizing. The VPA in recommendation mode, applying no changes, tells you what is being wasted:
kubectl -n rutas-norte-pro get vpa bookings-api \
-o jsonpath='{.status.recommendation.containerRecommendations[0]}' | jq{"containerName":"api","lowerBound":{"cpu":"142m","memory":"198Mi"},
"target":{"cpu":"186m","memory":"241Mi"},"upperBound":{"cpu":"312m","memory":"402Mi"}}With requests: 200m and target: 186m, bookings-api is well sized. The interesting case was notifications-worker, with requests: 1 of CPU and a target of 120m: 88 % waste multiplied by its replicas.
Lever 2: switching off at night. The best return on effort of them all.
apiVersion: batch/v1
kind: CronJob
metadata:
name: shutdown-nonpro
namespace: platform
spec:
schedule: "0 20 * * 1-5" # 20:00 Monday to Friday (another CronJob starts them at 07:30)
jobTemplate:
spec:
template:
spec:
serviceAccountName: schedule-manager
restartPolicy: OnFailure
containers:
- name: shutdown
image: registry.rutasnorte.example/tools/kubectl:1.30
command:
- /bin/sh
- -c
- |
for NS in rutas-norte-dev rutas-norte-pre; do
# We save the current replica counts in a ConfigMap so we can restore them.
kubectl -n "$NS" get deploy -o json \
| jq -r '.items[] | "\(.metadata.name)=\(.spec.replicas)"' > /tmp/r.txt
kubectl -n "$NS" create configmap saved-replicas \
--from-file=/tmp/r.txt --dry-run=client -o yaml | kubectl -n "$NS" apply -f -
kubectl -n "$NS" scale deploy --all --replicas=0
done
# bookings-postgres in pre is NOT switched off: restoring it costs
# more time than it saves in money.With start-up at 07:30, dev and pre run 57 hours a week instead of 168: a 66 % saving in those environments.
Lever 3: spot instances. Continuing what was decided in 10-06:
| Workload | Spot? | Reason |
|---|---|---|
bookings-api |
Partially (50 %) | With a PDB and topology spread it tolerates interruptions; an on-demand base is kept |
web-store |
Yes | Stateless, starts in seconds |
notifications-worker |
Yes | Queue processing, retryable by design |
occupancy-reports |
Yes | Nightly batch job, with a backoffLimit |
bookings-postgres |
No | An interruption of the primary causes a failover. On demand, always |
| Prometheus | No | Losing the pod loses unpersisted data |
Lever 4: orphaned resources. The sweep worth doing quarterly:
kubectl get pv --no-headers | awk '$5=="Released" || $5=="Available" {print $1, $2, $5}'
aws ec2 describe-volumes --filters Name=status,Values=available \
--query 'Volumes[].{ID:VolumeId,GB:Size,Created:CreateTime}' --output table
aws ec2 describe-addresses --query 'Addresses[?AssociationId==null].PublicIp'
aws elbv2 describe-load-balancers --query 'LoadBalancers[].LoadBalancerName'Rutas Norte's first sweep turned up 14 orphaned volumes (1.8 TB, about €180/month) from the ephemeral environments in 11-03 deleted before the cleanup CronJob existed.
Lever 5: retention. Rutas Norte's revised policy:
| Data | Before | After | Justification |
|---|---|---|---|
| Application logs | 30 days, all of them | 7 days complete + 90 days of level>=warn only |
Debug logs from three weeks ago are never consulted |
| Prometheus metrics | 30 days at full resolution | 15 days full + 1 year aggregated in remote storage | History is consulted in aggregate |
| Traces | 30 days | 7 days, sampled at 10 % | Enough to investigate |
| PostgreSQL backups | 30 days | 30 days | Not touched. Subject to compliance |
That last row matters: retention of backups containing personal data is not a cost lever, it is a compliance decision. Reducing it to save money is a serious mistake.
6.5. The bill before and after
| Item | Before (Mar 2026) | After (Jul 2026) | Saving | Lever |
|---|---|---|---|---|
pro compute |
€2,840 | €2,190 | −€650 | Right-sizing (1) + spot (3) |
dev+pre compute |
€1,420 | €510 | −€910 | Night shutdown (2) + right-sizing (1) |
| Observability | €890 | €520 | −€370 | Retention (5) |
| Storage | €640 | €445 | −€195 | Orphans (4) + volumes (6) |
| Network traffic | €310 | €285 | −€25 | Zone spread tuned |
| Load balancers | €180 | €95 | −€85 | Consolidation into a single Ingress |
| Control planes | €146 | €146 | €0 | — |
| Monthly total | €6,426 | €4,191 | −€2,235 | −34.8 % |
Nearly 27,000 euros a year, for about three person-weeks of work and without degrading any SLO. The data for the period confirms that purchase availability stayed at 99.94 %, above the target.
The obligatory warning: cost optimisation has a point beyond which it starts costing reliability. Cutting requests below what the load needs causes evictions; moving everything to spot instances causes simultaneous interruptions; trimming metric retention leaves an incident analysis with no data. The bill is not the SLO. When they conflict, the SLO wins, and it is worth having that decision written down before it happens.
- Maintenance calendar
None of this is optional; the only optional part is whether it is done as planned work or as an emergency.
| Task | Frequency | Duration | Owner | If it is not done |
|---|---|---|---|---|
| Review of fired alerts and noise | Weekly | 30 min | Platform | Alert fatigue: they all get ignored |
| Review of postmortem actions | Weekly | 30 min | Rotating commander | The same incidents recur |
| Vulnerability review (Trivy, running images) | Weekly | 1 h | Security | They pile up until they are unmanageable |
| Cost review per team | Monthly | 1 h | Platform + finance | The bill grows with no explanation |
| Add-on updates (Helm) | Monthly | 2-4 h | Platform | Impossible version jumps accumulate |
| Application secret rotation | Quarterly | 2 h | Platform | Credentials years old, with no expiry |
| Backup restore test | Quarterly | 4 h | Platform | The backup does not exist (11-02) |
| RBAC and access review | Quarterly | 3 h | Security | Permissions for people who have left |
| Runbook review and testing | Quarterly | 3 h | The whole team | Out-of-date runbooks = worse than none |
| SLO and error budget review | Quarterly | 2 h | Platform + product | Targets that no longer reflect the business |
| Minor cluster upgrade | Quarterly | 1 day | Platform | The version falls out of support |
| Load test with the peak profile | Half-yearly | 1 day | Platform + development | Surprises in peak season |
| Disaster recovery drill | Annual | 2 days | The whole team | The plan from 11-05 does not work (and nobody knows) |
| Retention and compliance review | Annual | 1 day | Platform + compliance | Legal risk |
| Renewal of non-automated certificates | As they expire | — | Platform | An outage from an expired certificate |
About the certificates: the cert-manager ones renew themselves, but there is always one outside the automation (client certificates towards the payment gateway, internal mesh certificates, signing certificates). An inventory with alerts at 30, 14 and 7 days is worth having.
The maintenance budget. Adding up the tasks, maintenance consumes roughly 20-25 % of the platform team's capacity. It is a number that has to be said out loud and defended to whoever does the planning: a team 100 % on new features is a team accumulating operational debt on credit, and that debt is collected in the form of incidents.
- Team maturity
A platform's maturity is not measured by the tools it uses. It is measured by what happens when something goes wrong.
| Level | How you recognise it |
|---|---|
| Reactive | You find out from customers. Nobody knows what was deployed. Changes are made by hand. Every incident is faced from scratch. One specific person is indispensable |
| Instrumented | There are metrics and alerts, but many are noise. There are runbooks, some up to date. Deployments are automated but incidents are handcrafted |
| Managed | The SLOs are defined and used to decide. Every alert that wakes somebody has a runbook. Postmortems generate actions that get closed. Capacity is planned. Costs are attributed |
| Optimised | The error budget governs the planning. Drills are routine. Most incidents mitigate themselves. The team spends more time preventing than firefighting |
Rutas Norte went from reactive to managed in about fourteen months, and it was not the technology that made it possible but four changes of habit: blameless postmortems on every incident, which by the sixth revealed that half of them shared an underlying cause; runbooks written by whoever suffered the incident, immediately afterwards; the error budget as a shared argument, which turned the tension between product and platform into a conversation with data; and the maintenance calendar defended as real work, with a slot in the plan rather than in the spare moments.
Three signs that a team is genuinely maturing: incidents get shorter before they stop happening (you do not eliminate failures, you respond better); the number of indispensable people falls, because only one person knowing how to fix something is a risk and not a virtue; and the time spent on prevention rises, which is the definitive indicator of having left firefighting mode behind.
Common Mistakes and Tips
- Diagnosing before mitigating. Understanding the root cause is important, but not while users are suffering. First make it stop hurting.
- The incident commander with their hands on the keyboard. They lose perspective and stop coordinating. In sev 1 and 2, the commander touches nothing.
- Postmortems that look for someone to blame. People stop telling you what happened and the analysis loses all its value.
- Follow-up actions with no owner and no date. They do not get done. Three actions with a name and a date beat twelve on a list.
- Alerts with no runbook that wake somebody up. Either you write the runbook, or the alert should not be waking anybody.
- A 99.99 % SLO without having worked out what it costs. Each nine multiplies the cost by roughly ten. Choose the number on business criteria.
- The error budget as a decorative dashboard. If it does not change what the team does when it runs out, it is worth nothing.
- Optimising costs without watching the SLOs. Cutting
requeststoo far causes evictions; trimming retention blinds the next investigation. - Forgetting to undo the peak adjustments. Raising
maxReplicasand the quota for the bank holiday and not lowering them afterwards is the most common way for the bill to rise permanently. - Tip: time your runbooks. A runbook that in practice takes forty minutes when it says ten needs revising.
- Tip: make the on-call rota genuinely rotate. If the same person always answers, the knowledge does not spread and that person burns out.
- Tip: celebrate well-handled incidents. A team that only gets attention when something goes wrong ends up hiding problems.
Exercises
Exercise 1: managing an incident
Saturday, 09:14. The BookingsApiHighErrorRate alert arrives (12 % of 5xx errors). It is the first weekend of July, with a lot of leisure traffic. The last deployment to pro was Thursday at 14:30. You are on call and there is one other person reachable. Describe: (a) the severity you declare and why; (b) the roles you assign; (c) the exact commands for your first five minutes; (d) the communication message you send at ten minutes.
Exercise 2: deciding with the error budget
It is 18 September. The error budget for purchase availability (SLO 99.9 %, 30-day window) is 118 % consumed: two sev 2 incidents took 51 minutes. Product wants to deploy the new price comparator, which has been ready for three weeks and which the board wants for the autumn campaign. Platform has four outstanding postmortem actions. Apply the policy from section 3.3: what do you decide, who decides, what do you propose as an alternative and what conditions would you set for lifting the freeze?
Exercise 3: a cost reduction plan
A cluster's monthly bill is: pro compute €3,100, non-production compute €1,900, observability €1,200, storage €800, network €400, load balancers €300. Additional data: the VPA recommends an average of 45 % of the current requests; dev and pre run 24×7; Prometheus retains 45 days at full resolution; there are 22 volumes in Released state; no workload uses spot instances. Propose a plan ordered by return, with the estimated saving of each lever, the associated risk and what you would measure to check that the service does not degrade.
Solutions
Solution 1.
(a) Severity 2. A 12 % error rate is a serious degradation of critical functionality (purchasing), but not a total outage: 88 % of attempts work. It would be sev 1 if it went above 50 % or if buying were impossible altogether. The context of a July weekend with heavy traffic reinforces the urgency and justifies calling the second person.
(b) With two of us: I take command and communications; the reachable person takes the responder role. If it escalates to sev 1, a third person is called and command and communications are separated. What is not done is the commander starting to debug.
(c) The first five minutes:
# Scope
curl -s http://alertmanager.rutas-norte.example/api/v2/alerts \
| jq -r '.[] | select(.status.state=="active") | "\(.labels.alertname)\t\(.startsAt)"'
kubectl -n rutas-norte-pro get pods -l app.kubernetes.io/name=bookings-api
kubectl -n rutas-norte-pro get hpa bookings-api
# What has changed? (the deployment was 43 h ago: probably NOT the cause)
git -C manifests log --since='48 hours ago' --oneline -- overlays/pro
kubectl get events -A --sort-by=.lastTimestamp | tail -20
# What exactly is failing?
kubectl -n rutas-norte-pro logs -l app.kubernetes.io/name=bookings-api \
--tail=100 --since=20m | grep -i error | head -20
# The usual suspects on a high-traffic weekend
kubectl -n rutas-norte-pro get cluster bookings-postgres
kubectl -n rutas-norte-pro exec bookings-postgres-1 -- df -h /var/lib/postgresql/data
kubectl -n rutas-norte-pro logs deploy/bookings-postgres-pooler-rw --tail=30 | grep -i waitThe key reasoning: since the deployment was 43 hours ago and the problem starts now, the most likely hypothesis is not a code change but saturation from weekend traffic (HPA at the ceiling, connections exhausted, disk full) or a cumulative problem (memory leak, table bloat). You go to the latency runbook and the disk one.
(d) Communication at ten minutes:
[SEV 2] Intermittent failures buying tickets
Started: 09:14 · Status: investigating
Impact: roughly 12 % of purchase attempts fail and have
to be retried. Timetable search is working normally.
Action: investigating saturation from weekend traffic;
the HPA is scaling. Thursday's deployment ruled out.
Next update: 09:40
Commander: [name] · Responder: [name]Solution 2. Decision: the price comparator is not deployed. The budget is exhausted (118 %), which triggers the policy's feature freeze. The decision belongs to the board, not the team, precisely because an exception has a business cost and must be owned by whoever holds that responsibility. Platform's role is to present the number, the agreed policy and the alternatives, not to block unilaterally.
The alternative proposed: over the next two weeks the team closes the four outstanding actions, and the comparator is deployed behind a switched-off feature flag (11-04). That way the code reaches production without changing behaviour, it is validated technically, and activation is ready for the moment the budget recovers. This partly satisfies product without consuming budget.
Conditions for lifting the freeze: (1) the four actions closed and verified; (2) the budget in the 30-day rolling window below 100 %, which will happen automatically as the incidents leave the window provided there are no new ones; (3) the comparator must be deployed with a full canary, with no promote --full, with analysis of the conversion metric; (4) a joint review of whether the 99.9 % SLO is still the right one, because two sev 2 incidents in a month may indicate the target is too demanding for the current investment, or that there is an unresolved underlying problem.
Solution 3. Current total: €7,700/month.
| Order | Lever | Calculation | Saving/month | Risk |
|---|---|---|---|---|
| 1 | Switch non-production off at nights and weekends | 1,900 × 0.66 | ~€1,254 | None. You only have to manage the morning start-up |
| 2 | Right-size per the VPA (apply with margin, not exactly 45 %) | (3,100 + 646) × ~0.30 | ~€1,124 | Low-medium. Apply with a 20 % margin over target and watch for evictions |
| 3 | Prometheus retention from 45 to 15 days + remote aggregate | 1,200 × 0.50 | ~€600 | Medium. Fine-grained history is lost; mitigate with aggregated remote storage |
| 4 | Spot instances for tolerant workloads | ~40 % of eligible compute × 0.65 | ~€550 | Low with correct PDBs and topologySpread. Never for the database |
| 5 | Delete the 22 Released volumes |
Estimated | ~€150 | None, once verified they hold no needed data |
| 6 | Consolidate load balancers into a single Ingress | 300 × 0.50 | ~€150 | Low |
| Total | ~€3,828 (−50 %) |
The order is justified: first the zero-risk, low-effort items (1, 5), then the biggest absolute saving (2), and last the ones requiring most care (3, 4).
What to measure to check the service does not degrade: (a) the error budget of every SLO before and after each lever, which is the definitive indicator; (b) evictions from memory pressure and OOMKilled restarts after applying lever 2; (c) p95 latency and HPA reaction time after lever 4, because interruptions increase pod churn; (d) incident resolution time after lever 3, which is where the lack of history would show. Rule of application: one lever at a time, with two weeks of observation between them, so that any degradation can be attributed to its cause.
Conclusion
We have walked through the day-to-day of whoever keeps a platform alive. Incident management with severities defined in advance, separated roles — and the commander not touching the keyboard — the script for the first ten minutes that puts mitigating ahead of diagnosing, and the blameless postmortem whose actions have an owner and a date. Runbooks, with their template and four real Rutas Norte cases linked from the alert annotations. SLIs and SLOs chosen on business criteria, and the error budget turned into a policy that decides what the team does next month. Change management, with its windows, its May bank-holiday freeze and the arithmetical reasons why you do not deploy on Friday afternoons. Capacity planning, with the ×10 numbers and the load test that validates them. Costs, with the levers ordered by return and a real 35 % reduction without touching a single SLO. And the maintenance calendar, which consumes a quarter of the team and has to be defended as real work.
That closes module 11 and, with it, the Rutas Norte journey. We started with a company selling coach tickets and a handful of loose manifests. We gave it Pods, Deployments and Services; configuration and secrets; networking, Ingress and TLS; persistent storage with backups; StatefulSets, operators and advanced scheduling; observability with metrics, logs and alerts; security from the image through to RBAC; autoscaling for the May bank-holiday weekend; Helm, Kustomize, GitOps and a managed cluster. And in this last module we have seen it all working together: a complete web application in production with its thirteen objects, a stateful database with its measured failover and its timed restore, a pipeline that goes from git push to production without ever touching kubectl apply, canary deployments that abort by themselves when Prometheus says something is wrong, a fleet of clusters governed from one repository, and the daily operation that holds all of the above up.
If you have got this far, there is something worth saying plainly: you already have the knowledge the Kubernetes certifications measure. The CKA asks about clusters, nodes, networking, storage and troubleshooting: that is what we did in modules 1 to 7 and in this module's incidents. The CKAD asks about Pods, Deployments, configuration, probes and jobs: that is modules 2 to 6 and the whole of lesson 11-01. The CKS asks about hardening, policies, image security and auditing: that is the whole of module 8.
What is missing is not knowledge, it is format. The exams are practical, timed, with a terminal and the official documentation as your only help, and they punish slowness with kubectl mercilessly. It is a different kind of skill from the one needed to operate a real platform: there, what matters is deciding well; here, you also have to do it fast and without second-guessing the syntax.
That is what module 12, Preparing for Kubernetes Certification, is about: what exactly each exam measures, how the time is distributed, which kubectl shortcuts make the difference between finishing and not finishing, what to look for in the permitted documentation and how to prepare with timed mock exams. We will start with the CKA.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
