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 <agent@opencode.local>
This commit is contained in:
Sergey 2026-07-29 21:19:01 +03:00 committed by GitHub
parent 2a6d6eee2a
commit e72d435b82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 188 additions and 5 deletions

View file

@ -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:

View file

@ -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 зомби-процессов (`<defunct>`: `[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/с — отдельная утечка, не блокирующая данный фикс.

View file

@ -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 зомби-процессов (`<defunct>`), 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 не заберёт — но это всё равно убирает основную массу.

View file

@ -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)

View file

@ -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