From e72d435b82a48eef9e6bb6624385c50b153a13ea Mon Sep 17 00:00:00 2001 From: Sergey <93754860+slaid098@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:19:01 +0300 Subject: [PATCH] fix(docker): increase opencode limits and add tini reaper + healthcheck (#132) * fix(docker): increase opencode memory and cpu limits * fix(docker): add tini init for zombie reaping * fix(docker): add healthcheck for hang auto-restart * test(docker): assert opencode limits, init and healthcheck * docs(handoff): add handoff and ADR for opencode limits fix * docs(handoff): set PR number * docs(project-map): update docker-compose and test descriptions for PR#132 --------- Co-authored-by: opencode-agent --- docker-compose.yml | 13 +- .../059-pr-132-fix-docker-opencode-limits.md | 30 +++++ .../pr-132-fix-docker-opencode-limits.md | 26 ++++ docs/project-map/README.md | 4 +- tests/test_docker_compose.py | 120 ++++++++++++++++++ 5 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 docs/decisions/059-pr-132-fix-docker-opencode-limits.md create mode 100644 docs/handoff/pr-132-fix-docker-opencode-limits.md diff --git a/docker-compose.yml b/docker-compose.yml index f6fb418..3fcd70a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: dockerfile: Dockerfile container_name: opencode restart: unless-stopped + init: true command: ["serve", "--hostname", "0.0.0.0", "--port", "4096", "--cors", "*"] working_dir: /root/workspace env_file: @@ -52,11 +53,17 @@ services: deploy: resources: limits: - cpus: '3' - memory: 6G - pids: 1024 + cpus: '4' + memory: 8G + pids: 2048 reservations: memory: 2G + healthcheck: + test: ["CMD-SHELL", "curl -s -o /dev/null -w '%{http_code}' http://localhost:4096/ | grep -qE '^(200|401)$$'"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s networks: opencode_network: diff --git a/docs/decisions/059-pr-132-fix-docker-opencode-limits.md b/docs/decisions/059-pr-132-fix-docker-opencode-limits.md new file mode 100644 index 0000000..6f232f8 --- /dev/null +++ b/docs/decisions/059-pr-132-fix-docker-opencode-limits.md @@ -0,0 +1,30 @@ +# ADR-059: Increase opencode container limits, add tini reaper and healthcheck + +## Статус +Accepted (2026-07-29) + +## Контекст +`opencode serve` (веб-UI на `:4096`, проксирован через NPM как `opencode.slaid098.dev`) зависал при активной работе агента — `504 Gateway Time-out`, контейнер не отвечал по IP. Живая диагностика cgroup v2 внутри контейнера: +- `memory.peak` = 5.72 GB из лимита 6 GB (95%), `oom_kill` = 0 → ядро не убивает, а делает direct reclaim (синхронный scan/free страниц в event loop процесса) → Node.js event loop блокируется → serve зависает. +- CPU: `nr_throttled` = 879, `throttled_usec` = 104.7 с — упирался в лимит 3 ядра (`cpu.max=300000/100000`); `cpu.pressure avg300` = 0.11 (11% устойчивый). +- >120 зомби-процессов (``: `[git]`, `[gh]`, `[node]`); opencode — PID 1, не вызывает `wait()` для reaping детей. +- Swap на хосте linux-1 отключён (`Swap: 0B`) — нет буфера при пиках. + +Все корневые причины устранимы изменениями в `docker-compose.yml` (область репо). Сервис `dind` использовал 32 MB из 4 GB (0.78%), без троттлинга — не трогаем. + +## Решение +Три изменения в сервисе `opencode` (dind — без изменений): + +1. **Увеличить лимиты ресурсов** (`deploy.resources.limits`): + - `memory`: 6G → **8G** (+2.3 GB над наблюдённым пиком 5.72 GB — устраняет direct-reclaim блокировку event loop). + - `cpus`: '3' → **'4'** (убирает 879 троттлинг-событий; на хосте 6 ядер, запас есть). + - `pids`: 1024 → **2048** (буфер для зомби; основной фикс — `init: true`, это страховка). + +2. **`init: true`** на верхнем уровне сервиса: Docker подставит **tini** как PID 1, который авто-reap'ит осиротевших зомби (процессы, чей родитель умер — переподчиняются init). Прямых детей живого opencode tini не заберёт, но основную массу зомби уберёт. Best practice для контейнеров, порождающих подпроцессы. + +3. **`healthcheck`** (после `deploy`): `CMD-SHELL` с `curl` к `localhost:4096`, допускает 200|401 как healthy (`serve` требует basic auth → 401 без credentials). `interval: 30s`, `timeout: 10s`, `retries: 3`, `start_period: 30s`. Независание (HTTP не отвечает) → Docker детектит unhealthy и авто-рестартует через уже существующий `restart: unless-stopped` (НЕ дублировать). Используем `CMD-SHELL` (не `CMD` exec-form) — нужен pipe в grep; `$$` экранирует `$` для shell. + +## Альтернативы +- **Swap на хосте** — рассмотрена, но вне репо (ops-задача на linux-1). Даёт буфер при пиках, но не решает direct-reclaim при 95% утилизации и не убирает зомби/троттлинг. +- **cron-рестарт контейнера** — вне репо, реактивный workaround, не детектит зависание (только по расписанию). Healthcheck proactive. +- **Откат версии opencode** — отвергнута: диагностика показала resource-проблемы (память/CPU/зомби), а не регрессию версии. Живой рост RSS ~11 MB/с — отдельная утечка, не блокирующая данный фикс. \ No newline at end of file diff --git a/docs/handoff/pr-132-fix-docker-opencode-limits.md b/docs/handoff/pr-132-fix-docker-opencode-limits.md new file mode 100644 index 0000000..a6b33b6 --- /dev/null +++ b/docs/handoff/pr-132-fix-docker-opencode-limits.md @@ -0,0 +1,26 @@ +--- +pr: 132 +title: fix(docker): increase opencode limits and add tini reaper + healthcheck +--- + +## Что сделано +- `docker-compose.yml` сервис `opencode`: `limits.memory` 6G → 8G, `limits.cpus` '3' → '4', `limits.pids` 1024 → 2048 +- Добавлен `init: true` на уровне сервиса (tini как PID 1 — reaping осиротевших зомби) +- Добавлен `healthcheck`: `CMD-SHELL` curl к `localhost:4096`, допускает 200|401 как healthy (serve требует basic auth → 401 без credentials), `interval: 30s`, `timeout: 10s`, `retries: 3`, `start_period: 30s`. Независание → Docker рестартует через существующий `restart: unless-stopped` +- `restart: unless-stopped` НЕ дублирован (уже был на строке 25) +- Сервис `dind` — БЕЗ ИЗМЕНЕНИЙ +- `tests/test_docker_compose.py`: +8 raw-text assertions (memory/cpu/pids limits, init:true, healthcheck block + timing, restart-not-duplicated, dind-unchanged). Итого 13 passed (5 baseline + 8 новых) +- ADR-059 создан + +## Почему +Живая диагностика cgroup v2 внутри зависшего контейнера: `memory.peak` 5.72 GB из 6 GB (95%), `oom_kill`=0 → ядро делало direct reclaim (синхронный scan/free страниц в event loop) → Node.js event loop блокировался → `opencode serve` зависал (504 + не отвечал по IP). CPU: `nr_throttled`=879, `throttled_usec`=104.7s при лимите 3 ядра. >120 зомби-процессов (``), opencode — PID 1, не вызывает `wait()`. Swap на хосте отключён (`Swap: 0B`). 8G даёт +2.3 GB над пиком 5.72 GB; 4 ядра убирают троттлинг (на хосте 6 ядер); tini reaping зомби; healthcheck → авто-рестарт при зависании. + +## Pending +— Развёртывание на проде и наблюдение: подтвердить что direct-reclaim stalls и троттлинг исчезли, RSS-рост не упирается в новый 8G лимит. Если упрётся — копать в сторону утечки MCP-серверов (5 дубликатных групп puppeteer+context7, ~30 процессов, ~500 MB) — это вне scope данного issue. +— Swap на хосте linux-1 остаётся отключённым — ops-задача (вне репо). + +## Watch out +— Healthcheck использует `CMD-SHELL` (не `CMD`) — нужен pipe `| grep -qE '^(200|401)$'` для допуска 401. В exec-форме (`CMD`) pipe не работает. `$$` в compose экранирует `$` для shell. +— `curl -sf` трактует 401 как не-2xx → ложный unhealthy. Поэтому grep на 200|401, а не `-sf`. +— Docker недоступен на CI-хосте — `docker compose config` не запускается; YAML валидирован через PyYAML. +— `init: true` reaping'ит только осиротевших зомби (родитель умер). Прямых детей живого opencode tini не заберёт — но это всё равно убирает основную массу. \ No newline at end of file diff --git a/docs/project-map/README.md b/docs/project-map/README.md index e7a3c39..a373365 100644 --- a/docs/project-map/README.md +++ b/docs/project-map/README.md @@ -86,7 +86,7 @@ opencode-config/ │ ├── test_check_adr_refs.py # adr-check.yml validator │ ├── test_check_permissions.py # permissions-check.yml validator │ ├── test_cli.py # src/memory/cli.py -│ ├── test_docker_compose.py # docker-compose.yml port exposure (0.0.0.0:4096, no 127.0.0.1) — PR#51 +│ ├── test_docker_compose.py # docker-compose.yml port exposure (0.0.0.0:4096, no 127.0.0.1) + opencode limits/init/healthcheck assertions (8 raw-text: 8G/4cpu/2048pids, init:true, healthcheck block+timing, restart-not-duplicated, dind-unchanged) — PR#51, PR#132 │ ├── test_dockerfile.py # Dockerfile npm install (opencode-ai + repomix; @mathew-cf/opencode-memory REMOVED PR#103, assertions inverted) — PR#57, PR#103 │ ├── test_commit_tool.py # .opencode/tools/commit.ts (via _ts_loader.mjs exec_stub_json) — PR#38 │ ├── test_commit_tool.ts # TS wrapper test (mjs loader) — PR#38 @@ -124,7 +124,7 @@ opencode-config/ ├── pyproject.toml # Python project (uv, ruff, pytest config) ├── uv.lock # Locked deps for Python project ├── .pre-commit-config.yaml # ruff + UV hooks -├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts, port 4096 on 0.0.0.0 — PR#24, PR#51 +├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts, port 4096 on 0.0.0.0; opencode: init: true (tini reaper), healthcheck (curl :4096, 200|401 healthy), limits 8G/4cpu/2048pids — PR#24, PR#51, PR#132 ├── Dockerfile # node:22-trixie-slim (Debian 13 trixie, glibc 2.41) + uv + gh + chromium + docker.io + opencode-ai + repomix + cloudflared + ripgrep (apt fallback для keyword search) (@mathew-cf/opencode-memory REMOVED PR#103) — PR#24, PR#34, PR#57, PR#71, PR#103, PR#124 │ # Memory deps install layers (PR#107): COPY .opencode/package.json → npm install --omit=dev (runtime: @vscode/ripgrep for memory-search.ts); COPY pyproject.toml uv.lock → uv sync --no-dev --frozen (runtime: httpx, numpy, tenacity for python -m src.memory) ├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN), PR#36 (OPENCODE_MEMORY_REMOTE/DIR), PR#106 (AI_PROVIDER_* removed, OPENCODE_MEMORY_REMOTE now optional), PR#118 (OPENCODE_SERVER_USERNAME) diff --git a/tests/test_docker_compose.py b/tests/test_docker_compose.py index ada1254..58cf29a 100644 --- a/tests/test_docker_compose.py +++ b/tests/test_docker_compose.py @@ -122,6 +122,126 @@ def test_no_localhost_only_binding_raw(): ) +# ── opencode resource limits increased (issue #131 — anti-regression) ─────── + + +def test_opencode_memory_limit_is_8g(): + """opencode service memory limit is 8G (was 6G; peak hit 95% of 6G).""" + content = _compose_text() + assert "memory: 8G" in content, ( + "docker-compose.yml opencode limits.memory must be 8G — 6G caused " + "direct-reclaim stalls blocking the serve event loop" + ) + assert "memory: 6G" not in content, ( + "docker-compose.yml must not retain the old 6G opencode memory limit" + ) + + +def test_opencode_cpu_limit_is_4(): + """opencode service cpu limit is 4 cores (was 3; 879 throttle events observed).""" + content = _compose_text() + assert "cpus: '4'" in content, ( + "docker-compose.yml opencode limits.cpus must be '4' — '3' caused " + "879 throttle events and sustained CPU pressure" + ) + assert "cpus: '3'" not in content, ( + "docker-compose.yml must not retain the old '3' opencode cpu limit" + ) + + +def test_opencode_pids_limit_is_2048(): + """opencode service pids limit is 2048 (was 1024; >120 zombies observed).""" + content = _compose_text() + assert "pids: 2048" in content, ( + "docker-compose.yml opencode limits.pids must be 2048 — 1024 left no " + "buffer for accumulated zombie processes" + ) + assert "pids: 1024" not in content, ( + "docker-compose.yml must not retain the old 1024 opencode pids limit" + ) + + +# ── opencode init: true for zombie reaping (issue #131) ────────────────────── + + +def test_opencode_has_init_true(): + """opencode service enables ``init: true`` so tini reaps orphaned zombies. + + opencode runs as PID 1 and never calls ``wait()``; without an init process + zombies accumulate (>120 observed). Docker's ``init: true`` injects tini + as PID 1 to reap orphans. + """ + content = _compose_text() + assert "\n init: true\n" in content, ( + "docker-compose.yml opencode service must set init: true — without it " + "orphaned zombie processes accumulate (opencode is PID 1 and does not " + "reap children)" + ) + + +# ── opencode healthcheck for hang auto-restart (issue #131) ────────────────── + + +def test_opencode_has_healthcheck_block(): + """opencode service defines a healthcheck probing localhost:4096. + + ``opencode serve`` requires basic auth and returns 401 without credentials + — that is healthy. The check accepts 200 or 401 (server responds) and + fails only when serve hangs (no HTTP response). Docker restarts unhealthy + containers via the existing ``restart: unless-stopped``. + """ + content = _compose_text() + assert "healthcheck:" in content, ( + "docker-compose.yml opencode service must define a healthcheck — " + "without it Docker cannot detect serve hangs to auto-restart" + ) + assert "curl" in content and "localhost:4096" in content, ( + "healthcheck must probe opencode serve on localhost:4096 via curl" + ) + assert "200" in content and "401" in content, ( + "healthcheck must accept both 200 and 401 as healthy — serve returns " + "401 without basic-auth credentials" + ) + + +def test_opencode_healthcheck_timing(): + """healthcheck timing matches issue #131 spec (interval/timeout/retries/start).""" + content = _compose_text() + assert "interval: 30s" in content + assert "timeout: 10s" in content + assert "retries: 3" in content + assert "start_period: 30s" in content + + +# ── restart not duplicated (issue #131 — must not regress) ─────────────────── + + +def test_opencode_restart_not_duplicated(): + """``restart: unless-stopped`` appears exactly once for opencode. + + It already exists; the healthcheck relies on it for auto-restart. A + duplicate key would be a YAML error or a silent override. + """ + content = _compose_text() + opencode_block = content.split(" opencode:", 1)[1] + assert opencode_block.count("restart: unless-stopped") == 1, ( + "docker-compose.yml opencode service must have exactly one " + "'restart: unless-stopped' (already present, must not duplicate)" + ) + + +# ── dind service unchanged (issue #131 — must not regress) ────────────────── + + +def test_dind_limits_unchanged(): + """dind service keeps its original limits (4G/2cpu/512pids) — not in scope.""" + content = _compose_text() + dind_block = content.split(" dind:", 1)[1].split(" opencode:", 1)[0] + assert "cpus: '2'" in dind_block + assert "memory: 4G" in dind_block + assert "pids: 512" in dind_block + + if __name__ == "__main__": import pytest