Образ из монорепо: слои как кеш

выкладка≈ 17 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Первая сборка образа API ставит 753 пакета и собирает три пакета монорепо. Вторая, после правки одной строки в обработчике, может повторить всё это, а может не тронуть установку вовсе, и решает это не мощность машины, а порядок строк в Dockerfile. Сборщик образов держит кеш того же устройства, что и кеш с ключом из входа в части IV: у каждого шага есть ключ из того, что в шаг вошло. Разберём, из чего складывается этот ключ, что уезжает сборщику с вашей машины и как собрать образ API из монорепо так, чтобы установку зависимостей сбивали только манифесты тех пакетов, которые API действительно нужны.

Разделы темы:·

В выкладке доски задач образ Next собирался в две стадии: в сборочной ставились пакеты и шла next build, в рантайм уезжали server.js, статика и урезанные зависимости. Тогда вопрос был в том, что попадёт в образ. Теперь другой: что произойдёт на второй сборке того же Dockerfile, когда в проекте поменялась одна строка.

FROM берёт базовый образ, а каждая следующая инструкция Dockerfile, например COPY или RUN, выполняется поверх результата предыдущей, и изменения файловой системы, которые она сделала, сохраняются слоем. Перед тем как выполнить шаг, сборщик (в Docker это BuildKit) считает ключ шага и ищет в кеше слой с таким ключом. Нашёл — печатает CACHED и берёт слой как есть, команду не выполняя. В ключ входят ключ предыдущего шага, текст инструкции и — у COPY — контрольные суммы копируемых файлов. Суммы считаются по содержимому и правам доступа, а время изменения в них не входит: на Docker Desktop 4.91.0 touch файла кеш не сбил, а правка содержимого и chmod +x сбили (проверено 15.09.2026).

Ключ предыдущего шага делает ключи цепочкой. Если шаг промахнулся, у следующего сменился родитель, и промахнётся он тоже, даже если его собственные файлы не менялись. Отсюда весь вопрос порядка. Ниже модель: ключ — строка из родителя, инструкции и забранных файлов, кеш — множество таких строк. Настоящий сборщик хранит вместо строки её хеш, как отпечаток нативной части в части V хранил хеш набора файлов. Два Dockerfile для одного приложения и три сборки подряд: первая, после правки src/main.js и после новой зависимости. Прогноз: сколько шагов каждого возьмётся из кеша на второй и на третьей сборке?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

У «всё сразу» правка исходника и новая зависимость неотличимы: оба файла входят в один COPY . ., его ключ меняется, и npm ci ставит пакеты заново после каждой запятой в коде. «Сначала манифесты» разводит два вида правок по разным шагам. Правка src меняет ключ только у COPY src ./src, и установка остаётся в кеше. А на новой зависимости видна цепочка: COPY src ./src выполнился заново, хотя исходники с прошлой сборки не менялись, — у него сменился родитель.

Правило из этого одно: инструкции идут от того, что меняется реже, к тому, что меняется чаще. Базовый образ и системные пакеты — наверху, манифесты и lock-файл — перед установкой, исходники — после неё, сборка — после исходников. Всё, что скопировано до RUN с установкой, становится частью её ключа, поэтому до установки копируют ровно то, от чего установка зависит, и ничего больше.

Ключ RUN — это родитель и текст команды, а не то, что команда сделала. Опыт: шаг RUN date +%s%N > /installed-at.txt после правки исходников остался в кеше, и файл в образе хранил время первой сборки. Для date это забавно, для RUN apt-get update или RUN npm install без lock-файла — нет: слой держит то, что реестр отдал в день первой сборки, а на машине с пустым кешем та же строка поставит другие версии. Это то же слепое пятно, что у обёртки кеша перед замыканием: всё, что не вошло в ключ явно, для кеша не существует.

