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:
parent
2a6d6eee2a
commit
e72d435b82
5 changed files with 188 additions and 5 deletions
|
|
@ -23,6 +23,7 @@ services:
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: opencode
|
container_name: opencode
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
command: ["serve", "--hostname", "0.0.0.0", "--port", "4096", "--cors", "*"]
|
command: ["serve", "--hostname", "0.0.0.0", "--port", "4096", "--cors", "*"]
|
||||||
working_dir: /root/workspace
|
working_dir: /root/workspace
|
||||||
env_file:
|
env_file:
|
||||||
|
|
@ -52,11 +53,17 @@ services:
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpus: '3'
|
cpus: '4'
|
||||||
memory: 6G
|
memory: 8G
|
||||||
pids: 1024
|
pids: 2048
|
||||||
reservations:
|
reservations:
|
||||||
memory: 2G
|
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:
|
networks:
|
||||||
opencode_network:
|
opencode_network:
|
||||||
|
|
|
||||||
30
docs/decisions/059-pr-132-fix-docker-opencode-limits.md
Normal file
30
docs/decisions/059-pr-132-fix-docker-opencode-limits.md
Normal 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/с — отдельная утечка, не блокирующая данный фикс.
|
||||||
26
docs/handoff/pr-132-fix-docker-opencode-limits.md
Normal file
26
docs/handoff/pr-132-fix-docker-opencode-limits.md
Normal 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 не заберёт — но это всё равно убирает основную массу.
|
||||||
|
|
@ -86,7 +86,7 @@ opencode-config/
|
||||||
│ ├── test_check_adr_refs.py # adr-check.yml validator
|
│ ├── test_check_adr_refs.py # adr-check.yml validator
|
||||||
│ ├── test_check_permissions.py # permissions-check.yml validator
|
│ ├── test_check_permissions.py # permissions-check.yml validator
|
||||||
│ ├── test_cli.py # src/memory/cli.py
|
│ ├── 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_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.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
|
│ ├── 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)
|
├── pyproject.toml # Python project (uv, ruff, pytest config)
|
||||||
├── uv.lock # Locked deps for Python project
|
├── uv.lock # Locked deps for Python project
|
||||||
├── .pre-commit-config.yaml # ruff + UV hooks
|
├── .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
|
├── 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)
|
│ # 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)
|
├── .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)
|
||||||
|
|
|
||||||
|
|
@ -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__":
|
if __name__ == "__main__":
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue