A 25-minute rollback: pinned traffic, deleted revisions, and migrations on startup
A team ships 30 Cloud Run services from a single Cloud Build trigger on every push to main. Deploys use gcloud run deploy --source ., and the Cloud Build service account holds roles/run.admin at project level. Database credentials are passed as --set-env-vars inside cloudbuild.yaml, which is committed to the repo. Every container runs migrate up at startup before serving. A bad release last week took 25 minutes to roll back: traffic was pinned to "latest", and a nightly cleanup job deletes all but the three newest revisions, so the previous good revision had already been deleted and the team had to rebuild from an older commit. When several new instances started at once during the deploy, the migration step deadlocked.
1. What in this setup turned a bad release into a 25-minute outage, and which single change would most shorten recovery time?
Read the worked answer
Three compounding decisions:
- Traffic pinned to "latest". Every successful deploy immediately takes 100% of traffic. There is no phase in which a bad build is visible to a small number of users or no users.
- Revisions deleted by a cleanup job. Revisions are immutable, cheap, and are the rollback unit. Keeping three is arbitrary and, because deploys are frequent across 30 services, three revisions can be hours old - or already gone before the incident is even understood.
- Rollback therefore became a rebuild. With no addressable previous revision, recovery time is dominated by cloning an old commit, rebuilding, and re-deploying. That is the 25 minutes.
Highest-leverage single change: stop deleting revisions, and deploy with --no-traffic so the previous revision stays addressable.
Concretely: keep revisions for N days (or keep anything carrying a latest-good tag) instead of "three newest", and change the deploy to publish a new revision with no traffic first. Rollback then becomes:
gcloud run services update-traffic orders-api --to-revisions=orders-api-00417=100
which is a traffic-table update - seconds, not minutes, and it does not depend on the registry, the build, or the source repo. Note that if the bad release did write to the database, traffic shift alone is not sufficient; see the schema-change strategy in the next question.
2. Design the deploy so a bad image cannot reach 100% of traffic, including how schema changes fit in.
Read the worked answer
Release sequence (per service):
- Build and push the image; record the digest.
gcloud run deploy --no-traffic --tag=cand-<short-sha>- the new revision serves only via its tag URL. Tag URLs route directly to that revision, so it is testable before any user traffic arrives (mind auth: tag URLs still enforce the service's auth policy).- Gate: run smoke tests against the tag URL - a health endpoint, one read path, one write path against a test tenant.
- Gradual shift with automated verification: 1% → 10% → 50% → 100%, with a wait and a check at each step on error rate and p95 latency from Cloud Monitoring. Abort on breach by shifting traffic back to the previous revision. Cloud Deploy models exactly this as a canary strategy with a verification job; use it rather than hand-rolling the loop.
- On success, tag the revision
latest-goodand only then allow traffic to advance further. Never pin tolatest.
Schema changes - expand/contract, never on container start:
- Run migrations in a Cloud Run Job (or Cloud Build step) that completes before the new revision receives traffic, not in the container entrypoint. This removes the concurrent-startup deadlock entirely and gives one failure surface.
- Only additive, backward-compatible DDL in the same release as code: add nullable columns, add tables, add indexes concurrently. Old and new revisions must both work against the schema during the traffic shift.
- Drop or rename columns in a later release, after 100% of traffic and all old revisions are on code that no longer reads them.
- Keep an advisory lock in the migration job for safety, and have the application fail fast at startup if the schema version is unexpected rather than attempting to migrate.
Why this matters for rollback: with expand/contract, the previous revision remains valid against the new schema, so a traffic shift is a complete rollback, not a partial one.
3. Fix the credential handling and reduce the blast radius of the CI/CD identity.
Read the worked answer
Credentials.
- Move the database credentials to Secret Manager and reference them at deploy time:
--set-secrets=DB_PASSWORD=orders-db-password:latest(or pin a specific version for reproducibility rather thanlatest). - Grant the runtime service account
roles/secretmanager.secretAccessoron that one secret, not project-wide. - Rotate the exposed credential immediately and purge it from the repo. It is in git history, so treat it as public: rotation precedes cleanup. Add a secret scanner (e.g., Cloud Build + a pre-commit hook) so it cannot recur. Note that
--set-env-varsalso exposes the value in revision metadata and console output, which is a second reason the secret must never travel that path.
Blast radius.
roles/run.adminat project level means one compromised build script can redeploy, or delete, all 30 services. Split identities:- A build SA: write to Artifact Registry, read source, no deploy rights.
- A deploy SA:
roles/run.deployscoped per service via IAM conditions on resource name, or at minimumroles/run.developerplusroles/iam.serviceAccountUseronly on the specific runtime SAs.
- Pin
--service-accountexplicitly on each service so deploys cannot silently run as the default compute SA. - Enable Binary Authorization in require-attestation mode, with an attestation produced by the pipeline. This makes "only images built by this pipeline can run" an enforced property rather than a convention.
- Put the trigger on a private pool with a scoped VPC/egress policy, and require PR review on
cloudbuild.yamlitself via CODEOWNERS - the pipeline definition is production code.