🐛 Fix container stop issue
This commit is contained in:
@@ -53,8 +53,9 @@ Bootstrap sequence on first start:
|
|||||||
the MQTT `$CONTROL/dynamic-security/v1` API. No files involved.
|
the MQTT `$CONTROL/dynamic-security/v1` API. No files involved.
|
||||||
* **Generated mosquitto.conf (rare)** — listeners/global settings are
|
* **Generated mosquitto.conf (rare)** — listeners/global settings are
|
||||||
generated from the DB model and deployed through `mosquitto-control`,
|
generated from the DB model and deployed through `mosquitto-control`,
|
||||||
which shares the broker's PID namespace (`pid: "service:mosquitto"`)
|
which OWNS the shared PID namespace (`pid: "service:mosquitto-control"`
|
||||||
to signal reloads. No Docker socket is mounted anywhere (spec §29).
|
on the broker); it signals the broker process and can restart it — and
|
||||||
|
survives broker restarts — without Docker sockets (spec §29).
|
||||||
* Mosquitto 2.1's HTTP API is **not** used as a management channel: it
|
* Mosquitto 2.1's HTTP API is **not** used as a management channel: it
|
||||||
has no authentication and (currently) exposes read-only plugin info.
|
has no authentication and (currently) exposes read-only plugin info.
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
# broker-control — internal control sidecar (spec §29)
|
# broker-control — internal control sidecar (spec §29)
|
||||||
#
|
#
|
||||||
# Runs in the mosquitto container's PID namespace (compose `pid: "service:mosquitto"`)
|
# PID-namespace OWNER: the broker joins THIS container's PID namespace
|
||||||
# so it can signal the broker process (PID 1) without docker.sock access.
|
# (compose `pid: "service:mosquitto-control"` on the mosquitto service).
|
||||||
# The backend calls its HTTP API over the backend-net network.
|
# Owning the namespace means the sidecar survives broker restarts — it
|
||||||
|
# was previously the other way around and the kernel killed the sidecar
|
||||||
|
# whenever the broker exited. tini runs as PID 1: it forwards signals to
|
||||||
|
# python and reaps the broker's zombies (the broker re-parents to tini
|
||||||
|
# when its container entrypoint forks).
|
||||||
#
|
#
|
||||||
# Responsibilities (deliberately minimal, spec §29):
|
# Responsibilities (deliberately minimal, spec §29):
|
||||||
# - validate staged configuration (mosquitto -c <staged> --test-config)
|
# - validate staged configuration (mosquitto -c <staged> --test-config)
|
||||||
# - activate a configuration revision (atomic rename into place)
|
# - activate a configuration revision (atomic rename into place)
|
||||||
# - signal config reload (mosquitto_signal -p 1 config-reload == SIGHUP)
|
# - signal config reload (mosquitto_signal -p <broker-pid> config-reload)
|
||||||
|
# - signal broker restart (SIGTERM; Docker policy revives the broker)
|
||||||
# - check broker process state
|
# - check broker process state
|
||||||
#
|
|
||||||
# Release 1 ships validate/activate/reload/status only; restarts are a
|
|
||||||
# Release 2 concern (reload-vs-restart classification, spec §28).
|
|
||||||
|
|
||||||
FROM eclipse-mosquitto:2.1.2-alpine
|
FROM eclipse-mosquitto:2.1.2-alpine
|
||||||
|
|
||||||
@@ -20,7 +22,8 @@ FROM eclipse-mosquitto:2.1.2-alpine
|
|||||||
|
|
||||||
# The base image ships NO Python. control.py is stdlib-only, so the
|
# The base image ships NO Python. control.py is stdlib-only, so the
|
||||||
# distro package is sufficient (no pip, no venv needed).
|
# distro package is sufficient (no pip, no venv needed).
|
||||||
RUN apk add --no-cache python3
|
# tini: minimal init as PID 1 — signal forwarding + zombie reaping.
|
||||||
|
RUN apk add --no-cache python3 tini
|
||||||
|
|
||||||
COPY control.py /app/control.py
|
COPY control.py /app/control.py
|
||||||
|
|
||||||
@@ -36,4 +39,4 @@ CMD []
|
|||||||
|
|
||||||
EXPOSE 8090
|
EXPOSE 8090
|
||||||
|
|
||||||
ENTRYPOINT ["python3", "/app/control.py"]
|
ENTRYPOINT ["/sbin/tini", "--", "python3", "/app/control.py"]
|
||||||
+51
-19
@@ -11,7 +11,10 @@ Security model:
|
|||||||
Docker network; the port is not published to the host.
|
Docker network; the port is not published to the host.
|
||||||
* Requires a bearer token (CONTROL_API_TOKEN) shared with the backend.
|
* Requires a bearer token (CONTROL_API_TOKEN) shared with the backend.
|
||||||
* Never mounts docker.sock; signals the broker over the shared PID
|
* Never mounts docker.sock; signals the broker over the shared PID
|
||||||
namespace (broker is PID 1 in this container's view).
|
namespace. OWNERSHIP IS INVERTED: this sidecar owns the namespace
|
||||||
|
(tini is PID 1) and the broker joins it — so the sidecar survives
|
||||||
|
broker restarts and can orchestrate them (spec section 29). The
|
||||||
|
broker's PID is discovered via /proc, not hardcoded.
|
||||||
|
|
||||||
Implementation notes:
|
Implementation notes:
|
||||||
* Atomic activation writes content to a temp file in the SAME
|
* Atomic activation writes content to a temp file in the SAME
|
||||||
@@ -98,18 +101,47 @@ def _activate(content: str) -> None:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _broker_pid() -> int | None:
|
||||||
|
"""Find the broker process in this PID namespace.
|
||||||
|
|
||||||
|
The namespace is OWNED by this sidecar (compose inversion: the broker
|
||||||
|
joins us via `pid: "service:mosquitto-control"`), so the broker is
|
||||||
|
NOT PID 1 — discover it by scanning /proc for the mosquitto binary.
|
||||||
|
PID 1 here is tini (the sidecar's init).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
for entry in os.listdir("/proc"):
|
||||||
|
if not entry.isdigit():
|
||||||
|
continue
|
||||||
|
pid = int(entry)
|
||||||
|
if pid == 1:
|
||||||
|
continue # tini (this sidecar's init), never the broker
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/comm", encoding="utf-8") as f:
|
||||||
|
if f.read().strip() == "mosquitto":
|
||||||
|
return pid
|
||||||
|
except OSError:
|
||||||
|
continue # process vanished mid-scan
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _signal_reload() -> None:
|
def _signal_reload() -> None:
|
||||||
"""Signal the broker (PID 1 in the shared PID namespace) to reload."""
|
"""Signal the broker to reload its configuration."""
|
||||||
|
pid = _broker_pid()
|
||||||
|
if pid is None:
|
||||||
|
raise _Error(503, "broker process not found in the PID namespace")
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
["mosquitto_signal", "-p", "1", "config-reload"],
|
["mosquitto_signal", "-p", str(pid), "config-reload"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# Fallback if the helper app is unavailable for any reason.
|
# Fallback if the helper app is unavailable for any reason.
|
||||||
os.kill(1, signal.SIGHUP)
|
os.kill(pid, signal.SIGHUP)
|
||||||
return
|
return
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
raise _Error(500, f"mosquitto_signal failed: {proc.stderr.strip()}")
|
raise _Error(500, f"mosquitto_signal failed: {proc.stderr.strip()}")
|
||||||
@@ -118,25 +150,25 @@ def _signal_reload() -> None:
|
|||||||
def _restart_broker() -> None:
|
def _restart_broker() -> None:
|
||||||
"""Gracefully stop the broker; Docker's restart policy revives it.
|
"""Gracefully stop the broker; Docker's restart policy revives it.
|
||||||
|
|
||||||
The broker is PID 1 in the shared PID namespace. SIGTERM makes it
|
The broker is a normal process in THIS sidecar's PID namespace (we
|
||||||
shut down cleanly (persistence saved); `restart: unless-stopped`
|
own it; the broker joined us). SIGTERM makes it shut down cleanly
|
||||||
on the mosquitto service then starts the container again. This
|
(persistence saved); `restart: unless-stopped` on the mosquitto
|
||||||
sidecar is a separate container and survives, so it can report
|
service then starts the container again. Because the sidecar is the
|
||||||
status while the broker is down (spec section 29: restart broker
|
namespace init, it SURVIVES the broker's death and keeps reporting
|
||||||
where explicitly supported — no Docker socket involved).
|
status through the outage (spec section 29 — no Docker socket).
|
||||||
"""
|
"""
|
||||||
# The HTTP response MUST be flushed before PID 1 dies, so the kill
|
# The HTTP response MUST be flushed before the broker dies, so the
|
||||||
# happens after the handler returns — orchestrated by the route.
|
# kill happens after the handler returns — orchestrated by the route.
|
||||||
os.kill(1, signal.SIGTERM)
|
pid = _broker_pid()
|
||||||
|
if pid is None:
|
||||||
|
# Already down (restart in flight): nothing to signal, and the
|
||||||
|
# caller still gets a clean "restarting" response.
|
||||||
|
return
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
|
||||||
|
|
||||||
def _broker_pid_alive() -> bool:
|
def _broker_pid_alive() -> bool:
|
||||||
try:
|
return _broker_pid() is not None
|
||||||
# Broker is PID 1 in the shared PID namespace.
|
|
||||||
with open("/proc/1/comm", encoding="utf-8") as f:
|
|
||||||
return f.read().strip() == "mosquitto"
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _post_broker_health() -> bool:
|
def _post_broker_health() -> bool:
|
||||||
|
|||||||
+20
-8
@@ -50,8 +50,18 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./mosquitto
|
context: ./mosquitto
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Spec §29: no docker.sock. Control sidecar shares the broker's PID namespace
|
# Spec §29: no docker.sock. INVERTED PID ownership: the broker joins
|
||||||
# and the mosquitto_config volume; it signals PID 1 (the broker) directly.
|
# the CONTROL SIDECAR's PID namespace, not the other way around.
|
||||||
|
# The sidecar is the namespace init and survives broker restarts
|
||||||
|
# (previously the sidecar joined the broker's namespace and was
|
||||||
|
# kernel-killed whenever the broker exited — with a race on its
|
||||||
|
# own restart because it could not re-join a still-down broker).
|
||||||
|
# The broker is therefore NOT PID 1: control.py discovers its PID
|
||||||
|
# via /proc instead of hardcoding PID 1.
|
||||||
|
pid: "service:mosquitto-control"
|
||||||
|
depends_on:
|
||||||
|
mosquitto-control:
|
||||||
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
# Used by the dynsec plugin on first start to seed the 'admin' client
|
# Used by the dynsec plugin on first start to seed the 'admin' client
|
||||||
# (verified: Mosquitto 2.1 dynamic security docs).
|
# (verified: Mosquitto 2.1 dynamic security docs).
|
||||||
@@ -87,12 +97,14 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./broker-control
|
context: ./broker-control
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
# PID-namespace OWNER: the broker joins us via its `pid:` directive.
|
||||||
mosquitto:
|
# Owning the namespace means a broker restart (SIGTERM to the broker
|
||||||
condition: service_started
|
# process) only kills the broker — this sidecar keeps running to
|
||||||
# Shares PID namespace with the broker so `mosquitto_signal -p 1 ...`
|
# report status and orchestrate the revival. Runs tini as PID 1
|
||||||
# reaches the broker process without mounting docker.sock.
|
# (signal forwarding + zombie reaping). If THIS container dies, the
|
||||||
pid: "service:mosquitto"
|
# kernel tears down the namespace and kills the broker too — the
|
||||||
|
# sidecar is deliberately minimal so that path should never trigger.
|
||||||
|
# No depends_on: the broker depends on us (inverted), not vice versa.
|
||||||
environment:
|
environment:
|
||||||
# Minimal default token for the internal control API; in production
|
# Minimal default token for the internal control API; in production
|
||||||
# this MUST be replaced (it only guards a backend-net-only endpoint).
|
# this MUST be replaced (it only guards a backend-net-only endpoint).
|
||||||
|
|||||||
Reference in New Issue
Block a user