From 1104a44d2d3b181d9278a3370ad5b14deba34321 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 28 May 2026 21:18:39 +0000 Subject: [PATCH] =?UTF-8?q?fix(compose):=20generate=5Fscaled=5Fcompose=20?= =?UTF-8?q?=E3=81=AB=20init:=20true=20=E3=82=92=E6=B3=A8=E5=85=A5=E3=81=97?= =?UTF-8?q?=E3=81=A6=E3=82=BE=E3=83=B3=E3=83=93=20reap=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devbase コンテナの PID 1 は entrypoint の `tail -f /dev/null` で orphan を reap しないため、`nohup ... & disown` で起動したプロセスがゾンビ化して蓄積する。 特に cross-review の monitor.py がゾンビを「実行中」と誤判定し hard timeout まで 待たされる二次被害が出る。 generate_scaled_compose() の dev / non-dev 両サービス生成ループで `setdefault('init', True)` を注入し、docker が tini を PID 1 に挿入するように する。devbase up は常にこの生成ファイル単独で compose up するため scale=1 でも 網羅される。setdefault のため明示的な `init: false` は尊重する。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/volume/compose.py | 7 ++++ tests/volume/__init__.py | 0 tests/volume/test_compose.py | 74 +++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 tests/volume/__init__.py create mode 100644 tests/volume/test_compose.py diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index b907036..0c00fc0 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -203,6 +203,9 @@ def generate_scaled_compose( if service_name != dev_service_name: copied = _deep_copy(service_config) _rewrite_depends_on(copied, dev_service_name, scale) + # Insert tini as PID 1 so orphaned children are reaped (no zombies). + # setdefault keeps an explicit `init: false` if the project set one. + copied.setdefault('init', True) scaled_config['services'][service_name] = copied # Generate a service for each instance @@ -216,6 +219,10 @@ def generate_scaled_compose( # Update container name service['container_name'] = f"${{COMPOSE_PROJECT_NAME}}-{dev_service_name}-{i}" + # Insert tini as PID 1 so orphaned children are reaped (no zombies). + # setdefault keeps an explicit `init: false` if the project set one. + service.setdefault('init', True) + # Remove environment section (use env_file instead to avoid exposing secrets) if 'environment' in service: del service['environment'] diff --git a/tests/volume/__init__.py b/tests/volume/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/volume/test_compose.py b/tests/volume/test_compose.py new file mode 100644 index 0000000..da7f22c --- /dev/null +++ b/tests/volume/test_compose.py @@ -0,0 +1,74 @@ +"""compose.py: generate_scaled_compose() の `init: true` 注入挙動 (issue #28) + +devbase コンテナの PID 1 は entrypoint の `tail -f /dev/null` であり orphan を reap しない。 +docker の `init: true` で tini を PID 1 に挿入しゾンビ蓄積を防ぐため、生成される全サービスに +`init` を注入する。ユーザーが明示した `init: false` は setdefault で尊重する。 +""" + +from __future__ import annotations + +import os + +import yaml +import pytest + +from devbase.volume import compose + + +@pytest.fixture +def in_tmp_cwd(tmp_path, monkeypatch): + """生成物 (.docker-compose.scale.yml) が散らからないよう CWD を tmp に移す。""" + monkeypatch.chdir(tmp_path) + # DEV_SERVICE_NAME が外部環境に左右されないよう既定に固定 + monkeypatch.delenv("DEV_SERVICE_NAME", raising=False) + return tmp_path + + +def _write_compose(tmp_path, services: dict) -> None: + (tmp_path / "compose.yml").write_text( + yaml.safe_dump({"services": services}, sort_keys=False), + encoding="utf-8", + ) + + +def _load_scaled(tmp_path) -> dict: + return yaml.safe_load((tmp_path / ".docker-compose.scale.yml").read_text()) + + +def test_dev_and_non_dev_services_get_init_true(in_tmp_cwd): + """dev インスタンスと non-dev サービスの双方に init: true が注入される。""" + _write_compose(in_tmp_cwd, { + "dev": {"image": "dev:latest"}, + "mysql": {"image": "mysql:8"}, + }) + + compose.generate_scaled_compose(scale=1, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + assert scaled["dev-1"]["init"] is True + assert scaled["mysql"]["init"] is True + + +def test_init_injected_for_every_scaled_instance(in_tmp_cwd): + """scale>1 でも各 dev-i 全てに init: true が付く。""" + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=3, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + for i in (1, 2, 3): + assert scaled[f"dev-{i}"]["init"] is True + + +def test_explicit_init_false_is_preserved(in_tmp_cwd): + """明示的な init: false は setdefault により上書きされない。""" + _write_compose(in_tmp_cwd, { + "dev": {"image": "dev:latest", "init": False}, + "mysql": {"image": "mysql:8", "init": False}, + }) + + compose.generate_scaled_compose(scale=1, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + assert scaled["dev-1"]["init"] is False + assert scaled["mysql"]["init"] is False