Skip to content

Commit 4f4fb34

Browse files
authored
fix(selfhost): add memory limits to docker-compose services (#2569)
* 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. * fix(selfhost): guard the possibly-undefined service lookup in the resource-limits test
1 parent 32be1ba commit 4f4fb34

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
@@ -192,6 +192,20 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
192192
# MIGRATIONS_DIR=/app/migrations
193193
# CRON_INTERVAL_MS=120000 # maintain/sweep + sync cadence (default ~2 min)
194194

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

docker-compose.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ services:
9898
pgbouncer:
9999
condition: service_healthy
100100
required: false
101+
# Sane defaults so a runaway optional service (below) can't OOM-kill or starve the core review pipeline;
102+
# override via .env for a bigger/smaller host (#1828).
103+
deploy:
104+
resources:
105+
limits:
106+
memory: "${GITTENSORY_MEM_LIMIT:-2g}"
101107
healthcheck:
102108
# Probe /ready (not /health): /health is a liveness stub that is 200 even when the DB is down,
103109
# whereas /ready returns 503 until the DB answers AND migrations are applied — so dependents that
@@ -131,6 +137,10 @@ services:
131137
- ""
132138
- --appendonly
133139
- "no"
140+
deploy:
141+
resources:
142+
limits:
143+
memory: "${REDIS_MEM_LIMIT:-512m}"
134144
healthcheck:
135145
test: ["CMD", "redis-cli", "ping"]
136146
interval: 10s
@@ -147,6 +157,10 @@ services:
147157
POSTGRES_DB: gittensory
148158
volumes:
149159
- gittensory-pg:/var/lib/postgresql/data
160+
deploy:
161+
resources:
162+
limits:
163+
memory: "${POSTGRES_MEM_LIMIT:-2g}"
150164
healthcheck:
151165
test: ["CMD-SHELL", "pg_isready -U gittensory"]
152166
interval: 10s
@@ -218,6 +232,10 @@ services:
218232
- "127.0.0.1:6334:6334" # gRPC (localhost only)
219233
volumes:
220234
- qdrant-data:/qdrant/storage
235+
deploy:
236+
resources:
237+
limits:
238+
memory: "${QDRANT_MEM_LIMIT:-2g}"
221239
# The image is Debian (bash present) but ships no curl/wget/nc. bash's /dev/tcp pseudo-device speaks
222240
# HTTP without an external binary, so we hit Qdrant's k8s-style /readyz probe (200 once it accepts
223241
# traffic). This lets the app's depends_on gate on a serving Qdrant. /readyz is unauthenticated even
@@ -251,6 +269,12 @@ services:
251269
timeout: 5s
252270
start_period: 15s
253271
retries: 5
272+
# Ollama can load multi-GB models into memory; default high enough for common 7-8B quantized models but
273+
# cap it so a large model pull can't exhaust host RAM and take down the core review pipeline (#1828).
274+
deploy:
275+
resources:
276+
limits:
277+
memory: "${OLLAMA_MEM_LIMIT:-8g}"
254278

255279
# ── Litestream (--profile litestream) ─────────────────────────────────────
256280
# Continuous WAL backup of the SQLite DB to S3/B2/R2. Copy litestream.yml.example
@@ -308,6 +332,10 @@ services:
308332
- "--config.file=/etc/prometheus/prometheus.yml"
309333
- "--storage.tsdb.path=/prometheus"
310334
- "--storage.tsdb.retention.time=${PROMETHEUS_RETENTION_TIME:-180d}"
335+
deploy:
336+
resources:
337+
limits:
338+
memory: "${PROMETHEUS_MEM_LIMIT:-1g}"
311339

312340
# Routes Prometheus alerts to your notification channel. Ships SILENT: alerts go to a
313341
# null receiver until you fill in a receiver in alertmanager/alertmanager.yml.
@@ -352,6 +380,10 @@ services:
352380
GF_INSTALL_PLUGINS: frser-sqlite-datasource,grafana-github-datasource
353381
# Read-only fine-grained PAT for the GitHub data source provisioning ($GITHUB_TOKEN expansion). From .env.
354382
GITHUB_TOKEN: "${GITHUB_TOKEN:-}"
383+
deploy:
384+
resources:
385+
limits:
386+
memory: "${GRAFANA_MEM_LIMIT:-512m}"
355387
entrypoint:
356388
- /bin/sh
357389
- -ec
@@ -419,6 +451,10 @@ services:
419451
volumes:
420452
- ./loki/loki-config.yml:/etc/loki/loki-config.yml:ro
421453
- loki-data:/loki
454+
deploy:
455+
resources:
456+
limits:
457+
memory: "${LOKI_MEM_LIMIT:-1g}"
422458
# No healthcheck: grafana/loki:latest is distroless (no shell/wget/curl), so an in-container probe
423459
# can't run — a CMD-SHELL check would leave the container stuck "starting". Readiness is observable
424460
# at GET /ready; Promtail retries/backs off until Loki accepts pushes and Grafana retries the
@@ -494,6 +530,10 @@ services:
494530
volumes:
495531
- ./tempo/tempo.yaml:/etc/tempo/tempo.yaml:ro
496532
- tempo-data:/var/tempo
533+
deploy:
534+
resources:
535+
limits:
536+
memory: "${TEMPO_MEM_LIMIT:-1g}"
497537
healthcheck:
498538
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3200/ready"]
499539
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)