Consumer bump by operator (blocker-pattern 2026-07-04 resolved without dev-source): pin apps/web/package.json:12 + yarn install (lock 0.42.1/core 0.24.1/liquid 0.10.2/data 0.14.1) + commit 0cd9351 + push origin (ls-remote confirmed). Build on VDS → registry.kzntsv.site/tandemmebel:0cd9351 (digest f29c187f). Throwaway-staging :5020 from live env, healthy. Completeness-gate С VDS: 184/184 parity (NEW==PROD), /articles 404 identical to prod oracle → benign. 0.42.1 order-fix inert on blog-portfolio (no catalog). Operator-gated PUT stack 20 (env 8/8 preserved, prune:false pullImage:true) → container 0cd9351+healthy ~8s. Live-smoke GREEN, TLS cert untouched. Closes snolla 0.42.1 rollout — all 5 snolla sites now on VDS. Rollback = tag ed96b18 (+ b02ca18) in registry. - compose source-of-truth: tag + comment (0.42.1) - board task: decisions log entry - wiki: new tandemmebel-vds-deploy-runbook, bump recipe (4 sites), index, log
125 lines
9.6 KiB
Markdown
125 lines
9.6 KiB
Markdown
---
|
||
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]], [[tandemmebel-vds-deploy-runbook]]
|
||
updated: 2026-07-12
|
||
---
|
||
|
||
# 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).
|
||
2026-07-12: tandemmebel.ru (стек 20) — in-place bump 0.42.0→0.42.1 поверх cutover'а того же дня.
|
||
Класс риска = **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/<site> archive --format=tar <sha> \
|
||
| ssh vitya@89.253.255.94 'rm -rf ~/build/<img> && mkdir -p ~/build/<img> && tar -x -C ~/build/<img>'
|
||
# 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/<img> && VERDACCIO_TOKEN='$TOKEN' docker build -f deploy/Dockerfile \
|
||
--build-arg VERDACCIO_TOKEN -t registry.kzntsv.site/<img>:<sha> -t registry.kzntsv.site/<img>:latest . \
|
||
&& docker push registry.kzntsv.site/<img>:<sha> && docker push registry.kzntsv.site/<img>: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 <container> --format "{{range .Config.Env}}{{println .}}{{end}}" \
|
||
| grep -vE "^(HOSTNAME|PATH|NODE_VERSION|YARN_VERSION|HOME)=" > ~/build/<img>/.staging.env
|
||
docker run -d --name <img>-staging --env-file ~/build/<img>/.staging.env \
|
||
-p 127.0.0.1:50NN:5000 registry.kzntsv.site/<img>:<sha>
|
||
# health: poll curl -H "Host: <domain>" 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 "<loc>[^<]+" | sed "s|<loc>||"); do
|
||
su="${su#https://<domain>}"; su="${su#https://www.<domain>}"; su="${su#$base}"
|
||
curl -s "${hdr[@]}" "$base$su" | grep -oE "<loc>[^<]+" | sed "s|<loc>||; 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: `<loc>` секции-с-одним-изделием отдаёт 301→изделие (прод идентичен) — не дефект.
|
||
- **0.42.x реструктурирует sitemap** (per-category product-sitemaps `sitemap-catalog-<GUID>-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/<ID>" -o /tmp/st<ID>.json # Env с values
|
||
META=/tmp/st<ID>.json node put-stack.js <stackId> <endpointId=1> <oldTag> <newTag>
|
||
```
|
||
`prune:false, pullImage:true`. После PUT — poll `docker inspect <container> --format {{.Config.Image}}`+`{{.State.Health.Status}}`
|
||
до `<newTag>`+`healthy`.
|
||
|
||
### 5. Live-smoke боевого домена + cleanup
|
||
`curl -L https://www.<domain>/…` ключевые страницы 200; order вживую; sitemap page-locs; TLS-серт (CN не должен
|
||
дёрнуться — in-place swap серт не трогает). `docker rm -f <img>-staging`.
|
||
|
||
### 6. Закрытие
|
||
Compose source-of-truth (`admin/host-stacks/vds-kzntsv/<img>.compose.yml`) — обновить тег + коммент rollback.
|
||
**Проверить `mem_limit`** на стеке (обязателен для всех app-стеков — см. [[portainer-stack-management-vds]] § Convention).
|
||
snolla → `512m`; если стек его ещё не несёт (старые до 2026-07-05) — добавить в этот же PUT и в compose-копию.
|
||
Борд-таска 🟢, отчёт прогу (Notify=сам сайт, минуя workshop), rollback-тег остаётся в registry.
|
||
|
||
## put-stack.js (env-preserving Portainer stack PUT)
|
||
```js
|
||
// args: <stackId> <endpointId> <oldTag> <newTag> ; 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, на стороне прога.
|