gitlab-ci-docker-vue-nuxt.md — vim

Деплой 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. Дальше станет заметно спокойнее.

Ссылки