Курс Лекція 3
▶ СлайдиСРС 05+06⌨️ команди 9 · 5 нових
Конспект · Лекція 3

ОБРАЗ ЯК ШАРИ, DOCKERFILE, КЕШ, БАГАТОЕТАПНА ЗБІРКА

Від інструкцій у тексті до бінарних шарів на диску

Ключова ідея. Docker-образ (*image*) — це не файл, а впорядкований стек незмінних шарів (*layers*); Dockerfile — рецепт, за яким кожна інструкція створює новий шар.
Демо. docker history <image> — розібрати готовий образ на складові шари, показати розмір кожної інструкції Dockerfile і побачити, звідки береться "вага" образу.
🧩 СРС ДО ЦІЄЇ ЛЕКЦІЇ · 2-га година заняття

Після презентації теми — самостійна робота в аудиторії над двома темами. Вони ж готують до захисту та розширення ЛР3. Матеріали публікуються в Google Classroom разом із лекцією.

📚 ТЕОРЕТИЧНІ ВІДОМОСТІ

Вступ: образ як послідовність diff-ів

Попередні дві лекції були про запущений контейнер: процес, якому namespaces підмінили видимість, а cgroups обмежили апетит. Сьогодні ми відступаємо на крок назад і дивимось на те, з чого цей процес узагалі стартує — на образ (image). Найпоширеніша інтуїтивна помилка — уявляти образ як файл, схожий на ISO-файл ОС або архів. Це невірно на фундаментальному рівні: образ — це впорядкований стек незмінних шарів (layers), кожен з яких є diff-ом файлової системи відносно попереднього. Розуміння цього факту пояснює одразу кілька практичних речей: чому образи можна кешувати частково, чому docker pull для спільної бази качає лише один раз, і чому порядок інструкцій у Dockerfile напряму впливає на швидкість збірки.

Образ — це не файл, а стек шарів

Кожен шар образу — це результат виконання однієї інструкції Dockerfile, зафіксований як зміна файлової системи: які файли з'явились, змінились чи були позначені як видалені порівняно з попереднім шаром. Кожен шар:

  • незмінний (immutable) — після створення шар ніколи не редагується;
  • адресований за вмістом (content-addressable) — ідентифікатор шару це криптографічний хеш (SHA-256) його вмісту, а не довільне ім'я;
  • потенційно спільний — якщо два образи побудовані на тій самій базі (наприклад, ubuntu:24.04), вони буквально посилаються на той самий шар на диску й у реєстрі, без дублювання.

Саме тому docker pull для другого образу з тією ж базою завантажує значно менше даних — Docker перевіряє за хешем, які шари вже є локально, і качає лише відсутні.

Dockerfile: кожна інструкція — потенційний шар

Не кожна інструкція 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: як шари стають однією файловою системою

Під час запуску контейнера всі шари образу об'єднуються в єдиний вигляд файлової системи за допомогою об'єднаної файлової системи (OverlayFS) — файлової системи ядра Linux, спеціально призначеної для накладання (overlay) кількох каталогів один на одного:

  • lowerdir — стек шарів образу, змонтований лише для читання (їх може бути декілька, у порядку від найглибшого до найближчого);
  • upperdir — один порожній на старті шар для запису, унікальний для кожного запущеного контейнера;
  • workdir — службовий каталог, потрібний OverlayFS для внутрішніх операцій (атомарне перейменування тощо), не використовується напряму;
  • merged — фінальний, об'єднаний вигляд, який і бачить процес усередині контейнера як звичайну кореневу файлову систему.

Коли процес усередині контейнера змінює файл, який фізично лежить у нижньому (read-only) шарі, спрацьовує механізм copy-up: файл спершу копіюється в upperdir, і вже там змінюється — сам образ (lowerdir) лишається незайманим. Це і є copy-on-write на рівні файлової системи. Видалення контейнера просто відкидає upperdir: образ, з якого він був запущений, ніяк не постраждав і готовий до наступного запуску.

Багатоетапна збірка (multi-stage build)

Типова проблема: інструменти, потрібні лише для збірки (компілятор, 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, distroless та alpine: що обрати для фінального шару

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-резолвінг, локалі), тому це не завжди «безкоштовна» заміна.

Digests: незмінна ідентичність образу

