Skip to content

Commit 011b06f

Browse files
committed
fix(selfhost): add memory limits to docker-compose services
No service declared a memory or CPU limit, including the core gittensory app itself. Any --profile combination let one runaway optional service (Ollama loading a multi-GB model, Postgres, Qdrant, or the whole observability stack) exhaust host RAM and OOM-kill or starve the core review pipeline container instead of the intended optional service -- the exact gap #1828 calls out, with zero mitigation anywhere in the compose file. Add deploy.resources.limits.memory to the core gittensory/redis services and every heavyweight optional service (postgres, qdrant, ollama, prometheus, loki, tempo, grafana), each overridable via a documented .env variable so an operator can size a bigger/smaller host without editing the compose file.
1 parent 1092bf3 commit 011b06f

3 files changed

Lines changed: 112 additions & 0 deletions

File tree

.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,20 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
189189
# MIGRATIONS_DIR=/app/migrations
190190
# CRON_INTERVAL_MS=120000 # maintain/sweep + sync cadence (default ~2 min)
191191

192+
# --- Per-service memory limits (docker-compose.yml; #1828/#2495) ---
193+
# Every service below gets a beginner-friendly default `deploy.resources.limits.memory` cap so one runaway
194+
# optional profile (e.g. Ollama loading a multi-GB model) can't OOM-kill or starve the core review pipeline.
195+
# Override any of these for a bigger/smaller host; values use Docker's byte-size shorthand (e.g. 512m, 4g).
196+
# GITTENSORY_MEM_LIMIT=2g # core app (always runs)
197+
# REDIS_MEM_LIMIT=512m # core app (always runs)
198+
# POSTGRES_MEM_LIMIT=2g # --profile postgres / --profile pgbouncer
199+
# QDRANT_MEM_LIMIT=2g # --profile qdrant
200+
# OLLAMA_MEM_LIMIT=8g # --profile ollama; raise this before pulling a large local model
201+
# PROMETHEUS_MEM_LIMIT=1g # --profile observability
202+
# LOKI_MEM_LIMIT=1g # --profile observability
203+
# TEMPO_MEM_LIMIT=1g # --profile observability
204+
# GRAFANA_MEM_LIMIT=512m # --profile observability
205+
192206
# --- Continuous backup (optional; the Litestream sidecar in docker-compose.yml) ---
193207
# Blank is valid until --profile litestream is enabled.
194208
# LITESTREAM_ACCESS_KEY_ID=

docker-compose.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ services:
8686
qdrant:
8787
condition: service_healthy
8888
required: false
89+
# Sane defaults so a runaway optional service (below) can't OOM-kill or starve the core review pipeline;
90+
# override via .env for a bigger/smaller host (#1828).
91+
deploy:
92+
resources:
93+
limits:
94+
memory: "${GITTENSORY_MEM_LIMIT:-2g}"
8995
healthcheck:
9096
# Probe /ready (not /health): /health is a liveness stub that is 200 even when the DB is down,
9197
# whereas /ready returns 503 until the DB answers AND migrations are applied — so dependents that
@@ -119,6 +125,10 @@ services:
119125
- ""
120126
- --appendonly
121127
- "no"
128+
deploy:
129+
resources:
130+
limits:
131+
memory: "${REDIS_MEM_LIMIT:-512m}"
122132
healthcheck:
123133
test: ["CMD", "redis-cli", "ping"]
124134
interval: 10s
@@ -135,6 +145,10 @@ services:
135145
POSTGRES_DB: gittensory
136146
volumes:
137147
- gittensory-pg:/var/lib/postgresql/data
148+
deploy:
149+
resources:
150+
limits:
151+
memory: "${POSTGRES_MEM_LIMIT:-2g}"
138152
healthcheck:
139153
test: ["CMD-SHELL", "pg_isready -U gittensory"]
140154
interval: 10s
@@ -197,6 +211,10 @@ services:
197211
- "127.0.0.1:6334:6334" # gRPC (localhost only)
198212
volumes:
199213
- qdrant-data:/qdrant/storage
214+
deploy:
215+
resources:
216+
limits:
217+
memory: "${QDRANT_MEM_LIMIT:-2g}"
200218
# The image is Debian (bash present) but ships no curl/wget/nc. bash's /dev/tcp pseudo-device speaks
201219
# HTTP without an external binary, so we hit Qdrant's k8s-style /readyz probe (200 once it accepts
202220
# traffic). This lets the app's depends_on gate on a serving Qdrant. /readyz is unauthenticated even
@@ -222,6 +240,12 @@ services:
222240
profiles: ["ollama"]
223241
volumes:
224242
- ollama-models:/root/.ollama
243+
# Ollama can load multi-GB models into memory; default high enough for common 7-8B quantized models but
244+
# cap it so a large model pull can't exhaust host RAM and take down the core review pipeline (#1828).
245+
deploy:
246+
resources:
247+
limits:
248+
memory: "${OLLAMA_MEM_LIMIT:-8g}"
225249

