Transmission 023 · 2026-08-22

The API left the cluster and landed on Cloud Run.

The Dockerfile had port 8000 hardcoded in four separate places: the ENV block, EXPOSE, the HEALTHCHECK URL, and the gunicorn --bind flag. Cloud Run injects a dynamic PORT at runtime. A hardcoded port means the process binds to the wrong address, the health check fires against the wrong port, and Cloud Run kills the container before the first request. All four were fixed. The image was built and verified locally at PORT=9000, then deployed to Cloud Run europe-west3. The /health endpoint returned 200 from the live URL. A curl against /predict with a real image returned five ResNet-50 predictions.

The Kubernetes cluster was the deployment target for the last fourteen transmissions. Cloud Run is the target now.


The constraint

Cloud Run is serverless. It assigns a port at startup and injects it as an environment variable called PORT. The process inside the container must bind to 0.0.0.0:$PORT. If it binds to a fixed address instead, Cloud Run health checks the port it told the container to use, gets no response, and kills the container before the first request lands.

The Dockerfile had 8000 written in four places:

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1
# no PORT variable defined

HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
    CMD ["python", "-c", \
         "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]

EXPOSE 8000

CMD ["gunicorn", "main:app", \
     "--workers", "1", \
     "--worker-class", "uvicorn.workers.UvicornWorker", \
     "--bind", "0.0.0.0:8000", \
     "--timeout", "120"]

The CMD was in JSON exec-form. Exec-form passes arguments directly to the kernel with no shell involved. Writing ${PORT} inside a JSON array does nothing — the shell never runs, so the variable is never substituted.

docker-compose.yml had the same hardcoded value in three places: the --bind flag, the ports mapping, and the healthcheck test command.


The proof

The diff

Four changes to Dockerfile:

 ENV PYTHONDONTWRITEBYTECODE=1 \
-    PYTHONUNBUFFERED=1
+    PYTHONUNBUFFERED=1 \
+    PORT=8000

 HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
-    CMD ["python", "-c", \
-         "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
+    CMD python -c \
+        "import os, urllib.request; urllib.request.urlopen('http://localhost:' + os.environ.get('PORT','8000') + '/health')"

-EXPOSE 8000
+EXPOSE ${PORT}

-CMD ["gunicorn", "main:app", \
-     "--workers", "1", \
-     "--worker-class", "uvicorn.workers.UvicornWorker", \
-     "--bind", "0.0.0.0:8000", \
-     "--timeout", "120"]
+CMD gunicorn main:app \
+    --workers 1 \
+    --worker-class uvicorn.workers.UvicornWorker \
+    --bind "0.0.0.0:${PORT}" \
+    --timeout 120

PORT=8000 in the ENV block sets the fallback for local docker run. Cloud Run overrides it at runtime. CMD is now in shell form so /bin/sh expands ${PORT} before passing it to gunicorn.

Three changes to docker-compose.yml:

-      --bind 0.0.0.0:8000
+      --bind 0.0.0.0:${PORT:-8000}

-      - "8000:8000"
+      - "${PORT:-8000}:${PORT:-8000}"

-             "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
+             "import os, urllib.request; urllib.request.urlopen('http://localhost:' + os.environ.get('PORT','8000') + '/health')"]

Compose uses ${VAR:-default} syntax. If PORT is not set in the environment or a .env file, it falls back to 8000. Local behaviour is unchanged.


Local verification

Before pushing to Cloud Run I verified the binding worked locally by running the image with -e PORT=9000:

docker run -e PORT=9000
[2026-08-22 08:15:33 +0000] [7] [INFO] Starting gunicorn 22.0.0
[2026-08-22 08:15:33 +0000] [7] [INFO] Listening at: http://0.0.0.0:9000 (7)
[2026-08-22 08:15:33 +0000] [7] [INFO] Using worker: uvicorn.workers.UvicornWorker
[2026-08-22 08:15:33 +0000] [8] [INFO] Booting worker with pid: 8
[2026-08-22 08:15:39 +0000] [8] [INFO] Started server process [8]
[2026-08-22 08:15:39 +0000] [8] [INFO] Waiting for application startup.
[2026-08-22 08:15:40 +0000] [8] [INFO] Application startup complete.

Gunicorn bound to 0.0.0.0:9000. The image picks up whatever port you give it.

The full build took 1681 seconds. PyTorch download and ResNet-50 weight bake dominate that time. Subsequent builds skip those layers from cache.

docker build — 1681s
[+] Building 1681.7s (15/15) FINISHED
 => [builder 4/5] RUN python -m venv /opt/venv && pip install -r requirements.txt  923.1s
 => [runtime 4/6] RUN python -c "import torchvision; ..."                          251.4s
 => exporting to image                                                               305.3s
 => => naming to docker.io/library/simple-model-api:test

Cloud Run deployment

Service deployed in europe-west3. All three creation steps completed in under two minutes:

Cloud Run console showing revision simple-model-api-00001-zkc, 100% traffic routed to latest, all three creation steps showing Completed

Fig 1: Revision simple-model-api-00001-zkc receiving 100% of traffic, deployed to europe-west3.

Health check

GET /health — live
$ curl https://simple-model-api-547441435199.europe-west3.run.app/health
{"success":true,"status":"healthy","model":"ResNet-50","version":"1.0.0"}

Prediction

POST /predict — test_image.jpg
$ curl -X POST "https://simple-model-api-547441435199.europe-west3.run.app/predict" \
     -H "accept: application/json" \
     -H "Content-Type: multipart/form-data" \
     -F "file=@test_image.jpg"
{
  "success": true,
  "predictions": [
    {"rank":1,"class_index":490,"class_name":"chain mail","confidence":0.070772},
    {"rank":2,"class_index":903,"class_name":"wig","confidence":0.035625},
    {"rank":3,"class_index":643,"class_name":"mask","confidence":0.031815},
    {"rank":4,"class_index":488,"class_name":"chain","confidence":0.026007},
    {"rank":5,"class_index":219,"class_name":"cocker spaniel","confidence":0.020445}
  ],
  "meta": {"model":"ResNet-50","top_k":5,"filename":"test_image.jpg"}
}

The model returned five predictions. Top confidence is 7% — ResNet-50 is uncertain, which is expected for a mixed image. The endpoint is working.


Where this sits

ItemStatus
PORT=8000 default added to Dockerfile ENV blockDone
HEALTHCHECK switched to shell form, reads $PORT at runtimeDone
EXPOSE updated to ${PORT}Done
CMD switched from exec-form to shell form, binds 0.0.0.0:${PORT}Done
docker-compose.yml --bind, ports, and healthcheck updated to ${PORT:-8000}Done
Local smoke test at PORT=9000 confirmed gunicorn bound to the right addressDone
Image deployed to Cloud Run europe-west3Done
/health returned 200 from the live URLDone
/predict returned five ResNet-50 predictions from a real imageDone

The service is live. https://simple-model-api-547441435199.europe-west3.run.app is the address.