Skip to content

Commit 69cb9fc

Browse files
committed
fix(selfhost): prune stopped containers by actual stop time, not creation time
Docker's `container prune --filter until=<n>h` filters by creation time, not stop time, so a long-lived container stopped moments ago was already eligible for deletion on the very next scheduled run. prune_stopped_containers() now inspects each exited container's State.FinishedAt directly, comparing it against a cutoff computed from epoch arithmetic and formatted portably for both GNU and BusyBox date, so only containers actually stopped for at least RETAIN_HOURS are ever removed.
1 parent 03b5e45 commit 69cb9fc

3 files changed

Lines changed: 307 additions & 56 deletions

File tree

apps/gittensory-ui/src/lib/selfhost-env-reference.ts

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
1111
},
1212
{
1313
name: "AI_EMBED_API_KEY",
14-
firstReference: "src/server.ts:419",
14+
firstReference: "src/server.ts:420",
1515
},
1616
{
1717
name: "AI_EMBED_BASE_URL",
18-
firstReference: "src/server.ts:416",
18+
firstReference: "src/server.ts:417",
1919
},
2020
{
2121
name: "AI_EMBED_MODEL",
@@ -43,7 +43,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
4343
},
4444
{
4545
name: "BACKUP_ACKNOWLEDGED",
46-
firstReference: "src/server.ts:358",
46+
firstReference: "src/server.ts:359",
4747
},
4848
{
4949
name: "BROWSER_WS_ENDPOINT",
@@ -75,11 +75,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
7575
},
7676
{
7777
name: "CRON_INTERVAL_MS",
78-
firstReference: "src/server.ts:839",
78+
firstReference: "src/server.ts:845",
7979
},
8080
{
8181
name: "DATABASE_PATH",
82-
firstReference: "src/server.ts:241",
82+
firstReference: "src/server.ts:242",
8383
},
8484
{
8585
name: "DATABASE_URL",
@@ -103,11 +103,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
103103
},
104104
{
105105
name: "GITHUB_CACHE_TTL_SECONDS",
106-
firstReference: "src/server.ts:487",
106+
firstReference: "src/server.ts:488",
107107
},
108108
{
109109
name: "GITTENSORY_REPO_CONFIG_DIR",
110-
firstReference: "src/server.ts:275",
110+
firstReference: "src/server.ts:276",
111111
},
112112
{
113113
name: "GITTENSORY_VERSION",
@@ -119,7 +119,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
119119
},
120120
{
121121
name: "MIGRATIONS_DIR",
122-
firstReference: "src/server.ts:371",
122+
firstReference: "src/server.ts:372",
123123
},
124124
{
125125
name: "OBSERVABILITY_SMOKE_POLL_MS",
@@ -179,7 +179,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
179179
},
180180
{
181181
name: "ORB_BROKER_URL",
182-
firstReference: "src/server.ts:883",
182+
firstReference: "src/server.ts:889",
183183
},
184184
{
185185
name: "ORB_COLLECTOR_TOKEN",
@@ -195,7 +195,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
195195
},
196196
{
197197
name: "ORB_RELAY_MODE",
198-
firstReference: "src/server.ts:885",
198+
firstReference: "src/server.ts:891",
199199
},
200200
{
201201
name: "OTEL_EXPORTER_OTLP_ENDPOINT",
@@ -227,11 +227,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
227227
},
228228
{
229229
name: "PGVECTOR_ENABLED",
230-
firstReference: "src/server.ts:221",
230+
firstReference: "src/server.ts:222",
231231
},
232232
{
233233
name: "PORT",
234-
firstReference: "src/server.ts:643",
234+
firstReference: "src/server.ts:644",
235235
},
236236
{
237237
name: "PUBLIC_API_ORIGIN",
@@ -247,7 +247,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
247247
},
248248
{
249249
name: "QDRANT_URL",
250-
firstReference: "src/server.ts:506",
250+
firstReference: "src/server.ts:507",
251251
},
252252
{
253253
name: "QUEUE_BACKGROUND_CONCURRENCY",
@@ -259,7 +259,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
259259
},
260260
{
261261
name: "REVIEW_AUDIT_DIR",
262-
firstReference: "src/server.ts:551",
262+
firstReference: "src/server.ts:552",
263263
},
264264
{
265265
name: "SELFHOST_BUNDLE_ALL",
@@ -295,42 +295,42 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
295295
},
296296
{
297297
name: "SETUP_OUTPUT_PATH",
298-
firstReference: "src/server.ts:760",
298+
firstReference: "src/server.ts:761",
299299
},
300300
];
301301

