Skip to main content

Command Palette

Search for a command to run...

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

Стратегії зменшення розміру Docker образу Node.js, починаючи від node:lts

Published
15 min read
Як зменшити розмір Docker образу з Node.js
E

I write in Ukrainian about DevOps, architecture, and everything that makes code (and teams) flow better. Markdown, diagrams, and real-world messes included.

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

Disclaimer

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

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

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

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

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

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

Почнемо з того, що зазвичай можна знайти в просторах інтернету, простий й примітивний 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 ми вже маємо сервер, який готовий приймати наші запити:

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:

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, то ми можемо побачити там, з яких інших образів був створений цей образ, які шари застосовані й т.п.

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

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

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

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

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

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

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

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

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”, “вічно дивитись як тече вода, збираються портажі на 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, так шо нам норм, поїхали 🙂

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, то побачимо, що наш сервер все ще працює й слухає порт:

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

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

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

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

FROM node:lts-alpine
WORKDIR /app

COPY . .

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

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

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 вже теж там 🙂

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

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 неочікувано зміниться на іншу версію:

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:

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

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

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

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

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

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

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

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

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

./configure --without-intl
make

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

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

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

{
  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.

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

Закінчуємо

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

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

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