Курс ЛР2 · Місія 2
Академічна↔ Сюжет С.І.Д.СРС до ЛР⌨️ команди 7 · 1 нова

Лабораторна робота № 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 вручну.

Обладнання та програмне забезпечення

Порядок виконання

  1. Реалізувати мінімальний бінарник у src/, що при запуску друкує рядок у stdout і завершується з кодом 0. Очікуваний результат: бінарник компілюється й локально запускається без помилок.
  2. Зібрати наївний однорівневий образ (Dockerfile.naive): один FROM повнофункціональної ОС/тулчейну, встановлення залежностей, компіляція й запуск в одному Dockerfile; зафіксувати розмір через docker images. Очікуваний результат: розмір у сотні мегабайтів–одиниці гігабайтів.
  3. Переписати Dockerfile на багатоетапну збірку: окремий FROM ... AS build для компіляції, окремий фінальний FROM (мінімальний дистрибутив, наприклад debian:12-slim) для виконання. Очікуваний результат: образ успішно збирається і запускається.
  4. Порівняти розміри обох образів командою docker images і задокументувати різницю в мегабайтах/гігабайтах. Очікуваний результат: багатоетапний образ у рази менший за наївний.
  5. Змінити порядок інструкцій у build-етапі так, щоб файли залежностей (Cargo.toml/ Cargo.lock або аналог) копіювалися й компілювалися окремим шаром до копіювання решти коду. Очікуваний результат: структура Dockerfile відповідає патерну «залежності раніше за код».
  6. Перевірити ефект кешування: змінити один файл вихідного коду (не залежності) і повторно зібрати образ. Очікуваний результат: шар встановлення/компіляції залежностей позначений CACHED у виводі docker build, загальний час збірки помітно менший.
  7. Додати .dockerignore з виключенням артефактів збірки (target/, .git/) і переконатися, що розмір контексту, надісланого демону, зменшився. Очікуваний результат: рядок Sending build context to Docker daemon показує помітно менший обсяг, ніж без .dockerignore.
  8. Переписати фінальний етап на FROM scratch, переконатися, що бінарник зібрано статично, і зібрати мінімальний образ. Очікуваний результат: розмір у одиниці–десятки мегабайтів, docker run завершується без помилок динамічного лінкера.

Завдання за рівнями

РІВЕНЬ 1 · «3» · ПЕРШОКУРСНИК

Рівень 1 (оцінка 3, автотести). Написати Dockerfile, застосувати багатоетапну збірку і порівняти розміри (гігабайт проти мегабайтів). Відповідає крокам 1–4 порядку виконання.

РІВЕНЬ 2 · «4» · МАГІСТР

Рівень 2 (оцінка 4, захист). Оптимізувати порядок інструкцій для максимального використання кешування шарів; на захисті продемонструвати CACHED-шари при повторній збірці. Відповідає крокам 5–7 порядку виконання.

РІВЕНЬ 3 · «5» · ЛЕГЕНДА

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

❓ КОНТРОЛЬНІ ПИТАННЯ

Контрольні питання

  1. Чому проміжний build-етап не впливає на розмір фінального образу при багатоетапній збірці?
  2. Що станеться з кешем усіх наступних шарів, якщо змінити файл, скопійований у першому COPY?
  3. Чому копіювання Cargo.toml/package.json окремим кроком до копіювання всього коду покращує кешування?
  4. Чому в scratch-образ не можна помістити динамічно злінкований бінарник без додаткових бібліотек?
  5. Які інструкції Dockerfile створюють новий шар файлової системи, а які — лише метадані?
  6. Як docker history допомагає знайти, яка саме інструкція додала найбільше мегабайтів до образу?
  7. Яку роль відіграє .dockerignore у продуктивності та коректності кешування збірки?
  8. Чому середня "вага" build-етапу не має значення для фінального розміру образу, тоді як вага фінального етапу — має пряме значення?

Парні теми СРС

Критерії оцінювання та форма звіту

Здача відбувається тегом submit/lab02. Звіт REPORT.md містить: таблицю розмірів Dockerfile.naive й фінального образу, вивід docker history для фінального образу з поясненням, який шар дав найбільший приріст розміру, і відповіді на контрольні питання.

Автотести (рівень 1) перевіряють: образ успішно збирається за поданим Dockerfile, використовує щонайменше два етапи FROM, і розмір фінального образу менший за заданий поріг (орієнтовно менше ніж 50% розміру наївного однорівневого образу). Захист (рівень 2) — усне пояснення, який порядок інструкцій обрано і чому, та демонстрація повторної збірки з CACHED-шарами після зміни лише прикладного коду. Розширення (рівень 3) перевіряється автотестами на використання FROM scratch у фінальному етапі та на те, що зібраний образ успішно запускається (docker run завершується без помилки лінкера чи відсутньої бібліотеки).

⌨️ КОМАНДИ НА ЦІЙ СТОРІНЦІ 7 · 1 нова

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

docker build
Збирає образ за Dockerfile (docker build -t ім'я:тег . — крапка = контекст збірки; -f — інший Dockerfile).
docker
Клієнт Docker: усі дії з образами й контейнерами — через його підкоманди (docker <підкоманда> --help — довідка).
docker images
Список локальних образів з тегами й розмірами.
grep
Фільтрує текст: виводить лише рядки, що містять шаблон (ps aux | grep bash).
docker historyнове
Показує шари образу: яка інструкція Dockerfile створила кожен і скільки він займає.
docker run
Створює й запускає контейнер з образу (docker run -it ubuntu:24.04 bash — інтерактивно з терміналом; -d — у фоні; --rm — видалити після виходу; -p 8080:80 — прокинути порт).
cargo
Система збірки й менеджер пакетів Rust: створює проєкт, тягне залежності (крейти), збирає, тестує, запускає.

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