Рядом с Git · Про Git тут ни слова
Почему из пяти строк вырастает полторы тысячи папок, зачем нужен файл-замок и почему размеры, которые тебе показывают, регулярно врут в большую сторону.
Зачем это в твоей миссии
К Git отношения не имеет, но пробел тот же: слова мелькают каждый день, а картинки за ними нет. Агент говорит «ставлю зависимости» или «пересобираю образ» — полезно понимать, что при этом происходит с диском, почему нельзя разрешать ему удалять файл-замок и почему свежая папка проекта не запускается. В конце про то, куда девается место под докером и почему его собственные цифры не складываются.
Приложения давно не пишут с нуля. Принимать запросы из браузера, разбирать даты, отправлять письма, рисовать редактор текста — всё это уже написано другими людьми и выложено в открытый доступ. Готовый чужой кусок кода называется пакетом, или библиотекой.
Лежат они в общем хранилище, которое называется реестр npm: миллионы пакетов, открытых для всех. Раньше их таскали руками — скачал архив, распаковал в папку проекта, дальше сам следи, чтобы версии друг с другом ужились.
Менеджер пакетов делает это за тебя. Ты говоришь «мне нужен express», он идёт в реестр, приносит express, приносит всё, что нужно самому express, и раскладывает по папкам. Плюс запоминает, что именно принёс, чтобы в следующий раз собрать то же самое.
npm — это название сразу двух вещей: и хранилища, и стандартного менеджера, который приезжает вместе с Node.js (это среда, в которой JavaScript работает не в браузере, а сам по себе — на твоей машине или на сервере). Имя выросло из node package manager, «менеджер пакетов для Node». Правда, потом авторы от расшифровки открестились и оставили в документации шутку: npm is not an acronym, «npm — это не акроним». Шутка в том, что фраза сама себя доказывает: сложи её первые буквы, и получится ninaa, а не npm. Значит и вправду не акроним. Пишется всегда строчными, даже с начала предложения.
pnpm — другая программа, которая ходит в то же самое хранилище за теми же пакетами. Тут расшифровка честная: performant npm, «быстрый npm». Отличаются они тем, как раскладывают скачанное на диске, и к этому мы вернёмся.
В твоём проекте GildiyaChat в списке нужного перечислено 5 пакетов. А на диске в папке зависимостей лежит 1432 пакета общим весом 992 мегабайта.
Замер на пустой папке: ставим один пакет, популярный express. На диск приезжает 65 пакетов.
Причина простая: у каждой библиотеки есть свои зависимости, у тех — свои. Ты просишь одну коробку, приезжает фура. Это называется транзитивными зависимостями, и это нормальный порядок вещей, а не чья-то ошибка.
Поэтому пять строк в списке и полторы тысячи папок на диске — не противоречие. Просто ты видишь верхушку, а менеджер разворачивает всё дерево целиком.
После установки в корне проекта лежат три вещи. Вместе они отвечают на три разных вопроса: чего проект хочет, что он в итоге получил и где это лежит. Дальше по очереди, на живых строчках из твоего MyMD4.
Это список нужных библиотек. Одна строка — одна библиотека и допустимая версия:
"@tiptap/extension-image": "^3.28.0"
Слева имя, справа версия. Но версия указана не жёстко, и всё дело в домике ^ перед цифрами. Он означает не «ровно 3.28.0», а «3.28.0 или новее, пока дело не дошло до четвёрки». Под такую запись подходит и 3.29, и 3.34, и 3.40.
Допуск сделан нарочно. Авторы библиотек постоянно выпускают мелкие исправления, и запись с домиком позволяет им приезжать самим, без правки списка.
Но у допуска есть цена. Одна и та же строчка в разные дни даёт разный результат. Поставил проект в июле — приехала 3.28. Поставил тот же самый проект на сервере в октябре — приехала 3.40. Список не менялся, а библиотеки разные. Это и есть знаменитое «у меня работает, а на сервере падает».
Ради этой проблемы второй файл и существует. Раз по списку нельзя заранее сказать, что встанет, менеджер записывает это после установки: что именно приехало в этот раз. Список — заказ, замок — накладная на то, что реально привезли. У npm этот же файл называется package-lock.json.
Тот же пакет, но в замке, который лежит рядом в той же папке:
'@tiptap/extension-image':
specifier: ^3.28.0
version: 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))
Две строчки, и в них весь смысл файла:
specifier — что просили. Скопировано из списка вместе с домиком.version — что встало. Точная версия, никаких допусков. Хвост в скобках говорит, с какими версиями соседей она собралась: это тоже влияет на поведение.Теперь установку можно повторить. На сервере менеджер не гадает по допуску, а смотрит в замок и ставит ровно 3.28.0. Июль и октябрь дают одинаковый результат — вот это и есть главная работа файла.
Рядом с каждым пакетом записан ещё и отпечаток содержимого:
resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
Это контрольная сумма. При установке она пересчитывается заново, и если приехали не те файлы, установка падает вместо того, чтобы молча продолжиться.
Замок лежит в снимках Git, поэтому у него есть и побочная польза: любое обновление зависимостей видно в истории — что было, что стало, когда. А пишет его только менеджер пакетов. Руками туда не лезут ни ты, ни агент: он меняется сам, когда меняется список.
Размах у двух файлов, кстати, несопоставимый. В MyMD4 список — 78 строк и 49 библиотек. Замок — 9498 строк и 1520 библиотек. Список описывает только то, что попросили напрямую, а замок фиксирует всё выросшее дерево целиком, вместе с транзитивными.
Папка с самими файлами библиотек. Её наполняет менеджер, и она же весит те самые сотни мегабайт.
В снимках Git она не хранится, и это осмысленно: её всегда можно собрать заново из двух предыдущих файлов. Незачем таскать в истории проекта гигабайт чужого кода, когда есть заказ и накладная.
Отсюда, кстати, засада из урока про worktrees: свежее рабочее дерево приезжает без этой папки, и пока не пройдёт установка, в нём ничего не запускается.
Если запомнить одно
package.json — чего хотим, с допуском по версиям. Файл-замок — что получили, точно и без допусков. node_modules — сами файлы. Первые два лежат в Git, третья восстанавливается из них в любой момент.
Самое частое: установка чужим менеджером
Агент приходит в проект и ставит зависимости той командой, к которой привык. Если в проекте лежит pnpm-lock.yaml, а он запускает npm install, рядом появляется второй замок — package-lock.json. С этого момента два менеджера видят разные версии, и какая картина поедет на сервер, зависит от того, какую команду там наберут. Отсюда правило: ставить тем менеджером, чей замок уже лежит в проекте.
Гигантский diff в замке — это нормально
Агент обновил одну библиотеку, а в изменениях девять тысяч строк по замку. Читать их построчно не нужно и никто не читает. Смотри на package.json: там будет одна-две строки, и они говорят всё, что нужно знать. Замок — это следствие.
Отдельно про удаление замка. Совет «снеси замок вместе с node_modules и поставь заново» до сих пор попадается в старых руководствах, иногда его повторяют и агенты. Установка после этого действительно пройдёт: менеджер возьмёт свежие версии и напишет замок с нуля. Только проблема не решена, а спрятана — версии разъехались с сервером, и вылезет это позже. Правильный ответ: «не удаляй, покажи ошибку целиком».
npm и pnpm ставят одни и те же пакеты из одного и того же места. Отличаются они только тем, как раскладывают их на диске.
npm — каждому проекту личная копия:
проект-1/node_modules/express/… ← файлы
проект-2/node_modules/express/… ← те же файлы ещё раз
проект-3/node_modules/express/… ← и ещё раз
pnpm — один склад, а в проектах ссылки на него:
склад/express@5.2.1/… ← файлы, один раз
проект-1/node_modules/express ──┐
проект-2/node_modules/express ──┼──▶ туда же
проект-3/node_modules/express ──┘
Замерил на одном и том же пакете:
| npm | pnpm | |
|---|---|---|
| один проект | 3,8 МБ | 4,3 МБ |
| два проекта с тем же пакетом | 7,5 МБ | 5,1 МБ |
Обрати внимание на первую строку: на одном проекте pnpm даже чуть тяжелее. Никакого чуда нет — служебная структура склада тоже занимает место. Выигрыш начинается со второго проекта: у npm вес удвоился, у pnpm вырос на восемьсот килобайт. На двадцати проектах разница уже в гигабайтах.
Механизм называется жёсткой ссылкой. Ярлык (он же symlink) ты, скорее всего, видел в Finder: папка со стрелочкой, которая перебрасывает в другое место. Жёсткая ссылка устроена иначе, и разница вылезает в тот момент, когда оригинал удаляют.
Кладём файл, рядом делаем ярлык и жёсткую ссылку:
echo "строка для проверки" > оригинал.txt
ln -s оригинал.txt ярлык.txt # ярлык
ln оригинал.txt второе-имя.txt # жёсткая ссылка
Теперь спросим у системы номера файлов на диске:
274690180 (2 имени) оригинал.txt
274690180 (2 имени) второе-имя.txt ← номер тот же
274690181 (1 имя) ярлык.txt ← номер свой
У оригинал.txt и второе-имя.txt номер совпадает. Это буквально один файл с двумя табличками на двери. А ярлык — отдельный маленький файл, внутри которого записан адрес: «иди туда».
Удаляем оригинал и пробуем прочитать оба:
ярлык.txt → файл не найден
второе-имя.txt → строка для проверки
Ярлык сломался: адрес, на который он показывал, опустел. Жёсткая ссылка работает как ни в чём не бывало.
Главное отсюда
У жёстких ссылок нет оригинала. Нет главного и производного, все имена равноправны. Файл живёт, пока не удалено последнее его имя — для этого система и держит счётчик. Именно этот счётчик показал 17 имён на файле из склада pnpm: семнадцать проектов пользуются одной физической копией, и снос любого из них ничего не сломает.
Жёсткие ссылки делаются только для файлов — папки так связать нельзя. И в Finder такая ссылка выглядит как самый обычный файл, без стрелочки на иконке: на глаз не отличить.
Отсюда и главное свойство: экономия без потери независимости. Проекты могут держать разные версии одной библиотеки и не мешать друг другу, а одинаковые куски просто не дублируются. Если бы pnpm раскладывал ярлыки, удаление склада положило бы разом все проекты на машине.
Побочный эффект, о который спотыкаются
Место освобождается не тогда, когда ждёшь. Удалил проект на гигабайт, а свободного места прибавилось сто мегабайт: остальное было общим, у тех файлов счётчик просто уменьшился на единицу. Поэтому размер папки — это «столько файлов сюда смотрит», а не «столько освободится, если её снести».
Приложение работает у тебя на маке. Ты выкладываешь его на сервер — и оно падает. Потому что на сервере другая версия системы, другие библиотеки, чего-то не хватает, а чего-то, наоборот, стоит лишнего. Знакомое «у меня работает» из прошлого раздела, только в полный рост.
Докер решает это грубо и надёжно: приложение упаковывается вместе со всем своим окружением. Не только твой код, а вся начинка — операционная система, нужные библиотеки, зависимости, настройки. Получается запечатанная коробка, которая одинаково запускается где угодно, потому что внутри у неё всегда одно и то же.
Три слова, которые чаще всего путают:
Рецепт. Текстовый файл: возьми такой-то линукс, поставь node, положи мой код, запускай вот такой командой. Пишет обычно агент.
Коробка. Собранный по рецепту результат. Сам по себе лежит и ничего не делает, как установочный файл на диске.
Запущенная коробка. Из одного образа можно запустить хоть десять контейнеров сразу, и они не будут мешать друг другу.
Пара «образ и контейнер» — та же история, что «программа на диске» и «запущенная программа». Твои luna-chat-web, luna-chat-worker и luna-chat-migrate — это три контейнера: один держит сайт, второй фоном разгребает задачи, третий обновляет базу.
Почему это не виртуальная машина
Контейнер не тащит с собой целую систему целиком: он пользуется ядром той машины, на которой запущен, а своё у него только то, что выше ядра. Поэтому он поднимается за секунды, а не за минуты.
Тонкость для мака: ядро-то нужно линуксовое, а у тебя macOS. Поэтому Docker Desktop держит на маке одну маленькую линуксовую виртуалку, и уже внутри неё живут все контейнеры. На сервере такой прослойки нет, там всё работает напрямую. Из-за этой виртуалки на маке докер и занимает место одним большим файлом, а не россыпью папок.
Образ несёт в себе целое окружение, поэтому весит прилично: гигабайт-полтора обычное дело. Докер применяет тот же приём, что pnpm, только делит не отдельные файлы, а куски файловой системы целиком. Замер на рабочем сервере:
Там 53 образа. Если взять список образов с их размерами и просто сложить столбец, получится 51,8 ГБ. А сам докер на том же сервере говорит, что образы занимают 41,81 ГБ. Десять гигабайт разницы из воздуха. Уже знакомая история.
Разницу объясняют три твоих образа. Докер сам печатает разбивку:
REPOSITORY SIZE SHARED SIZE UNIQUE SIZE
luna-chat-web 1.53GB 1.526GB 16.56kB
luna-chat-migrate 1.53GB 1.526GB 16.57kB
luna-chat-worker 1.53GB 1.526GB 16.57kB
Три образа по полтора гигабайта — на бумаге 4,6 ГБ. На диске: полтора гигабайта общего плюс три довеска по шестнадцать килобайт. Отличаются они друг от друга буквально командой запуска, всё остальное у них общее, и докер это честно печатает в столбцах SHARED и UNIQUE. Тот же счётчик имён, только показанный по-человечески.
Образ — не монолитный файл, а стопка слоёв. Каждый слой это зафиксированное изменение файловой системы, результат одного шага сборки: «поставили системные пакеты», «поставили зависимости проекта», «положили код».
слой 4: наш код ← свой у каждого образа
слой 3: зависимости проекта ─┐
слой 2: системные библиотеки ├─ общие, лежат в одном экземпляре
слой 1: базовая система (node:22) ─┘
Одинаковые слои докер хранит один раз и раздаёт всем образам, которым они нужны. Это тот же склад, что у pnpm, только единица хранения крупнее. У твоей троицы luna-chat совпали вообще все пятнадцать слоёв, поэтому уникального в каждой и осталось по шестнадцать килобайт.
Слои складываются по порядку, и докер их кэширует: если шаг и его входные данные не изменились с прошлой сборки, слой берётся готовым, а не собирается заново.
Отсюда правильный порядок в рецепте сборки, и он выглядит нелогично, пока не знаешь про слои:
# сначала только рецепт и замок
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# и только потом сам код
COPY . .
Смысл в том, что код меняется постоянно, а список зависимостей — редко. При таком порядке правка в коде задевает только последний слой, а тяжёлый слой с полутора тысячами пакетов берётся из кэша, и сборка занимает секунды. Если поставить COPY . . перед установкой, то любая исправленная буква в коде будет заставлять докер ставить все зависимости заново.
Ключевое слово тут --frozen-lockfile: «ставь ровно по замку, а если package.json с замком разошлись — упади, а не выкручивайся». У npm то же самое делает команда npm ci. Именно этого и хочется от сборки, которая поедет на сервер: чтобы она либо дала точно то же, что у тебя на маке, либо честно сломалась.
У докера есть своя команда для подсчёта места, docker system df — те же две буквы df, что и в системной команде про свободное место на диске. И она сама показывает, сколько можно освободить.
Пример вывода:
| занято | можно освободить | |
|---|---|---|
| Образы | 41,81 ГБ | 3,1 ГБ |
| Контейнеры | 951 МБ | 4 МБ |
| Тома с данными | 1,23 ГБ | 0 |
| Кэш сборок | 41,04 ГБ | 21,91 ГБ |
Смотри на последнюю строку. Кэш сборок весит столько же, сколько все образы вместе, и половина его уже никому не нужна. Это тот самый кэш, благодаря которому сборки идут быстро: он копит промежуточные слои от каждой пересборки и сам по себе не убирается.
Почему чистку не стоит отдавать агенту не глядя
Команды чистки удаляют по-настоящему и решают сами, что считать ненужным. Под «неиспользуемым» может оказаться остановленный контейнер, который ты собирался запустить, или том с данными. Разница между «убрать мусор» и «убрать базу» тут в одном ключе к команде. Поэтому сначала смотрим отчёт, потом решаем.
В уроке про worktrees была засада: свежее рабочее дерево приезжает без зависимостей, и их приходится ставить заново.
Если проект на pnpm, эта установка почти бесплатна: файлы уже на складе, создаются только ссылки. Если на npm — каждое новое дерево честно копирует свой гигабайт.
Одно условие
Склад должен лежать на том же диске, что и проекты — жёсткие ссылки не умеют пересекать границу между дисками. Если склад окажется на другом, pnpm молча начнёт копировать, и весь смысл пропадёт. По умолчанию он сам заводит по складу на каждый диск, так что обычно об этом можно не думать.
Обычная установка
Поставь зависимости тем менеджером, чей файл-замок лежит в проекте.
Одна фраза снимает целый класс проблем. Агент, пришедший в чужой проект, иногда ставит привычным ему менеджером — и рядом появляется второй файл-замок.
Установка не идёт
Покажи ошибку целиком. Ничего не удаляй и не переустанавливай, пока я не посмотрю.
Ошибка почти всегда называет конкретный пакет или версию. Автоматическая «починка» стирает эту информацию и заодно может переписать замок.
Новый проект
Заводи проект на pnpm.
Дальше это будет экономить и место, и время на каждом новом worktree. Переводить существующие проекты при этом не нужно — там уже всё установлено и работает.
Место кончается
Покажи docker system df и разбери, что из этого реально можно освободить. Ничего не удаляй, пока я не скажу.
Две части одинаково важны. Первая даёт картину, вторая не даёт агенту почистить лишнего в порыве услужливости.
Сборка идёт долго
Проверь порядок в Dockerfile: зависимости должны ставиться до копирования кода.
Самая частая причина, по которой сборка занимает пять минут вместо десяти секунд. Кэш слоёв сбрасывается на каждой правке кода.
Первоисточники к этому уроку
pnpm → Motivation — объяснение от самих авторов, зачем понадобился ещё один менеджер. Там же про склад и жёсткие ссылки. Про склад на другом диске — в разделе вопросов.
Документация npm про package-lock.json — официальное описание файла-замка и того, зачем он существует.
Docker → Build cache — как устроен кэш слоёв и почему порядок шагов в Dockerfile решает. Команда подсчёта места описана в справочнике по docker system df.
Цифры по докеру в этом уроке сняты с живого сервера, а не взяты из документации.