Skip to content

Commit edaa75c

Browse files
anth-volkclaude
andcommitted
Bind the Cloud Run port before app import via gunicorn
Cloud Run's startup probe hard-caps total probe time at 240s, and the app import (~232s, dominated by country package construction) races it on every instance start, failing roughly half of first-attempt deploys. gunicorn's master binds the listen socket before forking workers, and without --preload the import happens in the worker post-fork, so the probe passes in milliseconds and readiness is governed by the pipeline's health_check.sh /readiness-check poll (900s budget) instead of the platform cap. --timeout 0 disables the worker heartbeat watchdog that would otherwise kill the worker mid-import; --keep-alive 5 and --forwarded-allow-ips preserve today's uvicorn keep-alive and proxy header behavior (UvicornWorker leaves proxy_headers at uvicorn's default of enabled). health_check.sh curls now carry --max-time (default 10s, overridable via HEALTH_CHECK_CURL_MAX_TIME_SECONDS): with the port open before the app can answer, an unanswered poll would otherwise block until Cloud Run's 300s request timeout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c13f0be commit edaa75c

3 files changed

Lines changed: 28 additions & 19 deletions

File tree

.github/scripts/health_check.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ set -euo pipefail
55
health_url="${1:?health check URL is required}"
66
timeout_seconds="${HEALTH_CHECK_TIMEOUT_SECONDS:-900}"
77
interval_seconds="${HEALTH_CHECK_INTERVAL_SECONDS:-10}"
8+
curl_max_time_seconds="${HEALTH_CHECK_CURL_MAX_TIME_SECONDS:-10}"
89
deadline=$((SECONDS + timeout_seconds))
910

1011
while (( SECONDS < deadline )); do
11-
if curl --silent --show-error --fail "${health_url}" >/dev/null; then
12+
if curl --silent --show-error --fail \
13+
--max-time "${curl_max_time_seconds}" \
14+
"${health_url}" >/dev/null; then
1215
exit 0
1316
fi
1417
sleep "${interval_seconds}"

gcp/cloud_run/start.sh

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,21 @@ REDIS_READY_MAX_ATTEMPTS="${REDIS_READY_MAX_ATTEMPTS:-30}"
1010
export CACHE_REDIS_HOST CACHE_REDIS_PORT CACHE_REDIS_DB
1111

1212
redis_pid=""
13-
uvicorn_pid=""
13+
server_pid=""
1414

1515
shutdown() {
1616
trap - INT TERM
1717

18-
if [ -n "$uvicorn_pid" ] && kill -0 "$uvicorn_pid" 2>/dev/null; then
19-
kill "$uvicorn_pid" 2>/dev/null || true
18+
if [ -n "$server_pid" ] && kill -0 "$server_pid" 2>/dev/null; then
19+
kill "$server_pid" 2>/dev/null || true
2020
fi
2121

2222
if [ -n "$redis_pid" ] && kill -0 "$redis_pid" 2>/dev/null; then
2323
kill "$redis_pid" 2>/dev/null || true
2424
fi
2525

26-
if [ -n "$uvicorn_pid" ]; then
27-
wait "$uvicorn_pid" 2>/dev/null || true
26+
if [ -n "$server_pid" ]; then
27+
wait "$server_pid" 2>/dev/null || true
2828
fi
2929

3030
if [ -n "$redis_pid" ]; then
@@ -58,23 +58,29 @@ until redis-cli -h "$CACHE_REDIS_HOST" -p "$CACHE_REDIS_PORT" ping >/dev/null 2>
5858
sleep 1
5959
done
6060

61-
uvicorn policyengine_api.asgi:app \
62-
--host 0.0.0.0 \
63-
--port "$PORT" \
61+
# gunicorn's master binds the listen socket before forking workers, so the
62+
# Cloud Run TCP startup probe passes immediately instead of racing the
63+
# multi-minute app import (which happens in the worker, post-fork, because
64+
# --preload is NOT set). --timeout 0 is required: a worker mid-import does
65+
# not heartbeat, and the default 30s watchdog would kill it before boot.
66+
gunicorn policyengine_api.asgi:app \
67+
--worker-class uvicorn.workers.UvicornWorker \
6468
--workers "$WEB_CONCURRENCY" \
65-
--proxy-headers \
69+
--bind "0.0.0.0:${PORT}" \
70+
--timeout 0 \
71+
--keep-alive 5 \
6672
--forwarded-allow-ips '*' &
67-
uvicorn_pid="$!"
73+
server_pid="$!"
6874

6975
set +e
70-
wait -n "$redis_pid" "$uvicorn_pid"
76+
wait -n "$redis_pid" "$server_pid"
7177
status="$?"
7278
set -e
7379

7480
if ! kill -0 "$redis_pid" 2>/dev/null; then
7581
echo "Redis exited; stopping Cloud Run container" >&2
76-
elif ! kill -0 "$uvicorn_pid" 2>/dev/null; then
77-
echo "Uvicorn exited; stopping Cloud Run container" >&2
82+
elif ! kill -0 "$server_pid" 2>/dev/null; then
83+
echo "API server exited; stopping Cloud Run container" >&2
7884
else
7985
echo "A supervised Cloud Run process exited; stopping container" >&2
8086
fi

tests/unit/test_cloud_run_deploy_scripts.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -270,20 +270,20 @@ def test_cloud_run_dockerfile_runs_startup_with_bash():
270270
assert 'CMD ["/bin/sh", "/app/start.sh"]' not in dockerfile
271271

272272

273-
def test_cloud_run_startup_supervises_redis_and_uvicorn_children():
273+
def test_cloud_run_startup_supervises_redis_and_server_children():
274274
start_script = (REPO / "gcp/cloud_run/start.sh").read_text(encoding="utf-8")
275275

276276
assert "#!/usr/bin/env bash" in start_script
277277
assert 'redis_pid="$!"' in start_script
278-
assert 'uvicorn_pid="$!"' in start_script
278+
assert 'server_pid="$!"' in start_script
279279
assert "REDIS_READY_MAX_ATTEMPTS" in start_script
280280
assert "Redis exited before becoming ready" in start_script
281281
assert "Redis did not become ready" in start_script
282282
assert "Redis exited; stopping Cloud Run container" in start_script
283-
assert "Uvicorn exited; stopping Cloud Run container" in start_script
284-
assert 'wait -n "$redis_pid" "$uvicorn_pid"' in start_script
283+
assert "API server exited; stopping Cloud Run container" in start_script
284+
assert 'wait -n "$redis_pid" "$server_pid"' in start_script
285285
assert 'kill -0 "$redis_pid"' in start_script
286-
assert 'kill -0 "$uvicorn_pid"' in start_script
286+
assert 'kill -0 "$server_pid"' in start_script
287287
assert "trap 'shutdown; exit 143' INT TERM" in start_script
288288
assert "pkill" not in start_script
289289
assert re.search(r"(?m)^ *wait 2>/dev/null", start_script) is None

0 commit comments

Comments
 (0)