Лечится это тем же приёмом — вход делают явным. Lock-файл, скопированный перед установкой, превращает ответ реестра в содержимое файла: тот же ключ — те же версии. А когда нужно именно свежее, например обновления безопасности системных пакетов, кеш выключают руками: docker build --pull --no-cache проверит, не вышел ли новый базовый образ, и выполнит все шаги заново.

Время изменения файла в ключ не входит: переключение веток туда и обратно и touch кеш не сбивают. Права входят: chmod +x на файле в src пересоберёт всё, что ниже его COPY.

Точка в docker build -f apps/api/Dockerfile . — контекст: папка, из которой COPY берёт файлы. Её содержимое сборщик получает с той машины, где запущена команда. На сервере после git clone это репозиторий и то немногое, что положили рядом руками. На ноутбуке — репозиторий плюс всё, что появилось поверх за день работы: node_modules после pnpm install, dist после локальной сборки, .env с вашими секретами.

И всё это участвует в ключах. COPY . . или COPY apps/api apps/api забирают вложенные node_modules и dist, и ключ шага меняется от локальной сборки, хотя исходники те же. .env уезжает в слой: если это слой промежуточной стадии, в итоговый образ он не попадёт, но в кеше сборщика останется. И сервер от этого не спасает: по инструкции выкладки автора .env.prod с боевыми секретами лежит прямо в корне репозитория на сервере — ровно там, откуда его заберёт COPY . .. Отсекает всё это файл .dockerignore в корне контекста.

В части V архив для облачной сборки EAS сам не брал node_modules и отсекал всё, что перечислено в .gitignore. Docker не делает ни того, ни другого. .gitignore он не читает вовсе: с node_modules и .env в .gitignore и без .dockerignore и корневой, и вложенный node_modules, и apps/api/.env оказались внутри образа после COPY . /ctx.

Вторая ловушка ждёт в монорепо. Строка node_modules в .dockerignore считается от корня контекста: она исключает корневую папку и не трогает apps/api/node_modules. Исключение на любой глубине пишется со звёздочками — **/node_modules. Вот что оставалось в контексте из корневого node_modules, вложенного apps/api/node_modules, apps/api/.env и исходников при разных настройках:

НастройкаЧто попало в образ через COPY . /ctx
node_modules в корневом .dockerignoreисходники, Dockerfile, apps/api/node_modules и apps/api/.env
**/node_modules и **/.envтолько исходники, Dockerfile и сам .dockerignore
то же плюс apps/api/Dockerfile.dockerignore с одной строкой apps/api/srcоба node_modules и apps/api/.env, но не apps/api/src

Последняя строка — не опечатка. Файл <Dockerfile>.dockerignore рядом с Dockerfile не дополняет корневой .dockerignore, а заменяет его для этой сборки. Удобно, когда у образов одного монорепо разные нужды, и опасно, когда о корневом файле забыли. .dockerignore для образа API — на второй вкладке прогона в разделе о настоящей сборке: зависимости и сборки со звёздочками, .git в корне контекста и секреты на любой глубине. В монорепо учебника корневой .dockerignore один на все четыре образа, и у строки **/.env в нём есть единственное исключение — !packages/content/runs/*/.env: в прогонах статей .env не секрет, а часть примера, и без него сборка сайта падает на своей сверке прогонов. Возврат намеренно узок с двух сторон, и обе границы проверены сборкой-пробой: со ** вместо * в контекст уехал бы и .env из вложенной папки прогона, а с .env* вместо .env — .env.local, случайно оставленный рядом.

На сервере после свежего git clone нет ни node_modules, ни dist, и ошибка в .dockerignore там не видна. Та же сборка на ноутбуке повезёт их в контекст, поэтому проверять .dockerignore нужно на рабочей копии, а не на чистой.

В монорепо учебника девять пакетов, и API нужны пять из них: сам @jsp/api, @jsp/contracts и @jsp/db, от которых он зависит, и два пакета настроек — @jsp/tsconfig и @jsp/eslint-config. Установку сужает фильтр: pnpm install --filter @jsp/api..., где троеточие после имени значит «пакет и всё, от чего он зависит внутри workspace». Но фильтр решает, что ставить, а в ключ шага идёт то, что скопировано до него. Это два разных списка.

До исправления 15.09.2026 в apps/api/Dockerfile монорепо — том самом, на который ссылается compose-файл выкладки автора, — манифесты были перечислены строками COPY руками. Комментарий объяснял, почему в список попали web и mobile: без манифестов всех пакетов --frozen-lockfile будто бы не сойдётся с lock-файлом. При этом в монорепо есть ещё packages/content и packages/sandbox — пакеты сайта. В Dockerfile сайта их манифесты копировались, в Dockerfile API — нет. Ниже сборка того файла как есть, ревизия 5625dbe; кеш сборщика уже знал его прошлую сборку, и с тех пор поменялись только файлы в apps/api/src. Прогноз: какие шаги возьмутся из кеша и чем кончится сборка?

запись с машины: macOS arm64, Docker Desktop 4.91.0 (BuildKit 0.33.0), монорепо учебника 5625dbe · 15 сентября 2026
apps/api/Dockerfile монорепо учебника до исправления 15.09.2026, ревизия 5625dbe. Строки CACHED идут не в порядке файла: сборщик проверяет шаги параллельно
# syntax=docker/dockerfile:1
# Собирается из корня монорепо: docker build -f apps/api/Dockerfile .

# ---------- base: node + pnpm через corepack ----------
FROM node:24-slim AS base
# corepack берёт версию pnpm из "packageManager" в package.json —
# локально и в образе гарантированно один и тот же pnpm@11
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN corepack enable
WORKDIR /app

# ---------- build: установка воркспейса + сборка цепочки contracts → db → api ----------
FROM base AS build

# сначала только манифесты: слой с install переживает правки исходников.
# package.json нужны ВСЕХ пакетов воркспейса (включая web/mobile) — иначе
# frozen-lockfile не сходится с importers из pnpm-lock.yaml
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY apps/mobile/package.json apps/mobile/
COPY packages/db/package.json packages/db/
COPY packages/contracts/package.json packages/contracts/
COPY packages/tsconfig/package.json packages/tsconfig/
COPY packages/eslint-config/package.json packages/eslint-config/

# ставим только @jsp/api и его workspace-зависимости — web/mobile в образ не тянем
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm install --frozen-lockfile --filter @jsp/api...

COPY packages/tsconfig packages/tsconfig
COPY packages/contracts packages/contracts
COPY packages/db packages/db
COPY apps/api apps/api

# pnpm идёт по топологии: contracts и db соберутся раньше api —
# их workspace-зависимости резолвятся в dist (main: dist/index.js)
RUN pnpm --filter @jsp/api... run build

# прод-зависимости в отдельную папку — проверенный способ для pnpm-монорепо.
# --legacy, потому что новый deploy требует inject-workspace-packages=true,
# а менять режим резолва всего воркспейса ради образа не хотим;
# legacy-режим физически копирует @jsp/db и @jsp/contracts (вместе с их dist) в node_modules
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm --filter @jsp/api deploy --legacy --prod /prod/api

# ---------- runtime: только прод-deps + dist ----------
FROM node:24-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app

COPY --from=build /prod/api/package.json ./package.json
COPY --from=build /prod/api/node_modules ./node_modules
# dist берём из сборки, а не из deploy-копии: рантайм не зависит от того,
# что deploy посчитал «файлами пакета»; src/@generated в рантайме не нужен
# (это только типы для клиентов), но отдельно вырезать его из dist смысла нет
COPY --from=build /app/apps/api/dist ./dist

# каталог для LocalFileStorage: main.ts создаёт его при старте, поэтому
# /app должен быть записываемым для node; в проде фото уходят в MinIO
RUN mkdir -p uploads && chown -R node:node /app
USER node

EXPOSE 8000
# health лежит под глобальным префиксом /api; curl в slim нет — хватает встроенного fetch
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:8000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

CMD ["node", "dist/main.js"]
docker build -f apps/api/Dockerfile .

Установка и сборка прошли: pnpm install даже не выполнялся, его слой взят из кеша, а в первой сборке без кеша он выполнился и тоже прошёл. Упал pnpm deploy, и не на API, а на apps/web: манифест сайта скопирован, в нём зависимость @jsp/content@workspace:*, а манифеста @jsp/content в образе нет. Установка с фильтром смотрит на выбранные пакеты, а deploy разбирает workspace целиком. Сломал сборку лишний манифест, а не нехватка нужного: всё, что нужно самому API, в списке было.

Лечится двумя способами, и оба проверены: дописать манифесты content и sandbox или убрать web и mobile. Второй заодно опровергает тот комментарий: с манифестами только пяти нужных пакетов pnpm install --frozen-lockfile в pnpm 11.17.0 проходит, и deploy тоже. Первый оставляет в ключе установки API манифесты сайта и приложения, и правка одного скрипта в apps/web/package.json заставляет pnpm заново разложить 753 пакета API.

Модель ниже сравнивает три способа собрать ключ установки на трёх правках: скрипт в web, новая зависимость в web, новая зависимость в db. Lock-файл в ней — разделы по папкам пакетов, как раздел importers в pnpm-lock.yaml. Прогноз: какой способ переживёт больше правок без переустановки?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Список руками проигрывает на всех трёх правках: web в нём есть. «Только манифесты API» переживает скрипт, но не зависимость в web: lock-файл у монорепо один, новая зависимость сайта меняет его, а скопирован он перед установкой целиком. На настоящей сборке вышло так же: после pnpm add в web установка API пошла заново. Зависимость в db сбивает установку при любом способе, и это правильно: пакет, которым API пользуется, поменял зависимости.

Третий способ даёт turbo prune @jsp/api --docker. Turborepo в монорепо учебника уже стоит: корневые скрипты build и check запускают через него задачи всех пакетов. Он сам обходит граф workspace и кладёт в out/json манифесты пяти нужных пакетов и урезанный pnpm-lock.yaml, в разделе importers которого остаются только они и корень, а в out/full — их исходники. Список больше не пишется руками и не может разойтись с графом, а чужие правки lock-файла в срез не попадают. Проверено на настоящей сборке: после новой зависимости в web, а потом в mobile заново выполнился только сам срез, все остальные шаги взялись из кеша. Так и исправлен apps/api/Dockerfile репозитория: 15.09.2026 ручной список манифестов и комментарий про --frozen-lockfile сменила отдельная стадия со срезом.

Соберём всё вместе. Dockerfile ниже — образ API из того же монорепо, переписанный по этой статье, на второй вкладке — .dockerignore. Кроме общей base с pnpm, стадий три. prune копирует весь контекст и строит срез. build берёт из среза сначала out/json и ставит зависимости, потом out/full и собирает. runtime получает прод-зависимости и dist — о нём следующий раздел, а как этот образ встаёт рядом с базой, разбирает «Compose: база, приложение, тома и порядок старта». Файл прогона учебный, и с нынешним apps/api/Dockerfile репозитория совпадает не дословно: там установка вынесена в общую стадию deps, от которой параллельно отходят сборка и срез прод-зависимостей; рантайм не меняет владельца — код в нём остаётся за root; строки # syntax=docker/dockerfile:1, которую видно на вкладке, в нём нет — по причине из заметки в конце раздела; а .dockerignore там общий на все образы монорепо и шире здешнего.

Образ уже собирался, после чего в apps/api/src/health/health.module.ts изменилась одна строка: ответ /api/health получил поле uptime. Прогноз по пяти тяжёлым шагам — turbo prune, pnpm install, nest build, pnpm deploy и копирование node_modules в рантайм: какие из них выполнятся заново?

запись с машины: macOS arm64, Docker Desktop 4.91.0 (BuildKit 0.33.0), монорепо учебника 5625dbe · 15 сентября 2026
Вторая сборка: с первой изменилась одна строка в apps/api/src/health/health.module.ts
# syntax=docker/dockerfile:1
# Собирается из корня монорепо: docker build -f apps/api/Dockerfile .

FROM node:24-slim AS base
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN corepack enable
WORKDIR /app

# ---------- prune: срез монорепо под один пакет ----------
FROM base AS prune
ENV TURBO_TELEMETRY_DISABLED=1
RUN npm install -g turbo@2.10.11
COPY . .
# out/json — package.json пакета @jsp/api и его пакетов workspace плюс урезанный lock,
# out/full — их исходники; web и mobile в срез не попадают
RUN turbo prune @jsp/api --docker

# ---------- build ----------
FROM base AS build
COPY --from=prune /app/out/json/ .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm install --frozen-lockfile --filter @jsp/api...

COPY --from=prune /app/out/full/ .
RUN pnpm --filter @jsp/api... run build

# прод-зависимости отдельной папкой: пакеты workspace в ней лежат копиями, а не ссылками
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm --filter @jsp/api deploy --legacy --prod /prod/api

# ---------- runtime: прод-зависимости и dist ----------
FROM node:24-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
# владелец задаётся при копировании: chown -R отдельным RUN записал бы node_modules в образ второй раз
COPY --from=build --chown=node:node /prod/api/package.json ./package.json
COPY --from=build --chown=node:node /prod/api/node_modules ./node_modules
COPY --from=build --chown=node:node /app/apps/api/dist ./dist
RUN mkdir -p uploads && chown node:node /app /app/uploads
USER node

EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:8000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

CMD ["node", "dist/main.js"]
docker build -f apps/api/Dockerfile .

Срез выполнился заново, иначе и быть не могло: COPY . . видит весь контекст, и правка исходника меняет его ключ. А следующая строка, COPY --from=prune /app/out/json/ ., — CACHED, и за ней pnpm install. Ключ COPY --from складывается так же, как у обычного COPY: из содержимого копируемых файлов, а не из того, выполнялась ли стадия-источник. Манифесты и урезанный lock после правки исходника совпали байт в байт, значит, и ключ прежний.

nest build и pnpm deploy выполнились: out/full изменился. В рантайме package.json взят из кеша, а node_modules скопирован заново, хотя ни одна зависимость не менялась. Причина в самом pnpm deploy: он пишет в node_modules/.modules.yaml время, поле prunedAt. Выход двух прогонов deploy с разными исходниками сравнили по всем 17 011 файлам и ссылкам — отличается один этот файл. Ключ копирования меняется, и рантайм получает новый слой в 130 МБ на каждую правку кода. Кеш установки при этом цел, а беречь нужно было прежде всего его.

Когда установка всё-таки выполняется, пакеты заново не качаются. --mount=type=cache подключает к шагу хранилище pnpm, но в слой его не пишет и в ключ не включает: после правки манифеста шаг выполнился, и pnpm взял из хранилища 751 пакет из 753, не скачав ни одного. Время это экономит не целиком: pnpm 11 ещё сверяет lock-файл с политиками реестра по сети. В замерах переустановка из хранилища заняла 10–13 секунд, из них 7–10 ушло на эту сверку, а первая установка, которая качала все пакеты, — 18 секунд. И живёт такое хранилище только в кеше сборщика этой машины: на чистом сервере первая сборка качает всё.

Строка # syntax=docker/dockerfile:1 заставляет сборщик на каждой сборке спрашивать Docker Hub о фронтенде. 15.09.2026 реестр ответил 502, и сборка, у которой всё остальное лежало в кеше, упала на первой строке файла.

Сборочная стадия тяжела: образ после неё весит больше гигабайта. В её node_modules лежат Jest, Nest CLI и всё, что нужно только на сборке, а папка apps/api/node_modules у pnpm состоит из одних ссылок: fastify в ней ведёт в корневой node_modules/.pnpm, а @jsp/db — на ../../../../packages/db. Скопируй в рантайм одну эту папку — и ссылки поведут в пустоту.

pnpm deploy --prod /prod/api собирает отдельную папку: package.json API и node_modules только с боевыми зависимостями, где @jsp/db и @jsp/contracts лежат копиями вместе со своим dist. На диске это 181 МБ против 445 МБ корневого node_modules сборочной стадии — тот же отказ от dev-зависимостей, от которого образ худеет, только в монорепо. Флаг --legacy здесь не пережиток: начиная с pnpm 10 обычный deploy требует настройки inject-workspace-packages=true и без неё отказывает с ERR_PNPM_DEPLOY_NONINJECTED_WORKSPACE. Эта настройка меняет, как пакеты workspace подключаются во всём монорепо, и --legacy позволяет не трогать её ради одного образа.

Последняя ловушка — права. API работает от пользователя node, и прежний apps/api/Dockerfile монорепо — тот самый, что упал в разделе о монорепо, — отдавал ему /app строкой RUN mkdir -p uploads && chown -R node:node /app после копирования. Слой хранит изменённые файлы целиком, а смена владельца меняет каждый файл node_modules. Сам тот файл до рантайма не доходил — сборка падала раньше, — поэтому замер сделан на его копии без манифестов web и mobile, рантайм у неё тот же. По docker history слой COPY node_modules — 130 МБ, слой с chown -R — 131 МБ, весь образ — 510 МБ. С COPY --chown=node:node владелец ставится при копировании, chown остаётся для двух каталогов без рекурсии, и тот же образ весит 380 МБ.

Есть и третий вариант, и репозиторий учебника выбрал его: владельца не менять вовсе. COPY без --chown оставляет код и node_modules за root, а node получает единственный каталог, куда ему нужно писать: RUN mkdir uploads && chown node:node uploads. Слой с этой строкой — 0 Б, образ те же 380 МБ (у обоих вариантов ровно 379 545 107 Б, собрано и сверено 16.09.2026), а разница в том, что процесс больше не может переписать собственный dist: под node там Permission denied, тогда как с --chown=node:node запись проходит. --chown нужен, когда приложению правда надо писать в свой каталог; когда список записываемых мест известен и короток, короче и строже отдать только их.

Чего в срезе pnpm deploy нет, так это drizzle-kit: он dev-зависимость @jsp/db. Поэтому миграции у автора собираются отдельным образом deploy/Dockerfile.migrate, а почему им место в одноразовой джобе, а не в старте API, разбирает «Миграции при выкате: одноразовая джоба и обратная совместимость».

docker history показывает размер каждого слоя. Слой RUN размером с node_modules — повод искать chown -R или другую команду, которая трогает все файлы разом.

  • Ключ шага — ключ родителя, текст инструкции и у COPY содержимое и права файлов, но не время изменения. Промах одного шага сбивает все ниже, поэтому инструкции идут от редко меняющегося к часто меняющемуся: манифесты и lock — до установки, исходники — после.
  • Ключ RUN не видит сети: слой хранит то, что команда получила в первый раз. Честный ключ установки даёт lock-файл, скопированный перед ней, а свежий базовый образ и системные пакеты забирают через --pull --no-cache.
  • Контекст — папка с машины, где запущена сборка. .gitignore Docker не читает, а в .dockerignore монорепо строки пишут со **/: node_modules без звёздочек исключает только корневую папку.
  • В ключ установки API должны входить манифесты только его пакетов. Список COPY руками расходится с графом — в монорепо учебника лишний манифест сайта уронил pnpm deploy, — а общий lock-файл сбивает установку на чужих зависимостях. turbo prune --docker строит срез сам.
  • COPY --from берёт ключ из содержимого: стадия-источник может выполниться заново, а шаг — остаться в кеше. --mount=type=cache держит пакеты вне слоёв, и переустановка их не качает.
  • pnpm deploy --legacy --prod даёт рантайму боевые зависимости с копиями пакетов workspace вместо ссылок. chown -R отдельным слоем кладёт node_modules в образ второй раз: владельца ставят при копировании через COPY --chown или не меняют совсем, отдав пользователю единственный каталог для записи, — так в репозитории учебника: размер образа тот же, а процесс не перепишет свой dist.

1/4Между двумя сборками переключились на другую ветку и вернулись обратно: содержимое файлов прежнее, время изменения новое. Что будет с шагом COPY src ./src?

2/4В Dockerfile стоят COPY . . и следом RUN npm ci. Вы исправили опечатку в README.md. Что выполнится заново?

3/4В корне монорепо лежит .dockerignore с одной строкой node_modules. Что из этого попадёт в контекст сборки?

4/4Почему после правки исходника pnpm install в образе из этой статьи взялся из кеша, хотя стадия prune перед ним выполнилась заново?

Установка только по делу

Модель ключей из первого раздела и приложение на TypeScript: кроме манифестов, сборке нужен tsconfig.json. Три правки подряд — исходник, tsconfig.json, новая зависимость — и после каждой установка идёт заново. Переставьте и разделите строки steps так, чтобы установка выполнялась заново только после новой зависимости. Остальной код не трогайте.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Установке нужны только манифесты и lock-файл, поэтому они копируются отдельной строкой и сразу за ней идёт npm ci. tsconfig.json и src нужны сборке — их место между установкой и npm run build.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь правка исходника и правка tsconfig.json дают «установка из кеша», а новая зависимость — «установка заново». Порядок двух строк после установки на неё уже не влияет, а tsconfig.json стоит выше src по тому же правилу: он меняется реже, и правка исходника не заставит копировать его заново.