302302
export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
303303
"| Name | First reference |",
304304
"| --- | --- |",
305305
"| `AI_COMBINE` | `src/selfhost/ai.ts:888` |",
306-
"| `AI_EMBED_API_KEY` | `src/server.ts:419` |",
307-
"| `AI_EMBED_BASE_URL` | `src/server.ts:416` |",
306+
"| `AI_EMBED_API_KEY` | `src/server.ts:420` |",
307+
"| `AI_EMBED_BASE_URL` | `src/server.ts:417` |",
308308
"| `AI_EMBED_MODEL` | `src/selfhost/ai.ts:784` |",
309309
"| `AI_ON_MERGE` | `src/selfhost/ai.ts:890` |",
310310
"| `AI_PROVIDER` | `src/selfhost/ai-config.ts:43` |",
311311
"| `ANTHROPIC_AI_BASE_URL` | `src/selfhost/ai.ts:788` |",
312312
"| `ANTHROPIC_AI_MODEL` | `src/selfhost/ai.ts:57` |",
313313
"| `ANTHROPIC_API_KEY` | `src/selfhost/ai.ts:787` |",
314-
"| `BACKUP_ACKNOWLEDGED` | `src/server.ts:358` |",
314+
"| `BACKUP_ACKNOWLEDGED` | `src/server.ts:359` |",
315315
"| `BROWSER_WS_ENDPOINT` | `src/selfhost/stubs/puppeteer.ts:11` |",
316316
"| `CLAUDE_AI_EFFORT` | `src/selfhost/ai.ts:108` |",
317317
"| `CLAUDE_AI_MODEL` | `src/selfhost/ai.ts:49` |",
318318
"| `CLAUDE_AI_TIMEOUT_MS` | `src/selfhost/ai.ts:108` |",
319319
"| `CODEX_AI_EFFORT` | `src/selfhost/ai.ts:112` |",
320320
"| `CODEX_AI_MODEL` | `src/selfhost/ai.ts:53` |",
321321
"| `CODEX_AI_TIMEOUT_MS` | `src/selfhost/ai.ts:112` |",
322-
"| `CRON_INTERVAL_MS` | `src/server.ts:839` |",
323-
"| `DATABASE_PATH` | `src/server.ts:241` |",
322+
"| `CRON_INTERVAL_MS` | `src/server.ts:845` |",
323+
"| `DATABASE_PATH` | `src/server.ts:242` |",
324324
"| `DATABASE_URL` | `src/selfhost/preflight.ts:201` |",
325325
"| `DISCORD_REPO_WEBHOOKS` | `src/selfhost/discord-notify.ts:31` |",
326326
"| `DISCORD_WEBHOOK_URL` | `src/selfhost/discord-notify.ts:40` |",
327327
"| `GITHUB_APP_ID` | `src/selfhost/orb-collector.ts:59` |",
328328
"| `GITHUB_APP_PRIVATE_KEY` | `src/selfhost/orb-collector.ts:166` |",
329-
"| `GITHUB_CACHE_TTL_SECONDS` | `src/server.ts:487` |",
330-
"| `GITTENSORY_REPO_CONFIG_DIR` | `src/server.ts:275` |",
329+
"| `GITHUB_CACHE_TTL_SECONDS` | `src/server.ts:488` |",
330+
"| `GITTENSORY_REPO_CONFIG_DIR` | `src/server.ts:276` |",
331331
"| `GITTENSORY_VERSION` | `src/selfhost/health.ts:29` |",
332332
"| `MAINTENANCE_ADMISSION_ENABLED` | `src/selfhost/maintenance-admission.ts:83` |",
333-
"| `MIGRATIONS_DIR` | `src/server.ts:371` |",
333+
"| `MIGRATIONS_DIR` | `src/server.ts:372` |",
334334
"| `OBSERVABILITY_SMOKE_POLL_MS` | `scripts/smoke-observability-traces.mjs:8` |",
335335
"| `OBSERVABILITY_SMOKE_TIMEOUT_MS` | `scripts/smoke-observability-traces.mjs:6` |",
336336
"| `OLLAMA_AI_API_KEY` | `src/selfhost/ai.ts:781` |",
@@ -345,27 +345,27 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
345345
"| `ORB_AIR_GAP` | `src/selfhost/orb-collector.ts:161` |",
346346
"| `ORB_ANONYMIZE` | `src/selfhost/orb-collector.ts:174` |",
347347
"| `ORB_APP_ID` | `src/selfhost/orb-collector.ts:59` |",
348-
"| `ORB_BROKER_URL` | `src/server.ts:883` |",
348+
"| `ORB_BROKER_URL` | `src/server.ts:889` |",
349349
"| `ORB_COLLECTOR_TOKEN` | `src/selfhost/orb-collector.ts:205` |",
350350
"| `ORB_COLLECTOR_URL` | `src/selfhost/orb-collector.ts:172` |",
351351
"| `ORB_ENROLLMENT_SECRET` | `src/selfhost/orb-collector.ts:165` |",
352-
"| `ORB_RELAY_MODE` | `src/server.ts:885` |",
352+
"| `ORB_RELAY_MODE` | `src/server.ts:891` |",
353353
"| `OTEL_EXPORTER_OTLP_ENDPOINT` | `src/selfhost/otel.ts:47` |",
354354
"| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `src/selfhost/otel.ts:45` |",
355355
"| `OTEL_SERVICE_ENVIRONMENT` | `src/selfhost/otel.ts:60` |",
356356
"| `OTEL_SERVICE_NAME` | `src/selfhost/otel.ts:59` |",
357357
"| `OTEL_TRACES_EXPORTER` | `src/selfhost/otel.ts:40` |",
358358
"| `OTEL_TRACES_SAMPLER` | `src/selfhost/otel.ts:74` |",
359359
"| `OTEL_TRACES_SAMPLER_ARG` | `src/selfhost/otel.ts:76` |",
360-
"| `PGVECTOR_ENABLED` | `src/server.ts:221` |",
361-
"| `PORT` | `src/server.ts:643` |",
360+
"| `PGVECTOR_ENABLED` | `src/server.ts:222` |",
361+
"| `PORT` | `src/server.ts:644` |",
362362
"| `PUBLIC_API_ORIGIN` | `src/selfhost/preflight.ts:192` |",
363363
"| `QDRANT_API_KEY` | `src/selfhost/qdrant-vectorize.ts:50` |",
364364
"| `QDRANT_DIM` | `src/selfhost/qdrant-vectorize.ts:71` |",
365-
"| `QDRANT_URL` | `src/server.ts:506` |",
365+
"| `QDRANT_URL` | `src/server.ts:507` |",
366366
"| `QUEUE_BACKGROUND_CONCURRENCY` | `src/selfhost/queue-common.ts:102` |",
367367
"| `REDIS_URL` | `src/selfhost/preflight.ts:144` |",
368-
"| `REVIEW_AUDIT_DIR` | `src/server.ts:551` |",
368+
"| `REVIEW_AUDIT_DIR` | `src/server.ts:552` |",
369369
"| `SELFHOST_BUNDLE_ALL` | `scripts/build-selfhost.mjs:13` |",
370370
"| `SELFHOST_SERVICE` | `scripts/smoke-observability-traces.mjs:5` |",
371371
"| `SELFHOST_SETUP_TOKEN` | `src/selfhost/preflight.ts:186` |",
@@ -374,5 +374,5 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
374374
"| `SENTRY_RELEASE` | `src/selfhost/otel.ts:62` |",
375375
"| `SENTRY_SERVER_NAME` | `src/selfhost/sentry.ts:373` |",
376376
"| `SENTRY_TRACES_SAMPLE_RATE` | `src/selfhost/sentry.ts:161` |",
377-
"| `SETUP_OUTPUT_PATH` | `src/server.ts:760` |",
377+
"| `SETUP_OUTPUT_PATH` | `src/server.ts:761` |",
378378
].join("\n");

