Від інструкцій у тексті до бінарних шарів на диску
Після презентації теми — самостійна робота в аудиторії над двома темами. Вони ж готують до захисту та розширення ЛР3. Матеріали публікуються в Google Classroom разом із лекцією.
Попередні дві лекції були про запущений контейнер: процес, якому namespaces
підмінили видимість, а cgroups обмежили апетит. Сьогодні ми відступаємо на крок
назад і дивимось на те, з чого цей процес узагалі стартує — на образ (image).
Найпоширеніша інтуїтивна помилка — уявляти образ як файл, схожий на ISO-файл ОС або
архів. Це невірно на фундаментальному рівні: образ — це впорядкований стек
незмінних шарів (layers), кожен з яких є diff-ом файлової системи відносно попереднього.
Розуміння цього факту пояснює одразу кілька практичних речей: чому образи можна
кешувати частково, чому docker pull для спільної бази качає лише один раз, і чому
порядок інструкцій у Dockerfile напряму впливає на швидкість збірки.
Кожен шар образу — це результат виконання однієї інструкції Dockerfile, зафіксований як зміна файлової системи: які файли з'явились, змінились чи були позначені як видалені порівняно з попереднім шаром. Кожен шар:
ubuntu:24.04), вони буквально посилаються на той самий шар на диску й у реєстрі,
без дублювання.Саме тому docker pull для другого образу з тією ж базою завантажує значно менше
даних — Docker перевіряє за хешем, які шари вже є локально, і качає лише відсутні.
Не кожна інструкція Dockerfile створює шар з реальним вмістом файлової системи. Практично важливий поділ:
| Інструкція | Створює шар з вмістом ФС? | Призначення |
|---|---|---|
FROM |
так (базові шари) | точка старту, базовий образ |
RUN |
так | виконує команду, фіксує diff файлової системи |
COPY |
так | копіює файли з контексту збірки в образ |
ADD |
так | як COPY, але вміє розпаковувати архіви й тягнути URL |
WORKDIR |
ні (метадані) | встановлює робочий каталог для наступних інструкцій |
ENV, ARG |
ні (метадані) | змінні середовища / збірки |
CMD, ENTRYPOINT |
ні (метадані) | команда за замовчуванням при старті контейнера |
EXPOSE, LABEL |
ні (метадані) | документація портів і довільні мітки образу |
ADD навмисно уникають на користь COPY, коли не потрібне автоматичне
розпакування: ADD з URL-джерелом не кешує вміст за етагом надійно й може непомітно
розпакувати архів там, де очікувалась проста копія файлу — менш передбачувана
поведінка для звичайного випадку.
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y curl
COPY app.sh /app.sh
CMD ["/app.sh"]
Тут — три змістовні шари з файловим вмістом (базовий образ, встановлений curl,
скопійований файл) і одна метадані-інструкція CMD.
Docker кешує кожен шар за комбінацією хеша інструкції та хеша її вхідних даних
(вмісту файлів для COPY/ADD, тексту команди для RUN). Якщо для конкретного
кроку збірки хеш збігається з уже наявним у кеші шаром — Docker перевикористовує
готовий шар, не виконуючи інструкцію заново.
Критичний нюанс: кеш ламається каскадно. Щойно один шар змінився (наприклад,
змінився вміст файлу, який копіює COPY), усі наступні за ним інструкції в
Dockerfile гарантовано виконуються заново, навіть якщо самі вони не залежать від
зміненого файлу — Docker не аналізує залежності між кроками глибше, ніж «попередній
шар відрізняється».
# погано: COPY усього коду перед install ламає кеш на кожній зміні коду
COPY . .
RUN npm install
# добре: спершу залежності — кешуються, доки package.json не змінився
COPY package*.json .
RUN npm install
COPY . .
Практичне правило: розташовуй стабільні, рідкозмінні інструкції (встановлення
залежностей за lock-файлом) на початку Dockerfile, а мінливі (копіювання власного
коду) — якомога ближче до кінця. Сучасний бекенд збірки BuildKit (типовий у
Docker з версії 23+) додає ще один інструмент — кеш-монтування
(--mount=type=cache), яке дозволяє кешувати вміст каталогу (наприклад, кеш
пакетного менеджера) між збірками, навіть коли сам шар перебудовується:
RUN --mount=type=cache,target=/root/.cargo/registry \
cargo build --release
Під час запуску контейнера всі шари образу об'єднуються в єдиний вигляд файлової системи за допомогою об'єднаної файлової системи (OverlayFS) — файлової системи ядра Linux, спеціально призначеної для накладання (overlay) кількох каталогів один на одного:
Коли процес усередині контейнера змінює файл, який фізично лежить у нижньому
(read-only) шарі, спрацьовує механізм copy-up: файл спершу копіюється в
upperdir, і вже там змінюється — сам образ (lowerdir) лишається незайманим. Це і є
copy-on-write на рівні файлової системи. Видалення контейнера просто відкидає
upperdir: образ, з якого він був запущений, ніяк не постраждав і готовий до
наступного запуску.
Типова проблема: інструменти, потрібні лише для збірки (компілятор, dev-заголовки, кеш пакетного менеджера), лишаються в фінальному образі, хоча під час виконання не потрібні взагалі. Наслідок — образ вагою 1.5 ГБ замість 15 МБ для одного статичного бінарника.
Багатоетапна збірка дозволяє описати кілька незалежних стадій FROM в одному
Dockerfile, кожна — свій ізольований контекст файлової системи, і переносити з однієї
стадії в іншу лише конкретні артефакти через COPY --from=<stage>:
FROM rust:1.80 AS build
WORKDIR /src
COPY . .
RUN cargo build --release
FROM scratch
COPY --from=build /src/target/release/app /app
ENTRYPOINT ["/app"]
Проміжна стадія build (з компілятором Rust, cargo-кешем, вихідним кодом) ніколи не
потрапляє в фінальний образ — лише готовий бінарник /app, скопійований у порожній
scratch. Стадій може бути й більше двох (наприклад, окрема стадія для тестів), і
збірку можна зупинити на конкретній стадії прапорцем docker build --target=build,
що зручно для CI, де потрібен лише проміжний артефакт або лише прогін тестів.
scratch — буквально порожній образ: жодного шару, ні shell, ні libc, ні coreutils.
Підходить винятково для статично зібраних бінарників (Rust зі статичним лінкуванням,
Go). Плюс — мінімальний розмір і мінімальна поверхня атаки; мінус — усередині
контейнера неможливо навіть зайти через sh для діагностики.
Distroless (образи від Google, gcr.io/distroless/*) — компроміс: містить лише
мінімальний runtime (наприклад, glibc й necessary shared libraries для запуску
динамічно злінкованого бінарника), але без пакетного менеджера, shell чи
допоміжних утиліт — сильно звужена поверхня атаки порівняно з повноцінним
дистрибутивом, і водночас працює з динамічно злінкованими бінарниками, на відміну
від scratch.
Alpine — повноцінний, але надзвичайно компактний дистрибутив (~5 МБ) на основі
musl libc замість glibc, із власним пакетним менеджером apk. Зручний, коли
потрібен shell для діагностики або динамічні залежності, яких немає в distroless;
підводний камінь — musl іноді поводиться інакше за glibc в деяких Rust/Go
бінарниках (DNS-резолвінг, локалі), тому це не завжди «безкоштовна» заміна.
Тег (nginx:1.27) — зручне, але змінне ім'я: власник реєстру може перезаписати, на
який саме вміст він указує. Digest — криптографічний хеш SHA-256 маніфесту
образу — незмінний ідентифікатор:
docker inspect demo:1 | grep -A5 RootFS
# "RootFS": { "Type": "layers", "Layers": [ "sha256:1a2b...", "sha256:3c4d..." ] }
docker pull nginx@sha256:8f9c... # точний, незмінний вміст, а не "поточний nginx:latest"
У production-пайплайнах прийнято пінити саме digest (а не лише тег) для критичних
базових образів — гарантія, що завтрашній docker pull не підмінить вміст
несподівано оновленим тегом.
Docker — не єдиний інструмент, що вміє будувати й запускати такі шаруваті образи.
Формат образу і шарів стандартизований організацією Open Container Initiative
(OCI): OCI Image Spec визначає, як виглядає маніфест образу (перелік шарів з
їхніми SHA-256-хешами, конфігурація запуску, мітки), а OCI Runtime Spec — як шари
розгортаються в кореневу файлову систему й запускаються як процес. Завдяки цьому
образ, зібраний docker build, можна запустити через podman, containerd чи
будь-який інший OCI-сумісний рантайм без жодних змін — специфічного для Docker у
самому образі немає нічого, лише в CLI поверх нього.
Це також пояснює, чому образ ubuntu:24.04 важить приблизно 80 МБ, а не кілька
гігабайтів повноцінної інсталяції ОС: базовий образ — це мінімальний набір файлів
дистрибутиву (coreutils, apt, базові бібліотеки), без ядра (ядро спільне з хостом!),
без графічного стеку, без документації та локалей, які прибирають ще на етапі
збірки офіційного образу.
latest — не «найновіший», а просто тег за замовчуванням, який будь-хто може
перепризначити на інший вміст; для відтворюваних збірок пінь версію або digest.COPY . . на початку Dockerfile — найпоширеніша причина «завжди
перебудовується з нуля»: будь-яка зміна в репозиторії (навіть у README) ламає весь
подальший кеш..dockerignore забувають, і контекст збірки (усе, що надсилається демону)
роздувається до гігабайтів через node_modules, .git, артефакти збірки.scratch несумісний з динамічним лінкуванням: бінарник, який лінкується з
glibc динамічно, у scratch просто не запуститься — потрібне статичне лінкування
або distroless/alpine з відповідною бібліотекою.RUN замість одного об'єднаного через && множить кількість
шарів і фінальний розмір, бо видалені в наступному RUN файли не звільняють місце
в попередньому шарі — вони лише позначені як видалені (whiteout-файли OverlayFS).Docker-образ — не файл, а впорядкований стек незмінних, адресованих за вмістом
шарів; кожна інструкція Dockerfile з файловим ефектом (FROM, RUN, COPY, ADD)
створює новий шар, тоді як метадані-інструкції (ENV, CMD, WORKDIR) шару з
вмістом не додають. Порядок інструкцій визначає ефективність кешу збірки —
BuildKit і кеш-монтування додатково пришвидшують повторні збірки. OverlayFS
об'єднує шари в єдину файлову систему через lowerdir/upperdir/merged, а
багатоетапна збірка разом зі scratch/distroless/alpine дає змогу відокремити
важкий інструментарій збірки від мінімального образу виконання. Наступний блок
лабораторних переходить від одного контейнера до кількох, що спілкуються через
ізольовану мережу.
Фокус: образ = шари
| Інструкція | Шар з файлами? | Призначення |
|---|---|---|
FROM | так (базові шари) | точка старту — базовий образ |
RUN | так | виконує команду, фіксує diff ФС |
COPY | так | файли з контексту збірки → образ |
ADD | так | як COPY + архіви й URL |
WORKDIR ENV ARG | ні — метадані | каталог, змінні середовища / збірки |
CMD ENTRYPOINT | ні — метадані | команда при старті контейнера |
EXPOSE LABEL | ні — метадані | документація портів, мітки |
Метадані живуть у конфігурації образу й розмір не збільшують
Правило: COPY за замовчуванням, ADD — свідомий виняток
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y curl
COPY app.sh /app.sh
CMD ["/app.sh"]Три змістовні шари: базовий образ, встановлений curl, скопійований файл
docker history покаже кожен із них окремим рядком із розміром
Правило: спершу те, що змінюється рідко (маніфест залежностей), потім код
# погано: COPY усього коду перед install ламає кеш на кожній зміні коду
COPY . .
RUN npm install
# добре: спершу залежності — кешуються, доки package.json не змінився
COPY package*.json .
RUN npm install
COPY . .COPY package.json окремо від решти коду зберігає кеш npm install
FROM rust:1.80 AS build
WORKDIR /src
COPY . .
RUN --mount=type=cache,target=/root/.cargo/registry \
cargo build --releaseКеш cargo-реєстру переживає перебудову шару, навіть якщо код змінився
Запис у контейнер ніколи не змінює образ — лише upperdir
Видалення файлу в контейнері не торкається образу — у верхньому шарі з'являється whiteout
Очікувано: Студенти бачать: "вага" образу — це сума конкретних шарів, і видно, яка саме інструкція додала найбільше мегабайтів
docker inspect demo:1 | grep -A5 RootFS
docker pull nginx@sha256:8f9c...
# digest — SHA-256 маніфесту: незмінний ідентифікатор вмісту,
# на відміну від тегу (latest, 1.27), який можна перепризначитиDigest ≠ тег: тег можна перезаписати, digest — криптографічно незмінний
docker pull nginx@sha256:… — точний вміст, а не «поточний latest»
Проблема: інструменти збірки потрібні лише під час збірки, а лишаються назавжди
Кожен FROM — окремий ізольований етап; проміжні у фінальний образ не потрапляють
Перевірка на розумність: чи є у фінальному образі компілятор? Якщо так — етапів мало
FROM rust:1.80 AS build
WORKDIR /src
COPY . .
RUN cargo build --release
FROM scratch
COPY --from=build /src/target/release/app /app
ENTRYPOINT ["/app"]Компілятор Rust лишається в build-етапі; у фінальному образі — лише бінарник
| База | Розмір | Shell / libc | Коли обирати |
|---|---|---|---|
scratch | 0 Б | немає / немає | статично злінкований бінарник (Rust, Go) |
| distroless | ~2–20 МБ | немає / glibc | динамічний бінарник без shell |
alpine | ~5 МБ | sh / musl | потрібен shell або apk для діагностики |
ubuntu:24.04 | ~80 МБ | bash / glibc | повна сумісність, apt |
musl (alpine) інколи поводиться інакше за glibc — не завжди безкоштовна заміна
Лише інструкції RUN, COPY та ADD створюють шари. Інші інструкції створюють тимчасові проміжні образи й не збільшують розмір збірки. — Docker Docs, «Best practices for writing Dockerfiles»
Деталі — на наступній лекції та в ЛР3; тут важливо: імена, а не IP, і -p як єдина хвіртка
Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.
docker buildновеdocker build -t ім'я:тег . — крапка = контекст збірки; -f — інший Dockerfile).dockerdocker <підкоманда> --help — довідка).docker imagesновеdocker inspect-f '{{.State.Pid}}' — витягнути одне поле.grepps aux | grep bash).docker pullновеcurlcurl -i http://localhost:8080/ — разом із заголовками); також уміє завантажувати файли й скрипти.shновеbash може бути відсутній.apkновеapk add curl) — аналог apt-get у маленьких образах.💡 Прочитати README прямо в терміналі: cat README.md (виведе весь файл) або less README.md (посторінково; вихід — клавіша q).
Довідка з будь-якої команди: man curl (повний посібник, вихід — q) або коротко curl --help; для підкоманд — docker run --help, cargo build --help.