Автоматизация деплоя: от пуша до прогретого кеша

22.03.2026
Автоматизация деплоя: от пуша до прогретого кеша

Разберу устройство: что происходит от момента пуша до того, как пользователь видит актуальную версию сайта.


Структура пайплайна

Весь процесс разбит на три последовательных джоба:

Copy
push → [test][build & deploy][warm cache]

Такое разделение не случайное. Каждый джоб имеет чёткую зону ответственности и может упасть независимо. Если тесты не прошли — сборка не запустится. Если деплой упал — прогрев кеша не произойдёт. Цепочка атомарная.

yaml Copy
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build
      - name: Sync to server
        run: rsync -az --delete .output/public/ user@host:/var/www/blog/

  warm:
    needs: deploy
    runs-on: ubuntu-latest
    steps:
      - name: Warm cache
        run: |
          curl -s https://blog.macrulez.ru/ > /dev/null
          # ... остальные URL

cache: npm в setup-node — экономия минуты на каждом джобе. GitHub кеширует ~/.npm между запусками, npm ci при попадании в кеш работает в разы быстрее.


Тесты

Тестирую три вещи: типы, юниты и E2E.

Проверка типов через vue-tsc --noEmit. Не сборка, просто типы. Падает быстро, ловит половину багов ещё до запуска браузера.

Юниты на Vitest — composables и утилиты. Здесь не мокаю API: использую реальные хэндлеры через msw, которые возвращают фикстуры в том же формате что и прод. Однажды моки разошлись с реальным API и тесты зеленели пока прод лежал — с тех пор такой подход.

E2E на Playwright — критичные сценарии: главная страница рендерится, пост открывается, пагинация переключает страницы. Запускаются против собранного приложения (preview-режим), не против дев-сервера.

typescript Copy
test('paginator works', async ({ page }) => {
  await page.goto('/');
  await page.click('[aria-label="Pagination"] a:has-text("Вперёд")');
  await expect(page.locator('.home__latest-posts'))
    .toContainText('Страница 2');
});

Общее время тестов — около 40 секунд. Приемлемо.


Деплой

Приложение собирается в статику (nuxt generate), результат синхронизируется на сервер через rsync.

Флаг --delete важен: удаляет на сервере файлы, которых больше нет в сборке. Без него старые чанки накапливаются, и при хэш-коллизии (редко, но бывает) пользователь может получить устаревший JS.

bash Copy
rsync -az --delete \
  --exclude='.cache' \
  .output/public/ \
  deploy@host:/var/www/blog/public/

Соединение — по SSH-ключу, хранится в secrets.DEPLOY_KEY. Никаких паролей в конфиге.

Весь деплой занимает около 15 секунд. Nginx подхватывает новые файлы без перезапуска — статика есть статика.


Прогрев кеша

Вот часть, которую часто упускают.

После деплоя страницы не закешированы на CDN и в памяти Nginx. Первый пользователь после выкладки получает холодный ответ — медленнее обычного. Особенно заметно на страницах с SSG-генерацией тяжёлых блоков.

Прогрев — это простой обход всех публичных URL сразу после деплоя:

bash Copy
SITE="https://blog.macrulez.ru"
URLS=(
  "/"
  "/about"
)

# Подтягиваем актуальный список постов из API
POSTS=$(curl -s "$SITE/api/posts?per_page=100" | jq -r '.data[].slug')

for slug in $POSTS; do
  URLS+=("/post/$slug")
done

for url in "${URLS[@]}"; do
  STATUS=$(curl -o /dev/null -s -w "%{http_code}" "$SITE$url")
  echo "$STATUS $SITE$url"
done

Скрипт логирует HTTP-статус каждого URL. Если что-то вернуло не 200 — видно сразу в логах Actions, деплой помечается как подозрительный.

Время прогрева зависит от количества постов. Сейчас — около 20 секунд на ~30 URL.


Визуализация пайплайна

GitHub Actions показывает граф джобов прямо в интерфейсе: видно что запущено, что ждёт, что упало. Для простой линейной цепочки этого достаточно.

Интереснее смотреть на тайминги в разрезе коммитов. В Settings → Actions есть метрики, но они базовые. Для нормального трекинга я вывожу итоговую сводку в $GITHUB_STEP_SUMMARY — она появляется на вкладке Summary каждого запуска:

