Files
homelab/ansible/roles/edge-host/templates/docker-compose.yml.j2
T
k.rennerandClaude Opus 4.8 9add6f1a56 edge-host: batch zwave-js fsync via libeatmydata (+ lazytime) to spare eMMC
With logs off flash, the dominant remaining eMMC writer is zwave-js-ui: its
value DB is fsync'd on every value change (jsonl-db; zwave-js-ui doesn't
expose the driver throttle). On a busy network that fsync storm churned
~2.6 GiB/day for ~0.1 KiB/s of real data — near-pure write amplification
(each fsync forces a block write + ext4 journal commit).

Preload libeatmydata into the zwave-js container (musl .so extracted from the
image's Alpine base, mounted ro, LD_PRELOAD) so fsync/fdatasync become no-ops
and the kernel batches writes via normal writeback. The WS/MQTT value stream
to HA and the on-disk cache are unchanged — full data resolution, no
re-interview on restart. No ext4 mount option can do this (fsync is a
durability contract; commit=/data=writeback/nobarrier don't batch it, and the
only mount-level alternative — tmpfs — makes the whole store volatile).

Also:
- noatime -> noatime,lazytime on root: batches the inode-timestamp writeback
  (e.g. the zwave-js lockfile heartbeat) without weakening data durability.
- edge-zwave-settings-backup.timer: hourly validated rolling backup of
  settings.json (Z-Wave network keys), since neutralized fsync means a
  power-loss mid settings-write could truncate it. Value-cache loss on crash
  is harmless (devices re-report; jsonl-db tolerates a partial trailing line).

Verify the drop on the eMMC-write monitor; rollback = drop the LD_PRELOAD env
+ .so mount from the compose file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 11:26:46 +02:00

57 lines
2.4 KiB
Django/Jinja

# Managed by ansible (roles/edge-host). Mirrors the former k8s manifests:
# kubernetes/apps/home-assistant-zwavejs/deployment.yaml and
# kubernetes/apps/home-assistant/sml2mqtt-deployment.yaml.
#
# No `privileged` needed here: docker's --device handles the device-cgroup
# grant that hostPath in k8s could not (that was the only reason the pods
# ran privileged).
# Both services use host networking: real host addresses (v4+v6), no
# bridge, no NAT. The broker LB is IPv6-first and docker bridge NAT66 is
# a tarpit (unmasqueraded SYNs die silently); the host stack just works.
services:
zwave-js:
image: {{ edge_zwavejs_image }}
container_name: zwave-js
restart: unless-stopped
stop_signal: SIGINT
tty: true
environment:
- TZ={{ edge_tz }}
# Batch the value-DB fsync storm to spare the eMMC (see role tasks /
# README "Logging & eMMC"). Neutralizes fsync only for this container;
# the WS/MQTT value stream to HA and cache persistence are unchanged.
- LD_PRELOAD=/usr/lib/libeatmydata.so
env_file:
- zwavejs.env
devices:
- "{{ edge_zwave_device }}:/dev/zwave"
volumes:
# musl libeatmydata.so extracted from the image's Alpine base (ro)
- ./zwave-js/libeatmydata.so:/usr/lib/libeatmydata.so:ro
# Persistent store: settings.json, device DB, network keys. Seeded
# from the k8s PVC `zwavejs-store` at cutover (see README). Since
# this host is stateful there is no init-container merge step —
# settings.json is edited via the UI and persists here; zwave.port
# stays /dev/zwave.
- ./zwave-js/store:/usr/src/app/store
# Host networking: binds 3000 (Z-Wave JS websocket for HA) and 8091
# (web UI) directly on the host.
network_mode: host
sml2mqtt:
image: {{ edge_sml2mqtt_image }}
container_name: sml2mqtt
restart: unless-stopped
# Outbound-only (serial in, MQTT out) — host networking sidesteps the
# docker NAT66 gap: the broker LB is IPv6-first and bridge-network v6
# SYNs leave unmasqueraded and die. The host stack reaches fd99::81
# fine (open3e proves it continuously).
network_mode: host
# Serial exclusivity: this is the ONLY reader of the meter port —
# HA's edl21 integration stays disabled (see sml2mqtt-config comment
# history in the k8s repo).
devices:
- "{{ edge_stromzaehler_device }}:/dev/stromzaehler"
volumes:
- ./sml2mqtt/config.yml:/sml2mqtt/config.yml:ro