scripts/selfhost-docker-prune.sh

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,20 @@
66
# service comments on why raw docker.sock exposure into a container is avoided).
77
#
88
# Age-filtered so nothing built/pulled recently is touched -- a rollback within the retention window still
9-
# has its image available. `docker container prune`, `docker image prune -a`, and `docker builder prune`
10-
# only ever remove resources Docker itself already reports as unused (a running container, its own image,
11-
# or an active build-cache entry a build is currently using, are never candidates) -- this script does not
12-
# change that safety property, it only adds the age floor on top of it.
9+
# has its image available. `docker image prune -a` and `docker builder prune` only ever remove resources
10+
# Docker itself already reports as unused (a running container's own image, or an active build-cache entry
11+
# a build is currently using, are never candidates) -- this script does not change that safety property, it
12+
# only adds the age floor on top of it. Docker's own `until=` filter for both is CREATION time, which is
13+
# fine there: an in-use image/cache entry can never be a prune candidate in the first place regardless of
14+
# how old it is, so the age floor only ever protects a recently built one that isn't in use yet.
15+
#
16+
# Containers are handled differently and deliberately do NOT use `docker container prune --filter until=`:
17+
# that filter is ALSO creation time, not stop time (verified against Docker's own docs) -- a long-running
18+
# container stopped moments ago (e.g. an operator's `docker compose stop <svc>` to inspect a live issue)
19+
# would already be older than the retention window by creation date, so a creation-time filter deletes it
20+
# on the very next scheduled run instead of giving the intended grace period. prune_stopped_containers()
21+
# below inspects each exited container's ACTUAL State.FinishedAt instead, so only a container that has
22+
# itself been stopped for at least RETAIN_HOURS is ever removed.
1323
#
1424
# SAFE BY DESIGN: only prunes stopped containers, unused images, and build cache -- NEVER volumes
1525
# (gittensory-data, gittensory-backups, postgres-data, qdrant-storage, runner-work, etc.), so it cannot
@@ -38,23 +48,70 @@ if ! command -v docker >/dev/null 2>&1; then
3848
exit 1
3949
fi
4050