bash Copy
echo "## Deploy summary" >> $GITHUB_STEP_SUMMARY
echo "| Step | Duration |" >> $GITHUB_STEP_SUMMARY
echo "|------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| Tests | ${TEST_TIME}s |" >> $GITHUB_STEP_SUMMARY
echo "| Build | ${BUILD_TIME}s |" >> $GITHUB_STEP_SUMMARY
echo "| Deploy | ${DEPLOY_TIME}s |" >> $GITHUB_STEP_SUMMARY
echo "| Warm | ${WARM_TIME}s |" >> $GITHUB_STEP_SUMMARY

Выглядит как простая таблица, но даёт мгновенный ответ на вопрос «почему деплой занял 5 минут вместо двух» — видно на каком шаге просадка.


Вывод лога при деплое:

Copy
out: ::group::📦 Git update
out: > macrulez-blog-nuxt@0.1.0 prepare
out: > husky install
err: husky - install command is DEPRECATED
out: added 978 packages in 19s
out: ::endgroup::
out: ::group::🔨 Building project
out: > macrulez-blog-nuxt@0.1.0 build
out: > nuxt build
out:   Building Nuxt for production...
out: 
out:   Nuxt 3.21.1 (with Nitro 2.13.1, Vite 7.3.1 and Vue 3.5.28)
out: 
out:   Nitro preset: node-server
out:  Building client...
out:  vite v7.3.1 building client environment for production...
out:  transforming...
out:   3975 modules transformed.
out:  rendering chunks...
out:  computing gzip size...
out:  .nuxt/dist/client/manifest.json                      61.00 kB  gzip:   6.86 kB
out:  .nuxt/dist/client/_nuxt/contact-image.DYyKJ5in.png  209.16 kB
out:  .nuxt/dist/client/_nuxt/about-image.BCfFxlHI.png    259.57 kB
out:  .nuxt/dist/client/_nuxt/blog-avatar.D8Nych3_.png    309.60 kB
out:  .nuxt/dist/client/_nuxt/_tag_.Dt37dkH6.css            0.49 kB  gzip:   0.27 kB
out:  .nuxt/dist/client/_nuxt/login.CIaAWTPG.css            1.37 kB  gzip:   0.59 kB
out:  .nuxt/dist/client/_nuxt/about.Jt1W5ijn.css            1.50 kB  gzip:   0.62 kB
out:  .nuxt/dist/client/_nuxt/index.B3RS2Sfu.css            1.66 kB  gzip:   0.68 kB
out:  .nuxt/dist/client/_nuxt/error-500.I1Dtv2V5.css        1.88 kB  gzip:   0.72 kB
out:  .nuxt/dist/client/_nuxt/index.ArWbQgZx.css            2.13 kB  gzip:   0.71 kB
out:  .nuxt/dist/client/_nuxt/contact.D9Huk_36.css          2.44 kB  gzip:   0.86 kB
out:  .nuxt/dist/client/_nuxt/post-card.RjwGa90D.css        2.45 kB  gzip:   0.88 kB
out:  .nuxt/dist/client/_nuxt/error-404.DL_4WIao.css        3.53 kB  gzip:   1.09 kB
out:  .nuxt/dist/client/_nuxt/_slug_.0L87NiVY.css           3.70 kB  gzip:   1.11 kB
out:  .nuxt/dist/client/_nuxt/post-editor.CkdUSkcS.css      3.73 kB  gzip:   1.10 kB
out:  .nuxt/dist/client/_nuxt/default.DyKQEuwm.css          6.73 kB  gzip:   1.71 kB
out:  .nuxt/dist/client/_nuxt/entry.czOdnh4F.css           83.82 kB  gzip:  17.35 kB
out:  .nuxt/dist/client/_nuxt/BbNl6jr_.js                   0.09 kB  gzip:   0.10 kB
out:  .nuxt/dist/client/_nuxt/DlAUqK2U.js                   0.09 kB  gzip:   0.10 kB
out:  .nuxt/dist/client/_nuxt/CkUYLzSk.js                   0.10 kB  gzip:   0.11 kB
out:  .nuxt/dist/client/_nuxt/Gi6I4Gst.js                   0.15 kB  gzip:   0.13 kB
out:  .nuxt/dist/client/_nuxt/B1va3MmD.js                   0.18 kB  gzip:   0.15 kB
out:  .nuxt/dist/client/_nuxt/FrcY3C94.js                   0.21 kB  gzip:   0.16 kB
out:  .nuxt/dist/client/_nuxt/fhuzIZ2o.js                   0.22 kB  gzip:   0.20 kB
out:  .nuxt/dist/client/_nuxt/DzgKki1a.js                   0.25 kB  gzip:   0.19 kB
out:  .nuxt/dist/client/_nuxt/DbItnlRl.js                   0.31 kB  gzip:   0.24 kB
out:  .nuxt/dist/client/_nuxt/CeQk0imn.js                   0.33 kB  gzip:   0.24 kB
out:  .nuxt/dist/client/_nuxt/Br8PssjK.js                   0.35 kB  gzip:   0.25 kB
out:  .nuxt/dist/client/_nuxt/CnLkB2KK.js                   0.35 kB  gzip:   0.25 kB
out:  .nuxt/dist/client/_nuxt/Bms2fKNH.js                   0.35 kB  gzip:   0.26 kB
out:  .nuxt/dist/client/_nuxt/58WOu5V3.js                   0.41 kB  gzip:   0.28 kB
out:  .nuxt/dist/client/_nuxt/CjLU9Jfo.js                   0.42 kB  gzip:   0.34 kB
out:  .nuxt/dist/client/_nuxt/C5gx4mTC.js                   0.49 kB  gzip:   0.35 kB
out:  .nuxt/dist/client/_nuxt/C4LP7Hcl.js                   0.61 kB  gzip:   0.33 kB
...
...
out:  .nuxt/dist/client/_nuxt/BsT-ppPF.js                  81.66 kB  gzip:  22.45 kB
out:  .nuxt/dist/client/_nuxt/BWh-Nszc.js                  97.76 kB  gzip:  26.82 kB
out:  .nuxt/dist/client/_nuxt/IKAAvmBR.js                  97.77 kB  gzip:  28.43 kB
out:  .nuxt/dist/client/_nuxt/ZXZMmLxz.js                 100.56 kB  gzip:  33.63 kB
out:  .nuxt/dist/client/_nuxt/B-h6JCHE.js                 103.18 kB  gzip:  46.07 kB
out:  .nuxt/dist/client/_nuxt/Bx4MALJp.js                 148.54 kB  gzip:  41.99 kB
out:  .nuxt/dist/client/_nuxt/BNtvGa-1.js                 191.89 kB  gzip:  72.02 kB
out:  .nuxt/dist/client/_nuxt/DGN8GczM.js                 261.23 kB  gzip:  76.80 kB
out:  .nuxt/dist/client/_nuxt/5J0xJHOV.js                 441.64 kB  gzip: 141.47 kB
out:  .nuxt/dist/client/_nuxt/DSqlfwJV.js                 452.69 kB  gzip: 107.14 kB
out:  .nuxt/dist/client/_nuxt/vjMZa8SK.js                 490.50 kB  gzip: 137.43 kB
out:  .nuxt/dist/client/_nuxt/DXTABfx-.js                 773.49 kB  gzip: 257.59 kB
err:  WARN 
err: (!) Some chunks are larger than 500 kB after minification. Consider:
err: - Using dynamic import() to code-split the application
err: - Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
err: - Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
out:   built in 1m 44s
out:  Client built in 104342ms
out:  Building server...
out:  vite v7.3.1 building ssr environment for production...
out:  transforming...
out:   186 modules transformed.
out:  rendering chunks...
out:   built in 14.24s
out:  Server built in 14833ms
out: [nitro]  Generated public .output/public
out: [nitro]  Building Nuxt Nitro server (preset: node-server, compatibility date: 2026-03-13)
out: [nitro]  Nuxt Nitro server built
out:   ├─ .output/server/chunks/_/error-500.mjs (4.9 kB) (2.06 kB gzip)
out:   ├─ .output/server/chunks/_/error-500.mjs.map (182 B) (154 B gzip)
out:   ├─ .output/server/chunks/build/_id_-2mKg9QA4.mjs (4.61 kB) (1.48 kB gzip)
out:   ├─ .output/server/chunks/build/_id_-2mKg9QA4.mjs.map (3.45 kB) (1.04 kB gzip)
out:   ├─ .output/server/chunks/build/_id_-sK9yVLLB.mjs (2.23 kB) (985 B gzip)
out:   ├─ .output/server/chunks/build/_id_-sK9yVLLB.mjs.map (1.53 kB) (585 B gzip)
out:   ├─ .output/server/chunks/build/_plugin-vue_export-helper-1tPrXgE0.mjs (254 B) (201 B gzip)
out:   ├─ .output/server/chunks/build/_plugin-vue_export-helper-1tPrXgE0.mjs.map (406 B) (248 B gzip)
out:   ├─ .output/server/chunks/build/_slug_-BSsjX5lP.mjs (10.7 kB) (3.26 kB gzip)
out:   ├─ .output/server/chunks/build/_slug_-BSsjX5lP.mjs.map (9.44 kB) (2.25 kB gzip)
out:   ├─ .output/server/chunks/build/_tag_-CezjcFyT.mjs (5.8 kB) (1.8 kB gzip)
out:   ├─ .output/server/chunks/build/_tag_-CezjcFyT.mjs.map (4.65 kB) (1.21 kB gzip)
out:   ├─ .output/server/chunks/build/about-nxRjRYOc.mjs (5.35 kB) (1.62 kB gzip)
out:   ├─ .output/server/chunks/build/about-nxRjRYOc.mjs.map (913 B) (326 B gzip)
...
...
out:   ├─ .output/server/chunks/routes/api/drafts.get.mjs (1.44 kB) (679 B gzip)
out:   ├─ .output/server/chunks/routes/api/drafts.get.mjs.map (1.39 kB) (372 B gzip)
out:   ├─ .output/server/chunks/routes/api/posts.post.mjs (1.13 kB) (583 B gzip)
out:   ├─ .output/server/chunks/routes/api/posts.post.mjs.map (1.01 kB) (309 B gzip)
out:   ├─ .output/server/chunks/routes/api/posts/_id_.mjs (5.62 kB) (1.46 kB gzip)
out:   ├─ .output/server/chunks/routes/api/posts/_id_.mjs.map (5.97 kB) (860 B gzip)
out:   ├─ .output/server/chunks/routes/api/uploads.post.mjs (2.23 kB) (908 B gzip)
out:   ├─ .output/server/chunks/routes/api/uploads.post.mjs.map (2.2 kB) (479 B gzip)
out:   ├─ .output/server/chunks/routes/renderer.mjs (18.2 kB) (6.1 kB gzip)
out:   ├─ .output/server/chunks/routes/renderer.mjs.map (751 B) (263 B gzip)
out:   ├─ .output/server/chunks/virtual/_virtual_spa-template.mjs (94 B) (100 B gzip)
out:   ├─ .output/server/chunks/virtual/_virtual_spa-template.mjs.map (90 B) (98 B gzip)
out:   ├─ .output/server/index.mjs (353 B) (204 B gzip)
out:   └─ .output/server/package.json (889 B) (388 B gzip)
out: Σ Total size: 2.88 MB (724 kB gzip)
out: [nitro]  You can preview this build using node .output/server/index.mjs
out: 
out:    Build complete!
out: ::endgroup::
out: ::group::🔄 PM2 restart
out: [PM2] Applying action deleteProcessId on app [blog.macrulez.ru](ids: [ 28 ])
out: [PM2] [blog.macrulez.ru](28) 
out: ┌────┬──────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
out:  id  name              namespace    version  mode     pid       uptime       status     cpu       mem       user      watching 
out: ├────┼──────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
out:  29  macrulez.ru       default      0.0.0    fork     1140573   2h      0     online     0%        11.3mb    root      disabled 
out:  18  retrosteam.ru     default      0.0.0    fork     558476    20D     0     online     0%        8.4mb     root      disabled 
out: └────┴──────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
out: [PM2] Starting /var/www/blog.macrulez.ru/.output/server/index.mjs in fork_mode (1 instance)
out: [PM2] Done.
out: ┌────┬─────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
out:  id  name                 namespace    version  mode     pid       uptime       status     cpu       mem       user      watching 
out: ├────┼─────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
out:  30  blog.macrulez.ru     default      0.1.0    fork     1142034   0s      0     online     0%        27.4mb    root      disabled 
out:  29  macrulez.ru          default      0.0.0    fork     1140573   2h      0     online     0%        11.3mb    root      disabled 
out:  18  retrosteam.ru        default      0.0.0    fork     558476    20D     0     online     0%        8.4mb     root      disabled 
out: └────┴─────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
out: [PM2] Saving current process list...
out: [PM2] Successfully saved in /root/.pm2/dump.pm2
out: ::endgroup::
out: ::group::🔥 Warming up cache
out:  Waiting for server to start...
out:   🔥 Warming up: https://blog.macrulez.ru/
out:      https://blog.macrulez.ru/ - OK
out:   🔥 Warming up: https://blog.macrulez.ru/about
out:      https://blog.macrulez.ru/about - OK
out:   🔥 Warming up: https://blog.macrulez.ru/contact
out:      https://blog.macrulez.ru/contact - OK
out: ::endgroup::
out: ::group::📊 Final status
out: ┌────┬─────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
out:  id  name                 namespace    version  mode     pid       uptime       status     cpu       mem       user      watching 
out: ├────┼─────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
out:  30  blog.macrulez.ru     default      0.1.0    fork     1142034   6s      0     online     0%        88.6mb    root      disabled 
out:  29  macrulez.ru          default      0.0.0    fork     1140573   2h      0     online     0%        16.2mb    root      disabled 
out:  18  retrosteam.ru        default      0.0.0    fork     558476    20D     0     online     0%        8.2mb     root      disabled 
out: └────┴─────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
out: 🎉 Deploy completed!
out: ::endgroup::
==============================================
 Successfully executed commands to all host.