Тег (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 не підмінить вміст несподівано оновленим тегом.

OCI: стандарт під капотом Docker

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 дає змогу відокремити важкий інструментарій збірки від мінімального образу виконання. Наступний блок лабораторних переходить від одного контейнера до кількох, що спілкуються через ізольовану мережу.

Розбір за слайдами

План лекції

Фокус: образ = шари

Образ — це не файл, а стек шарів

ОБРАЗ = СТЕК НЕЗМІННИХ ШАРІВ RW шар контейнераdocker runCOPY . /appDockerfileRUN npm ciDockerfileFROM node:22базовий образ кеш: незмінені нижні шари не перезбираються

Інструкції Dockerfile: які створюють шар

ІнструкціяШар з файлами?Призначення
FROMтак (базові шари)точка старту — базовий образ
RUNтаквиконує команду, фіксує diff ФС
COPYтакфайли з контексту збірки → образ
ADDтакяк COPY + архіви й URL
WORKDIR ENV ARGні — метаданікаталог, змінні середовища / збірки
CMD ENTRYPOINTні — метаданікоманда при старті контейнера
EXPOSE LABELні — метаданідокументація портів, мітки

Метадані живуть у конфігурації образу й розмір не збільшують

COPY ↔ ADD

COPY
  • Проста, передбачувана копія файлів
  • Лише з контексту збірки
  • Надійно кешується за вмістом
  • Типовий вибір
vs
ADD
  • Копія + автоматичне розпакування tar
  • Уміє тягнути файли за URL
  • URL-джерела кешуються ненадійно
  • Лише коли справді потрібне розпакування

Правило: COPY за замовчуванням, ADD — свідомий виняток

Мінімальний Dockerfile

dockerfile
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y curl
COPY app.sh /app.sh
CMD ["/app.sh"]

Три змістовні шари: базовий образ, встановлений curl, скопійований файл

Той самий Dockerfile як шари

FROM ubuntu:24.04базові шари · ~80 МБ
RUN apt-get …шар: +curl і залежності
COPY app.shшар: +1 файл
CMDметадані, без шару

docker history покаже кожен із них окремим рядком із розміром

Кеш збірки: чому порядок важливий

Як Docker вирішує: кеш чи перебудова

  1. 1
    Хеш інструкції + входутекст RUN або вміст файлів COPY/ADD
  2. 2
    Є такий шар у кеші?так — беремо готовий, далі до наступної
  3. 3
    Перша розбіжністьінструкція виконується заново → новий шар
  4. 4
    Каскадусі наступні шари перебудовуються без перевірки
  5. 5
    Висновокчим нижче зміна в Dockerfile — тим дешевша збірка

Кеш шарів: ціна одного рядка не на своєму місці

КЕШ ШАРІВ: ЗМІНА ЛАМАЄ ВСЕ, ЩО НИЖЧЕ ЗМІНИВСЯ ЛИШЕ КОД FROM rust:1.82 CACHED COPY Cargo.toml CACHED RUN cargo fetch CACHED COPY src/ REBUILD RUN cargo build REBUILD 2 c: залежності з кешу COPY src/ СТОЇТЬ ПЕРШИМ FROM rust:1.82 CACHED COPY src/ REBUILD COPY Cargo.toml REBUILD RUN cargo fetch REBUILD RUN cargo build REBUILD 3 хв: усе з нуля щоразу

Правило: спершу те, що змінюється рідко (маніфест залежностей), потім код

Порядок інструкцій і кеш

dockerfile
# погано: COPY усього коду перед install ламає кеш на кожній зміні коду
COPY . .
RUN npm install

# добре: спершу залежності — кешуються, доки package.json не змінився
COPY package*.json .
RUN npm install
COPY . .

COPY package.json окремо від решти коду зберігає кеш npm install

BuildKit і кеш-монтування

Кеш-монтування на практиці

dockerfile
FROM rust:1.80 AS build
WORKDIR /src
COPY . .
RUN --mount=type=cache,target=/root/.cargo/registry \
    cargo build --release

Кеш cargo-реєстру переживає перебудову шару, навіть якщо код змінився

Об'єднана файлова система (*OverlayFS*): чотири каталоги

lowerdirшари образу, лише читання; може бути кілька
upperdirшар запису контейнера; порожній на старті
workdirслужбовий каталог для атомарних операцій
mergedоб'єднаний вигляд — корінь ФС для процесу

Запис у контейнер ніколи не змінює образ — лише upperdir

OverlayFS: шари, верхній рівень і whiteout

OVERLAYFS: ШАРИ, ВЕРХНІЙ РІВЕНЬ, WHITEOUT lower ubuntu:24.04 lower apt-get install lower COPY app upper (запис контейнера) read-only read-write merge MERGED · те, що бачить процес /app/main /etc/passwd /tmp/cache (новий) /var/log/old нижні шари спільні для всіх контейнерів образу whiteout: «видалено» лише у верхньому шарі незмінні шаришар записувигляд процесу

Видалення файлу в контейнері не торкається образу — у верхньому шарі з'являється whiteout

Copy-on-write в OverlayFS

  1. 1
    Читанняфайл береться з найвищого шару, де він є
  2. 2
    Зміна файлу з lowerdircopy-up: копія в upperdir, зміна там
  3. 3
    Новий файл (.pixel_was_here)одразу в upperdir, шари образу не чіпає
  4. 4
    Видалення файлу образуwhiteout-позначка в upperdir; місце не звільняється
  5. 5
    docker rmupperdir відкинуто — образ незайманий

Живе демо: docker history

  1. docker build -t demo:1 .
  2. docker history demo:1
  3. Показати розмір кожного шару окремо
  4. docker inspect demo:1 | grep -A5 RootFS — список шарів за хешами

Очікувано: Студенти бачать: "вага" образу — це сума конкретних шарів, і видно, яка саме інструкція додала найбільше мегабайтів

docker inspect: digests і шари

bash
docker inspect demo:1 | grep -A5 RootFS
docker pull nginx@sha256:8f9c...
# digest — SHA-256 маніфесту: незмінний ідентифікатор вмісту,
# на відміну від тегу (latest, 1.27), який можна перепризначити

Digest ≠ тег: тег можна перезаписати, digest — криптографічно незмінний

Тег ↔ digest

Тег (nginx:1.27, latest)
  • Зручне людське ім'я
  • Змінний: власник може перепризначити
  • latest — просто тег за замовчуванням
  • Для розробки й читабельності
vs
Digest (sha256:8f9c…)
  • SHA-256 маніфесту образу
  • Незмінний: інший вміст — інший хеш
  • Гарантує той самий вміст завтра
  • Для production і відтворюваних збірок

docker pull nginx@sha256:… — точний вміст, а не «поточний latest»

Вага образу в цифрах

1.5 ГБ
образ з компілятором усередині
rust:1.80 + вихідний код + кеш
~80 МБ
ubuntu:24.04
без ядра, локалей і документації
~5 МБ
alpine
musl libc + apk + shell
0 Б
scratch
жодного шару — лише твій бінарник

Проблема: інструменти збірки потрібні лише під час збірки, а лишаються назавжди

Багатоетапна збірка (*multi-stage build*)

FROM rust AS buildкомпілятор, cargo, код
cargo build --releaseартефакт: один бінарник
COPY --from=buildпереносимо лише /app
FROM scratchфінальний образ: кілька МБ

Кожен FROM — окремий ізольований етап; проміжні у фінальний образ не потрапляють

Що саме переходить у фінальний образ

БАГАТОЕТАПНА ЗБІРКА: ІНСТРУМЕНТИ ЛИШАЮТЬСЯ ПОЗАДУ ЕТАП 1 · AS build rust:1.82 компілятор, cargo, git src/ Cargo.toml target/ (2.7 GB сміття) target/release/app 1.4 ГБ — і жодного байта в проді COPY --from=build ЕТАП 2 · РУНТАЙМ scratch / distroless ні оболонки, ні пакетів /app усе, що тут є, — один статичний бінарник 4 МБ · нічим зламати менший образ = менша поверхня атаки й швидший запуск

Перевірка на розумність: чи є у фінальному образі компілятор? Якщо так — етапів мало

Багатоетапна збірка

dockerfile
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Коли обирати
scratch0 Бнемає / немаєстатично злінкований бінарник (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)

МЕРЕЖА DOCKER: BRIDGE, VETH-ПАРИ, DNS ЗА ІМЕНАМИ ХОСТ · мережевий простір хоста КОНТЕЙНЕР storage eth0 172.19.0.2 КОНТЕЙНЕР agent eth0 172.19.0.3 docker0 / br-… віртуальний комутатор veth veth DNS 127.0.0.11 storage → 172.19.0.2 звертайся іменем, не IP NAT -p 8080:80 єдина хвіртка назовні усе інше — глухо user-defined мережа = приватний квартал: свій DNS, свій діапазон, назовні — лише через -p

Деталі — на наступній лекції та в ЛР3; тут важливо: імена, а не IP, і -p як єдина хвіртка

Підводні камені: шари, кеш і збірка

Підсумок

Джерела

🥚 ПАСХАЛКА
У прикладі OverlayFS новий файл названо .pixel_was_here — він існує лише в upperdir і зникає разом із контейнером. Цей рядок у файловій системі ще трапиться.
⌨️ КОМАНДИ НА ЦІЙ СТОРІНЦІ 9 · 5 нових

Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.

docker buildнове
Збирає образ за Dockerfile (docker build -t ім'я:тег . — крапка = контекст збірки; -f — інший Dockerfile).
docker
Клієнт Docker: усі дії з образами й контейнерами — через його підкоманди (docker <підкоманда> --help — довідка).
docker imagesнове
Список локальних образів з тегами й розмірами.
docker inspect
Друкує повний JSON-опис контейнера чи образу; -f '{{.State.Pid}}' — витягнути одне поле.
grep
Фільтрує текст: виводить лише рядки, що містять шаблон (ps aux | grep bash).
docker pullнове
Завантажує образ із реєстру (Docker Hub) на машину.
curl
Консольний HTTP-клієнт: надсилає запит на URL і друкує відповідь (curl -i http://localhost:8080/ — разом із заголовками); також уміє завантажувати файли й скрипти.
shнове
Мінімальна POSIX-оболонка; у маленьких образах (Alpine, BusyBox) є тільки вона, bash може бути відсутній.
apkнове
Менеджер пакетів Alpine Linux (apk add curl) — аналог apt-get у маленьких образах.

💡 Прочитати README прямо в терміналі: cat README.md (виведе весь файл) або less README.md (посторінково; вихід — клавіша q).
Довідка з будь-якої команди: man curl (повний посібник, вихід — q) або коротко curl --help; для підкоманд — docker run --help, cargo build --help.