Рядом с Git · Про Git тут ни слова

Зависимости на диске: npm, pnpm и Docker

Почему из пяти строк вырастает полторы тысячи папок, зачем нужен файл-замок и почему размеры, которые тебе показывают, регулярно врут в большую сторону.

~20 минутничего не требуетвводить в терминал ничего не надо

Зачем это в твоей миссии

К 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 пакетов.

Причина простая: у каждой библиотеки есть свои зависимости, у тех — свои. Ты просишь одну коробку, приезжает фура. Это называется транзитивными зависимостями, и это нормальный порядок вещей, а не чья-то ошибка.

Поэтому пять строк в списке и полторы тысячи папок на диске — не противоречие. Просто ты видишь верхушку, а менеджер разворачивает всё дерево целиком.

package.json, файл-замок и node_modules

После установки в корне проекта лежат три вещи. Вместе они отвечают на три разных вопроса: чего проект хочет, что он в итоге получил и где это лежит. Дальше по очереди, на живых строчках из твоего MyMD4.

package.json: чего проект хочет

Это список нужных библиотек. Одна строка — одна библиотека и допустимая версия:

"@tiptap/extension-image": "^3.28.0"

Слева имя, справа версия. Но версия указана не жёстко, и всё дело в домике ^ перед цифрами. Он означает не «ровно 3.28.0», а «3.28.0 или новее, пока дело не дошло до четвёрки». Под такую запись подходит и 3.29, и 3.34, и 3.40.

Допуск сделан нарочно. Авторы библиотек постоянно выпускают мелкие исправления, и запись с домиком позволяет им приезжать самим, без правки списка.

Но у допуска есть цена. Одна и та же строчка в разные дни даёт разный результат. Поставил проект в июле — приехала 3.28. Поставил тот же самый проект на сервере в октябре — приехала 3.40. Список не менялся, а библиотеки разные. Это и есть знаменитое «у меня работает, а на сервере падает».

pnpm-lock.yaml: что проект получил на самом деле

Ради этой проблемы второй файл и существует. Раз по списку нельзя заранее сказать, что встанет, менеджер записывает это после установки: что именно приехало в этот раз. Список — заказ, замок — накладная на то, что реально привезли. У 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))

Две строчки, и в них весь смысл файла:

Теперь установку можно повторить. На сервере менеджер не гадает по допуску, а смотрит в замок и ставит ровно 3.28.0. Июль и октябрь дают одинаковый результат — вот это и есть главная работа файла.

Рядом с каждым пакетом записан ещё и отпечаток содержимого:

resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}

Это контрольная сумма. При установке она пересчитывается заново, и если приехали не те файлы, установка падает вместо того, чтобы молча продолжиться.

Замок лежит в снимках Git, поэтому у него есть и побочная польза: любое обновление зависимостей видно в истории — что было, что стало, когда. А пишет его только менеджер пакетов. Руками туда не лезут ни ты, ни агент: он меняется сам, когда меняется список.

Размах у двух файлов, кстати, несопоставимый. В MyMD4 список — 78 строк и 49 библиотек. Замок — 9498 строк и 1520 библиотек. Список описывает только то, что попросили напрямую, а замок фиксирует всё выросшее дерево целиком, вместе с транзитивными.

node_modules: где всё это лежит

Папка с самими файлами библиотек. Её наполняет менеджер, и она же весит те самые сотни мегабайт.

В снимках Git она не хранится, и это осмысленно: её всегда можно собрать заново из двух предыдущих файлов. Незачем таскать в истории проекта гигабайт чужого кода, когда есть заказ и накладная.

Отсюда, кстати, засада из урока про worktrees: свежее рабочее дерево приезжает без этой папки, и пока не пройдёт установка, в нём ничего не запускается.

Если запомнить одно

package.json — чего хотим, с допуском по версиям. Файл-замок — что получили, точно и без допусков. node_modules — сами файлы. Первые два лежат в Git, третья восстанавливается из них в любой момент.

Файл-замок и агент

Самое частое: установка чужим менеджером

Агент приходит в проект и ставит зависимости той командой, к которой привык. Если в проекте лежит pnpm-lock.yaml, а он запускает npm install, рядом появляется второй замок — package-lock.json. С этого момента два менеджера видят разные версии, и какая картина поедет на сервер, зависит от того, какую команду там наберут. Отсюда правило: ставить тем менеджером, чей замок уже лежит в проекте.

Гигантский diff в замке — это нормально

