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

161 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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:
```bash
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)
```bash
#!/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 skip**`traefik` и `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 через API**`Invoke-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 $tmp``Get-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`), затем:
```powershell
# 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
```bash
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.