Тираж 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>
161 lines
13 KiB
Markdown
161 lines
13 KiB
Markdown
---
|
||
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.5–5× наблюдаемого пика. snolla-сайты (Node SSR, baseline ~100–200 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.
|