51+
# Prunes (real mode) or reports (dry-run) stopped containers whose ACTUAL stop time is at least
52+
# RETAIN_HOURS in the past -- see the header comment for why `docker container prune --filter until=`
53+
# itself is not used. Walks each exited container's `docker inspect` State.FinishedAt individually.
54+
#
55+
# Deliberately never parses FinishedAt's ISO-8601 string with `date -d` -- GNU coreutils' `date -d` accepts
56+
# it, but BusyBox `date` (Alpine and other minimal Linux hosts, a common lightweight Docker host choice)
57+
# only understands a small fixed set of `-d` inputs (`@<epoch>`, `hh:mm[:ss]`, `YYYY-MM-DD hh:mm[:ss]`, ...)
58+
# and rejects FinishedAt's fractional-second form outright. Because that failure was wrapped in
59+
# `2>/dev/null || continue`, it used to fail SILENTLY -- every container would be skipped forever, with no
60+
# error, defeating this feature's whole purpose on a BusyBox host without so much as a warning.
61+
#
62+
# Instead: format the cutoff (an EPOCH INTEGER computed by plain arithmetic, never parsed from a string) as
63+
# an ISO-8601-prefix string via `date -d @<epoch>` -- the `@<epoch>` form IS in BusyBox's small supported
64+
# set, unlike arbitrary ISO-8601 parsing -- then compare that against FinishedAt's own first-19-characters
65+
# prefix LEXICOGRAPHICALLY. That comparison is chronologically correct because both sides are the same
66+
# fixed-width, zero-padded, UTC "YYYY-MM-DDTHH:MM:SS" shape, and needs no date-string PARSING at all.
67+
prune_stopped_containers() {
68+
now_epoch=$(date -u +%s)
69+
cutoff_epoch=$((now_epoch - RETAIN_HOURS * 3600))
70+
cutoff_iso=$(date -u -d "@${cutoff_epoch}" +%Y-%m-%dT%H:%M:%S)
71+
container_list=$(mktemp)
72+
docker ps -a --filter status=exited --format '{{.ID}}' > "$container_list"
73+
# Reads from a FILE (not a pipe) deliberately: `cmd | while read; do ...; done` runs the loop body in a
74+
# subshell under POSIX sh, silently discarding any variable set inside it once the loop ends -- harmless
75+
# today (nothing here is read after the loop), but a real trap for a future edit that adds e.g. a
76+
# removed-count summary. `done < file` has no such subshell.
77+
while IFS= read -r cid; do
78+
[ -n "$cid" ] || continue
79+
finished_at=$(docker inspect -f '{{.State.FinishedAt}}' "$cid" 2>/dev/null) || continue
80+
finished_prefix=$(printf '%s' "$finished_at" | cut -c1-19)
81+
case "$finished_prefix" in
82+
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]) ;;
83+
*) continue ;; # not a recognizable timestamp shape -- skip rather than guess
84+
esac
85+
# `[ a \< b ]` is not defined by POSIX test for strings; `expr` IS specified to compare non-numeric
86+
# operands lexicographically, so it's used here for a portable string-ordering check.
87+
expr "$finished_prefix" '<' "$cutoff_iso" >/dev/null || continue
88+
if [ "$DRY_RUN" = 1 ]; then
89+
echo "[docker-prune] DRY RUN -- would remove stopped container $cid (stopped before ${cutoff_iso}Z)"
90+
elif docker rm "$cid" >/dev/null 2>&1; then
91+
echo "[docker-prune] removed stopped container $cid (stopped before ${cutoff_iso}Z)"
92+
else
93+
echo "[docker-prune] WARNING: failed to remove stopped container $cid" >&2
94+
fi
95+
done < "$container_list"
96+
rm -f "$container_list"
97+
}
98+
4199
echo "[docker-prune] $(date -u +%FT%TZ) starting (retain: ${RETAIN_HOURS}h, dry-run: ${DRY_RUN})"
42100
echo "[docker-prune] before:"
43101
docker system df
44102
echo "[docker-prune] root filesystem usage:"
45103
df -h / 2>/dev/null || true
46104

105+
echo "[docker-prune] pruning stopped containers older than ${RETAIN_HOURS}h (by actual stop time)..."
106+
prune_stopped_containers
107+
47108
if [ "$DRY_RUN" = 1 ]; then
48-
echo "[docker-prune] DRY RUN -- would run: docker container prune -f --filter until=${RETAIN_HOURS}h"
49109
echo "[docker-prune] DRY RUN -- would run: docker image prune -af --filter until=${RETAIN_HOURS}h"
50110
echo "[docker-prune] DRY RUN -- would run: docker builder prune -af --filter until=${RETAIN_HOURS}h"
51111
echo "[docker-prune] volumes are NEVER pruned by this script -- application data, backups, and runner state are always safe."
52112
exit 0
53113
fi
54114

55-
echo "[docker-prune] pruning stopped containers older than ${RETAIN_HOURS}h..."
56-
docker container prune -f --filter "until=${RETAIN_HOURS}h"
57-
58115
echo "[docker-prune] pruning unused images older than ${RETAIN_HOURS}h..."
59116
docker image prune -af --filter "until=${RETAIN_HOURS}h"
60117

0 commit comments

Comments
 (0)