# Як зменшити розмір Docker образу з Node.js

Всім привіт! В Twitter спитав чи було б цікаво почитати про Docker з Node.js та чи буде цікавим історія про те, як я зменшував розмір образу до 40-50 Мб. На твіт відгукнулись, тож вирішив, що можна й погратись трохи з цією історією (тим паче, що зараз трішки часу вільного зʼявилось).

## Disclaimer

Проте одразу залишу парочку дисклеймерів, хоча я знаю, що ніколи ці дисклеймери не захищали від занудних тіпків, які хочуть показати що вони все знають.

По перше, я пишу цей текст от як від себе до людини, яка сидить поруч і я їй розповідаю історію. В мене немає зараз часу й бажання займатись редагуванням текстів і оце все, тому читайте це з чайочком там, чи з кавою, не напинайтесь лишній раз 🙂

По друге, я не пропагую ніяких best practices в цій статті. Це просто цікава історія (або ні), що можна зробити, на що повпливати й так далі й тому подібне. Можливо у вас є певні обставини, за яких вам необхідно зменшувати розмір образів, можливо ні. В будь-якому разі, якщо ви із цієї статті дізнаєтесь щось нове, не забувайте, що у всього є контекст і потрібно аналізувати й дивитись, чи варто робити щось, чи не варто. Ви всі великі молодці й у вас є свої голови на плечах, для того, щоб подумати, чи варто робити речі із цієї статті, чи не варто.

## Почнемо з рішення “в лоб”

Я трішки подумав як би то можна було організувати, але чесно, зараз щось такий стан трохи втомлений, що я вирішив зробити це просто як інкрементальний процес від роздутого образу до чогось такого, до чого прийдемо згодом.

Я ще навіть не знаю куди ми прийдемо. Я пишу зараз цей текст без жодного контент-плану, імпровізую 🙂

Почнемо з того, що зазвичай можна знайти в просторах інтернету, простий й примітивний Dockerfile:

```dockerfile
FROM node:lts
WORKDIR /app

COPY dist dist
COPY package.json package.json

RUN npm install

CMD [ "node", "dist/index.js" ]
```

Зібравши цей образ через `docker build` й запустивши його через `docker run` ми вже маємо сервер, який готовий приймати наші запити:

```bash
Server is listening on http://69bfecfb40ae:36199
```

Сам сервер це звичайний Hapi сервер, ніякої екзотики.

Глянувши в `docker images` ми побачимо, що розмір цього образу **1.8GB** 🤯

І тут буде цілком очікуваним позадавати питання штибу “ви шо тут, вообще всі такі чи шо?”.

Тож давайте потроху розбиратись.

## Відділяємо run-time залежності від compile-time залежностей

Один із факторів який впливає на розмір вашого Docker образу - **node\_modules**.

Я бачив різні команди й різні підходи до розв'язання цієї проблеми, дехто їх і взагалі не вирішував, як ось `npm install` просто та і все.

Але загалом, правилом гарного тону все ж є використання `--production` при встановленні тільки тих залежностей, які вам критично необхідні для працездатності сервісу.

Коли ви вказуєте у своїх `package.json` залежності, то дотримуйтесь правила, що в `dependencies` ви вказуєте тільки ті залежності, які вам **потрібні** в runtime. В моєму випадку це `@hapi/hapi`, бо на ньому в мене сервер написаний. Без нього, мій сервер просто не запуститься. Це робить цю залежність необхідною в runtime.

Все інше, що вам потрібно щоб прогнати ESLint, чи може тести прогнати, чи ще щось — якщо це робиться під час розробки й не потрібно в runtime - виносьте в `devDependencies`.

Таким чином, у вас локально `npm install` буде встановлювати все що треба для розробки. А коли ви будете готувати ваш сервіс, який вже зібраний, до роботи в runtime, то використовуйте там `npm install --production`:

```dockerfile
FROM node:lts
WORKDIR /app

COPY dist dist
COPY package.json package.json

RUN npm install --production

CMD [ "node", "dist/index.js" ]
```

Зробили знову `docker build` і подивились скільки тепер займає образ - **1.69GB**. Ще дуже далеко до 40 Мб 😆

Давайте тепер поговоримо про різницю між різними образами від яких ви можете наслідуватись!

## lts, lts-slim

Коли ви обираєте який базовий образ використовувати, памʼятайте, що неправильно підібраний образ може мати різні наслідки починаючи від проблем з розміром і закінчуючи несумісністю архітектур й того що компілюється, коли ви встановлюєте якість нативні аддони під Node.js.

