Stop idle App Engine versions after deploy#3733
Merged
Merged
Conversation
App Engine Flexible keeps an always-on VM for every SERVING version regardless of traffic, so the staging version and superseded prod versions left SERVING after each deploy keep costing ~$212/mo each. The pipeline promoted traffic but never deactivated old versions. - Stop the staging version after its integration tests pass. - After a production promote, keep the live version plus the newest 2 prod versions warm for rollback and stop every other SERVING version; delete stopped versions beyond the newest 20 to respect App Engine's 210-versions-per-service limit. Stopping (not deleting) preserves rollback via `gcloud app versions start` + `set-traffic`. Adds stop_app_engine_version.sh and cleanup_app_engine_versions.sh, plus two jobs in push.yml. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #3733 +/- ##
=======================================
Coverage 79.82% 79.82%
=======================================
Files 70 70
Lines 4336 4336
Branches 808 808
=======================================
Hits 3461 3461
Misses 655 655
Partials 220 220 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The stopped-version retention was a single newest-20 pool shared across prod and staging. Since only prod versions are meaningful rollback targets, keep the newest 10 stopped prod versions and only the newest 3 stopped staging versions; delete everything else stopped (older prod, older staging, and legacy timestamp-named versions). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the KEEP_WARM_PROD / KEEP_STOPPED_PROD / KEEP_STOPPED_STAGING env block from the cleanup job so the retention counts have a single source of truth (the script defaults) instead of being duplicated in push.yml. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- cleanup_app_engine_versions.sh: abort before stopping if versions are SERVING but none report traffic, so a failed/empty traffic query cannot deactivate the live service. Delete already only targets STOPPED, so the live version can never be deleted. - tests/unit/test_app_engine_cleanup_scripts.py: exercise both scripts with a stubbed gcloud (mirroring test_cloud_run_deploy_scripts.py) covering live/warm retention, protection of an older traffic-serving version, delete-only-stopped, the fail-safe guard, missing-staging robustness, and the stop script's required-version check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
The v1 API runs on App Engine Flexible, which keeps at least one always-on VM for every
SERVINGversion regardless of traffic (currentapp.yaml: 4 vCPU / 24 GB ≈ ~$212/mo per version). The deploy pipeline promotes traffic to the new version but never deactivates the ones it supersedes, so both staging and old prod versions accumulate as idle-but-billing VMs. At the time of writing thedefaultservice had grown to 97 versions (16 SERVING / 81 STOPPED) with only one serving traffic — the idle SERVING ones are the bulk of a ~$6,050/mo App Engine bill.This adds post-deploy cleanup so idle versions stop costing money, while preserving rollback.
Changes
stop-staging-app-engine-version(afterintegration-tests-staging): stops the staging version once its tests pass. Staging holds 0 VMs between deploys; each deploy makes a fresh one.cleanup-prod-app-engine-versions(afterpromote-production): keeps the live version plus the newest 2 prod versions warm for instant rollback, and stops every otherSERVINGversion (older prod + leftover staging). It then prunes stopped versions to the newest 10 prod and newest 3 staging, deleting everything else stopped (older prod, older staging, and legacy timestamp-named versions) to stay under App Engine's 210-versions-per-service limit. Never touches the version receiving traffic.stop_app_engine_version.shandcleanup_app_engine_versions.sh— idempotent,DRY_RUN=1supported, written for bash 3.2 so they dry-run locally too.SERVINGbut none report traffic (so an empty/failed traffic query can't deactivate production), plus unit tests intests/unit/test_app_engine_cleanup_scripts.py.Why stop, not delete (for the warm/rollback set)
Stopping deactivates the VM (→ ~$0) but keeps the version deployed and restartable, so rollback still works:
gcloud app versions start <v>thengcloud app services set-traffic default --splits=<v>=1. Keeping the 2 newest prod versions warm gives instant rollback, and the newest 10 stopped prod versions remain as cold rollback targets — the original reason old versions were kept SERVING (a new deploy's simulation-API dependency could fail). Only prod versions are meaningful rollback targets, so staging retention is kept small.Verification
gcloud app versions stop/startconfirmed working live on this project's automatic-scaling Flex versions (stoppedprod-2393→STOPPED→ started →SERVING); it's the sameVersions.UpdateVersioncall already used to trim the fleet on 2026-07-02. Noapp.yamlscaling-mode change needed.default: 16 SERVING / 81 STOPPED), the first real run keeps the live version + 2 warm prod, stops the other ~14 idle SERVING versions, then prunes the stopped set (now ~95) to the newest 10 prod + 3 staging — deleting ~82 and leaving the service at ~15 versions.DRY_RUN: the preview reports fewer deletions (currently 68) than the real run (~82) because it doesn't perform the stops first, so the STOPPED set it prunes is smaller. The larger real-run count is expected and stays hard-bounded by the retention policy (newest 10 prod + 3 staging kept; everything else stopped is deleted — it can't exceed that).SERVINGbut none report traffic; and delete only ever targetsSTOPPED, so the live version can never be deleted.tests/unit/test_app_engine_cleanup_scripts.py, 10 cases with a stubbedgcloud) cover live/warm retention, protection of an older traffic-serving version, delete-only-stopped, the guard, missing-staging robustness, and the stop script — all passing,ruffclean.push.ymlvalidated as YAML; changelog fragment added.Scope
Deploy-pipeline / cost only — no API contract or route changes; everything stays on the App Engine v1 path. This is a bridge until v1 finishes migrating to Cloud Run (min=0 = true scale-to-zero, at which point this machinery retires — see #3737 for the Cloud Run revision-cleanup follow-up).
Expected impact
Steady-state running VMs drop from ~14–16 to 2 (live + previous prod); staging ~$0 except during a deploy's test window → roughly $6,050/mo → ~$450–500/mo.
🤖 Generated with Claude Code