Деплой Vue и Nuxt через GitLab CI: один образ на все окружения
Пайплайн без дублей, кэш pnpm, образ для SPA и для Nitro, сборка через dind и Kaniko, runtimeConfig вместо пересборки и откат одной командой.
Есть ситуация, которую я узнаю по интонации в чате. Пайплайн зелёный, образ уехал в реестр, контейнер поднялся, в логах чисто, а снаружи 502. И дальше полчаса пять человек смотрят в разные стороны, потому что у каждого из них всё в порядке.
Почти всегда это одна из шести одинаковых ошибок. Ниже пайплайн, который я ставлю на новых проектах, и те самые грабли - чтобы не собирать их заново самому.
Общий каркас фронтового CI я разбирал отдельно, тут конкретика: GitLab, Docker, Vue и Nuxt.
Сначала решаем, что едет в прод: папка или процесс
В гите два проекта выглядят почти одинаково. package.json, компоненты, .gitlab-ci.yml. В проде это разные звери, и путаница начинается именно здесь.
vite build и nuxt generate дают статику. Финальный образ - это nginx с папкой внутри. Node в рантайме нет, масштабирование сводится к «добавь реплику» или вообще к CDN.
nuxt build даёт .output, самодостаточный сервер Nitro. Финальный образ запускает node .output/server/index.mjs. Появляются healthcheck, лимиты памяти, graceful shutdown и понимание, что процесс живёт между запросами.
Режим рендеринга выбирается на маршрут, а не на проект, но упаковка зависит от одного: осталась ли в сборке серверная часть. Если хоть один маршрут не предгенерён, у тебя серверный образ, даже когда 90% страниц статические. Я пару раз видел, как команда считала свой Nuxt статикой, потому что «там же почти всё prerender», и удивлялась, почему в контейнере зачем-то крутится Node.
Два пайплайна на один коммит
Первое, что я чиню в любом новом проекте. GitLab по умолчанию запускает пайплайн на push в ветку и ещё один на событие merge request. Оба считают одно и то же, оба жгут минуты раннера, и оба висят в интерфейсе, путая людей.
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
- when: never
stages: [prepare, check, build, package, deploy]
default:
image: node:22-alpine
interruptible: true
before_script:
- corepack enable
- pnpm config set store-dir .pnpm-store
interruptible: true плюс включённая в настройках проекта отмена избыточных пайплайнов - вторая бесплатная экономия. Запушил три коммита подряд, считается только последний. На команде из шестидесяти человек эти две настройки заметны по счёту за раннеры, а не по ощущениям.
Дальше needs, чтобы джобы не выстраивались в очередь просто потому, что так написано в stages:
lint:
stage: check
needs: [install]
script:
- pnpm lint
- pnpm typecheck # vue-tsc --noEmit
test:
stage: check
needs: [install]
script: pnpm test --coverage
Линт и тесты идут одновременно, сборка ждёт обоих. Экономия пара минут на пул-реквест, зато на каждом. И время от коммита до прода мы считаем, так что минуты тут вполне конкретные.
Кэшировать надо store, а не node_modules
Каждая джоба получает чистый контейнер, поэтому без кэша pnpm install каждый раз работает с нуля. Кэшировать надо не node_modules, а store: он общий для всех веток и лежит внутри проекта, откуда GitLab умеет его забирать. Отсюда и pnpm config set store-dir .pnpm-store в before_script выше.
install:
stage: prepare
cache:
key:
files: [pnpm-lock.yaml]
paths: [.pnpm-store]
script: pnpm install --frozen-lockfile
build:
stage: build
needs: [install]
cache:
key:
files: [pnpm-lock.yaml]
paths: [.pnpm-store]
policy: pull # эта джоба store не меняет, незачем его перезаливать
script: pnpm build
artifacts:
paths: [dist] # или .output для Nuxt
expire_in: 1 day
Ключ по хэшу lock-файла. Поменялись зависимости - переустановка, не поменялись - двадцать секунд вместо трёх минут. --frozen-lockfile не обсуждается: без него CI поставит не то, что стоит у тебя локально, и ты получишь классическое «у меня работает» с другой стороны.
Ещё одну вещь стоит проговорить вслух, потому что на ней путаются даже опытные. Кэш - это ускорение. Его может не оказаться, и джоба обязана отработать без него. Артефакт - это результат, который следующая джоба обязана получить. Положишь dist в кэш - однажды задеплоишь чужую сборку и будешь долго искать, почему в проде фича из соседней ветки.
И про память. vue-tsc и сборка большого Nuxt спокойно уходят в OOM внутри раннера, причём лог обрывается без внятной ошибки, просто на середине. Первый раз я убил на это полдня:
variables:
NODE_OPTIONS: --max-old-space-size=4096
NUXT_TELEMETRY_DISABLED: "1"
Vue: nginx, который не работает от root
Базовый multi-stage я показывал в прошлой статье. Вот что к нему добавляется, когда образ едет дальше стенда.
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginxinc/nginx-unprivileged:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.d/ /docker-entrypoint.d/
EXPOSE 8080
nginx-unprivileged вместо привычного nginx - не гигиена ради гигиены. Обычный образ стартует мастер-процессом от root, и в кластере с runAsNonRoot он просто не поднимется. Выясняется это ровно в тот день, когда девопсы закрутили политики, а ты про это не знал. Непривилегированный работает от пользователя nginx и слушает 8080, потому что порты ниже 1024 ему недоступны.
Порядок COPY важнее, чем кажется: сначала манифесты, потом установка, и только потом код. Иначе правка в одном компоненте инвалидирует слой с зависимостями, и каждая сборка ставит их заново.
И .dockerignore, которого нет у половины проектов:
node_modules
.git
.gitlab-ci.yml
dist
.output
.nuxt
*.log
.env*
Без него в build-контекст уезжает локальный node_modules, вся история .git и, в лучших традициях, .env со стендовыми ключами. Последнее я видел живьём: файл попал в слой, слой попал в реестр, а доступ к реестру есть у всех разработчиков. Формально утечки нет, фактически ключи знают тридцать человек.
Nuxt: в образ едет только .output
Главное про Nitro: .output самодостаточен. Нужные зависимости уже внутри, копировать node_modules в финальный образ не надо и вредно.
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production \
NITRO_PORT=3000 \
NITRO_HOST=0.0.0.0
COPY --from=build --chown=node:node /app/.output ./.output
USER node
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
Вот та самая строчка из начала статьи: NITRO_HOST=0.0.0.0. Без неё сервер слушает localhost внутри контейнера. Контейнер живой, логи чистые, порт проброшен, healthcheck снаружи красный, а внутри всё прекрасно. Полчаса на созвоне и ощущение, что сломался Docker.
USER node тоже не формальность. Процесс, который парсит пользовательский ввод и сам ходит в сеть, root внутри контейнера быть не должен.
И живость надо чем-то проверять. В Nuxt это три строчки:
// server/api/health.ts
export default defineEventHandler(() => ({ status: 'ok' }));
Почему в CI сборка идёт впятеро дольше, чем на ноутбуке
Потому что раннер каждый раз чистый, локальных слоёв там взяться неоткуда, и кэша у тебя нет вообще. Лечится тем, что кэш кладётся в реестр.
Docker-in-Docker - вариант по умолчанию, если раннер привилегированный:
package:
stage: package
needs: [build]
image: docker:27-cli
services: [docker:27-dind]
variables:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: /certs
DOCKER_TLS_VERIFY: 1
DOCKER_CERT_PATH: /certs/client
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
script:
- docker buildx create --use --name ci
- docker buildx build
--cache-from type=registry,ref=$CI_REGISTRY_IMAGE/cache
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE/cache,mode=max
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
--push .
Грабля тут ровно одна, зато неочевидная. Экспорт кэша в реестр работает только на драйвере docker-container, поэтому buildx create --use обязателен. На драйвере по умолчанию команда отработает молча, без единого предупреждения, и без кэша. Ты будешь уверен, что всё настроил, а время сборки не изменится.
Если привилегированного раннера нет и не предвидится, Kaniko собирает без демона:
package:
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
script:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(printf '%s:%s' "$CI_REGISTRY_USER" "$CI_REGISTRY_PASSWORD" | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json
- /kaniko/executor
--context $CI_PROJECT_DIR
--dockerfile $CI_PROJECT_DIR/Dockerfile
--destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
--cache=true --cache-ttl=24h
Тег по коммиту, потому что однажды придётся откатываться
Тег образа - это $CI_COMMIT_SHORT_SHA, всегда. latest можно навесить сверху, для людей, но деплоить по нему нельзя. По latest непонятно, что сейчас в проде, и откатываться некуда: остаётся собирать заново из старой ветки и ждать десять минут, пока пользователи смотрят на белый экран.
С тегом по коммиту откат - это тот же деплой с другим значением переменной. Минута вместо десяти, и главное - без мыслительной работы в три часа ночи.
Обратная сторона: образов по SHA копится много. В настройках реестра включается политика очистки, хранить N последних плюс всё, что помечено релизными тегами. Если этого не сделать, через год кто-то из девопсов придёт с вопросом, зачем фронту двести гигабайт.
Один образ, три окружения, и грабля runtimeConfig
Самая дорогая архитектурная ошибка - собирать отдельный образ на dev, stage и prod. Тогда фраза «проверено на stage» не значит ничего: в прод едет другая сборка. Собираем один раз, конфиг подставляем при запуске.
Для Nuxt это работает из коробки, но с условием:
export default defineNuxtConfig({
runtimeConfig: {
apiSecret: '', // только сервер, NUXT_API_SECRET
public: {
apiBase: '', // и клиент, и сервер, NUXT_PUBLIC_API_BASE
},
},
});
Nitro читает NUXT_PUBLIC_API_BASE при старте процесса, поэтому один образ поднимается с любым бэкендом. А условие такое: переменная подхватывается только для ключа, который уже объявлен в runtimeConfig. Нет ключа с дефолтом - нет и переопределения.
Это худший вид бага, потому что он молчит. Переменная в GitLab есть, в контейнере она есть, printenv её показывает, а useRuntimeConfig() возвращает пустоту, и никакой ошибки. Человек ищет проблему в деплое, в compose, в сети, где угодно, кроме одной недостающей строки в конфиге.
Второе, про что забывают: у полностью статической сборки (nuxt generate) значения зашиваются в момент билда. Подменить их в рантайме нельзя в принципе. Ещё один довод выбирать режим рендеринга осознанно, а не по привычке.
Для Vue-SPA рантайм-конфига нет, но трюк с config.js из entrypoint даёт ровно то же: контейнер при старте пишет файл, index.html его подключает.
Секреты: три места, куда их нельзя класть
В ARG. Build-аргументы остаются в метаданных образа, и docker history показывает их любому, у кого есть доступ к реестру. Токен приватного npm-реестра подключается через BuildKit-секрет, который в слой не попадает:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
pnpm install --frozen-lockfile
В VITE_* и NUXT_PUBLIC_*. Всё с префиксом public едет в браузер по определению. Это не «спрятано в бандле», это опубликовано. Ключи и токены живут не здесь, и повторять это новым людям приходится примерно раз в месяц.
В обычных переменных проекта. В GitLab у переменной есть два флага: masked вырезает значение из логов, protected отдаёт его только защищённым веткам и тегам. Без второго продовый секрет доступен из любой ветки, а ветку может создать кто угодно с правами разработчика.
Деплой должен быть скучным
Забрать образ по тегу, поднять, дождаться, пока он скажет, что живой. Всё.
deploy:prod:
stage: deploy
needs: [package]
resource_group: production
environment:
name: production
url: https://app.example.com
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
script:
- ssh deploy@$PROD_HOST "cd /srv/app
&& IMAGE_TAG=$CI_COMMIT_SHORT_SHA docker compose pull
&& IMAGE_TAG=$CI_COMMIT_SHORT_SHA docker compose up -d --wait"
resource_group: production - строчка, которой почти ни у кого нет и которая однажды спасает вечер. Без неё два смерженных подряд пул-реквеста запускают два деплоя параллельно, и что именно окажется в проде, решает случай. Отлаживать такое невозможно, потому что не воспроизводится.
--wait держит джобу, пока контейнер не станет healthy. То есть healthcheck из compose решает, зелёная джоба или красная, а не «команда выполнилась без ошибки»:
services:
web:
image: ${CI_REGISTRY_IMAGE}:${IMAGE_TAG}
restart: unless-stopped
environment:
NUXT_PUBLIC_API_BASE: https://api.example.com
NUXT_API_SECRET: ${API_SECRET}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
interval: 10s
timeout: 3s
retries: 5
Откат при такой схеме - та же команда с другим тегом. Оформи его отдельной ручной джобой с параметром: ночью никто не вспоминает синтаксис, а в интерфейсе GitLab есть ещё и кнопка «Rollback», но работает она только если каждый деплой честно привязан к environment.
Превью-стенд на каждый пул-реквест собирается из тех же кубиков: то же environment, только name: review/$CI_MERGE_REQUEST_IID, плюс on_stop и обязательный auto_stop_in. Для Nuxt это дороже, чем для статики, потому что живой процесс, а не папка на CDN. Так что auto_stop_in: 3 days здесь не пожелание, а способ не платить за полсотни забытых контейнеров. И всё это предполагает, что окружения создаются автоматически, а не настраивались руками по SSH полтора года назад.
Что докрутить, когда основное работает
Сканирование образа, Trivy или встроенный в GitLab Container Scanning. Сначала неблокирующим шагом, чтобы увидеть масштаб бедствия, и только потом блокирующим по HIGH и CRITICAL. Блокирующая проверка с первого дня на проекте с легаси-зависимостями научит команду ровно одному: как её обходить.
Контроль размера образа и размера бандла: сборка красная, если выросло больше чем на заданный процент. Без этого бандл растёт по двадцать килобайт за спринт, а через год все искренне удивляются.
И тесты до сборки, а не после, иначе платишь минутами раннера за упаковку заведомо сломанного кода.
Короткий список, чтобы не собирать это заново
- два пайплайна на один коммит - лечится
workflow: rules; - сборка без
--cache-from- в CI кэша слоёв нет в принципе; --cache-to type=registryбезbuildx create --use- молча собирает без кэша;- нет
.dockerignore- в контекст уезжаютnode_modules,.gitи.env; - Nuxt слушает localhost - нужен
NITRO_HOST=0.0.0.0; - ключа нет в
runtimeConfig- переменная окружения игнорируется без ошибки; - деплой по
latest- непонятно, что в проде, и откатываться некуда; - нет
resource_group- параллельные деплои перетирают друг друга; - секрет в
ARG- виден вdocker history; - закэшированный
index.html- люди после выкатки продолжают видеть старую версию.
Хороший пайплайн - тот, о котором не думают. Падает по делу, выкатывает ровно то, что проверили, откатывается одной кнопкой. Если у тебя сейчас не так, начни не с переписывания всего конфига, а с двух вещей: тег по коммиту и resource_group. Дальше станет заметно спокойнее.
Ссылки
workflow: rulesиneedsв GitLab CI- Сборка образов в GitLab CI - dind, TLS, Kaniko
- Кэш сборки в BuildKit и multi-stage
RUN --mount=type=secretruntimeConfigв Nuxt и деплой Nitro- Окружения и откаты,
resource_group - Политики очистки реестра