Закрыт пропуск: тираж 0.42.1 не был зафиксирован в вике (таски emspb/labtools.pro просили using-wiki). - NEW concepts/snolla-live-prod-inplace-image-bump.md — переиспользуемый рецепт (build на VDS → throwaway-staging-acceptance С VDS → env-preserving Portainer PUT put-stack.js → live-smoke), встроен put-stack.js, гочи. - UPDATE 3 рунбука секцией «0.42.1 in-place bump» (labtools.ru/emspb/labtools.pro) с образами/acceptance/rollback. - FIX orphan: все 3 deploy-рунбука добавлены в index.md (не были каталогизированы). - log.md: decision-запись. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.1 KiB
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 при пуше больших слоёв)
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 живого контейнера:
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'ы), НЕ список страниц. Разворачивать:
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).
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.
Борд-таска 🟢, отчёт прогу (Notify=сам сайт, минуя workshop), rollback-тег остаётся в registry.
put-stack.js (env-preserving Portainer stack PUT)
// 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, на стороне прога.