==============================================
Copy
Run npm ci --no-audit --no-fund
npm warn deprecated glob@10.5.0: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
> macrulez-blog-nuxt@0.1.0 prepare
> husky install
husky - install command is DEPRECATED
added 978 packages in 16s
> macrulez-blog-nuxt@0.1.0 test:ci
> vitest run --run && node ./scripts/show-test-summary.js
 RUN  v1.6.1 /home/runner/work/macrulez-blog/macrulez-blog
stdout | tests/integration/posts.spec.ts > integration: posts list and first post page > fetches posts list and opens first post page
[test-summary] apiList=***/macrulez-blog/posts, items=10, first=2d-rezhim-na-three-js-i-vsplyvayushchiy-spisok-aviakompaniy, frontend=skipped
stderr | tests/integration/posts.spec.ts > integration: posts list and first post page > fetches posts list and opens first post page
 ✓ tests/integration/posts.spec.ts  (1 test) 2338ms
[test-summary] apiList=***/macrulez-blog/posts, items=10, first=2d-rezhim-na-three-js-i-vsplyvayushchiy-spisok-aviakompaniy, frontend=skipped
 ✓ tests/example.spec.ts  (1 test) 3ms
 Test Files  2 passed (2)
      Tests  2 passed (2)
   Start at  16:38:59
   Duration  2.88s (transform 71ms, setup 1ms, collect 85ms, tests 2.34s, environment 0ms, prepare 167ms)
