Files
admin/.wiki/concepts/portainer-stack-management-vds.md
vitya 9a92ff0b87 chore(vds): mem_limit 512m всему тиражу snolla (5 стеков live + compose-копии + вики-конвенция)
Тираж snolla (labtools.ru/17, emspb/18, labtools.pro/19, tandemmebel/20,
kupimknigi/21) шёл без mem_limit → cgroup-cap = вся память хоста (12.9 GiB),
одна течь могла съесть весь бокс. Выставил 512m (baseline ~100-200M, 2.5-5x запас)
на всех 5 живых стеках через env-preserving Portainer PUT + синхронизировал
source-of-truth compose. Все healthy, limit=536870912 подтверждён, labtools.pro 200.

Конвенция «app-стек обязан нести mem_limit» закреплена в
portainer-stack-management-vds § Convention + step 6 snolla-bump-рецепта.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 14:32:55 +03:00

13 KiB
Raw Permalink Blame History

title: Portainer stack management on vds-kzntsv — canonical pattern status: live tags: [vds, portainer, docker-compose, ops] related: vds-kzntsv, mssql-on-vds, minio-imgproxy-on-vds updated: 2026-06-17

Portainer stack management on vds-kzntsv

Canonical rule: all docker-compose stacks на VDS управляются через Portainer (https://portainer.vds.kzntsv.site). Ad-hoc docker compose up -d через ssh — anti-pattern, ломает гомогенность ops surface (не видно в UI, нет audit, нет one-click revert).

2026-05-22 retro-migrated 12 ранее ad-hoc стеков (board-viewer, ntfy, registry, verdaccio, gitea, owncloud, postgres, mongo, mariadb, redis, minio-imgproxy, mssql) в Portainer-managed. Skipped traefik + portainer (management plane — recreate ломает access).

Portainer API auth

Portainer API token из vds-kzntsv/full-env PORTAINER_API_KEY ранее давал 401 (см. owncloud-vds-deploy follow-up; требует regen через UI). Workaround — JWT через admin password:

JWT=$(curl -ksS -X POST https://portainer.vds.kzntsv.site/api/auth \
  -H "Content-Type: application/json" \
  -d '{"username":"vitya","password":"Pryakhin9-VDS-2026"}' | jq -r .jwt)

Pass-store: pass show vds-kzntsv/full-env (full env file со всеми creds).

Migration script (ad-hoc → Portainer-managed)

#!/bin/bash
# /tmp/portainer-migrate.sh — runs on VDS
set -e
STACK="$1"          # e.g. "mssql"
DIR="$2"            # e.g. "/opt/stacks/databases/mssql"
PORTAINER_URL="https://portainer.vds.kzntsv.site"

JWT=$(curl -ksS -X POST "$PORTAINER_URL/api/auth" -H "Content-Type: application/json" \
  -d '{"username":"vitya","password":"<from vds-kzntsv/full-env>"}' | jq -r .jwt)

# Transform compose: strip env_file directives (Portainer передаёт env через API array),
# absolutize ./ paths (Portainer-managed working_dir = /data/compose/<id>/, не stack dir)
COMPOSE=$(sudo cat "$DIR/docker-compose.yml" | sed \
  -e '/^\s*env_file:/d' \
  -e '/^\s*-\s*\.env\s*$/d' \
  -e "s|\\./|$DIR/|g")

# Parse .env into array
ENV_ARRAY="[]"
if sudo test -f "$DIR/.env"; then
  ENV_ARRAY=$(sudo cat "$DIR/.env" | grep -vE '^\s*(#|$)' | jq -Rs '
    split("\n") | map(select(length>0) | split("=") | {name: .[0], value: (.[1:] | join("="))})')
fi

PAYLOAD=$(jq -n --arg name "$STACK" --arg compose "$COMPOSE" --argjson env "$ENV_ARRAY" \
  '{name:$name, stackFileContent:$compose, env:$env, fromAppTemplate:false}')

# Cleanup existing Portainer stack with same name (idempotency)
EXISTING_ID=$(curl -ksS -H "Authorization: Bearer $JWT" "$PORTAINER_URL/api/stacks" | \
  jq -r ".[] | select(.Name==\"$STACK\") | .Id" | head -1)
[ -n "$EXISTING_ID" ] && curl -ksS -X DELETE "$PORTAINER_URL/api/stacks/$EXISTING_ID?endpointId=1" -H "Authorization: Bearer $JWT" >/dev/null

# Down ad-hoc compose
sudo docker ps -aq --filter "label=com.docker.compose.project=$STACK" | grep -q . && \
  (cd "$DIR" && sudo docker compose down 2>&1 | tail -2)

# Create via Portainer
curl -ksS -X POST "$PORTAINER_URL/api/stacks/create/standalone/string?endpointId=1" \
  -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" --data "$PAYLOAD"

Convention: mem_limit на app-стеках (обязательно)

Каждый app/site-стек несёт mem_limit. Без него cgroup-limit = вся память хоста (12.9 GiB) → одна течь в контейнере может съесть весь бокс, а не упереться в свой потолок. С restart: unless-stopped упор в лимит = OOM-kill контейнера + авто-подъём (изоляция вместо каскада на всю машину).

  • standalone-стек (не swarm) → ключ mem_limit, НЕ deploy.resources.limits.memory (тот игнорится вне swarm).
  • Значение = ~2.55× наблюдаемого пика. snolla-сайты (Node SSR, baseline ~100200 MB) → 512m. Прочерк-пример: owncloud oCIS → 1g.
  • Ставить в момент создания/правки стека — не откладывать. Проверка постфактум: ops.docker.stats <name> поле limit == заданному (512m = 536870912), не 13890813952.
  • Синхронизировать обе копии: боевой стек в Portainer (PUT) И source-of-truth compose в admin/host-stacks/vds-kzntsv/<img>.compose.yml. Иначе следующий redeploy из гита откатит лимит.

Применено 2026-07-05 ко всему тиражу snolla (labtools.ru/17, emspb/18, labtools.pro/19, tandemmebel/20, kupimknigi/21) → все 512m.

Gotchas

  1. env_file: .env ломает Portainer string-mode — Portainer не материализует .env файл в /data/compose/<id>/. Compose pull fails: env file /data/compose/<id>/.env not found. Fix: sed '/^\s*env_file:/d' + передавать env через API env array.
  2. Sibling files (nginx.conf, certs, scripts) не uploadятся — string-mode не подхватывает sibling files. ./nginx.conf:/etc/nginx/conf.d/default.conf Portainer resolves в /data/compose/<id>/nginx.conf где файла нет → container mount fails. Fix: абсолютизировать bind через sed s|\./|$DIR/|g. Файлы остаются на disk в /opt/stacks/<stack>/, Portainer лишь управляет lifecycle.
  3. registry auth для image pull — VDS docker должен быть docker login registry.kzntsv.site до docker compose pull. Иначе pull access denied. Login persistent в /root/.docker/config.json после первого раза.
  4. Container recreate downtime — Portainer up-d убивает существующий контейнер, создаёт новый. Для prod stacks (mssql, owncloud, gitea) ~30-60s простой. Connections retry прозрачно если клиент resilient (IIS reconnects).
  5. Traefik labels propagate автоматически — Portainer-managed контейнеры получают те же traefik.* labels из compose, traefik docker provider их видит через socket event. Никаких отдельных шагов не нужно.
  6. com.docker.compose.project.working_dir меняется с /opt/stacks/<name> (ad-hoc) на /data/compose/<id> (Portainer-managed). Сохранённые /opt/stacks/<name>/.env остаются на disk — лишь для backup/reference, не используются compose runtime.
  7. Volumes preserved across migration — named volumes (docker compose down без -v) и binds (/opt/stacks/<name>/data) сохраняются. Data layer не теряется при ad-hoc→Portainer переезде.
  8. Management plane skiptraefik и portainer сами через себя нельзя безопасно recreate (теряется access). Оставлены ad-hoc; их docker-compose.yml в /opt/stacks/{traefik,portainer}/. Future: bootstrap script для cold-start ставит их first перед всем остальным.
  9. PowerShell 5.1 коррапит кириллицу при round-trip stack-file через APIInvoke-RestMethod на GET /api/stacks/<id>/file декодит тело как ISO-8859-1 (нет charset в Content-Type ответа), UTF-8 кириллица в комментариях compose превращается в mojibake (БоевойБоевой). Обратный PUT шлёт mojibake → docker compose загрузчик падает yaml: line N: could not find expected ':' на строке с битым комментарием. На диске исходный файл корректен — портит именно round-trip. Fix (PS 5.1): качать байтами Invoke-WebRequest -OutFile $tmpGet-Content $tmp -Raw -Encoding UTF8 | ConvertFrom-Json, а тело PUT слать UTF-8-байтами: $bytes=[Text.Encoding]::UTF8.GetBytes($json); Invoke-RestMethod -Body $bytes -ContentType 'application/json; charset=utf-8'. (pilorama98/pilonuxt redeploy 2026-06-17.)
  10. Пустой env в PUT-payload — Portainer ждёт env: [] ([]portainer.Pair). PS @{env=@()}|ConvertTo-Json схлопывает пустой массив → Invalid request payload. Fix: подставить literal через плейсхолдер — (... | ConvertTo-Json) -replace '"__ENV__"','[]'.

Stack redeploy (existing stack, новый image tag)

Перекат уже-managed стека на новый тег образа (НЕ создание). Применялось для pilonuxt (stack Id 16) на redeploy-pilonuxt-gsc-* 2026-06-16/17. Образ собирается/пушится на workstation (≈400 МБ влезает; multi-GB → собирать на VDS, см. memory registry-large-push-499-build-on-vds), затем:

# 1. JWT
$jwt = (Invoke-RestMethod -Method Post -Uri 'https://portainer.vds.kzntsv.site/api/auth' `
  -ContentType 'application/json' -Body (@{username='vitya';password='<vds-kzntsv/full-env>'}|ConvertTo-Json)).jwt
$h = @{ Authorization = "Bearer $jwt" }
# 2. GET текущий compose БАЙТАМИ (gotcha #9), подменить тег
$tmp = "$env:TEMP\stack.json"
Invoke-WebRequest -Headers $h -Uri 'https://portainer.vds.kzntsv.site/api/stacks/16/file' -OutFile $tmp
$new = ((Get-Content $tmp -Raw -Encoding UTF8 | ConvertFrom-Json).StackFileContent) -replace 'pilonuxt:OLD','pilonuxt:NEW'
# 3. PUT с pullImage:true (форсит свежий pull нового тега), env=[] через плейсхолдер, тело UTF-8-байтами
$json = (@{stackFileContent=$new; env='__ENV__'; prune=$false; pullImage=$true}|ConvertTo-Json -Depth 10) -replace '"__ENV__"','[]'
Invoke-RestMethod -Method Put -Headers $h -ContentType 'application/json; charset=utf-8' `
  -Body ([Text.Encoding]::UTF8.GetBytes($json)) -Uri 'https://portainer.vds.kzntsv.site/api/stacks/16?endpointId=1'

Verify: контейнер на новом образе — ops.docker.ps name=<stack> (vds-ops MCP) показывает image: registry.kzntsv.site/<name>:NEW. Прод-smoke мимо LAN-DNS воркстейшна — curl --resolve <host>:443:89.253.255.94 (иначе резолвится локальная копия, см. memory workstation-lan-dns-serves-local-cms-copy); краулить реальные nav-страницы + data-endpoint, не 2 роута (smoke-crawl-real-pages-not-two-routes). Предусловие pull: registry.kzntsv.site зарегистрирован в Portainer как Custom registry Id 1 (иначе pull access denied / no basic auth). См. memory portainer-vds-needs-registry-registered.

Stack inventory (2026-05-22, Portainer Ids)

Id Name Dir Note
3 board-viewer /opt/stacks/board-viewer basicauth Traefik
4 ntfy /opt/stacks/ntfy
5 registry /opt/stacks/registry + registry-ui
6 verdaccio /opt/stacks/verdaccio npm registry
7 gitea /opt/stacks/gitea git.kzntsv.site
8 owncloud /opt/stacks/owncloud oCIS 7.1, mem_limit 1G
9 postgres /opt/stacks/databases/postgres TLS via traefik raw-TCP
10 mongo /opt/stacks/databases/mongo TLS via traefik raw-TCP
11 mariadb /opt/stacks/databases/mariadb TLS via traefik raw-TCP
12 redis /opt/stacks/databases/redis TLS via traefik raw-TCP
13 minio-imgproxy /opt/stacks/storage/minio-imgproxy MinIO 2025-09 + imgproxy + nginx
14 mssql /opt/stacks/databases/mssql Express 2022, traefik TCP :1433

Non-Portainer (management plane):

  • traefik/opt/stacks/traefik/, ad-hoc compose
  • portainer/opt/stacks/portainer/, ad-hoc compose
  • vds-docker-proxy-ro + vds-ops-mcp — ad-hoc из /tmp (часть synology/vds-ops setup)

Smoke post-migration

JWT=$(curl -ksS -X POST https://portainer.vds.kzntsv.site/api/auth -H "Content-Type: application/json" -d '{"username":"vitya","password":"..."}' | jq -r .jwt)

# All 12 stacks visible in Portainer
curl -ksS -H "Authorization: Bearer $JWT" https://portainer.vds.kzntsv.site/api/stacks | jq -r '.[].Name'

# Critical service smokes
curl -ksS -o /dev/null -w "%{http_code}\n" https://git.kzntsv.site/
curl -ksS -o /dev/null -w "%{http_code}\n" https://owncloud.kzntsv.site/
curl -ksS -u "viewer:..." https://board.kzntsv.site/ -o /dev/null -w "%{http_code}\n"
# MSSQL external (via PowerShell since git-bash mangles path):
docker exec mssql /opt/mssql-tools18/bin/sqlcmd -S 'mssql.kzntsv.site,1433' -U snolla -P '...' -d MoreThenCms -C -No -Q "SELECT 1"

Все 8 IIS prod hosts (emspb / snolla / on.snolla / pilorama98 / labtools.{ru,pro} / tandemmebel / kupimknigi) → 200 через VDS MSSQL после migration.