Skip to content

preview: the container NEVER sleeps - node is PID 1 and ignores SIGTERM (the "~8.5min idle tail" was a metrics backfill artifact) #120

Description

@wormeyman

The title and the premise below were REFUTED - corrected 2026-08-05 (901e832, #155)

There is no idle tail. The container never slept at all. Node runs as PID 1 under the
Dockerfile's exec-form ENTRYPOINT, Linux gives PID 1 no default signal dispositions, and
@cloudflare/containers stops an idle instance with SIGTERM and never escalates to
SIGKILL
- so every stop request was silently discarded. The instance only ever went away
when a deploy replaced the placement.

The "~8.5 minutes" was a metrics backfill artifact. containersUsageAdaptiveGroups
backfills, and that figure came from reading the 5-minute buckets ~14 minutes after a test
render and treating the missing tail as sleep. Re-read once settled, the same window has
every bucket present and the placement stayed allocated 29.3 hours straight - 352 of
352 buckets, no gaps
- on a total of 5 worker requests. Wait at least an hour before
reading absence as sleep, and confirm with placementId continuity, never bucket presence.

Also refuted: the response-drain fix (2026-07-29) did not stop this on its own. Billing
shows 100% allocation every full day through 2026-08-03, including the five days after it
deployed. Keep the drain guard - the hazard is real - but do not credit it with the bill.

Neither fix is deployed as of 2026-08-05. See #168.

The original report follows unchanged.


#116 fixed the container staying awake forever. It now sleeps - but ~25x later than configured. Costs almost nothing at current traffic; worth understanding before traffic grows.

Measured

One POST /preview fired against production on 2026-08-03 (worker version 2e67470f, container app a039bbb1-c8a4-4ecb-b6f5-bb3a1209a093), with a fresh seed to miss the R2 cache:

fired    22:39:01Z
returned 22:39:11Z   status=200  10.59s  350,848B image/png

Billing buckets from containersUsageAdaptiveGroups (a 1 GiB instance awake for a whole 5-minute bucket = 300 GiB-seconds):

bucket GiB-seconds
22:35Z 35
22:40Z 455
22:45Z 170
22:50Z absent - asleep

Awake roughly 22:39:00 -> 22:47:50, about 8.5 minutes, for a 10.6-second render. sleepAfter is "20s" in preview-service/worker/src/container.ts. Total billed: 660 GiB-seconds for one render.

Two things this same test confirmed, both good, both worth recording here because they were open questions:

  • basic (1 GiB) is genuinely live and sufficient. The disk-to-memory ratio reads 4.0 (1 GiB / 4 GB); every pre-deploy sample read 2.0 (standard-1, 4 GiB / 8 GB). A full 1024px Nauvis render completes inside it, cold start included.
  • Sleeping works at all. Before this test, 21:40Z -> 22:35Z shows no buckets whatsoever - a full hour of zero allocation. That is what fix(preview): stop the container from being billed 24/7 #116 bought.

Why this is probably NOT the bug #116 fixed

The inflight-counter leak (isActivityExpired() returning false while inflightRequests > 0) makes a container sleep never, not late. A leaked counter has no timeout to expire. So this is a different mechanism.

The likely candidate is scheduling granularity in @cloudflare/containers' own alarm loop rather than anything in this repo. In dist/lib/container.js the loop computes minTime = Math.min(minTimeFromSchedules, minTime, this.sleepAfterMs) where minTime is already clamped to a coarse interval (the comment there reads Math.min(3m or maxTime, sleepTimeout)), then re-arms via setAlarm. If the effective floor is minutes, a 20s sleepAfter cannot be honoured no matter what it is set to. Unverified - it needs someone to actually trace the alarm scheduling rather than infer it from a comment.

Cost, so nobody over-reacts

At ~7 renders/day: 660 GiB-s x 7 x 30 / 3600 = 38.5 GiB-hours/month against 25 included in Workers Paid, so about $0.12/month. Against the ~$28/month #116 fixed, this is noise.

It stops being noise if traffic grows: the tail is a fixed ~8.5 minutes of 1 GiB per wake, so cost scales with the number of cold wakes, not with render work. At ~50 renders/day it is ~275 GiB-hours/month, or roughly $2.25. Requests that arrive inside an existing awake window are free, so bursty traffic is cheap and sparse traffic is not.

What would settle it

  1. Fire two renders ~30s apart and confirm the second does not extend the tail (proves the timer is not being renewed by something in this repo's code path).
  2. Set sleepAfter to something large, e.g. "10m", and check whether the tail changes at all. If the tail stays ~8.5 minutes regardless, the setting is inert and the floor is upstream - that is the decisive experiment and it is cheap.
  3. Only then consider filing upstream at cloudflare/containers, with these numbers.

Do not "fix" this by lowering sleepAfter further. If step 2 shows the value is inert, changing it is theatre.

Reproducing the measurement

curl -s -X POST https://fmw-preview.wormeyman.workers.dev/preview \
  -H 'content-type: application/json' \
  -d '{"mapGenSettings":{"width":0,"height":0,"seed":<FRESH>},"planet":"nauvis","seed":<FRESH>,"size":1024}' \
  -o out.png -w 'status=%{http_code} time=%{time_total}s size=%{size_download}B\n'

Use a seed you have not used before, or R2 serves a cache hit and the container never wakes. Omitting the Origin header is deliberate and passes - the worker's check is if (origin && origin !== env.ALLOWED_ORIGIN).

Then read the 5-minute buckets from containersUsageAdaptiveGroups. Note that wrangler containers instances <ID> is not a reliable check here: it reported state: running with an 80-minute-old created timestamp during an hour when billing showed zero allocation. It describes the placement, not whether you are paying.

🤖 Generated with Claude Code

https://claude.ai/code/session_018AB7J1qK6kSBnJJmgDqMst

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions