Образ из монорепо: слои как кеш
Здесь будет встроен видео-разбор темы (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 и после новой зависимости. Прогноз: сколько шагов каждого возьмётся из кеша на второй и на третьей сборке?
У «всё сразу» правка исходника и новая зависимость неотличимы: оба файла входят в один 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. Прогноз: какие шаги возьмутся из кеша и чем кончится сборка?
# 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"]#0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile #1 transferring dockerfile: 3.87kB done #1 DONE <время> #2 resolve image config for docker-image://docker.io/docker/dockerfile:1 #2 DONE <время> #3 docker-image://docker.io/docker/dockerfile:1@sha256:<хеш> #3 CACHED #4 [internal] load metadata for docker.io/library/node:24-slim #4 DONE <время> #5 [internal] load .dockerignore #5 transferring context: 2B done #5 DONE <время> #6 [base 1/3] FROM docker.io/library/node:24-slim@sha256:<хеш> #6 DONE <время> #7 [internal] load build context #7 transferring context: 845.39kB done #7 DONE <время> #8 [build 5/15] COPY packages/db/package.json packages/db/ #8 CACHED #9 [build 9/15] RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --filter @jsp/api... #9 CACHED #10 [build 6/15] COPY packages/contracts/package.json packages/contracts/ #10 CACHED #11 [build 10/15] COPY packages/tsconfig packages/tsconfig #11 CACHED #12 [build 8/15] COPY packages/eslint-config/package.json packages/eslint-config/ #12 CACHED #13 [build 2/15] COPY apps/api/package.json apps/api/ #13 CACHED #14 [build 4/15] COPY apps/mobile/package.json apps/mobile/ #14 CACHED #15 [build 11/15] COPY packages/contracts packages/contracts #15 CACHED #16 [base 2/3] RUN corepack enable #16 CACHED #17 [build 1/15] COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./ #17 CACHED #18 [build 3/15] COPY apps/web/package.json apps/web/ #18 CACHED #19 [base 3/3] WORKDIR /app #19 CACHED #20 [build 7/15] COPY packages/tsconfig/package.json packages/tsconfig/ #20 CACHED #21 [build 12/15] COPY packages/db packages/db #21 CACHED #22 [build 13/15] COPY apps/api apps/api #22 DONE <время> #23 [build 14/15] RUN pnpm --filter @jsp/api... run build #23 Scope: 5 of 8 workspace projects #23 packages/contracts build$ tsc -p tsconfig.json #23 packages/db build$ tsc -p tsconfig.build.json #23 packages/contracts build: Done #23 packages/db build: Done #23 apps/api build$ nest build #23 apps/api build: Done #23 DONE <время> #24 [build 15/15] RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter @jsp/api deploy --legacy --prod /prod/api #24 [WARN] Shared workspace lockfile detected but configuration forces legacy deploy implementation. #24 Packages are copied from the content-addressable store to the virtual store. #24 Content-addressable store is at: /pnpm/store/v11 #24 Virtual store is at: ../prod/api/node_modules/.pnpm #24 ✓ Lockfile passes supply-chain policies (verified <минут> ago) #24 /app/apps/web: #24 [ERR_PNPM_WORKSPACE_PKG_NOT_FOUND] In apps/web: "@jsp/content@workspace:*" is in the dependencies but no package named "@jsp/content" is present in the workspace #24 #24 This error happened while installing a direct dependency of /app/apps/web #24 #24 Packages found in the workspace: java-script-pro, @jsp/api, @jsp/mobile, @jsp/web, @jsp/contracts, @jsp/db, @jsp/eslint-config, @jsp/tsconfig #24 ERROR: process "/bin/sh -c pnpm --filter @jsp/api deploy --legacy --prod /prod/api" did not complete successfully: exit code: 1 ------ > [build 15/15] RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter @jsp/api deploy --legacy --prod /prod/api: Content-addressable store is at: /pnpm/store/v11 Virtual store is at: ../prod/api/node_modules/.pnpm ✓ Lockfile passes supply-chain policies (verified <минут> ago) Progress: resolved 1, reused 0, downloaded 0, added 0 /app/apps/web: [ERR_PNPM_WORKSPACE_PKG_NOT_FOUND] In apps/web: "@jsp/content@workspace:*" is in the dependencies but no package named "@jsp/content" is present in the workspace This error happened while installing a direct dependency of /app/apps/web Packages found in the workspace: java-script-pro, @jsp/api, @jsp/mobile, @jsp/web, @jsp/contracts, @jsp/db, @jsp/eslint-config, @jsp/tsconfig ------ Dockerfile:45 -------------------- 44 | # legacy-режим физически копирует @jsp/db и @jsp/contracts (вместе с их dist) в node_modules 45 | >>> RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ 46 | >>> pnpm --filter @jsp/api deploy --legacy --prod /prod/api 47 | -------------------- ERROR: failed to build: failed to solve: process "/bin/sh -c pnpm --filter @jsp/api deploy --legacy --prod /prod/api" did not complete successfully: exit code: 1 View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux/<сборка>
Установка и сборка прошли: 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. Прогноз: какой способ переживёт больше правок без переустановки?
Список руками проигрывает на всех трёх правках: 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 в рантайм: какие из них выполнятся заново?
# 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"]#0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile #1 DONE <время> #1 [internal] load build definition from Dockerfile #1 transferring dockerfile: 2.04kB done #1 DONE <время> #2 [auth] docker/dockerfile:pull token for registry-1.docker.io #2 DONE <время> #3 resolve image config for docker-image://docker.io/docker/dockerfile:1 #3 DONE <время> #4 docker-image://docker.io/docker/dockerfile:1@sha256:<хеш> #4 CACHED #5 [auth] library/node:pull token for registry-1.docker.io #5 DONE <время> #6 [internal] load metadata for docker.io/library/node:24-slim #6 DONE <время> #7 [internal] load .dockerignore #7 transferring context: 375B done #7 DONE <время> #8 [base 1/3] FROM docker.io/library/node:24-slim@sha256:<хеш> #8 DONE <время> #9 [internal] load build context #9 transferring context: 55.99kB done #9 DONE <время> #10 [base 2/3] RUN corepack enable #10 CACHED #11 [base 3/3] WORKDIR /app #11 CACHED #12 [prune 1/3] RUN npm install -g turbo@2.10.11 #12 CACHED #13 [prune 2/3] COPY . . #13 DONE <время> #14 [prune 3/3] RUN turbo prune @jsp/api --docker #14 • turbo 2.10.11 #14 Generating pruned monorepo for @jsp/api in /app/out #14 - Added @jsp/api #14 - Added @jsp/contracts #14 - Added @jsp/db #14 - Added @jsp/eslint-config #14 - Added @jsp/tsconfig #14 DONE <время> #15 [build 1/5] COPY --from=prune /app/out/json/ . #15 CACHED #16 [build 2/5] RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --filter @jsp/api... #16 CACHED #17 [build 3/5] COPY --from=prune /app/out/full/ . #17 DONE <время> #18 [build 4/5] RUN pnpm --filter @jsp/api... run build #18 Scope: 5 of 6 workspace projects #18 packages/contracts build$ tsc -p tsconfig.json #18 packages/db build$ tsc -p tsconfig.build.json #18 packages/contracts build: Done #18 packages/db build: Done #18 apps/api build$ nest build #18 apps/api build: Done #18 DONE <время> #19 [build 5/5] RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter @jsp/api deploy --legacy --prod /prod/api #19 [WARN] Shared workspace lockfile detected but configuration forces legacy deploy implementation. #19 Packages are copied from the content-addressable store to the virtual store. #19 Content-addressable store is at: /pnpm/store/v11 #19 Virtual store is at: ../prod/api/node_modules/.pnpm #19 ✓ Lockfile passes supply-chain policies (verified <минут> ago) #19 apps/api | [WARN] deprecated eslint@9.39.5 #19 [WARN] 5 deprecated subdependencies found: @esbuild-kit/core-utils@3.3.2, @esbuild-kit/esm-loader@2.6.5, glob@7.2.3, inflight@1.0.6, uuid@9.0.1 #19 Progress: resolved 842, reused 0, downloaded 0, added 0, done #19 [WARN] Issues with peer dependencies found. Run "pnpm peers check" to list them. #19 . | +243 ++++++++++++++++++++++++ #19 DONE <время> #20 [runtime 2/6] WORKDIR /app #20 CACHED #21 [runtime 3/6] COPY --from=build --chown=node:node /prod/api/package.json ./package.json #21 CACHED #22 [runtime 4/6] COPY --from=build --chown=node:node /prod/api/node_modules ./node_modules #22 DONE <время> #23 [runtime 5/6] COPY --from=build --chown=node:node /app/apps/api/dist ./dist #23 DONE <время> #24 [runtime 6/6] RUN mkdir -p uploads && chown node:node /app /app/uploads #24 DONE <время> #25 exporting to image #25 exporting layers #25 exporting layers done #25 writing image sha256:<хеш> done #25 DONE <время> View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux/<сборка>
Срез выполнился заново, иначе и быть не могло: 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. - Контекст — папка с машины, где запущена сборка.
.gitignoreDocker не читает, а в.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 так, чтобы установка выполнялась заново только после новой зависимости. Остальной код не трогайте.
Установке нужны только манифесты и lock-файл, поэтому они копируются отдельной строкой и сразу за ней идёт npm ci. tsconfig.json и src нужны сборке — их место между установкой и npm run build.
Теперь правка исходника и правка tsconfig.json дают «установка из кеша», а новая зависимость — «установка заново». Порядок двух строк после установки на неё уже не влияет, а tsconfig.json стоит выше src по тому же правилу: он меняется реже, и правка исходника не заставит копировать его заново.
.dockerignore для монорепо
Функция ignored понимает две формы строк, как .dockerignore: путь от корня контекста и путь со **/ на любой глубине; папка исключается вместе с содержимым. Контекст — рабочая копия монорепо после установки, локальной сборки и настройки .env. Поправьте patterns так, чтобы в контекст ушли только манифесты, lock-файл, исходники и сам .dockerignore.
Корневые формы node_modules, dist и .env не достают до вложенных папок пакетов, поэтому им нужен префикс **/. .git бывает только в корне, и его строку можно не менять.
Вывод — семь строк: .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 секунды.