--- title: snolla live-prod in-place image bump — рецепт (build → staging-acceptance → env-preserving PUT → live-smoke) type: concept tags: [snolla, vds, deploy, docker, portainer, runbook, recipe, in-place, image-bump, acceptance] related: [[../entities/vds-kzntsv]], [[portainer-stack-management-vds]], [[labtools-vds-deploy-runbook]], [[labtools.pro-vds-deploy-runbook]], [[emspb-vds-deploy-runbook]] updated: 2026-07-05 --- # snolla live-prod in-place image bump Переиспользуемый рецепт для случая: сайт УЖЕ боевой на [[../entities/vds-kzntsv]] в docker-стеке за traefik (cutover сделан ранее), надо **обновить движок/образ на живом стеке in-place** — без staging-first, без DNS-флипа. Отработан 2026-07-05 на тираже snolla 0.42.1: labtools.ru (стек 17), emspb.ru (18), labtools.pro (19). Класс риска = **needs-human** (боевой контейнер): боевой swap тега подтверждает оператор ПЕРЕД apply. Отличие от per-site cutover-рунбуков (те про ПЕРВЫЙ вынос на VDS + DNS-флип): здесь домен уже на VDS, меняется только тег образа на существующем стеке. rollback = предыдущий тег (в registry) / стек PUT назад. ## Инвариант: acceptance на НОВОМ образе ДО подмены (не наследовать dev-GREEN) Прог даёт dev-верификацию с воркстейшна, но там **LAN-DNS перехватывает прод-домены** (см. memory `workstation-lan-dns-serves-local-cms-copy`) → его «vs бой» не вполне боевой. **Перепрогонять гейты С VDS** против живого прода-оракула, на throwaway-контейнере из нового образа, ДО касания живого стека. ## Шаги ### 1. Build на VDS (обход traefik-499 при пуше больших слоёв) ```bash git -C ~/projects/ archive --format=tar \ | ssh vitya@89.253.255.94 'rm -rf ~/build/ && mkdir -p ~/build/ && tar -x -C ~/build/' # guard: config/default.json ABSENT в архиве (.dockerignore); пин snolla в apps/web/package.json = целевой TOKEN=$(pass show vds-kzntsv/full-env | sed -n 's/^VERDACCIO_CI_TOKEN=//p') ssh vitya@89.253.255.94 "cd ~/build/ && VERDACCIO_TOKEN='$TOKEN' docker build -f deploy/Dockerfile \ --build-arg VERDACCIO_TOKEN -t registry.kzntsv.site/: -t registry.kzntsv.site/:latest . \ && docker push registry.kzntsv.site/: && docker push registry.kzntsv.site/:latest" ``` ⚠️ Имя образа = имя стека (labtools.ru→`labtools`, labtools.pro→`labtools-pro` — НЕ коллизить!). ### 2. Throwaway-staging из env ЖИВОГО стека (не трогает прод) Новый образ рендерит только с runtime-env (DB/S3/imgproxy/smtp). Копируем env живого контейнера: ```bash ssh vitya@89.253.255.94 'set -e docker inspect --format "{{range .Config.Env}}{{println .}}{{end}}" \ | grep -vE "^(HOSTNAME|PATH|NODE_VERSION|YARN_VERSION|HOME)=" > ~/build//.staging.env docker run -d --name -staging --env-file ~/build//.staging.env \ -p 127.0.0.1:50NN:5000 registry.kzntsv.site/: # health: poll curl -H "Host: " http://127.0.0.1:50NN/robots.txt → 200' ``` ### 3. Completeness-gate С VDS (sitemap — ИНДЕКС, разворачивать!) `/sitemap.xml` у snolla = **sitemapindex** (ссылки на под-sitemap'ы), НЕ список страниц. Разворачивать: ```bash expand(){ base="$1"; shift; hdr=("$@") for su in $(curl -s "${hdr[@]}" "$base/sitemap.xml" | grep -oE "[^<]+" | sed "s|||"); do su="${su#https://}"; su="${su#https://www.}"; su="${su#$base}" curl -s "${hdr[@]}" "$base$su" | grep -oE "[^<]+" | sed "s|||; s|https://[^/]*||" done | sort -u; } ``` Два чека: (1) **self-consistency** — все page-locs нового sitemap → 200 на новом образе; (2) **content-not-lost** — каждая реальная страница прода-оракула → 200/3xx на новом. GREEN = 0 регрессий в 404/5xx. - Каталожные сайты — плюс **order-парити** секций (`{% order by list_priority %}`), новый == прод. - Benign: `` секции-с-одним-изделием отдаёт 301→изделие (прод идентичен) — не дефект. - **0.42.x реструктурирует sitemap** (per-category product-sitemaps `sitemap-catalog--products-1.xml`); старые 0.28.x sitemap'ы бывали дефицитными (labtools.ru: sections 1→6, products 2→24). Сверять page-locs, не имена под-sitemap'ов. ### 4. Боевой swap — Portainer PUT с сохранением env (operator-gated) НЕ через PS Invoke-RestMethod (корраптит кириллицу в compose — см. [[portainer-stack-management-vds]]). Node-скрипт: GET stack-file → заменить тег → PUT с Env массивом (name+value) живого стека. Полный скрипт ниже (§ put-stack.js). ```bash export PK=$(pass show vds-kzntsv/full-env | sed -n 's/^PORTAINER_API_KEY=//p') export PU=$(pass show vds-kzntsv/full-env | sed -n 's/^PORTAINER_URL=//p') curl -sk -H "X-API-Key: $PK" "$PU/api/stacks/" -o /tmp/st.json # Env с values META=/tmp/st.json node put-stack.js ``` `prune:false, pullImage:true`. После PUT — poll `docker inspect --format {{.Config.Image}}`+`{{.State.Health.Status}}` до ``+`healthy`. ### 5. Live-smoke боевого домена + cleanup `curl -L https://www./…` ключевые страницы 200; order вживую; sitemap page-locs; TLS-серт (CN не должен дёрнуться — in-place swap серт не трогает). `docker rm -f -staging`. ### 6. Закрытие Compose source-of-truth (`admin/host-stacks/vds-kzntsv/.compose.yml`) — обновить тег + коммент rollback. Борд-таска 🟢, отчёт прогу (Notify=сам сайт, минуя workshop), rollback-тег остаётся в registry. ## put-stack.js (env-preserving Portainer stack PUT) ```js // args: ; env: PK (api key), PU (url), META (/tmp/stNN.json) const https = require('https'), fs = require('fs'); const [stackId, endpointId, oldTag, newTag] = process.argv.slice(2); const K = process.env.PK, U = process.env.PU; const meta = JSON.parse(fs.readFileSync(process.env.META, 'utf8')); // Env with values const base = new URL(U); function req(method, path, bodyObj) { return new Promise((res, rej) => { const body = bodyObj ? Buffer.from(JSON.stringify(bodyObj), 'utf8') : null; const r = https.request({ hostname: base.hostname, port: 443, path, method, headers: { 'X-API-Key': K, 'Content-Type': 'application/json', ...(body ? { 'Content-Length': body.length } : {}) }, rejectUnauthorized: false }, resp => { let d = []; resp.on('data', c => d.push(c)); resp.on('end', () => res({ status: resp.statusCode, body: Buffer.concat(d).toString('utf8') })); }); r.on('error', rej); if (body) r.write(body); r.end(); }); } (async () => { const compose = JSON.parse((await req('GET', `/api/stacks/${stackId}/file`)).body).StackFileContent; if (!compose.includes(oldTag)) { console.error('OLD TAG NOT FOUND:', oldTag); process.exit(2); } const env = (meta.Env || []).map(e => ({ name: e.name, value: e.value })); const put = await req('PUT', `/api/stacks/${stackId}?endpointId=${endpointId}`, { stackFileContent: compose.split(oldTag).join(newTag), env, prune: false, pullImage: true }); console.log('PUT', put.status, put.status >= 300 ? put.body.slice(0, 500) : 'OK'); })(); ``` ## Гочи - **Не гнать два тяжёлых docker build разом** на VDS — последовательно (CPU/диск/слои). - **MSYS на Windows** ломает `~`-пути при `export MSYS_NO_PATHCONV=1` и корраптит `git show rev:path` (двоеточие) — для `git show` ставить `MSYS_NO_PATHCONV=1`, но тогда `git -C ~/...` пути ломаются; не смешивать в одном шелле. - **Токен** передавать через локальную переменную в двойных кавычках ssh-строки (`'$TOKEN'`) — значение раскрывается локально, в транскрипт попадает литерал `$TOKEN`. - **build-secret** (VERDACCIO_TOKEN печётся в ARG/ENV build-стадии) — известный follow-up на всех snolla-Dockerfile; НЕ блокер (runtime-стадия отдельная, токена в финальном образе нет). Фикс = docker build-secret mount, на стороне прога.