226250
# ── Litestream (--profile litestream) ─────────────────────────────────────
227251
# Continuous WAL backup of the SQLite DB to S3/B2/R2. Copy litestream.yml.example
@@ -279,6 +303,10 @@ services:
279303
- "--config.file=/etc/prometheus/prometheus.yml"
280304
- "--storage.tsdb.path=/prometheus"
281305
- "--storage.tsdb.retention.time=${PROMETHEUS_RETENTION_TIME:-180d}"
306+
deploy:
307+
resources:
308+
limits:
309+
memory: "${PROMETHEUS_MEM_LIMIT:-1g}"
282310

283311
# Routes Prometheus alerts to your notification channel. Ships SILENT: alerts go to a
284312
# null receiver until you fill in a receiver in alertmanager/alertmanager.yml.
@@ -323,6 +351,10 @@ services:
323351
GF_INSTALL_PLUGINS: frser-sqlite-datasource,grafana-github-datasource
324352
# Read-only fine-grained PAT for the GitHub data source provisioning ($GITHUB_TOKEN expansion). From .env.
325353
GITHUB_TOKEN: "${GITHUB_TOKEN:-}"
354+
deploy:
355+
resources:
356+
limits:
357+
memory: "${GRAFANA_MEM_LIMIT:-512m}"
326358
entrypoint:
327359
- /bin/sh
328360
- -ec
@@ -390,6 +422,10 @@ services:
390422
volumes:
391423
- ./loki/loki-config.yml:/etc/loki/loki-config.yml:ro
392424
- loki-data:/loki
425+
deploy:
426+
resources:
427+
limits:
428+
memory: "${LOKI_MEM_LIMIT:-1g}"
393429
# No healthcheck: grafana/loki:latest is distroless (no shell/wget/curl), so an in-container probe
394430
# can't run — a CMD-SHELL check would leave the container stuck "starting". Readiness is observable
395431
# at GET /ready; Promtail retries/backs off until Loki accepts pushes and Grafana retries the
@@ -465,6 +501,10 @@ services:
465501
volumes:
466502
- ./tempo/tempo.yaml:/etc/tempo/tempo.yaml:ro
467503
- tempo-data:/var/tempo
504+
deploy:
505+
resources:
506+
limits:
507+
memory: "${TEMPO_MEM_LIMIT:-1g}"
468508
healthcheck:
469509
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3200/ready"]
470510
interval: 10s
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { readFileSync } from "node:fs";
2+
import { parse } from "yaml";
3+
import { describe, expect, it } from "vitest";
4+
5+
function readYaml(path: string): Record<string, unknown> {
6+
const value = parse(readFileSync(path, "utf8"));
7+
if (!value || typeof value !== "object" || Array.isArray(value)) {
8+
throw new Error(`${path} must be a YAML object`);
9+
}
10+
return value as Record<string, unknown>;
11+
}
12+
13+
// Pure structural checks only (no `docker` CLI invocation): the self-hosted runner container this actually
14+
// runs on does not have Docker-in-Docker access, so a test that shells out to `docker compose config`
15+
// would be unreliable/environment-dependent here (same constraint as docker-compose-override-example.test.ts).
16+
describe("docker-compose.yml — per-service memory limits (#1828, #2495)", () => {
17+
const EXPECTED_LIMITS: Record<string, string> = {
18+
gittensory: "${GITTENSORY_MEM_LIMIT:-2g}",
19+
redis: "${REDIS_MEM_LIMIT:-512m}",
20+
postgres: "${POSTGRES_MEM_LIMIT:-2g}",
21+
qdrant: "${QDRANT_MEM_LIMIT:-2g}",
22+
ollama: "${OLLAMA_MEM_LIMIT:-8g}",
23+
prometheus: "${PROMETHEUS_MEM_LIMIT:-1g}",
24+
loki: "${LOKI_MEM_LIMIT:-1g}",
25+
tempo: "${TEMPO_MEM_LIMIT:-1g}",
26+
grafana: "${GRAFANA_MEM_LIMIT:-512m}",
27+
};
28+
29+
it("caps the core app and every heavyweight optional service with an operator-overridable memory limit", () => {
30+
const compose = readYaml("docker-compose.yml");
31+
const services = (compose.services as Record<string, Record<string, unknown>>) ?? {};
32+
33+
for (const [name, expected] of Object.entries(EXPECTED_LIMITS)) {
34+
const service = services[name];
35+
expect(service, name).toBeTruthy();
36+
const deploy = service.deploy as { resources?: { limits?: { memory?: unknown } } } | undefined;
37+
expect(deploy?.resources?.limits?.memory, name).toBe(expected);
38+
}
39+
});
40+
41+
it("documents every memory-limit override variable in .env.example", () => {
42+
const env = readFileSync(".env.example", "utf8");
43+
44+
for (const key of [
45+
"GITTENSORY_MEM_LIMIT",
46+
"REDIS_MEM_LIMIT",
47+
"POSTGRES_MEM_LIMIT",
48+
"QDRANT_MEM_LIMIT",
49+
"OLLAMA_MEM_LIMIT",
50+
"PROMETHEUS_MEM_LIMIT",
51+
"LOKI_MEM_LIMIT",
52+
"TEMPO_MEM_LIMIT",
53+
"GRAFANA_MEM_LIMIT",
54+
]) {
55+
expect(env, key).toContain(key);
56+
}
57+
});
58+
});

0 commit comments

Comments
 (0)