Test Summary
┌────────────┬────────────────────────────────────────────────────────────────┐
│ API        │ ***/macrulez-blog/posts                                        │
├────────────┼────────────────────────────────────────────────────────────────┤
│ Items      │ 10                                                             │
├────────────┼────────────────────────────────────────────────────────────────┤
│ First2d-rezhim-na-three-js-i-vsplyvayushchiy-spisok-aviakompaniy    │
├────────────┼────────────────────────────────────────────────────────────────┤
│ Frontend   │ skipped                                                        │
└────────────┴────────────────────────────────────────────────────────────────┘
Run: npm run test:ci

Итог

Весь пайплайн от пуша до прогретого кеша занимает около 2.5 минут. Тесты — 40 секунд, сборка — 50 секунд, деплой — 15 секунд, прогрев — 20 секунд, накладные расходы Actions — остальное.

Главное что поменялось после автоматизации — деплой перестал быть событием. Пуш в master и через пару минут всё готово. Нет ритуала «сначала запущу тесты, потом соберу, потом залью», нет забытых шагов, нет ручных ошибок.

Кеш-прогрев оказался неожиданно важной частью — первые недели после запуска я его не делал и несколько раз замечал просадку метрик сразу после деплоя. Теперь первым посетителем после выкладки всегда является сам пайплайн.

