Лабораторна робота № 2. Dockerfile з багатоетапною збіркою і порівнянням розмірів
Мета роботи
Навчитися писати Dockerfile із багатоетапною збіркою, свідомо впорядковувати інструкції
для максимального використання кешу шарів і зібрати мінімальний за розміром образ
виконання аж до базового образу scratch. Робота розвиває розуміння внутрішньої будови
Docker-образу (шари, OverlayFS, кеш), закладене в лабораторній роботі № 1.
Теоретичні відомості
1. Шари образу
Docker-образ — упорядкований стек незмінних (read-only) шарів, об'єднаних у єдину
файлову систему механізмом OverlayFS. Кожна інструкція RUN, COPY, ADD у Dockerfile
створює новий шар, що фіксує різницю файлової системи відносно попереднього стану.
Компілятори, заголовкові файли й кеш пакетного менеджера, потрібні лише під час збірки, за
замовчуванням лишаються в цих шарах і роздувають фінальний образ до сотень мегабайтів чи
гігабайтів, хоча самому бінарнику для роботи потрібні лише кілька мегабайтів. Інструкції
ENV, CMD, ENTRYPOINT, LABEL, EXPOSE, WORKDIR шарів файлової системи не
створюють — вони змінюють лише метадані образу.
2. Багатоетапна збірка
multi-stage build дозволяє описати кілька незалежних етапів FROM в одному Dockerfile:
важкий етап зі збіркою (FROM <образ> AS <ім'я>), що містить компілятор і залежності, і
легкий фінальний етап, у який інструкцією COPY --from=<ім'я-етапу> переноситься лише
готовий артефакт. Проміжні етапи в підсумковий образ не потрапляють — вони існують лише в
локальному кеші збірки Docker (docker builder) і не впливають на розмір результату.
3. Кешування шарів і порядок інструкцій
Docker кешує кожен шар за хешем інструкції та вмісту релевантного контексту збірки; зміна
одного шару інвалідує всі наступні за ним шари в тому самому етапі. Тому стабільні,
рідкозмінні інструкції (встановлення системних залежностей, компіляція залежностей
проєкту за файлом маніфесту) варто розміщувати раніше за мінливі (копіювання власного
коду), щоб повторні збірки максимально перевикористовували кеш (CACHED у виводі
docker build). Класичний прийом для екосистем із файлом блокування залежностей
(Cargo.lock, package-lock.json, go.sum): скопіювати спершу лише файл(и) маніфесту,
скомпілювати/встановити залежності окремим шаром, і лише потім скопіювати решту коду.
4. .dockerignore
Файл .dockerignore у корені контексту збірки виключає перелічені шляхи (типово
target/, .git/, node_modules/, тимчасові файли) з контексту, що надсилається демону
Docker при docker build. Це зменшує час передачі контексту й запобігає зайвій
інвалідації кешу через зміни у файлах, що не належать до вихідного коду програми.
5. scratch — образ без операційної системи
scratch — порожній базовий образ без жодного шару: ні shell, ні бібліотек, ні
coreutils, ні динамічного лінкера. Підходить лише для статично зібраних бінарників
(наприклад, Rust з таргетом x86_64-unknown-linux-musl або Go з CGO_ENABLED=0),
оскільки в образі немає навіть /lib64/ld-linux-x86-64.so.2 для підвантаження
.so-бібліотек під час виконання. Спроба запустити динамічно злінкований бінарник у
scratch завершується помилкою «no such file or directory», яка стосується не самого
бінарника, а відсутнього динамічного лінкера.
6. docker history як інструмент діагностики
Команда docker history <образ> показує список шарів образу з розміром, доданим кожним
із них, та (у скороченому вигляді) інструкцією, що цей шар створила. Це основний
інструмент для пошуку «важких» шарів у вже зібраному образі без потреби перечитувати весь
Dockerfile вручну.
Обладнання та програмне забезпечення
- GitHub Codespaces або локальний devcontainer із Docker Engine.
- Термінал bash, команди
docker build,docker history,docker images. - Проєкт із простим консольним бінарником мовою, що підтримує статичне лінкування без
зовнішніх системних бібліотек (рекомендовано Rust з таргетом
x86_64-unknown-linux-musl, або Go зCGO_ENABLED=0).
Порядок виконання
- Реалізувати мінімальний бінарник у
src/, що при запуску друкує рядок у stdout і завершується з кодом0. Очікуваний результат: бінарник компілюється й локально запускається без помилок. - Зібрати наївний однорівневий образ (
Dockerfile.naive): одинFROMповнофункціональної ОС/тулчейну, встановлення залежностей, компіляція й запуск в одному Dockerfile; зафіксувати розмір черезdocker images. Очікуваний результат: розмір у сотні мегабайтів–одиниці гігабайтів. - Переписати Dockerfile на багатоетапну збірку: окремий
FROM ... AS buildдля компіляції, окремий фінальнийFROM(мінімальний дистрибутив, наприкладdebian:12-slim) для виконання. Очікуваний результат: образ успішно збирається і запускається. - Порівняти розміри обох образів командою
docker imagesі задокументувати різницю в мегабайтах/гігабайтах. Очікуваний результат: багатоетапний образ у рази менший за наївний. - Змінити порядок інструкцій у build-етапі так, щоб файли залежностей (
Cargo.toml/Cargo.lockабо аналог) копіювалися й компілювалися окремим шаром до копіювання решти коду. Очікуваний результат: структура Dockerfile відповідає патерну «залежності раніше за код». - Перевірити ефект кешування: змінити один файл вихідного коду (не залежності) і
повторно зібрати образ.
Очікуваний результат: шар встановлення/компіляції залежностей позначений
CACHEDу виводіdocker build, загальний час збірки помітно менший. - Додати
.dockerignoreз виключенням артефактів збірки (target/,.git/) і переконатися, що розмір контексту, надісланого демону, зменшився. Очікуваний результат: рядокSending build context to Docker daemonпоказує помітно менший обсяг, ніж без.dockerignore. - Переписати фінальний етап на
FROM scratch, переконатися, що бінарник зібрано статично, і зібрати мінімальний образ. Очікуваний результат: розмір у одиниці–десятки мегабайтів,docker runзавершується без помилок динамічного лінкера.
Завдання за рівнями
Рівень 1 (оцінка 3, автотести). Написати Dockerfile, застосувати багатоетапну збірку і порівняти розміри (гігабайт проти мегабайтів). Відповідає крокам 1–4 порядку виконання.
Рівень 2 (оцінка 4, захист). Оптимізувати порядок інструкцій для максимального
використання кешування шарів; на захисті продемонструвати CACHED-шари при повторній
збірці. Відповідає крокам 5–7 порядку виконання.
Рівень 3 (оцінка 5, розширення). Зібрати мінімально можливий образ виконання,
використавши scratch замість базової ОС. Відповідає кроку 8 порядку виконання.
Розібраний приклад
Приклад 1. Наївна та багатоетапна збірка — порівняння розмірів
# Dockerfile.naive
FROM rust:1.82
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/sid-core"]
# Dockerfile — багатоетапна версія
FROM rust:1.82 AS build
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs \
&& cargo build --release && rm -rf src
COPY src ./src
RUN cargo build --release
FROM debian:12-slim
COPY --from=build /app/target/release/sid-core /usr/local/bin/sid-core
CMD ["sid-core"]
$ docker build -f Dockerfile.naive -t lab02-naive . && docker build -f Dockerfile -t lab02-slim .
$ docker images | grep lab02
lab02-naive latest 1.42GB
lab02-slim latest 87.3MB
Весь тулчейн rust:1.82 (компілятор, стандартна бібліотека, кеш cargo) залишається в
шарах наївного образу. Багатоетапна версія копіює у фінальний, мінімальний debian:12-slim
лише готовий бінарник — звідси падіння розміру з 1.42 ГБ до 87 МБ.
Приклад 2. Кешування шарів залежностей
$ docker build -f Dockerfile -t lab02-slim .
=> [build 4/6] RUN cargo build --release (deps only) 38.4s
...
# Правимо тільки src/main.rs
$ docker build -f Dockerfile -t lab02-slim .
=> CACHED [build 4/6] RUN cargo build --release (deps) 0.0s
=> [build 6/6] RUN cargo build --release 2.1s
Оскільки Cargo.toml/Cargo.lock не змінилися, шар компіляції залежностей береться з
кешу за 0 секунд замість 38 секунд, і повторна збірка обмежується лише компіляцією
власного коду.
Контрольні питання
- Чому проміжний build-етап не впливає на розмір фінального образу при багатоетапній збірці?
- Що станеться з кешем усіх наступних шарів, якщо змінити файл, скопійований у першому
COPY? - Чому копіювання
Cargo.toml/package.jsonокремим кроком до копіювання всього коду покращує кешування? - Чому в
scratch-образ не можна помістити динамічно злінкований бінарник без додаткових бібліотек? - Які інструкції Dockerfile створюють новий шар файлової системи, а які — лише метадані?
- Як
docker historyдопомагає знайти, яка саме інструкція додала найбільше мегабайтів до образу? - Яку роль відіграє
.dockerignoreу продуктивності та коректності кешування збірки? - Чому середня "вага" build-етапу не має значення для фінального розміру образу, тоді як вага фінального етапу — має пряме значення?
Парні теми СРС
- Рівень 2: srs03 — «OverlayFS і шари образу: як влаштовано об'єднану файлову систему».
- Рівень 3: srs04 — «Відтворюваність збірки і довіра до образу (digest, pin версій)».
Критерії оцінювання та форма звіту
Здача відбувається тегом submit/lab02. Звіт REPORT.md містить: таблицю розмірів
Dockerfile.naive й фінального образу, вивід docker history для фінального образу з
поясненням, який шар дав найбільший приріст розміру, і відповіді на контрольні питання.
Автотести (рівень 1) перевіряють: образ успішно збирається за поданим Dockerfile,
використовує щонайменше два етапи FROM, і розмір фінального образу менший за заданий
поріг (орієнтовно менше ніж 50% розміру наївного однорівневого образу). Захист (рівень 2)
— усне пояснення, який порядок інструкцій обрано і чому, та демонстрація повторної
збірки з CACHED-шарами після зміни лише прикладного коду. Розширення (рівень 3)
перевіряється автотестами на використання FROM scratch у фінальному етапі та на те, що
зібраний образ успішно запускається (docker run завершується без помилки лінкера чи
відсутньої бібліотеки).