Агент обновил одну библиотеку, а в изменениях девять тысяч строк по замку. Читать их построчно не нужно и никто не читает. Смотри на package.json: там будет одна-две строки, и они говорят всё, что нужно знать. Замок — это следствие.

Отдельно про удаление замка. Совет «снеси замок вместе с node_modules и поставь заново» до сих пор попадается в старых руководствах, иногда его повторяют и агенты. Установка после этого действительно пройдёт: менеджер возьмёт свежие версии и напишет замок с нуля. Только проблема не решена, а спрятана — версии разъехались с сервером, и вылезет это позже. Правильный ответ: «не удаляй, покажи ошибку целиком».

npm и pnpm: разная раскладка на диске

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  ──┘

Замерил на одном и том же пакете:

npmpnpm
один проект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 раскладывал ярлыки, удаление склада положило бы разом все проекты на машине.

Побочный эффект, о который спотыкаются

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

Докер: коробка с окружением

Приложение работает у тебя на маке. Ты выкладываешь его на сервер — и оно падает. Потому что на сервере другая версия системы, другие библиотеки, чего-то не хватает, а чего-то, наоборот, стоит лишнего. Знакомое «у меня работает» из прошлого раздела, только в полный рост.

Докер решает это грубо и надёжно: приложение упаковывается вместе со всем своим окружением. Не только твой код, а вся начинка — операционная система, нужные библиотеки, зависимости, настройки. Получается запечатанная коробка, которая одинаково запускается где угодно, потому что внутри у неё всегда одно и то же.

Три слова, которые чаще всего путают:

Dockerfile

Рецепт. Текстовый файл: возьми такой-то линукс, поставь 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 совпали вообще все пятнадцать слоёв, поэтому уникального в каждой и осталось по шестнадцать килобайт.

Кэш слоёв и порядок в Dockerfile

Слои складываются по порядку, и докер их кэширует: если шаг и его входные данные не изменились с прошлой сборки, слой берётся готовым, а не собирается заново.

Отсюда правильный порядок в рецепте сборки, и он выглядит нелогично, пока не знаешь про слои:

# сначала только рецепт и замок
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

В уроке про worktrees была засада: свежее рабочее дерево приезжает без зависимостей, и их приходится ставить заново.

Если проект на pnpm, эта установка почти бесплатна: файлы уже на складе, создаются только ссылки. Если на npm — каждое новое дерево честно копирует свой гигабайт.

Одно условие

Склад должен лежать на том же диске, что и проекты — жёсткие ссылки не умеют пересекать границу между дисками. Если склад окажется на другом, pnpm молча начнёт копировать, и весь смысл пропадёт. По умолчанию он сам заводит по складу на каждый диск, так что обычно об этом можно не думать.

Как сказать агенту

Обычная установка

Поставь зависимости тем менеджером, чей файл-замок лежит в проекте.

Одна фраза снимает целый класс проблем. Агент, пришедший в чужой проект, иногда ставит привычным ему менеджером — и рядом появляется второй файл-замок.

Установка не идёт

Покажи ошибку целиком. Ничего не удаляй и не переустанавливай, пока я не посмотрю.

Ошибка почти всегда называет конкретный пакет или версию. Автоматическая «починка» стирает эту информацию и заодно может переписать замок.

Новый проект

Заводи проект на pnpm.

Дальше это будет экономить и место, и время на каждом новом worktree. Переводить существующие проекты при этом не нужно — там уже всё установлено и работает.

Место кончается

Покажи docker system df и разбери, что из этого реально можно освободить. Ничего не удаляй, пока я не скажу.

Две части одинаково важны. Первая даёт картину, вторая не даёт агенту почистить лишнего в порыве услужливости.

Сборка идёт долго

Проверь порядок в Dockerfile: зависимости должны ставиться до копирования кода.

Самая частая причина, по которой сборка занимает пять минут вместо десяти секунд. Кэш слоёв сбрасывается на каждой правке кода.

Проверь себя

Первоисточники к этому уроку

pnpm → Motivation — объяснение от самих авторов, зачем понадобился ещё один менеджер. Там же про склад и жёсткие ссылки. Про склад на другом диске — в разделе вопросов.

Документация npm про package-lock.json — официальное описание файла-замка и того, зачем он существует.

Docker → Build cache — как устроен кэш слоёв и почему порядок шагов в Dockerfile решает. Команда подсчёта места описана в справочнике по docker system df.

Цифры по докеру в этом уроке сняты с живого сервера, а не взяты из документации.

Если что-то не сошлось — назови конкретное место. Например: «не понял, почему на одном проекте pnpm тяжелее» или «не понял про семнадцать имён». Разберу отдельно.