If you ask AlpinaShop's management what level of service they expect from the shop, the answer will be "that it never goes down". It is an understandable answer and a completely useless one, for three reasons: it cannot be met, it cannot be measured, and it does not let you decide anything.
It cannot be met because 100 % availability does not exist: hardware fails, the customer's network fails, the DNS provider fails, a deployment fails, a person makes a mistake. It cannot be measured because nobody has defined what counts as "down". And it does not let you decide anything because if the objective is perfection, any investment in reliability is justified and none is ever enough, which in practice means decisions get taken on intuition.
The previous lesson ended by pointing at exactly that: AlpinaShop pays for Cloud SQL's high availability, has duplicated the VPN tunnels and keeps the global load balancer without any declared objective that justifies those three decisions in numbers. And since 07-04 it has been carrying a debt rated critical: nobody has ever checked that the backups can be restored.
This lesson turns reliability into engineering. By the end you will know how to define SLIs, SLOs and SLAs precisely, calculate and use an error budget to decide when you deploy and when you stop, create SLOs and burn rate alerts in Cloud Monitoring, choose the level of high availability for each component knowing what it costs and what it gives you, design against real failure modes, and have a disaster recovery plan with RTO and RPO — rehearsed, not written.
Contents
- What reliability means and why perfection is not an objective
- SLI, SLO and SLA: the three acronyms everybody mixes up
- Choosing good SLIs for AlpinaShop
- AlpinaShop's SLOs, with their real queries
- Error budget: what it is and what it is really for
- SLOs in Cloud Monitoring and burn rate alerts
- Tiered high availability architectures
- Failure modes and their mitigation
- Disaster recovery: RTO, RPO and strategies
- AlpinaShop's recovery plan
- Backups that have not been tested do not exist
- Operational practice: on-call, runbooks and post-mortems
- Chaos testing at a sensible scale
- What reliability means and why perfection is not an objective
Reliability is the probability that a system does what is expected of it over a given period. It is not a binary property: it is a continuous quantity that you choose, pay for and manage.
The curve to keep in your head:
| Objective | Relative cost | What it demands |
|---|---|---|
| 99 % | 3 | One well-configured server |
| 99.9 % | 8 | Multi-zone, canary deployment, fast rollback |
| 99.95 % | 15 | The above + on-call over extended hours |
| 99.99 % | 35 | Multi-region, 24×7 on-call, automated failover |
| 99.999 % | 95 | Active-active, dedicated engineering, much slower deployments |
Every additional nine multiplies the cost, and not linearly. Going from 99 % to 99.9 % is usually a matter of good practice. Going from 99.99 % to 99.999 % requires active-active multi-region, 24×7 on-call, dedicated engineering and accepting that you deploy much more slowly.
And the other half of the equation, the one that almost never gets calculated:
| Availability | Downtime per month | Orders lost (AlpinaShop) | Approximate cost of the outage |
|---|---|---|---|
| 99 % | 7 h 18 min | ~12 | ~€780 |
| 99.5 % | 3 h 39 min | ~6 | ~€390 |
| 99.9 % | 43 min 50 s | ~1.2 | ~€78 |
| 99.95 % | 21 min 55 s | ~0.6 | ~€39 |
| 99.99 % | 4 min 23 s | ~0.1 | ~€8 |
| 99.999 % | 26 s | ~0 | ~€0 |
With 1,200 orders a month and an average basket of €65, going from 99.9 % to 99.99 % would save AlpinaShop around €70 a month in orders not lost — and it would cost active-active multi-region, that is, several hundred euros a month plus the engineering work.
The uncomfortable, correct conclusion: for AlpinaShop, 99.99 % would be a bad investment. And being able to say that with the numbers in hand, instead of chasing nines out of inertia, is one of the most valuable things an engineer can contribute.
With two caveats that need to be made explicit:
- Not all time is worth the same. Forty minutes of downtime in the small hours of February costs almost nothing; forty minutes on the Friday of the autumn campaign can cost twenty times more. Availability matters weighted by traffic.
- There are costs that are not lost orders: reputation, customers who do not come back, support, and — in a long incident — the wear on the team.
- SLI, SLO and SLA: the three acronyms everybody mixes up
| Acronym | What it is | Nature | Example at AlpinaShop |
|---|---|---|---|
| SLI (Service Level Indicator) | What you measure | A number, a proportion | % of catalogue requests with a code < 500 |
| SLO (Service Level Objective) | The objective you set yourself | Internal, a threshold over the SLI | 99.9 % over 28 days |
| SLA (Service Level Agreement) | What you promise contractually | External, with a penalty | 99.5 %, or 10 % is refunded |
The relationship between the three, which is what has to sink in:
flowchart LR
M["Raw metrics<br/>Cloud Monitoring"] --> SLI["SLI<br/>good / valid"]
SLI --> SLO["SLO<br/>internal threshold"]
SLO --> EB["Error<br/>budget"]
EB --> DEC["Decisions:<br/>deploy or stabilise"]
SLO -.->|"always stricter"| SLA["SLA<br/>external commitment"]
Three rules that avoid almost every mistake:
- The SLO is always stricter than the SLA. If you promise 99.5 % and your internal objective is 99.5 %, you will breach the contract half the time. The usual practice is a generous margin: SLA 99.5 %, SLO 99.9 %.
- The SLI is measured from where the user is, not from where it is convenient. A server responding perfectly while the load balancer returns 502 has an SLI of 100 % and a furious user.
- An SLO with no consequence is an ornament. If nothing changes when you miss it, it is not an objective: it is a number on a dashboard.
And a precise definition of an SLI that resolves most of the doubts:
SLI = good events / valid events
Everything difficult lies in defining those two terms. Does a 404 count as "bad"? (No: the user asked for something that does not exist.) And a 429 from the rate limiter? (It depends: if it is an attack, no; if it is a legitimate customer, yes.) And a request from a crawler? (It probably should not be a valid event at all.) Those decisions are 80 % of the work of defining an SLI, and they have to be taken in writing.
- Choosing good SLIs for AlpinaShop
A good SLI meets four conditions:
| Condition | It means | Counter-example |
|---|---|---|
| It reflects the user experience | If the SLI is fine, the user is happy | Server CPU |
| It is measurable with what you already have | It comes from existing metrics | "Customer satisfaction" |
| It is predictive | When it degrades, the business suffers | Number of log lines |
| It is actionable | You know what to do if it drops | "Overall system health" |
The commonest mistake is measuring what is easy instead of what is important. CPU is extremely easy to measure and no customer cares about it. The "four golden signals" — latency, traffic, errors and saturation — are a good starting point, but only the first two and the third are SLI candidates; saturation is a cause, not an experience.
AlpinaShop's four SLIs, chosen by walking the customer journey:
SLI 1 — Catalogue availability
Valid events: HTTP requests to the load balancer on shop routes, excluding known crawlers. Good events: those returning a code < 500.
Decisions taken and written down:
| Case | Valid? | Good? | Reason |
|---|---|---|---|
200 OK |
Yes | Yes | — |
404 for a non-existent product |
Yes | Yes | The system worked correctly |
429 from Cloud Armor to an attacker |
No | — | Not a user we want to serve |
500, 502, 503 |
Yes | No | Our failure |
499 client disconnected |
No | — | The client left, the system did not fail |
| A crawler request | No | — | Not user experience |
SLI 2 — Catalogue latency
Valid events: the same requests as in SLI 1. Good events: those served in less than 800 ms.
Watch out for the classic mistake: a latency SLI is not "the p95 is under X". It is the proportion of requests faster than X. The difference matters: a percentile cannot be combined with an error budget, and a proportion can.
The 800 ms threshold is not chosen on intuition: you measure the current distribution, check where the point is beyond which users abandon, and pick a value that is comfortably met today but that will detect a real degradation.
SLI 3 — Checkout success
The most important of the four, because it is the one that touches money.
Valid events: payment attempts started. Good events: those ending in a confirmation or in a legitimate refusal from the gateway (insufficient funds, expired card).
The nuance that makes it correct: a gateway refusal for insufficient funds is not our failure. Counting it as an error would make the SLI depend on the solvency of the customers, not on the quality of the system. What does count as bad: timeouts, 5xx errors, exceptions and transactions left in an indeterminate state.
SLI 4 — Freshness of the analytics data
Different from the previous ones because it is not a request: it is a process.
Valid events: scheduled runs of the orders pipeline. Good events: those that leave the data in BigQuery with less than 2 hours of lag.
The SLI table
| SLI | What it measures | Source | Criticality |
|---|---|---|---|
| Catalogue availability | Can people buy? | Load balancer logs | Critical |
| Catalogue latency | Is it usable? | Load balancer logs | High |
| Checkout success | Do we get paid? | Custom metric (06-04) | Critical |
| Analytics freshness | Does Lucía decide well? | Dataflow / Workflows metric | Medium |
Note that there is no SLI for CPU, memory, instance count or queue length. All of that is monitored — you need it to diagnose — but it is not a service level objective, because no customer cares about it.
- AlpinaShop's SLOs, with their real queries
| SLI | SLO | Window | Error budget | Justification |
|---|---|---|---|---|
| Catalogue availability | 99.9 % | 28 rolling days | 40 min 19 s | The cost/value balance from section 1 |
| Latency < 800 ms | 99 % | 28 rolling days | 6 h 43 min | A slow request is annoying; it does not stop you buying |
| Checkout success | 99.95 % | 28 rolling days | 20 min 10 s | Stricter: this is where money is lost directly |
| Analytics freshness < 2 h | 99 % | 28 rolling days | 6 h 43 min | An occasional delay does not affect the decisions |
Why 28 days and not a calendar month: a rolling 28-day window contains exactly four weeks, so it always includes the same number of weekends and Mondays. With calendar months, February and March are not comparable, and the budget "resets" on the 1st, which creates the perverse incentive of waiting for the month to turn before deploying anything risky.
The real queries, over the metrics from 06-04, in PromQL — which is the language Cloud Monitoring supports today alongside MQL:
# SLI 1 - Catalogue availability
sum(rate(loadbalancing_googleapis_com:https_request_count{
monitored_resource="https_lb_rule",
url_map_name="alpinashop-url-map",
response_code_class!="500"
}[5m]))
/
sum(rate(loadbalancing_googleapis_com:https_request_count{
monitored_resource="https_lb_rule",
url_map_name="alpinashop-url-map"
}[5m]))# SLI 2 - Latency: proportion of requests under 800 ms
sum(rate(loadbalancing_googleapis_com:https_total_latencies_bucket{
url_map_name="alpinashop-url-map", le="800"
}[5m]))
/
sum(rate(loadbalancing_googleapis_com:https_total_latencies_count{
url_map_name="alpinashop-url-map"
}[5m]))For checkout, the custom metric the application has been emitting since 06-04 is used:
# In the catalogue code, after each payment attempt
from opentelemetry import metrics
medidor = metrics.get_meter("alpinashop.pagos")
contador = medidor.create_counter(
"pagos_intentos",
description="Payment attempts by outcome",
)
def registrar_pago(resultado: str, pasarela: str):
# resultado: "confirmado" | "rechazado_legitimo" | "error_tecnico"
contador.add(1, {"resultado": resultado, "pasarela": pasarela})# SLI 3 - Checkout success: confirmed + legitimate refusals / total
sum(rate(custom_googleapis_com:pagos_intentos{
resultado=~"confirmado|rechazado_legitimo"
}[5m]))
/
sum(rate(custom_googleapis_com:pagos_intentos[5m]))Separating rechazado_legitimo from error_tecnico is an instrumentation decision that has to be taken in the code, and it is exactly where you can see whether somebody has thought the SLI through or copied it. If the application only records "success" and "failure", the checkout SLI is irreparably ambiguous.
- Error budget: what it is and what it is really for
Error budget = 100 % − SLO
With a 99.9 % SLO over 28 days, the budget is 0.1 %: 40 minutes and 19 seconds of unavailability, or its equivalent in failed requests.
The "nines" table translated into time, worth keeping to hand:
| SLO | Per day | Per week | Per 28 days | Per year |
|---|---|---|---|---|
| 99 % | 14 min 24 s | 1 h 40 min | 6 h 43 min | 3 d 15 h |
| 99.5 % | 7 min 12 s | 50 min | 3 h 22 min | 1 d 19 h |
| 99.9 % | 1 min 26 s | 10 min 5 s | 40 min 19 s | 8 h 46 min |
| 99.95 % | 43 s | 5 min 2 s | 20 min 10 s | 4 h 23 min |
| 99.99 % | 8.6 s | 1 min | 4 min 2 s | 52 min 36 s |
| 99.999 % | 0.9 s | 6 s | 24 s | 5 min 15 s |
What it is really for
Here is the part that turns the concept from a curiosity into a management tool. The error budget resolves the permanent tension between development and operations:
| Development wants | Operations wants | |
|---|---|---|
| Pace | To deploy fast and often | To change little |
| Risk | To try new things | Stability |
| Incentive | Features | The phone not to ring |
Without an objective criterion, that tension is resolved by authority, by exhaustion or by who shouts loudest. With an error budget, it is resolved with a number:
While there is budget left, you deploy. When it runs out, you stop and stabilise.
And this turns reliability into something development wants too, because a fragile system consumes the budget and stops it shipping features. The incentives are no longer opposed.
AlpinaShop's error budget policy
Written, agreed and in the README of alpinashop-infra:
| Budget consumed | Status | What you can do |
|---|---|---|
| < 50 % | 🟢 Green | All normal. Deploy daily. You can experiment |
| 50-75 % | 🟡 Amber | You deploy, but with a longer canary and no schema changes on Fridays |
| 75-100 % | 🟠 Orange | Fixes and reliability improvements only. No new features |
| > 100 % | 🔴 Red | Feature freeze. The whole team on stabilising. Mandatory post-mortem |
And the clause that makes the policy credible:
If the budget is exhausted because of a failure in Google's platform and not because of our changes, it is documented and there is no freeze. The objective is to fix what is within our reach, not to punish the team for what it does not control.
Without that clause, the first regional Google outage would turn the policy into something the team would stop respecting — and a policy that gets ignored is worse than not having one.
The use almost nobody makes: spending the budget on purpose
The error budget is not an objective to be minimised. If at the end of the 28 days you have used 5 %, you have not won: you were probably being too conservative and could have shipped faster, or your SLO is too lax and does not reflect what users notice.
A budget that is never touched is as bad a sign as one that is always exhausted. Healthy consumption is around 50-80 %.
- SLOs in Cloud Monitoring and burn rate alerts
Cloud Monitoring has native support for SLOs, and it is worth using instead of building it by hand.
Creating the SLO
gcloud monitoring slos create \
--service=alpinashop-web-service \
--slo-id=disponibilidad-catalogo \
--display-name="Catalogue availability 99.9%" \
--goal=0.999 \
--rolling-period=28d \
--request-based-good-total-ratio \
--project=alpinashop-prodAnd in Terraform, which is where it should live:
resource "google_monitoring_slo" "disponibilidad_catalogo" {
project = var.proyecto_prod
service = google_monitoring_custom_service.tienda.service_id
slo_id = "disponibilidad-catalogo"
display_name = "Catalogue availability 99.9%"
goal = 0.999
rolling_period_days = 28
request_based_sli {
good_total_ratio {
good_service_filter = join(" AND ", [
"metric.type=\"loadbalancing.googleapis.com/https/request_count\"",
"resource.type=\"https_lb_rule\"",
"resource.label.\"url_map_name\"=\"alpinashop-url-map\"",
"metric.label.\"response_code_class\"!=\"500\"",
])
total_service_filter = join(" AND ", [
"metric.type=\"loadbalancing.googleapis.com/https/request_count\"",
"resource.type=\"https_lb_rule\"",
"resource.label.\"url_map_name\"=\"alpinashop-url-map\"",
])
}
}
}Two types of SLI to distinguish:
| Type | How it counts | When to use it |
|---|---|---|
Request-based (request_based) |
The proportion of good requests | The usual one. Services with traffic |
Window-based (windows_based) |
The proportion of "good" minutes | Low-traffic services, or when the duration of the outage matters more than the number of people affected |
For AlpinaShop, request-based. With one honest caveat: in the small hours, with three requests a minute, a single error is 33 % of that minute. Request-based SLIs are noisy at low traffic, and that is why the alerts in the next section require long windows for the softer thresholds.
Burn rate alerts
This is the most valuable part of the section, and it replaces the fixed-threshold alerts from 06-04.
The burn rate is how many times faster than sustainable the budget is being spent:
- A rate of 1 = at this pace, the budget runs out exactly at the end of the 28 days. It is the sustainable pace.
- A rate of 14.4 = it runs out in 2 days.
- A rate of 6 = it runs out in a little under 5 days.
The problem with alerting on a fixed error threshold is well known: if the threshold is sensitive, you get false alarms; if it is lax, it does not detect slow degradations. Burn rate alerts solve both with two complementary alerts.
| Alert | Rate | Windows | Consumption | Action |
|---|---|---|---|---|
| Fast | 14.4× | 1 h and 5 min | 2 % of the budget in 1 h | 📟 Wake somebody up |
| Slow | 6× | 6 h and 30 min | 5 % of the budget in 6 h | 🎫 Raise a ticket, working hours |
Why two windows per alert. The long window (1 h) avoids false alarms from a thirty-second spike. The short window (5 min) makes the alert clear quickly when the problem is resolved — without it, an alert with a one-hour window would still be firing fifty minutes after the fault was fixed. It is a small detail with an enormous impact on the life of whoever is on call.
resource "google_monitoring_alert_policy" "consumo_rapido" {
project = var.proyecto_prod
display_name = "Catalogue SLO: fast budget burn (14.4x)"
combiner = "AND"
conditions {
display_name = "Burn rate > 14.4 over 1h and over 5min"
condition_threshold {
filter = join(" AND ", [
"select_slo_burn_rate(\"${google_monitoring_slo.disponibilidad_catalogo.name}\", \"3600s\")",
])
threshold_value = 14.4
comparison = "COMPARISON_GT"
duration = "300s"
}
}
notification_channels = [var.canal_movil_marta, var.canal_slack_incidentes]
documentation {
content = <<-EOT
# Fast burn of the error budget
At this pace, the 28-day budget runs out in less than 2 days.
## First steps
1. Campaign dashboard: https://console.cloud.google.com/monitoring/dashboards/...
2. Last deployment: `gcloud run revisions list --service=alpinashop-web --region=europe-west1`
3. If it coincides with a recent deployment, ROLL BACK FIRST:
`gcloud run services update-traffic alpinashop-web --region=europe-west1 --to-revisions=PREVIOUS_REVISION=100`
4. Errors by type: Error Reporting
5. Full runbook: alpinashop-infra/runbooks/catalogo-5xx.md
EOT
mime_type = "text/markdown"
}
}The documentation block is the most important part of the policy, and the one almost everybody leaves empty. An alert that arrives on the phone at 3 in the morning with the text "Threshold exceeded" forces the person to rebuild the context half asleep. One that arrives with the first three steps and the link to the runbook turns fifteen minutes of disorientation into two of action. It is developed in section 12.
- Tiered high availability architectures
High availability is built by removing single points of failure, and it is done in tiers, each with its own price.
| Tier | What it survives | What it does NOT survive | Relative cost |
|---|---|---|---|
| Single zone | A machine failure | A zone outage (maintenance, power cut) | 1× |
| Multi-zone (regional) | A whole zone going down | The entire region going down | 1.5-2× |
| Multi-region | A region going down | A global platform failure or your own replicated mistake | 2.5-4× |
| Multicloud | A global provider failure | Your own mistakes | 4-10× |
The relative frequency of each event is what should guide the decision, and it is rarely looked at:
| Event | Approximate frequency |
|---|---|
| A specific VM failing | Weekly in large fleets |
| A zone degrading | A few times a year |
| A whole region going down | Very infrequent, but it happens |
| A global provider failure | Extraordinary |
| Human error or a faulty deployment | The commonest cause of all |
The last row is the one that changes the priorities: most outages are not caused by infrastructure but by a change. Investing in multi-region while you have neither canary deployments nor fast rollback is armour-plating the door and leaving the window open. AlpinaShop did the right thing: pipeline and rollback first (module 6 and 07-02), architecture afterwards.
Component by component
| Component | Current tier | How to raise it | Extra cost | Is it worth it? |
|---|---|---|---|---|
| Global load balancer | Multi-region by design | — | — | ✅ Already there |
| Cloud DNS | Global anycast | — | — | ✅ Already there |
| Cloud Armor | Global | — | — | ✅ Already there |
| Cloud CDN | Global | — | — | ✅ Already there |
| Cloud Run | Regional: it spreads across zones only | A second service in europe-southwest1 + a NEG |
~+€7/month | ⏸️ Documented, not done |
| Cloud SQL | Regional HA (standby in another zone) | A read replica in another region | ~+€55/month | ⏸️ Evaluated, not done |
| Cloud Storage | Regional | A dual-region or multi-region bucket | ~+30 % | ✅ Done on the critical bucket |
| Firestore | Multi-region | — | — | ✅ Already there |
| BigQuery | Multi-region EU | — | — | ✅ Already there |
| Artifact Registry | Regional | A replica | Low | ⏸️ Not critical |
| Secret Manager | Automatic replication | — | — | ✅ Already there |
| VPN to Sabadell | Two tunnels | A second gateway in Sabadell | ~+€30/month | ❌ No: the shop is not critical |
An important observation: a large part of AlpinaShop's architecture is already multi-region with no effort, because Google's global services are multi-region by design. The load balancer, DNS, CDN, Armor, Firestore, BigQuery and Secret Manager need no decision at all. The two components that are regional and critical are Cloud Run and Cloud SQL.
Cloud SQL: what regional HA is exactly
It deserves detail because you pay double for it and many people do not know what they are buying.
flowchart LR
APP["Cloud Run"] --> IP["Single private IP"]
IP --> P["Primary instance<br/>europe-west1-b"]
P <-->|"synchronous disk<br/>replication"| S["Standby instance<br/>europe-west1-c"]
P --> R["Read replica<br/>alpinashop-pedidos-replica-informes"]
R -.->|"read only"| LU["Lucia queries"]
style S fill:#e8f0fe,stroke:#4285f4
| Aspect | Detail |
|---|---|
| What it replicates | The disk, synchronously. Zero data loss |
| Where | Another zone in the same region |
| Failover | Automatic, typically 1-2 minutes |
| What the application sees | The same IP. It reconnects on its own if it has retries |
| Cost | Double the instance |
| What it does not cover | The whole region going down; accidental data deletion; logical corruption |
The last row is the one to underline. Regional HA protects against infrastructure failures. It does not protect against a DELETE FROM pedidos with no WHERE, which is replicated instantly to the standby instance. That is what backups and point-in-time recovery are for — section 11.
What multi-region Cloud Run would look like
Documented in alpinashop-infra as a design ready to switch on:
# 1. Deploy the same service in the second region
gcloud run deploy alpinashop-web \
--image=europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:a3f91c2 \
--region=europe-southwest1 \
[... same flags ...]
# 2. Serverless NEG in that region
gcloud compute network-endpoint-groups create neg-catalogo-run-esw1 \
--region=europe-southwest1 \
--network-endpoint-type=serverless \
--cloud-run-service=alpinashop-web
# 3. Add it to the SAME backend service
gcloud compute backend-services add-backend bs-catalogo-web \
--global \
--network-endpoint-group=neg-catalogo-run-esw1 \
--network-endpoint-group-region=europe-southwest1Three commands. The global load balancer sends each request to the nearest region with capacity, and if a region fails, it stops sending traffic there automatically.
What stops it being switched on today is not Cloud Run: it is Cloud SQL. With the database only in europe-west1, the Madrid instances would have to cross regions on every query — latency and traffic cost — and if europe-west1 goes down, the database goes down anyway. High availability is determined by the least available component, and at AlpinaShop that is Cloud SQL.
- Failure modes and their mitigation
Distributed systems fail in specific, well-known ways. Designing against them pays off better than adding redundancy.
Dependencies and their classification
The first thing is to distinguish two types, because the treatment is the opposite:
| Type | Definition | Example | If it fails |
|---|---|---|---|
| Hard | Without it the service cannot work | Cloud SQL for the catalogue | Service failure |
| Soft | It adds value but is not essential | The recommender, the analytics | The service must carry on |
The commonest design flaw is treating a soft dependency as if it were hard. A recommender that takes 5 seconds must not make the product page take 5 seconds: it must disappear from the page.
Timeouts
Every network call must have a timeout. No exceptions.
A missing or over-long timeout is the mechanism by which a small failure turns into a total outage: requests pile up waiting, threads run out, and the service stops responding to everything, including the things that did not depend on the slow system.
| Call | Timeout | Reason |
|---|---|---|
| Cloud SQL, normal query | 2 s | If it takes longer, something is wrong |
| Cloud SQL, connection | 5 s | — |
| Payment gateway | 10 s | It is slow by nature |
| Recommender (soft) | 300 ms | If it does not answer fast, it is not shown |
| Sabadell ERP over VPN | 5 s | The link may be patchy |
| Secret Manager | 3 s | It is cached in memory after the first read |
Golden rule: the timeout of a call must always be shorter than the timeout of the caller. If Cloud Run cuts off at 60 s and your query waits 90 s, that code never runs.
Retries with exponential backoff and jitter
Retrying is fine. Retrying badly makes the failure worse:
# WRONG: immediate retry. On a failure it multiplies the load on
# a service that is already suffering. It is like kicking someone when they are down.
for intento in range(5):
try:
return llamar()
except Exception:
continue
# RIGHT: exponential backoff + jitter + a limit + transient errors only
import random, time
TRANSITORIOS = (503, 502, 504, 429)
def llamar_con_reintentos(fn, max_intentos=4, base=0.2, tope=8.0):
for intento in range(max_intentos):
try:
return fn()
except ErrorHTTP as e:
if e.codigo not in TRANSITORIOS or intento == max_intentos - 1:
raise # permanent errors are not retried
espera = min(tope, base * (2 ** intento)) # 0.2, 0.4, 0.8, 1.6...
espera = espera * (0.5 + random.random()) # jitter: +/- 50%
time.sleep(espera)The four elements, and why each one:
- Exponential backoff: giving the other side time to recover.
- Jitter: without it, a thousand clients that failed at the same time retry at the same time, creating a synchronised wave that knocks the service over again just as it was getting back up. It is a real and frequent failure, and jitter costs one line.
- A retry limit: retrying indefinitely turns a failure into an attack on your own system.
- Transient errors only: retrying a
400or a403will never fix it; it only wastes time and resources.
Circuit breaker
When a service has been failing for a while, stop calling it:
class Interruptor:
"""Closed = calls pass. Open = fails fast. Half-open = tries one."""
def __init__(self, umbral=5, espera=30):
self.umbral, self.espera = umbral, espera
self.fallos, self.abierto_desde = 0, None
def llamar(self, fn, alternativa=None):
import time
if self.abierto_desde:
if time.time() - self.abierto_desde < self.espera:
return alternativa() if alternativa else None # fails fast
self.abierto_desde = None # half-open: it tries
try:
r = fn()
self.fallos = 0
return r
except Exception:
self.fallos += 1
if self.fallos >= self.umbral:
self.abierto_desde = time.time()
if alternativa:
return alternativa()
raiseThe double benefit: the caller stops waiting (it fails in microseconds instead of seconds) and the failing service stops receiving load, which lets it recover.
Graceful degradation
The principle that makes a partial failure invisible:
The shop carries on selling even if the recommender fails.
@app.route("/producto/<sku>")
def producto(sku):
p = catalogo.obtener(sku) # HARD dependency: if it fails, error 500
# SOFT dependency: if it fails, the page is served without recommendations
try:
recomendados = interruptor_reco.llamar(
lambda: recomendador.para(sku, timeout=0.3),
alternativa=lambda: catalogo.mas_vendidos_de(p.categoria), # plan B
)
except Exception:
logging.warning(json.dumps({
"severity": "WARNING", "message": "Recommender unavailable",
"sku": sku, "degradado": True,
}))
recomendados = [] # plan C
return render("producto.html", producto=p, recomendados=recomendados)The three levels are the key to the pattern: plan A the recommender; plan B something simple and local — the category's bestsellers, which almost nobody will tell apart; plan C nothing, and the page is served anyway. A customer can buy in all three cases, which is the only thing that matters.
And the detail that makes it measurable: the log carries "degradado": true. Without that marker, the service runs degraded for weeks and nobody notices, because the availability SLI stays green. An alert on the percentage of degraded requests is what stops graceful degradation becoming silent degradation.
Load shedding
When demand exceeds capacity, there are two options: degrade for everybody or reject some and serve the rest well. The second is almost always better.
| Mechanism | Where | What it does |
|---|---|---|
Cloud Armor rate-based-ban |
Edge | Cuts off abuse before it arrives (03-05) |
Cloud Run's --max-instances |
Service | A cost and connection firewall (07-02) |
| A queue with a maximum length | Application | Rejects with 503 instead of piling up |
| Priority-based load shedding | Application | Rejects crawlers before buyers |
The last one is the most sophisticated and the most useful: when there is saturation, not all requests are worth the same. A user at checkout is worth far more than a crawler indexing page 47 of the catalogue.
- Disaster recovery: RTO, RPO and strategies
High availability and disaster recovery are not the same thing, and confusing them leads to expensive architectures that do not protect against what actually happens:
| High availability | Disaster recovery | |
|---|---|---|
| Protects against | Infrastructure failures | Disasters and human error |
| Action | Automatic | Usually manual |
| Timescale | Seconds or minutes | Hours or days |
| Example | A zone failover in Cloud SQL | Restoring after a mass deletion |
A fact that reorders priorities: the commonest cause of data loss is not a natural disaster or an attack. It is somebody running something in the wrong environment. And against that, high availability is no use at all: it replicates the mistake faithfully.
The two metrics
| Metric | Question | Determines |
|---|---|---|
| RTO (Recovery Time Objective) | How long can it be down for? | The strategy |
| RPO (Recovery Point Objective) | How much data can I lose? | The backup frequency |
flowchart LR
C1["Last backup<br/>03:00"] -.->|"RPO = data lost"| D["DISASTER<br/>10:15"]
D -.->|"RTO = downtime"| R["Service restored<br/>14:15"]
With daily backups at 03:00 and a disaster at 10:15, the real RPO is 7 hours and 15 minutes of lost data. For AlpinaShop, that would be a whole morning's orders: unacceptable.
The four strategies
| Strategy | RTO | RPO | Cost | How |
|---|---|---|---|---|
| Backup and restore | Hours to days | Hours | € | Backups in another region; you rebuild |
| Pilot light | Tens of minutes to hours | Minutes | €€ | A minimal core switched on (a replicated database); the rest is brought up |
| Warm standby | Minutes | Seconds to minutes | €€€ | Everything deployed at reduced scale |
| Active-active | Seconds | ~0 | €€€€ | Both regions serving |
flowchart TB
subgraph A["Backup and restore"]
A1["Backups in another region"] -.->|"hours"| A2["Everything is rebuilt"]
end
subgraph B["Pilot light"]
B1["Database replica switched on"] -.->|"minutes"| B2["The rest is brought up"]
end
subgraph C["Warm standby"]
C1["Everything deployed, minimal scale"] -.->|"minutes"| C2["It is scaled up"]
end
subgraph D["Active-active"]
D1["Both regions serving"] -.->|"seconds"| D2["The balancer redirects"]
end
How you choose, and it is not by the technology: you calculate the cost of an hour of downtime and compare it against the monthly cost of each strategy. For AlpinaShop, an hour of downtime on a normal day is about €100 of orders; during the campaign, perhaps €800. With those numbers, active-active — several hundred euros a month, every month — is not justified.
- AlpinaShop's recovery plan
Written, with owners, and also kept outside Google Cloud (because if the disaster is about access to Google Cloud, the plan has to be reachable).
Scenarios and response
| # | Scenario | Probability | Target RTO | Target RPO | Strategy |
|---|---|---|---|---|---|
| 1 | A zone failure | Medium | 2 min | 0 | Automatic (Cloud SQL HA, multi-zone Cloud Run) |
| 2 | A faulty deployment | High | 5 min | 0 | Revision rollback (07-02) |
| 3 | Accidental data deletion | Medium | 1 h | 5 min | Point-in-time recovery |
| 4 | Logical corruption detected late | Low | 4 h | Up to 24 h | Restore a backup + reprocess from Pub/Sub |
| 5 | The europe-west1 region going down |
Low | 4 h | 5 min | Pilot light in europe-southwest1 |
| 6 | Ransomware / a compromised account | Low | 8 h | 1 h | Immutable backups + rebuild with Terraform |
| 7 | Loss of the link to Sabadell | Medium | 1 h | 0 | Pub/Sub retains; the shop carries on (DA-004) |
Scenario 2 is by far the most likely, and its 5-minute RTO has already been solved since 07-02. It is a good reminder that the most profitable reliability work was already done before this lesson.
Backup configuration
resource "google_sql_database_instance" "pedidos" {
name = "alpinashop-pedidos"
database_version = "POSTGRES_15"
region = "europe-west1"
settings {
tier = "db-custom-2-7680"
availability_type = "REGIONAL" # HA: synchronous replica in another zone
backup_configuration {
enabled = true
start_time = "02:00"
location = "eu" # MULTI-REGION: survives the region
point_in_time_recovery_enabled = true # <<< the most important one
transaction_log_retention_days = 7 # point-in-time recovery window
backup_retention_settings {
retained_backups = 30
retention_unit = "COUNT"
}
}
maintenance_window {
day = 2 # tuesday
hour = 4
update_track = "stable"
}
}
deletion_protection = true # 06-07
}The four lines that do the work:
point_in_time_recovery_enabled = trueis what turns a 24-hour RPO into an RPO of minutes. Without it, you can only restore to the moment of the last backup; with it, to any instant within the transaction log window.location = "eu"keeps the backups outside the region. Backups in the same region that dies with the region are not backups: they are an illusion.transaction_log_retention_days = 7defines the point-in-time recovery window. Seven days covers the case of "somebody deleted something on Monday and we noticed on Thursday".deletion_protectionprevents the accidental deletion of the whole instance.
Exporting the configuration
Data is not the only thing you have to be able to rebuild:
| What | How | Where |
|---|---|---|
| Infrastructure | Terraform in alpinashop-infra |
GitHub + backend in alpinashop-terraform-estado |
| Secrets | Secret Manager with automatic replication | Managed by Google |
| Container images | Artifact Registry | With a replica |
| IAM policies | A periodic Cloud Asset Inventory export (07-07) | A bucket in another region |
| Dashboards and alerts | Versioned JSON (06-04) | Git |
| Cloud Armor rules | Exported to YAML (03-05) | Git |
| The DR plan itself | A document | Outside GCP: Drive, paper, a password manager |
- Backups that have not been tested do not exist
This is where the debt 07-04 flagged as critical gets paid.
An untested backup is not a backup: it is a hope with a technical name.
The reasons a restore fails on the day you need it, all real and all frequent:
| Reason | How you find out |
|---|---|
| The backup was empty or incomplete | Only by restoring |
| Permissions to restore were missing | Only by trying |
| The process takes 6 h and the RTO was 1 h | Only by timing it |
| The restored schema is not compatible with the current application | Only by starting the application against it |
| Nobody knows how to do it and the person who did has left | Only on the day of the disaster |
| The backup was in the same region that died | On the day of the disaster |
The restore drill for alpinashop-pedidos
The complete procedure, run quarterly by Marta, and timed.
Step 1 — Prepare and note the start time.
INICIO=$(date +%s)
echo "Restore drill started: $(date)"
gcloud sql backups list --instance=alpinashop-pedidos --project=alpinashop-prod \
--format="table(id, windowStartTime, status, type, location)" --limit=5Step 2 — Restore to a NEW instance.
Never over the production one. The restore creates a separate instance, which makes the drill completely safe:
# Point-in-time recovery: 2 hours ago
MOMENTO=$(date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%S.000Z)
gcloud sql instances clone alpinashop-pedidos alpinashop-pedidos-ensayo \
--point-in-time="$MOMENTO" \
--project=alpinashop-prodStep 3 — Verify the integrity, which is what almost nobody does.
Restoring without verifying only proves that the command works:
-- 1. The tables exist and have rows
SELECT schemaname, relname, n_live_tup
FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;
-- 2. The last order is consistent with the restored moment
SELECT MAX(fecha_pedido) AS last_order, COUNT(*) AS total FROM pedidos;
-- 3. Referential integrity: there are no orphan lines
SELECT COUNT(*) AS orphan_lines
FROM lineas_pedido lp
LEFT JOIN pedidos p ON p.id = lp.pedido_id
WHERE p.id IS NULL;
-- 4. Compare the totals with production (they must match up to the restored moment)
SELECT DATE(fecha_pedido) AS day, COUNT(*) AS orders, SUM(importe_total) AS amount
FROM pedidos
WHERE fecha_pedido >= NOW() - INTERVAL '7 days'
GROUP BY day ORDER BY day;Step 4 — Start the application against the restored copy.
This is the step that uncovers the real problems:
gcloud run deploy alpinashop-web-ensayo \
--image=europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:a3f91c2 \
--region=europe-west1 --no-traffic --tag=ensayo \
--add-cloudsql-instances=alpinashop-prod:europe-west1:alpinashop-pedidos-ensayo \
--set-env-vars=ENTORNO=ensayo
URL=$(gcloud run services describe alpinashop-web-ensayo --region=europe-west1 \
--format='value(status.traffic[?tag==`ensayo`].url)')
curl -sf -H "Authorization: Bearer $(gcloud auth print-identity-token)" "$URL/salud"
curl -sf -H "Authorization: Bearer $(gcloud auth print-identity-token)" "$URL/producto/1"Step 5 — Time it, clean up and document.
FIN=$(date +%s)
echo "Total restore time: $(( (FIN - INICIO) / 60 )) minutes"
gcloud sql instances delete alpinashop-pedidos-ensayo --quiet
gcloud run services delete alpinashop-web-ensayo --region=europe-west1 --quietThe result of the first real drill
And here is the value of the exercise, because the first drill never goes well:
| Finding | Impact |
|---|---|
| The restore took 47 minutes, not the "a few minutes" everybody assumed | The 1-hour RTO for scenario 3 is tight, not comfortable |
| The application user did not exist in the cloned instance | The application would not start until it was created by hand. It would have cost 20 minutes in a real incident |
roles/cloudsql.admin was missing from the gcp-infra@ group in order to clone |
Marta had to ask herself for it by another route |
| The PostgreSQL sequences were left out of sync | The first inserts would have failed on a duplicate key |
| Nobody had documented the procedure | Marta reconstructed it as she went |
Five problems that would have turned a 1-hour restore into a 3-hour one, with the shop down and everybody watching. And none of them was detectable without a drill.
The five fixes were written into a runbook, creating the user and resetting the sequences was automated in a post-restore script, and the RTO for scenario 3 was adjusted from 1 hour to 90 minutes, which is an honest number.
The drill is not there to confirm that the backups work. It is there to find the five problems that stop them working.
- Operational practice: on-call, runbooks and post-mortems
Sustainable on-call for a team of three
Reliability is held up by people, and a burnt-out team is the biggest reliability risk there is. With three technical people, a traditional 24×7 rota is unworkable: it would mean two weeks out of every three permanently on call.
AlpinaShop's realistic model:
| Slot | Coverage | What gets handled |
|---|---|---|
| Mon-Fri 08:00-20:00 | A weekly rotation of one person | Everything |
| Mon-Fri 20:00-08:00 | Paging alerts only (rate 14.4×) | A total outage or money being lost |
| Weekends | Same | Same |
| Autumn campaign | Two people on call, with agreed compensation | Everything |
And the rules that make it sustainable, which matter as much as the calendar:
- Only wake someone for what has to be fixed now. If an alert arrives in the small hours and the action is "look at it tomorrow", that alert must not fire at night. It is the rule that most reduces burnout.
- Real compensation. Hours or money, agreed in writing. Unpaid on-call lasts until somebody has had enough.
- A maximum of two interrupted nights in a row. If it happens, they are relieved.
- Every night-time alert is reviewed the next day: was it necessary? If not, it is tuned or removed.
- Fewer alerts is better. Alert fatigue (06-04) is a reliability problem: a person who has learnt to ignore their phone will also ignore the one that mattered.
Runbooks linked from the alerts
A runbook is the document that says what to do about a specific alert. It lives in alpinashop-infra/runbooks/ and is linked from the documentation field of the alert policy.
# Runbook: 5xx errors in the catalogue
**Alert:** Catalogue SLO - fast budget burn (14.4x)
**Severity:** Page (wakes you up)
**Last reviewed:** 2026-08-05 · **Tested in a drill:** yes
## 1. Confirm (2 min)
- Campaign dashboard: [direct link]
- Does it affect all routes or one? Filter by `url_map_name` and route.
- External check: `curl -sS -o /dev/null -w '%{http_code}' https://alpinashop.example/salud`
## 2. Most likely cause: a recent deployment (3 min)gcloud run revisions list --service=alpinashop-web --region=europe-west1 --limit=5
If the active revision was deployed less than 2 hours ago, **ROLL BACK FIRST AND DIAGNOSE AFTERWARDS**:
gcloud run services update-traffic alpinashop-web --region=europe-west1
--to-revisions=PREVIOUS_REVISION=100
## 3. If it was not a deployment | Symptom | Probable cause | Action | | --- | --- | --- | | `Memory limit exceeded` in the logs | Not enough memory | Raise `--memory`, review concurrency | | Database connection errors | Pool exhausted or Cloud SQL degraded | Cloud SQL dashboard; failover | | High latency with no errors | A slow dependency | Cloud Trace, look for the slow span | | 502 from the load balancer | Cloud Run not responding | `gcloud run services describe` | | Only from one geography | Cloud Armor | Review recent rules (03-05) | ## 4. Escalate If there is no diagnosis in 30 min: notify Dani (backend) and open a case with Google support. ## 5. Close - Check the SLI recovers. - Note the start, the end and the budget consumed. - **Post-mortem mandatory if the outage exceeded 15 minutes.**
The three properties of a useful runbook: it starts by confirming (not by diagnosing), it puts the most likely cause first — a recent deployment, in 70 % of cases — and it says explicitly when to escalate, so that the person on call is not left going round in circles alone at 4 in the morning.
And a fourth that you can see in the header: "Tested in a drill: yes". A runbook nobody has ever executed is a hypothesis.
Blameless post-mortems
After a significant incident, a document with a fixed structure:
| Section | Contents |
|---|---|
| Summary | What happened, in two sentences |
| Impact | Duration, users affected, error budget consumed, orders lost |
| Timeline | With times: detection, diagnosis, mitigation, resolution |
| Root cause | The "five whys", until you reach a systemic cause |
| What went well | Yes, this section matters |
| Actions | With an owner and a date. Specific, not "be more careful" |
Blameless does not mean there are no responsibilities. It means one very specific thing:
If the answer to "why did it happen" is a person's name, the analysis has stopped too soon.
An applied example:
❌ "Dani deployed without testing." ✅ "A change reached production with no test coverage for the empty-basket case. Why? The pipeline does not require a coverage threshold. Why? It was configured quickly in 06-01 and left outstanding. Why was it not caught in the canary? The canary ran for 10 min at low traffic (07-02). ACTIONS: 1. A 70% coverage threshold in the pipeline (Dani, 2026-08-20) 2. A minimum canary of 30 min or 500 requests, whichever is greater (Marta, 2026-08-15) 3. A regression test for the empty basket (Dani, 2026-08-12)"
The practical — not moral — reason for the absence of blame: in a culture where the culprit is hunted down, people hide mistakes, and hidden mistakes do not get fixed. In a blameless culture, people tell you what happened and the system improves. It is a decision about effectiveness.
The incident register
All the post-mortems in one place, tagged. After a year it lets you see patterns that no isolated incident reveals: "60 % of our incidents are deployments" is a sentence that changes an entire team's priorities.
- Chaos testing at a sensible scale
Chaos engineering is causing failures on purpose to verify that the system tolerates them. Netflix popularised the idea by killing instances in production; a small business does not need that, but it does need something.
Every system has disaster recovery tests. Some run them on purpose; others wait for them to happen.
AlpinaShop's experiments, ordered from least to most aggressive:
| # | Experiment | Where | Hypothesis | Frequency |
|---|---|---|---|---|
| 1 | Stop the recommender | dev |
The page is served with the bestsellers | Monthly |
| 2 | Add 3 s of latency to Cloud SQL | dev |
The timeout cuts in and a clean 503 comes out, not a cascade | Monthly |
| 3 | A manual Cloud SQL failover | prod, planned window |
Outage < 2 min, the application reconnects on its own | Quarterly |
| 4 | A backup restore | prod → a new instance |
RTO < 90 min | Quarterly |
| 5 | Cut the VPN tunnel to Sabadell | prod |
The shop carries on; Pub/Sub retains | Half-yearly |
| 6 | A tabletop drill: "europe-west1 goes down" |
Paper | The plan is executable | Annual |
| 7 | Roll back a deployment blind | prod |
Anybody on the team does it in < 5 min | Quarterly |
The rules that make this responsible rather than reckless:
- A written hypothesis beforehand. If you do not know what you expect, it is not an experiment: it is breaking things.
- Start in development.
- A small blast radius, widened only when the previous one went well.
- A stop button and the ability to abort at any moment.
- Never during the campaign. Nor on a Friday afternoon.
- Tell the team. Unannounced chaos with real on-call is a nasty practical joke.
Experiment 3 deserves a comment, because it is frightening and should not be: the Cloud SQL failover is going to happen one day, chosen by Google and at the worst possible moment. Provoking it on a Tuesday at 10 in the morning, with everybody watching and the ability to fix it, is infinitely better than discovering on a campaign Friday that the application does not reconnect because it lacks pool_pre_ping (07-02).
And experiment 7 is the most underrated: that only Marta knows how to roll back a deployment is a single point of human failure. That Dani and Lucía have each done it at least once is worth more than any infrastructure redundancy.
Common Mistakes and Tips
- Setting "it must never go down" as the objective. It cannot be met, measured or used to decide. Pick a number and pay for it.
- Confusing SLO and SLA. The SLO always stricter than the SLA, with a generous margin.
- Measuring the SLI from the server. It is measured from where the user is: the load balancer or an external check.
- Defining the latency SLI as "the p95 < X". It is "the proportion of requests < X". A percentile does not combine with an error budget.
- Counting legitimate gateway refusals as errors. The SLI would end up depending on the solvency of the customers.
- An SLO on CPU or memory. They are not user experience. You monitor them, you do not turn them into an objective.
- Calendar-month windows. They create the incentive to wait for the 1st before deploying anything risky. Use a rolling 28-day window.
- An SLO with no associated policy. If nothing changes when you miss it, it is an ornament.
- Freezing because the budget ran out when it was the platform's fault. The policy loses credibility and the team stops respecting it.
- Chasing a budget consumed at 5 %. A sign of excessive conservatism or of an SLO that is too lax.
- Fixed-threshold alerts on errors. Either they are noisy or they miss slow degradations. Use burn rate with two windows.
- Leaving the alert's
documentationfield empty. At 3 in the morning, that text is worth more than the alert itself. - Network calls with no timeout. It is the mechanism by which a small failure turns into a total outage.
- Retries without jitter. A thousand clients retrying at once knock the service over just as it was recovering.
- Retrying permanent errors. A
403does not improve with persistence. - Treating a soft dependency as hard. The recommender must not be able to take down the product page.
- Degrading without marking it in the logs. The service runs half-broken for weeks and the SLI stays green.
- Backups in the same region. They are not backups: they are an illusion.
- Believing HA protects against an accidental deletion. It replicates it instantly to the standby instance.
- Not testing the restore. It is the most dangerous debt because it is invisible until the day it matters.
- On-call with no compensation. It lasts until somebody has had enough, and then you have neither on-call nor the person.
- Hunting for culprits in a post-mortem. Mistakes get hidden, and hidden mistakes do not get fixed.
- Tip: start with an availability SLO and nothing else. Four SLOs from day one is too many. One good one is worth more than four copied ones.
- Tip: fast rollback is worth more than redundancy. The commonest cause of an outage is a change, not a hardware failure.
- Tip: make sure the whole team knows how to roll back a deployment. It is the cheapest redundancy there is.
- Tip: time the restore drill. The real number always surprises you, and it is the one that gives you an honest RTO.
Exercises
Exercise 1 — Defining the SLO for a new service
Dani is about to launch alpinashop-buscador (the service from the 07-02 exercise). Define its complete SLI and SLO:
- Which events are valid and which are good, explicitly resolving at least six edge cases.
- The SLO and the window, justified.
- The resulting error budget in minutes and in requests.
- The two burn rate alerts with their thresholds.
- The policy for what is done in each budget state.
Bear in mind that the search service is a soft dependency of the catalogue: if it fails, a link to browsing by category can be shown.
Exercise 2 — Designing a recovery plan with numbers
AlpinaShop is launching AlpinaPro, a subscription service for mountaineering clubs: a monthly fee, exclusive content and equipment bookings. The facts:
- 400 subscribers, €25/month each: €10,000/month of recurring revenue.
- Its own database
alpinapro-suscripciones(PostgreSQL). - An integration with the gateway for recurring charges on the 1st of each month.
- The clubs book equipment 48 h in advance; a lost booking is a serious complaint.
- Management says it "must never go down", but the additional budget is €150/month.
Design: the service's SLIs and SLOs, the RTO and RPO justified with business numbers, the recovery strategy chosen within the budget, and the specific backup configuration. Explain what you answer management about "it must never go down".
Exercise 3 — Analysing an incident and writing the post-mortem
The facts. Tuesday, 18:42. The fast burn alert fires. The reconstructed timeline:
- 18:30 — Cloud Build deploys revision
alpinashop-web-00051with a 10 % canary. The smoke tests pass. - 18:38 — Marta approves the move to 100 % (she was in a hurry, she was leaving at 18:45).
- 18:42 — Alert: burn rate 22×. 500 errors on 35 % of requests.
- 18:44 — Marta sees the alert already in the car. She cannot act.
- 18:51 — Dani, alerted by Marta, connects and rolls back to revision
00050. - 18:53 — The errors disappear.
- Impact: 11 minutes, ~230 failed requests, 4 lost orders, 27 % of the monthly budget consumed.
- Technical cause: the new revision read an environment variable
TARIFA_ENVIOthat was only defined inalpinashop-dev.
Write the complete post-mortem following the structure from section 12, with the five whys taken through to a systemic cause, and at least five specific actions with an owner. Also point out what the team did well.
Solutions
Solution 1 — Defining the SLO for alpinashop-buscador
1. Defining the SLI.
Availability SLI = search requests with a useful response / valid search requests
Edge cases, resolved and written down:
| Case | Valid? | Good? | Reason |
|---|---|---|---|
200 with 12 results |
Yes | Yes | — |
200 with 0 results |
Yes | Yes | The system worked; there simply is no "fuchsia pink ice axe" |
400 malformed query |
Yes | Yes | The system correctly rejected invalid input |
500 internal exception |
Yes | No | Our failure |
503 Elasticsearch down |
Yes | No | It is our dependency; the user cannot tell the difference |
429 rate limiting a crawler |
No | — | Not a user we want to serve |
A request with a bot User-Agent |
No | — | Not user experience |
| Timeout exceeded (> 3 s) | Yes | No | Worse than an error: the user waits and gets nothing |
| A health probe request | No | — | Not real traffic |
The "0 results" case is the one that generates the most argument and it is important to get it right. Counting it as bad would make the SLI measure the quality of the catalogue instead of the reliability of the service, and it would drop every time somebody searched for something we do not sell. Search quality is measured separately, with product metrics — the rate of searches with no results, the click-through rate — which are not an SLO.
Latency SLI = searches served in < 500 ms / valid searches
500 ms and not 800 ms like the catalogue, because a search box is interactive: the user types and waits. The perception of slowness kicks in sooner.
2. SLO and window.
| SLI | SLO | Window | Justification |
|---|---|---|---|
| Availability | 99.5 % | 28 days | Less strict than the catalogue (99.9 %) |
| Latency < 500 ms | 99 % | 28 days | Consistent with the catalogue |
The justification for the 99.5 % is the interesting part of the exercise. The search service is a soft dependency: if it fails, the catalogue shows a link to browsing by category and the customer can carry on buying. A soft service does not need the same objective as a critical one, and setting it at 99.9 % would mean spending money and engineering on a level of reliability that does not change the business outcome.
This is the reasoning to take away: the SLO is derived from the business impact of the failure, not from how fond you are of the service.
3. Error budget.
At 99.5 % over 28 days: 0.5 % = 3 h 21 min 36 s.
In requests: if the search service receives around 150,000 valid searches over 28 days, the budget is 750 failed searches.
It is comfortable, and deliberately so: it lets search improvements be deployed frequently without a one-off failure blocking the team.
4. The two alerts.
| Alert | Rate | Windows | Consumption in the window | Channel |
|---|---|---|---|---|
| Fast | 14.4× | 1 h and 5 min | 2 % (≈ 4 min) | 🎫 Ticket, not a page |
| Slow | 6× | 6 h and 30 min | 5 % (≈ 10 min) | 🎫 Ticket |
Neither of them wakes anybody up, and that is the logical consequence of being a soft dependency with a 99.5 % SLO. Getting up at 4 in the morning because the search service is failing, while the shop carries on selling, is exactly the kind of alert that produces fatigue and gets the one that does matter ignored.
The exception to plan for: if the search service goes down and on top of that the catalogue starts failing — because the timeout was not cutting in and the threads ran out — the alert that will fire is the catalogue's, which is a paging one. The alert design has to reflect the dependency design.
5. Budget policy.
| Consumed | Status | Action |
|---|---|---|
| < 60 % | 🟢 | Normal deployment |
| 60-90 % | 🟡 | Deployment carries on; the cause of the consumption is reviewed in the weekly meeting |
| > 90 % | 🟠 | Fixes only. Review whether the Elasticsearch dependency needs more reliability |
| > 100 % | 🔴 | Evaluate whether the catalogue's circuit breaker is working properly, which is what really matters: that a search failure does not drag the shop down with it |
The red row is the key to the exercise. When a soft service exhausts its budget, the priority question is not "how do I make it more reliable" but "is it properly isolated?". A search service that fails 2 % of the time without affecting anybody is a minor problem; a search service that fails 0.5 % of the time and every failure degrades the product page is a serious one.
Solution 2 — Designing a recovery plan for AlpinaPro
Starting point: translating the business into numbers.
| Item | Calculation | Value |
|---|---|---|
| Monthly recurring revenue | 400 × €25 | €10,000 |
| Revenue per hour (pro rata) | 10,000 / 730 | ~€13.70/h |
| Real cost of an hour of downtime | See below | Far higher than €13.70 |
And here is the nuance that separates a good analysis from a superficial one: in a subscription service, revenue is not lost by being down. The 400 subscribers pay regardless. What is lost is something else, and it is worse:
| Real impact | Estimate |
|---|---|
| Lost equipment bookings | One serious complaint per booking; risk of cancellation |
| Cancellations from a bad experience | A cancellation is €25/month × the customer's remaining months of life. With a 24-month average lifetime, a cancellation is €600 |
| Failed recurring charges on the 1st | Critical: if the system is down on the 1st, nobody gets charged |
| Reputation in the niche | Mountaineering clubs all know each other |
Conclusion: a 4-hour outage that causes 5 cancellations costs €3,000, not €55. The pro rata revenue analysis underestimates the impact by more than an order of magnitude, and it is a very common mistake in subscription services.
Proposed SLIs and SLOs.
| SLI | SLO | Justification |
|---|---|---|
| Portal availability | 99.5 % | 3 h 22 min a month. The clubs do not check daily |
| Booking success | 99.9 % | A lost booking is a complaint. Stricter |
| Recurring charge success | 99.99 % | Critical and concentrated: it fails on the 1st or it does not fail |
The third is the most interesting and the one usually forgotten. The recurring charge is not a continuous service: it is a process that runs one day a month. A monthly SLO over it makes no sense; what is needed is a different objective: "99.99 % of scheduled charges execute correctly within the following 24 hours", with retries. And a specific architectural treatment: a queue with retries and a DLQ, not a single execution that either works or is lost.
RTO and RPO justified.
| Scenario | RTO | RPO | Justification with numbers |
|---|---|---|---|
| A zone failure | 5 min | 0 | Automatic, it costs nothing to decide |
| A faulty deployment | 5 min | 0 | Revision rollback |
| Data deletion | 2 h | 5 min | Losing bookings is unacceptable. 5 min ≈ 0 bookings lost |
| A region going down | 6 h | 15 min | Low probability; 6 h down ≈ 3-5 cancellations ≈ €2,400 |
| A failure on the 1st (charges) | 2 h | 0 | It is retried; the process must be idempotent |
A strategy within €150/month.
A very tight budget. The split:
| Measure | Cost/month | What it covers |
|---|---|---|
| Cloud SQL regional HA | ~€60 | A zone failure, automatic, RPO 0 |
| Point-in-time recovery + multi-region backups | ~€15 | Accidental deletion with a 5-min RPO; surviving the region |
A read replica in europe-southwest1 |
~€55 | Pilot light: promotable in minutes if the region goes down |
| Uptime checks + alerts | ~€5 | Detection |
| Cloud Run (scales to zero) | ~€5 | Compute |
| Total | ~€140 |
Strategy chosen: pilot light. The database replicated in the second region is the only thing kept switched on; the Cloud Run compute is brought up with three commands (section 7) because the image already exists and Cloud Run scales from zero. Estimated RTO: 30-60 minutes, better than the 6 h objective.
What is not done and why: active-active would cost €400-600/month to cover a very low probability event, when the entire budget is €150.
Backup configuration.
resource "google_sql_database_instance" "alpinapro" {
name = "alpinapro-suscripciones"
database_version = "POSTGRES_15"
region = "europe-west1"
settings {
tier = "db-custom-1-3840"
availability_type = "REGIONAL"
backup_configuration {
enabled = true
start_time = "03:00"
location = "eu"
point_in_time_recovery_enabled = true
transaction_log_retention_days = 7
backup_retention_settings {
retained_backups = 30
retention_unit = "COUNT"
}
}
# Maintenance window: NEVER near the 1st
maintenance_window {
day = 3 # wednesday
hour = 4
update_track = "stable"
}
}
deletion_protection = true
}
resource "google_sql_database_instance" "alpinapro_replica_dr" {
name = "alpinapro-suscripciones-dr-esw1"
master_instance_name = google_sql_database_instance.alpinapro.name
region = "europe-southwest1" # ANOTHER region
database_version = "POSTGRES_15"
replica_configuration { failover_target = false }
settings {
tier = "db-custom-1-3840"
availability_type = "ZONAL" # the DR replica does not need HA of its own
}
}Two decisions worth pointing out: the Wednesday maintenance window, deliberately far from the 1st — Google maintenance during the charging cycle would be the worst possible moment — and the DR replica in ZONAL mode, because paying for HA on a replica only used in a disaster is doubling the cost of an insurance policy.
What you answer management.
"We cannot guarantee it will never go down, and anybody who promises that is not telling the truth. What we can do is decide how much downtime we are willing to accept and pay for it.
With the €150 a month we have, the commitment is this: if a machine or a specific data centre fails, it recovers by itself in five minutes and without losing any data. If somebody deletes something by mistake, we recover it in two hours losing at most five minutes of bookings. And if a whole Google region went down — something very infrequent but possible — we would be running from Madrid in under an hour.
For the charging process on the 1st we have separate treatment, because it is the critical moment of the month: if something fails, it is retried automatically for 24 hours and no charge is lost.
What we do not cover with this budget is two regions going down simultaneously or a global Google problem. Covering it would cost three to four times more, every month, for a scenario that may never happen.
And there is something I want to ask for besides the budget: testing all of this every quarter. A recovery plan that is not rehearsed does not work on the day you need it, and we know that because it happened to us with the shop: the first time we tried restoring the database we found five problems that would have tripled the recovery time."
Solution 3 — Post-mortem of the 18:42 incident
Post-mortem: 500 errors in the catalogue after deployment 00051
Incident date: Tuesday 2026-08-04, 18:42-18:53 Author: Marta Ruiz · Reviewed by: Dani, Lucía Status: actions in progress · Classification: blameless
Summary. A deployment promoted to 100 % of traffic contained code that depended on an environment variable that did not exist in production, causing 500 errors on 35 % of requests for 11 minutes.
Impact.
| Metric | Value |
|---|---|
| Duration | 11 min (18:42 – 18:53) |
| Failed requests | ~230 |
| Orders lost | 4 (~€260 of revenue) |
| Error budget consumed | 27 % of the monthly budget in 11 minutes |
| SLO status after the incident | 🟡 Amber (58 % consumed) |
Timeline.
| Time | Event |
|---|---|
| 18:30 | Cloud Build deploys 00051, 10 % canary. Smoke tests ✅ |
| 18:38 | Marta approves 100 %. She leaves the office at 18:45 |
| 18:42 | Fast burn alert (22×). 35 % errors |
| 18:44 | Marta sees the alert in the car. She cannot act |
| 18:47 | Marta calls Dani |
| 18:51 | Dani rolls back to 00050 |
| 18:53 | Errors at zero. Detection: 4 min. Mitigation: 11 min |
Root cause — the five whys.
- Why did the requests fail? The code read
os.environ["TARIFA_ENVIO"], which did not exist in production, raisingKeyError. - Why did it not exist? It was added to
alpinashop-devduring development and was not propagated to production. - Why was it not propagated? Production environment variables are managed in Terraform, but development configures them by hand with
gcloud. There is no mechanism guaranteeing parity. - Why did the canary not catch it? The smoke tests only check
/saludand/producto/1, and neither of those routes usesTARIFA_ENVIO(only the basket one does). On top of that the canary ran for 8 minutes at low traffic. - Why was it promoted to 100 % with that coverage? Because the process allows manual approval with no automated check on the candidate revision, and the approval was given in a hurry at the end of the day.
Systemic root cause: there is no automated verification that a candidate revision has all its configuration present in the target environment, nor an objective check that has to pass before promotion to 100 %. Manual approval is the only control and it depends on the state and availability of one person.
What went well — and this section matters as much as the previous one:
- The alert worked: 4 minutes from the first error to the notification. Exactly what 06-04 and the burn rate alerts promised.
- The rollback was trivial: one command, 2 minutes, nothing to rebuild. The work from 07-02 paid for itself in this incident.
- The escalation worked: Marta, unable to act, alerted Dani within 3 minutes instead of trying to fix it while driving.
- The error budget did its job: it turned "11 minutes, it was not that bad" into "27 % of the monthly budget", which is a figure that forces action.
- Nobody looked for culprits during the incident. It was fixed first and analysed afterwards.
Actions.
| # | Action | Owner | Date | Prevents |
|---|---|---|---|---|
| 1 | All environment variables in all environments managed in Terraform. gcloud run services update --set-env-vars forbidden outside the pipeline |
Marta | 2026-08-15 | Divergence between environments (whys 2 and 3) |
| 2 | A build step that compares the environment variables declared in the code with those defined in the target environment and fails if any are missing | Dani | 2026-08-20 | The direct cause |
| 3 | Extended smoke tests: /salud, /producto/1, /carrito, /api/categorias, covering every route that reads configuration |
Dani | 2026-08-12 | Why 4 |
| 4 | A minimum canary of 30 minutes or 500 requests, whichever is greater, with automated error rate verification before approval is enabled | Marta | 2026-08-18 | Why 5 |
| 5 | A deployment window: nothing is promoted to production after 17:00 or on Friday afternoons | The team | Immediate | Approving in a hurry at the end of the day |
| 6 | Lucía to learn how to roll back a deployment. Practice in the next chaos test | Marta | 2026-09-01 | The single point of human failure |
| 7 | Add this case to the incident register with the tag configuracion-divergente |
Marta | Immediate | Seeing patterns |
A note on action 5. It might look like a bureaucratic rule that slows the team down, and it deserves justifying: deploying in the afternoon is not forbidden — the canary can run all night at 10 % — what is forbidden is promoting to 100 % when there is nobody left available to react. The restriction is not about the risk of the change, but about the capacity to respond. That distinction is what makes the team accept the rule instead of working around it.
What this incident was NOT. It was not a mistake by Dani for writing code that reads a variable, nor by Marta for approving in a hurry. It was a system that allowed a change whose configuration nobody had verified to be promoted to 100 % of traffic, at a moment when nobody could react. Any of the three would have done the same, and that is why all seven actions are about the system and none about the people.
Conclusion
AlpinaShop has stopped aspiring to "never going down" and started managing reliability with numbers.
You know that every nine multiplies the cost and you have done the sum almost nobody does: going from 99.9 % to 99.99 % would save AlpinaShop around €70 a month in orders and would cost several hundred. Saying "99.99 % would be a bad investment" with the table in front of you is one of the most valuable things an engineer contributes, and it requires having calculated the two caveats too: that not all downtime is worth the same and that there are costs that are not lost orders.
You distinguish SLI, SLO and SLA precisely, with the three rules that avoid almost every mistake — the SLO always stricter than the SLA, the SLI measured from where the user is, and an SLO with no consequence is not an objective — and with the operational definition that resolves the doubts: good events / valid events, where the real work is deciding what each of those is.
You have chosen four SLIs by walking the customer journey — availability, latency, checkout success and analytics freshness — resolving the edge cases in writing: a 404 is good, a 429 to an attacker is not even valid, and a refusal for insufficient funds is not our failure, a decision that has to be taken in the code by instrumenting rechazado_legitimo against error_tecnico. And you know why there is no CPU SLI: because no customer cares.
You have mastered the error budget: the nines table translated into minutes, the rolling 28-day window that avoids the perverse incentive of the calendar month, and above all what it is really for — resolving with a number the tension between deploying fast and not breaking anything, with a four-state policy and the credibility clause of not freezing when the failure was the platform's. Plus the use almost nobody makes: a budget consumed at 5 % is as bad a sign as one that is exhausted.
You know how to create SLOs in Cloud Monitoring from Terraform, to distinguish request-based from window-based SLIs, and to set up burn rate alerts with two thresholds and two windows each — the long one to avoid false alarms, the short one so the alert clears when the problem is fixed. And you know that the documentation field, which almost everybody leaves empty, is worth more at 3 in the morning than the alert itself.
You have the tiered high availability architectures with AlpinaShop's component-by-component table, the observation that much of the platform is already multi-region with no effort because Google's global services are, and the two pieces that really are regional and critical. You know exactly what you buy with Cloud SQL HA — synchronous disk replication, another zone, failover in 1-2 minutes — and what it does not cover: a DELETE with no WHERE is replicated instantly. And you know that multi-region Cloud Run is three commands, but that it is not switched on because availability is determined by the least available component.
You design against the failure modes: hard and soft dependencies — with the commonest design flaw, treating a soft one as hard — timeouts on every network call with the rule that the inner one must always be shorter than the outer, retries with exponential backoff and jitter so as not to create synchronised waves, circuit breakers, graceful degradation in three levels with the "degradado": true marker without which degradation becomes silent, and load shedding with priority-based rejection.
You know that high availability and disaster recovery are not the same thing, and the fact that reorders priorities: the commonest cause of data loss is somebody running something in the wrong environment, and against that HA is no use because it replicates the mistake. You can handle RTO and RPO and the four strategies with their costs, choosing by comparison against the real cost of an hour of downtime.
You have AlpinaShop's plan with seven scenarios — where the most likely one, a faulty deployment, was already solved back in 07-02 — the backup configuration with the four lines that do the work (point_in_time_recovery, location = "eu", log retention and deletion_protection) and the configuration export, including the DR plan kept outside Google Cloud.
And you have paid off 07-04's critical debt: the restore drill, timed, with integrity verification and the application started against the copy. With the result that matters: five problems found — 47 minutes instead of "a few minutes", the non-existent application user, missing permissions, out-of-sync sequences and no written procedure — that would have tripled the real recovery time. The drill does not confirm that the backups work: it finds the five problems that stop them working.
Finally, you have the operational practice: sustainable on-call for three people with the rule that most avoids burnout — if the action is "look at it tomorrow", that alert does not fire at night — runbooks linked from the alerts that start with the most likely cause and say when to escalate, blameless post-mortems with the practical and not moral reason for that culture, and chaos testing at a sensible scale with its six rules, where the most underrated experiment is everybody on the team knowing how to roll back a deployment.
Everything in this lesson works because there are three people holding it up. And there is the limit: the SLOs live on a dashboard somebody maintains, the error budget policy is respected because the team agreed it, the runbooks get updated because Marta remembers, and nothing technically stops somebody creating a VM with a public IP in a United States region tomorrow, or disabling an audit log, or deleting a whole project by mistake.
That is no longer reliability: it is governance. And it is the only thing left to build. AlpinaShop's organization today has five projects, three folders, dozens of service accounts, four repositories, a half-finished perimeter and no policy governing the whole — plus a list of debts from 07-04 that starts with "organization policies not applied" and "audit logs without immutable retention".
The last lesson of the module closes the circle: organization structure, policies that are inherited and enforce themselves, an inventory of everything that exists, real auditing with immutable retention, and landing zones deployed with Terraform — so that what has been built following AlpinaShop does not depend on three people remembering.
Google Cloud Platform (GCP) Course
Module 1: Introduction to Google Cloud Platform
- What is Google Cloud Platform?
- Setting Up Your GCP Account
- A Tour of the GCP Console
- Projects, Resource Hierarchy and Billing
- Regions, Zones and the Shared Responsibility Model
- Cloud Shell and the gcloud CLI
Module 2: Core GCP Services
- Compute Engine: Virtual Machines on Google Cloud
- Cloud Storage: Object Storage
- Cloud SQL: Managed Relational Databases
- App Engine: Platform as a Service
- Google Kubernetes Engine (GKE)
- NoSQL Databases: Firestore, Bigtable and Spanner
- How to Choose the Right Compute Service
Module 3: Networking and Security
- VPC Networks
- Cloud Load Balancing
- Cloud CDN
- Identity and Access Management (IAM)
- Cloud Armor
- Secrets and Encryption: Secret Manager and Cloud KMS
- Cloud DNS, TLS Certificates and Publishing Services Securely
Module 4: Data and Analytics
- BigQuery: The Analytical Data Warehouse
- Cloud Dataflow: Batch and Streaming Data Processing
- Cloud Dataproc: Managed Spark and Hadoop
- Cloud Pub/Sub: Asynchronous Messaging
- Cloud Data Fusion: Code-Free Data Integration
- Orchestrating Pipelines with Cloud Composer and Workflows
- Data Governance and Dashboards with Dataplex and Looker Studio
Module 5: Machine Learning and AI
- Vertex AI: The Machine Learning Platform on GCP
- AutoML: Custom Models Without Writing Code
- TensorFlow on GCP: Training and Serving Models
- Natural Language API
- Vision API
- Generative AI on Vertex AI: Gemini Models and Embeddings
- MLOps: From Model to Product with Vertex AI Pipelines
Module 6: DevOps and Monitoring
- Cloud Build: Continuous Integration on GCP
- Cloud Source Repositories and Source Code Management
- Cloud Functions: Serverless Functions
- Cloud Monitoring (formerly Stackdriver): Metrics, Dashboards and Alerts
- Cloud Deployment Manager and Native Infrastructure as Code
- Cloud Logging and Cloud Trace: Logs, Traces and Diagnostics
- Terraform on GCP: Infrastructure as Code in Practice
Module 7: Advanced GCP Topics
- Hybrid and Multicloud with Anthos
- Serverless Computing with Cloud Run
- Advanced Networking: Shared VPC, Peering and Hybrid Connectivity
- Security Best Practices
- Cost Management and Optimization
- Reliability: SLOs, High Availability and Disaster Recovery
- Governance at Scale: Organization, Policies and Auditing