Читать далее

23.03.2026

Каталог авиакомпаний и аэропортов: модальное окно с навигацией по маршрутной сети

Новый каталог авиакомпаний и аэропортов позволяет исследовать маршрутные сети без необходимости знать IATA-код — достаточно выбрать букву, кликнуть на перевозчика и увидеть все его направления прямо на карте.

Метки
vue3three.jspostgresqlавиациявизуализация-данных
23.03.2026

Переход на модели Airport/Company и кеширование данных на клиенте

Обновил клиентскую архитектуру карты: перешёл с «сырых» API-объектов на модели Airport и Company, а также внедрил многоуровневое кеширование данных. В статье разобрал, как это упростило код, ускорило интерфейс и позволило безопасно кешировать каталог авиакомпаний/аэропортов с автоматической инвалидиацией по версии данных (newest_update). Есть практические фрагменты кода: нормализация моделей, lazy enrich и версионирование кеша.

Метки
vue3typescriptfrontend-architecturedata-modelingcachingperformance
24.03.2026

Камера в URL: синхронизация, восстановление и конвертация между 2D и 3D

Реализовал запись положения камеры в URL, восстановление вида при открытии ссылки и конвертацию координат камеры между плоской картой и глобусом. Разобрал несколько нетривиальных проблем с таймингом инициализации и вынес всю логику в отдельный Pinia-стор.

Метки
three.jsvuetypescriptcamerarouting