From 2f03bee45b7c3c82979841cb7e42ac5c7789944a Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Mon, 14 Sep 2026 20:09:32 +0200 Subject: [PATCH] Deploy: drop cpus: limits and stop assuming `docker compose` exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two NAS realities, both hit on a real Synology box. - "NanoCPUs" error part-way through the project build: `cpus:` maps to the NanoCPUs field, which DSM's Docker daemon rejects. Removed from all three services (and from .env.prod.example). mem_limit uses the much older Memory field and works, so memory caps remain the throttle. Noted in the header next to the other DSM constraints. - `docker compose build` on DSM: "'compose' is not a docker command". DSM and QTS ship the standalone `docker-compose` binary with no plugin subcommand, so every one of the script's 21 compose invocations was wrong for the target platform. The script now detects which is present, and only passes `--wait` when it finds v2 — on v1 it falls back to the health_gate it already had, which is also now run after `up` on both paths. That the build reached container creation before failing says their compose parses the rest of the file (anchors, profiles, service_completed_successfully, top-level name), so this was a daemon-level rejection, not a schema one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw --- deploy/webmail.sh | 42 +++++++++++++++++++++++++++++++++++----- docker-compose.yml | 7 ++++--- docker/.env.prod.example | 4 ++-- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/deploy/webmail.sh b/deploy/webmail.sh index 93cc01b..3d8ce6a 100755 --- a/deploy/webmail.sh +++ b/deploy/webmail.sh @@ -33,8 +33,33 @@ grn() { printf '\033[32m%s\033[0m\n' "$*"; } ylw() { printf '\033[33m%s\033[0m\n' "$*"; } die() { red "error: $*" >&2; exit 1; } +# Which compose is on this box. Synology DSM and QNAP QTS ship the standalone +# `docker-compose` binary and have no `docker compose` subcommand, so detect +# rather than assume. COMPOSE_WAIT is empty on v1, which has no `--wait`; the +# script's own health_gate covers that case. +COMPOSE_BIN=() +COMPOSE_WAIT="" +detect_compose() { + [ "${#COMPOSE_BIN[@]}" -gt 0 ] && return 0 + if docker compose version >/dev/null 2>&1; then + COMPOSE_BIN=(docker compose) + elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_BIN=(docker-compose) + else + die "no compose found: neither 'docker compose' nor 'docker-compose' is available" + fi + local ver + ver="$("${COMPOSE_BIN[@]}" version 2>/dev/null | head -1)" + case "$ver" in + *v2.*|*" 2."*) COMPOSE_WAIT="--wait" ;; + *) COMPOSE_WAIT="" + ylw "compose v1 detected (${ver:-unknown}) — using the script's own health checks" ;; + esac +} + compose() { - docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" + detect_compose + "${COMPOSE_BIN[@]}" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" } # Read KEY=VALUE pairs the way compose's .env parser does, WITHOUT sourcing. @@ -301,7 +326,10 @@ cmd_up() { # the `init` service fixes ownership. This line must not be fatal, because # DATA_ROOT is typically root-owned while you may be running as yourself. mkdir -p "${DATA_ROOT}"/{postgres,blobs,redis,backups,meili,state} 2>/dev/null || true - compose "${profile[@]}" up -d "${build[@]}" --wait + detect_compose + # shellcheck disable=SC2086 + compose "${profile[@]}" up -d "${build[@]}" $COMPOSE_WAIT + health_gate || die "services did not come up healthy — check: ./deploy/webmail.sh logs" record_version "${IMAGE_TAG:-local}" grn "up. open http://:${WEB_PORT:-8088}/" } @@ -395,7 +423,8 @@ cmd_update() { grn "updated to ${target}" else red "health gate failed — rolling back to ${previous}" - IMAGE_TAG="$previous" compose up -d --wait || true + # shellcheck disable=SC2086 + IMAGE_TAG="$previous" compose up -d $COMPOSE_WAIT || true die "update to ${target} failed; production is back on ${previous}" fi } @@ -405,7 +434,8 @@ cmd_rollback() { local target="${1:-$(cat "$(state_dir)/previous" 2>/dev/null || echo "")}" [ -n "$target" ] || die "no previous version recorded; pass a tag: ./deploy/webmail.sh rollback v1.0.0" ylw "rolling back to ${target}" - IMAGE_TAG="$target" compose up -d --wait + # shellcheck disable=SC2086 + IMAGE_TAG="$target" compose up -d $COMPOSE_WAIT if health_gate; then record_version "$target" set_env_value IMAGE_TAG "$target" @@ -459,7 +489,9 @@ cmd_restore() { || die "restore failed; the database was left as it was" echo "starting application containers" - compose up -d --wait + # shellcheck disable=SC2086 + compose up -d $COMPOSE_WAIT + health_gate || ylw "services are still starting; check: ./deploy/webmail.sh status" grn "restored. If the blob store was lost too, mail bodies will refill on next sync." } diff --git a/docker-compose.yml b/docker-compose.yml index a1d9d43..e65d2f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,10 @@ # Start: ./deploy/webmail.sh up # Manual: docker compose up -d (reads .env from this directory) # +# Likewise, no `cpus:` limits: that maps to NanoCPUs, which DSM's Docker +# daemon rejects ("NanoCPUs can not be set"). mem_limit uses the much older +# Memory field and works fine, so memory caps are the throttle here. +# # Interpolation here must stay on the oldest, dumbest forms: ${VAR} and # ${VAR:-default}. DSM's bundled Compose rejects `${VAR:?message}` with # "invalid interpolation format", and rejects `include:` outright. Required @@ -224,7 +228,6 @@ services: migrate: { condition: service_completed_successfully } restart: unless-stopped mem_limit: ${API_MEM_LIMIT:-512m} - cpus: ${API_CPUS:-1.0} logging: *logging worker-sync: @@ -243,7 +246,6 @@ services: migrate: { condition: service_completed_successfully } restart: unless-stopped mem_limit: ${WORKER_SYNC_MEM_LIMIT:-512m} - cpus: ${WORKER_CPUS:-1.5} # Leases are released on SIGTERM (workers/run.py targets <10 s); give it room. stop_grace_period: 30s logging: *logging @@ -262,7 +264,6 @@ services: migrate: { condition: service_completed_successfully } restart: unless-stopped mem_limit: ${WORKER_JOBS_MEM_LIMIT:-640m} - cpus: ${WORKER_CPUS:-1.5} stop_grace_period: 30s logging: *logging diff --git a/docker/.env.prod.example b/docker/.env.prod.example index 8c6ee0d..4d14b83 100644 --- a/docker/.env.prod.example +++ b/docker/.env.prod.example @@ -91,6 +91,8 @@ STREAM_PARSE_MB=8 PER_HOST_CONN_CAP=10 HOT_BUDGET_PER_WORKER=50 +# Container caps. Memory only: CPU limits (`cpus:`) are deliberately absent +# because Synology's Docker daemon rejects them (NanoCPUs). # Container caps. Total without Meilisearch: ~2.2 GB of *limits*; real steady # usage for one or two mail accounts is around 700 MB-1 GB. PG_MEM_LIMIT=768m @@ -102,10 +104,8 @@ PG_EFFECTIVE_CACHE_SIZE=768MB REDIS_MEM_LIMIT=160m REDIS_MAXMEMORY=96mb API_MEM_LIMIT=512m -API_CPUS=1.0 WORKER_SYNC_MEM_LIMIT=512m WORKER_JOBS_MEM_LIMIT=640m -WORKER_CPUS=1.5 WEB_MEM_LIMIT=96m BACKUP_MEM_LIMIT=256m MEILI_MEM_LIMIT=1g