От почав я з того, що обрав `node:lts` як базовий образ, але що там, в цьому образі?

Якщо піти глянути на Docker Hub, то ми можемо побачити там, з яких інших образів був створений цей образ, які шари застосовані й т.п.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1750854190855/cf9fdea4-0be2-4bd7-ad61-810eed6e2747.png align="center")

Ми бачимо що `node:lts` побудований з `debian:12` до якого потім додали `buildpack-deps` і пішло поїхало по ланцюжку.

Тобто наш образ `node:lts` насправді має в собі не тільки Node.js, який ми запустили й поїхали. Також, в нашому образі знаходиться велика купа різних бінарників, бібліотек, які насправді не потрібні нам для того, щоб запустити наш сервіс. Це можуть бути всякі `git`, `curl`, `ca-certificates` і що там ще можна згадати, мені ліньки 🙂

Чи потрібні нам всі ці пакети при збірці наших сервісів, компіляції, тестування — звісно! Чи потрібні вони нам, коли ми вже запускаємо це в runtime - ні!

Тому правилом гарного тону вважається, що у вас є оточення, яке ви використовуєте для збірки ваших сервісів, а є оточення, яке ви використовуєте вже безпосередньо для запуску цього сервісу.

І в нашому випадку, ми можемо позбутись цього роздутого образу з `buildpack-deps`, використавши `lts-slim`.

Якщо ми поглянемо на інвентар того, що є в цьому образі, то ми побачимо наступне:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1750854236364/928a195f-709d-4cb8-8f2f-9d9016b7d050.png align="center")

По перше, тепер у нас два базових образи, з яких будувався `lts-slim`. По друге, в шарах вже немає всіляких `apt-get update` та `apt-get install`.

Тобто ці образи хоч і містять меншу кількість різних бінарників та інструментів, що заважає розробці, але для production нам це і не потрібно. Тому тут мінус в тому, що ми втрачаємо всілякі зручності повноцінних ОС, але виграємо в потенційно захищенішому середовищі, бо менше потенційних векторів атаки, та виграємо в зменшеному розмірі нашого власного образу. Тому замінимо `lts` на `lts-slim`:

```dockerfile
FROM node:lts-slim
WORKDIR /app

COPY dist dist
COPY package.json package.json

RUN npm install --production

CMD [ "node", "dist/index.js" ]
```

Зібрали знову образ і подивились, а скільки він тепер займає місця - **429MB**.

Ну це вже прям непогано. Принаймні GB замінились на MB 😄

Але поговорім про ще один варіант, більш радикальний.

## Alpine Linux

По всіляких Linux дистрибутивам можна дуже довго розмовляти. Починаючи від “BTW, I’m using Arch”, “вічно дивитись <s>як тече вода</s>, збираються портажі на Gentoo”, а потім сісти за макбук і на Notion поток сознанія видавать.

Так от Alpine Linux це один із дистрибутивів Linux, але в якого є кардинальні відмінності від більш house-hold дистрибутивів Linux, таких як Debian (пробачте мені молодого, я не хотів називати їх house-hold).

Цей дистрибутив спеціально заточений, по дизайну, бути малим за розміром. Але досягає він цього дещо “радикальними” методами (хоча рівень радикальності тут можна задавати відносно окремо взятих систем координат).

По перше, він використовує musl замість glibc. Якщо хтось із читачів писав програми на С, то він знає, що в C є стандартна бібліотека. А чи то musl, чи то glibc - це вже реалізації цих стандартних бібліотек.

Так от, вважається, чи то вважалось, тут мої знання можуть бути вже не актуальні, але вважалось, що Node.js більше дружить з glibc і вірогідність що якийсь нативний аддон чи C-шний код не відпрацює з glibc набагато нижча, аніж якби це був musl. Можливо зараз там стан речей зовсім інший, але хай там як, потрібно усвідомлювати, що такі речі можуть принести багато цікавих проблем на вашу голову, якщо вам не пощастить.

По друге, він використовує BusyBox. От якщо ви звикли до того, що у всіляких Debian-ах у вас купа бінарників, де кожен надає якийсь свій інструментарій, то BusyBox це все один бінарник, в якому купа цих інструментів. Ну добре добре, не купа, менше 🙂

Ну і ще там OpenRC замість systemd… короче… ви зрозуміли — змін багацько. Радикальних змін.

Тож чим більше у вас всілякої екзотики у вашому сервісі, можливо кастомних нативних аддонів, абощо, тим більше уваги вам треба приділяти перевірці, що воно точно під Alpine заведеться.

Але у нас це звичайний сервер на Hapi, так шо нам норм, поїхали 🙂