.dockerignore для монорепо

Функция ignored понимает две формы строк, как .dockerignore: путь от корня контекста и путь со **/ на любой глубине; папка исключается вместе с содержимым. Контекст — рабочая копия монорепо после установки, локальной сборки и настройки .env. Поправьте patterns так, чтобы в контекст ушли только манифесты, lock-файл, исходники и сам .dockerignore.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Корневые формы node_modules, dist и .env не достают до вложенных папок пакетов, поэтому им нужен префикс **/. .git бывает только в корне, и его строку можно не менять.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод — семь строк: .dockerignore, три манифеста, lock-файл и два исходника. Проверено и на настоящем Docker: с node_modules без звёздочек apps/api/node_modules уезжает в контекст, с **/node_modules — нет.

Что пересоберётся

Возьмите образ API из раздела о настоящей сборке и четыре правки, каждую отдельно: новый обработчик в apps/api/src; новая зависимость в apps/mobile; новая зависимость в packages/db; строка в deploy/Caddyfile. Для каждой ответьте: выполнятся ли заново turbo prune, pnpm install и nest build и изменится ли итоговый образ?

  • Обработчик в apps/api/src — как в прогоне: срез заново, установка из кеша, nest build и pnpm deploy заново, образ новый.
  • Зависимость в apps/mobile — lock-файл монорепо поменялся, но mobile в срез не входит: out/json и out/full прежние. Заново выполнится только turbo prune, образ тот же.
  • Зависимость в packages/db — манифест db и его раздел lock-файла входят в out/json: установка заново и всё ниже, образ новый.
  • Строка в deploy/Caddyfile — COPY . . её видит, и срез выполнится, но deploy не пакет workspace и в out/full не попадает: всё остальное из кеша, образ тот же.

Все четыре ответа сверены сборками на Docker Desktop 4.91.0. turbo prune выполняется заново при любой правке контекста — это цена COPY . . в первой стадии, и она мала: в замерах срез занимал 0,2–0,4 секунды.