```dockerfile
FROM node:lts-alpine
WORKDIR /app

COPY dist dist
COPY package.json package.json

RUN npm install --production

CMD [ "node", "dist/index.js" ]
```

Зібрали цей образ і отримали розмір - **308MB**. Що ж можна придумати ще?

## Викидаємо npm з процесу збірки

Якщо нам обставини дозволяють, або можливо це навіть архітектурно прописано у вашій компанії, то ми можемо зрізати ще в одному місці 😉

Суть дуже проста. Ми коли викликаємо `npm install` то при встановленні залежностей, npm тягне то всьо з інтернету, завантажує це у своєму кеші й так далі й тому подібне. Ми то, звісно, можемо зробити потім `npm cache prune` чи як воно там, але особисто мені воно якесь meh.

Окрім цього, він ще й компілює нативні аддони, якщо залежності цього потребують, і компілює він їх під ту архітектуру, на якій ви запустили процес збірки. Тобто якщо ви запустили `npm install` на макі (`darwin-arm64v`, якщо не помиляюсь) то `node_modules` будуть містити в собі код, який працюватиме тільки на таких же маках з архітектурою `darwin-arm64v`. Якщо ви `node_modules` скопіюєте в образ і запустите його на `linux-amd64` то я сумніваюсь що воно у вас запрацює, але ви можете спробувати, звісно.

**Але! 😉**

Якщо ми впевнені в тому, що на CI пайплайнах використовується ферма із нод з такою ж архітектурою, яка використовується і в проді, то ми можемо зробити фінт вухами.

Замість того, щоб робити `npm install` під час збірки образу, ми можемо зробити його в контексті звичайного процесу збірки вашого репозиторію. Якщо архітектури збігаються, то тоді `node_modules` вашого репозиторію можна буде просто перенести в контейнер і не тягнути туди `npm install`.

А якщо ще й застосувати, як я їх називаю, екстрактори в пакетних менеджерах, то це взагалі можна зробити так, що `node_modules` будуть містити ну прям дуже гарний layout, ну прям 🤌

Наприклад у pnpm є команда `pnpm deploy`. Або ж якщо ви використовуєте підхід з моно репозиторієм і ви використовуєте Rush як інструмент для менеджменту вашого репозиторію, то в нього теж є `rush deploy --project PROJECT --target-folder path/to/folder`.

**В чому їх особливість?**

Ці команди аналізують ваші пакети, які ви хочете винести й аналізують залежності, які вашим пакетам потрібні. Маючи цю інформацію, ці команди здатні зробити вам окремо теку, де будуть лежати тільки ваші production файли разом з `node_modules`, де лежать тільки те, що справді потрібно в runtime - production залежності.

Воно настільки самодостатнє, що ви навіть можете зробити `rush deploy --project my-package --target-folder /app` і одразу викликати `node /app/dist/index.js` - воно спрацює. Бо всі `node_modules` вже там, всі `files` із вашого `package.json` вже теж там.

Хочете зробити просто архів з цим і десь передати щоб потім запустити через `node` - кидайте.

Хочете зробити якийсь свій дуже кастомний процес delivery цього на якісь ноди - робіть.

А ми ж зробимо наступним чином…

## Новий build context для образу

Ми дізнались що в пакетних менеджерах є команда, яка дозволяє нам виокремити із нашого репозиторію з купою коду й залежностей теку, яка буде містити в собі мінімальний набір необхідного для запуску в проді.

Якщо ми застосуємо це як новий build context для докера, то нам, насправді, вже й не треба ніякі `npm install` чи то `COPY dist dist` чи ще щось - в нас готовий артефакт, самодостатній, який працює і без докера. Тобто докер в цьому контексті починає виступати просто як, буквально, контейнер 🙂

Тож що ми робимо…

Ми беремо, викликаємо, скажімо `rush deploy --project hapi-server --target-folder common/deploy`.

Якщо ми тепер викличемо `node common/deploy/dist/index.js`, то побачимо, що наш сервер все ще працює й слухає порт:

```bash
Server is listening on http://ghaiklor---MacBook-Pro.local:52215
```

А по структурі файлів там просто `dist` з моїми JS файлами та й `node_modules`. Все.

Як же це тепер запакувати в образ? Та насправді дуже просто 🙂

Якщо ми враховуємо, що цей `common/deploy` стає нашим новим build context для докера, то нам вже не потрібні вибіркові `COPY`, ми можемо тупо зробити `COPY . .`:

```dockerfile
FROM node:lts-alpine
WORKDIR /app

COPY . .

CMD [ "node", "dist/index.js" ]
```

Можна навіть залишити коментар нашим наступникам, щоб вони не подумали, що цей `COPY . .` копіює взагалі весь репозиторій:

```dockerfile
FROM node:lts-alpine
WORKDIR /app

# The COPY command here copies the entire prepared folder into the Docker image.
# This works because we're using the `rush deploy` command.
# Which prepares a minimized subset of the monorepository containing only the necessary files and dependencies.
# The prepared folder acts as the build context on CI.
# So it's already optimized to exclude unnecessary files, such as development tools.
# As a result, copying the entire folder is safe since everything included in the build context is essential for the service’s operation.
# And there's no risk of including extraneous files from the broader monorepo.
COPY . .

CMD [ "node", "dist/index.js" ]
```

І, як бачимо, ми вже не викликаємо тут `npm install`, бо `node_modules` вже теж там 🙂

Що ж, почнімо збірку з врахуванням цього трюку:

```bash
rush deploy --project hapi-server --target-folder common/deploy
docker build --file common/deploy/Dockerfile --tag hapi-server common/deploy
```

З таким Dockerfile та цією комбінацією команд, ми, по великому рахунку, всю збірку вже зробили ще на самому хості. Тут же, ми просто прокидуємо вже зібране в сам образ - от і все.

Гляньмо, що в нас там зараз з місцем виходить - **192MB**! Ну це вже не майже 2Gb, правда ж? 😄

Щоб бути гарними хлопчиками, вкажімо ще конкретну версію Node.js рантайма, щоб не вистрілити собі в ногу випадково, коли `lts-alpine` неочікувано зміниться на іншу версію:

```dockerfile
FROM node:20.17.0-alpine
WORKDIR /app

# The COPY command here copies the entire prepared folder into the Docker image.
# This works because we're using the `rush deploy` command.
# Which prepares a minimized subset of the monorepository containing only the necessary files and dependencies.
# The prepared folder acts as the build context on CI.
# So it's already optimized to exclude unnecessary files, such as development tools.
# As a result, copying the entire folder is safe since everything included in the build context is essential for the service’s operation.
# And there's no risk of including extraneous files from the broader monorepo.
COPY . .

CMD [ "node", "dist/index.js" ]
```

Не те, щоб я очікував, що тут щось зміниться з розміром, але як правило гарного тону…

## Distroless

Ще один варіант, який можна застосувати, це distroless:

%[https://github.com/GoogleContainerTools/distroless] 

Конкретно в нашому випадку, в тому що я зробив до цього, то ми багато не виграємо, насправді. Я навіть не знаю, може ми нічого не виграємо, давайте перевіримо 😅

Специфіка цих образів в тому, що в них відсутнє все те, що зазвичай присутнє в дистрибутивах Linux. Ви в них не знайдете ніяких shell-ів, пакетних менеджерів - нічого.

В них є тільки необхідне для того, щоб запустити Node.js, в нашому випадку.

Спробуймо замінити наш `FROM` на distroless образ й подивитись, скільки ми отримаємо:

```dockerfile
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app

# The COPY command here copies the entire prepared folder into the Docker image.
# This works because we're using the `rush deploy` command.
# Which prepares a minimized subset of the monorepository containing only the necessary files and dependencies.
# The prepared folder acts as the build context on CI.
# So it's already optimized to exclude unnecessary files, such as development tools.
# As a result, copying the entire folder is safe since everything included in the build context is essential for the service’s operation.
# And there's no risk of including extraneous files from the broader monorepo.
COPY . .

CMD [ "dist/index.js" ]
```

Зверніть, до речі, увагу, що ми прибрали `node` із `CMD`. Через те що в distroless образах відсутній shell, то ми не можемо це запускати вже як раніше. Але ми можемо вказати прямо шлях до нашого файлу. В distroless це спрацює.

Гляньмо, скільки тепер займає місця - **189MB**. Не густо щось 😂

Із цих 189 Мб, сам тільки Node.js займає майже 100MB, але чому так багато?

## Що ми можемо викинути з Node.js?

Зараз вже майже восьма година вечора. Я приїхав з офісу додому й хочу вже поскоріш закінчити цю, і так довгу, історію 🙃

Тому на цьому етапі ми закінчимо з практичною частиною, проте, пофантазуємо, що ми можемо зробити ще, якщо це справді буде необхідно.

Річ у тім, що заздалегідь зібрані бінарники Node.js під різні платформи містять в собі ну-у-у-у занадто багато всього. Наприклад?

Наприклад Internationalization - [https://nodejs.org/api/intl.html](https://nodejs.org/api/intl.html)

Всі ці API, яка надає вам Node.js, по типу `Intl` обʼєкту чи `.localeCompare()` й тому подібні несуть за собою доволі відчутний розмір.

І що ми могли б зробити, насправді, це зібрати власний бінарник Node.js, в якому повикидати те що нам не потрібно. Ну, принаймні, те що ми можемо викинути.

У випадку з інтернаціоналізацією, якщо ми розуміємо, що ми ці API не використовуємо, то давайте зберемо Node.js без ICU. Зклонували собі репозиторій з вихідними кодами Node.js й еге-ге-й:

*Останній раз коли я контрібʼютив в Node.js, це робилось так, як зараз, я, чесно, не знаю, можливо вони щось змінили, тому take it with a grain of salt.*

```bash
./configure --without-intl
make
```

Тобто ми налаштовуємо білд систему й кажемо їй, що ми хочемо скомпілювати Node.js, але без використання ICU. Отриманий `node` бінарник буде вже займати, по грубим прикидкам, на 20-40 МБ менше, в залежності від купи факторів.

Також можна почати думати щось цікаве з zlib чи openssl, можливо викинути їх теж, або скомпілювати як з shared libraries. Загалом, можна буде подумати. Детальніше про те, як зібрати Node.js у себе вдома, можна почитати ось тут:

%[https://github.com/nodejs/node/blob/HEAD/BUILDING.md] 

До прикладу, ось залежності в збірці Node.js, яка в мене стоїть, встановлена через nvm, та яка займає 90MB:

```json
{
  node: '20.17.0',
  acorn: '8.11.3',
  ada: '2.9.0',
  ares: '1.32.3',
  base64: '0.5.2',
  brotli: '1.1.0',
  cjs_module_lexer: '1.2.2',
  cldr: '45.0',
  icu: '75.1',
  llhttp: '8.1.2',
  modules: '115',
  napi: '9',
  nghttp2: '1.61.0',
  nghttp3: '0.7.0',
  ngtcp2: '1.1.0',
  openssl: '3.0.13+quic',
  simdutf: '5.3.0',
  tz: '2024a',
  undici: '6.19.2',
  unicode: '15.1',
  uv: '1.46.0',
  uvwasi: '0.0.21',
  v8: '11.3.244.8-node.23',
  zlib: '1.3.0.1-motley-209717d'
}
```

Якщо буде стояти питання порізати Node.js, щоб воно вмістилось на якусь embedded приблуду, то можна буде подумати в напрямку збірки свого власного бінарника.

Буде це питання того, щоб повністю викинути якісь шматки, чи буде це питання вивчення конкретного дистрибутива, які бібліотеки в тому дистрибутиві є і чи можемо ми скомпілювати Node.js, який буде динамічно лінкуватись до цих бібліотек - то вже, як кажуть, треба буде розбирати конкретний кейс.

Просто для прикладу, наскільки я знаю, то Arch Linux має системний ICU, тож можна було б скомпілювати Node.js з `--with-intl=system-icu` і не тягнути його з бінарником Node.js.

Короче, гратись можна було б довго з цим й пробувати різні комбінації.

Я знаю випадки, коли такими танцями витягували Node.js бінарник до 15-20MB. Але, знову ж таки, вони всі зачіпають збірку Node.js з його вихідних кодів з різними тюнінгами 🙂

## Компресія образів

Весь цей час, всю цю історію, ми розглядали розмір образів як образів без компресії. Тобто це все розміри вже “розпакованих” образів.

Але ж ми, коли пушимо образ в реєстр, Docker нам додатково ще застосовує алгоритми компресії. Тому давайте глянемо, а скільки ж, такий образ, який ми створили на Alpine Linux, буде займати місця безпосередньо в реєстрі? Скільки трафіку буде бігати по мережі, коли буде завантажуватись образ?

Я запушив цей образ на GitLab Container Registry та глянув, скільки місця він займає в реєстрі - **45.40 MiB.**

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1750854670190/8204b2fd-640e-4fed-bba8-ccf80b9d558c.png align="center")

Тобто, після компресії, наш образ займає 45MB в реєстрах, а значить, по мережі буде йти 45MB, а не 192MB.

## Закінчуємо

На годиннику вже девʼята вечора, тож буду йти відпочивати.

Сподіваюсь, в цьому потоці сознанія, ви віднайшли для себе щось нове, можливо абсурдне, можливо креативне, то вже як буде. Я поділився історією, яку я сьогодні в себе на ноуті провів, а вам вже вирушівати для себе, що воно дає і чи дає взагалі.

Всіх благ і гарного вечора ❤️
