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

Лабораторна робота № 2. Dockerfile з багатоетапною збіркою і порівнянням розмірів

Мета роботи

Навчитися писати Dockerfile із багатоетапною збіркою, свідомо впорядковувати інструкції для максимального використання кешу шарів і зібрати мінімальний за розміром образ виконання аж до базового образу scratch. Робота розвиває розуміння внутрішньої будови Docker-образу (шари, OverlayFS, кеш), закладене в лабораторній роботі № 1.

📚Теоретичні відомості329 слів · ~2 хв

1. Освіжити: що з лекції 2 потрібно в цій роботі

Механізм шарів, кеш і багатоетапна збірка розглянуті на лекції 2 (того самого дня). Для виконання роботи достатньо трьох тверджень:

  1. Образ — упорядкований стек незмінних шарів; кожна інструкція RUN, COPY, ADD створює новий шар, і видалення файлу наступною інструкцією розміру попереднього шару не зменшує.
  2. Кеш інвалідується на першій зміненій інструкції та на всіх наступних; тому інструкції впорядковують від найрідше змінюваних до найчастіше змінюваних.
  3. Багатоетапна збірка (FROM ... AS <ім'я>, COPY --from=<ім'я>) дозволяє перенести у фінальний образ лише результат компіляції, не переносячи інструментарій.

Скелет багатоетапного Dockerfile, який використовується в роботі:

FROM rust:1.79 AS build
WORKDIR /src
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl

FROM scratch
COPY --from=build /src/target/x86_64-unknown-linux-musl/release/sid /sid
ENTRYPOINT ["/sid"]

Мова програмування, якою написано пакований бінарник, у цій роботі предметом вивчення не є: Rust розглядається з лекції 4. Тут він виступає як приклад програми, що збирається статично.

2. .dockerignore

Файл .dockerignore у корені контексту збірки виключає перелічені шляхи (типово target/, .git/, node_modules/, тимчасові файли) з контексту, що надсилається демону Docker при docker build. Це зменшує час передачі контексту й запобігає зайвій інвалідації кешу через зміни у файлах, що не належать до вихідного коду програми.

3. 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», яка стосується не самого бінарника, а відсутнього динамічного лінкера.

4. docker history як інструмент діагностики

Команда docker history <образ> показує список шарів образу з розміром, доданим кожним із них, та (у скороченому вигляді) інструкцією, що цей шар створила. Це основний інструмент для пошуку «важких» шарів у вже зібраному образі без потреби перечитувати весь Dockerfile вручну.

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

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

  1. Ініціалізувати мінімальний проєкт у корені репозиторію роботи (cargo new, потім перенести Cargo.toml і src/ у корінь) так, щоб при запуску друкувався рядок у stdout і програма завершувалася з кодом 0. Закомітити Cargo.lock, додати target/ у .gitignore. Очікуваний результат: cargo run --release друкує очікуваний рядок без помилок.
  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/) і переконатися, що розмір контексту, надісланого демону, зменшився. Очікуваний результат: у виводі BuildKit рядок => => transferring context: <розмір> показує суттєво менший обсяг контексту, надісланого демону. (Рядок Sending build context to Docker daemon друкував класичний білдер; із Docker 23+ типовим є BuildKit, і цього рядка немає.)
  8. Переписати фінальний етап на FROM scratch, переконатися, що бінарник зібрано статично, і зібрати мінімальний образ. Очікуваний результат: розмір у одиниці–десятки мегабайтів, docker run завершується без помилок динамічного лінкера.

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

Два репозиторії. Рівень 1 виконується в репозиторії завдання — його створює Classroom 50 на прийняття, окремий на кожну роботу, і саме там працюють автотести (здача тегом submit/labNN). Рівні 2 і 3 виконуються в наскрізному репозиторії vtfk-sid, який студент створює один раз у роботі № 1 і веде до кінця семестру; вони оцінюються на захисті, не автотестами. Шляхи виду core/... у тексті нижче — це шляхи в vtfk-sid.

Здача — тегом, а не пушем. Після коміту й пушу ставиться тег: git tag submit/lab02 && git push origin submit/lab02. Повторна здача — наступний вільний нумерований тег (submit/lab02-2, submit/lab02-3), старий тег не переставляється. Яка це робота, перевірка визначає за іменем репозиторію, а не тега.

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

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

Предмет здачі — файли в корені репозиторію завдання:

Що Вимога
src/ тека з вихідним кодом, непорожня
Dockerfile.naive одноетапна «наївна» збірка: рівно один FROM
Dockerfile багатоетапна збірка: ≥ 2 FROM, зв'язані COPY --from=<етап цього ж файла>

Що саме перевіряють автотести:

# Перевірка
1 Dockerfile, Dockerfile.naive і src/ на місці
2 Dockerfile багатоетапний і етапи зв'язані через COPY --from
3 Dockerfile збирається
4 Dockerfile.naive збирається (потрібен як мірило розміру)
5 розмір фінального образу строго менший за 50 % розміру наївного
6 образ запускається без аргументів: код завершення 0 і непорожній stdout

Рівень зараховується, лише коли пройшли всі шість. Три уточнення, кожне з яких коштувало комусь перевірки:

  • COPY --from= мусить указувати на етап цього ж файла (ім'я з AS або номер). Посилання на зовнішній образ (--from=busybox:latest) багатоетапністю не є.
  • Dockerfile.naive мусить лишитися одноетапним: якщо оптимізувати і його, порівняння розмірів втрачає зміст, і перевірка 2 це відхиляє.
  • Поріг 50 % — жорсткий і вимірюється грейдером самостійно, у байтах (docker image inspect). Числа зі звіту не читаються.

Конкретний рядок, який друкує бінарник, не зафіксовано ніде, тому перевірка 6 вимагає лише нульовий код і непорожній вивід.

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

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

Умова має два рівноцінні варіанти виконання (оцінка однакова):

  • A — на власному наскрізному артефакті: проєкт і багатоетапний Dockerfile рівня 1 цієї роботи переносяться у vtfk-sid — код у core/sid/, Dockerfile у core/capsule/Dockerfile, поруч зі скриптом core/capsule/deploy.sh роботи № 1, — і оптимізується вже він. Цей образ використовується далі — у роботах № 3 і № 10. Рівень 1 при цьому лишається в корені репозиторію завдання.
  • B — на референсному знімку: якщо власного core/capsule/ немає (робота № 1 не виконана), береться готовий стан зі знімка vtfk-sid-reference@after-lab01 (окремий репозиторій знімків ядра, по гілці на кожну ніч), і в нього так само переноситься рівень 1 цієї роботи.

Допуск до захисту. Перед захистом рівня 2 треба залишити три предметні зауваги до роботи № 1 іншого студента — в issue або коментарі до комміта його репозиторію завдання. Кого рецензувати: студента, чий номер у списку групи на 2 більший за власний (по колу — номер цієї роботи задає зсув, тому пари щоразу різні). Заувага предметна, якщо називає конкретний рядок, команду або крок і пояснює, у чому проблема; загальні оцінки («усе правильно», «гарний код») не враховуються. Це елемент ЗК7 — здатності працювати в команді: рівень 1 доводить, що код збирається, рецензія — що студент уміє його читати.

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

Рівень 3 (оцінка 5, розширення). Зібрати мінімально можливий образ виконання, використавши scratch замість базової ОС. Відповідає кроку 8 порядку виконання.

Ті самі два варіанти: A — доводиться до scratch власний core/capsule/Dockerfile; B — той самий результат від референсного знімка. В обох випадках у звіті фіксуються три розміри: наївний образ, багатоетапний на мінімальному дистрибутиві, scratch.

🧪 РОЗІБРАНИЙ ПРИКЛАД

Приклад 1. Наївна та багатоетапна збірка — порівняння розмірів

# Dockerfile.naive
FROM rust:1.79
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/vtfk-sid"]
# Dockerfile — багатоетапна версія
FROM rust:1.79 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/vtfk-sid /usr/local/bin/vtfk-sid
CMD ["vtfk-sid"]
$ 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.79 (компілятор, стандартна бібліотека, кеш 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-етапу не має значення для фінального розміру образу, тоді як вага фінального етапу — має пряме значення?

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

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

Здача рівня 1 відбувається тегом submit/lab02 у репозиторії завдання. Предмет здачі — файли src/, Dockerfile.naive і Dockerfile у його корені; автотести читають лише їх. Звіт REPORT.md (таблиця розмірів Dockerfile.naive й фінального образу — а для рівня 3 трьох: наївного, на мінімальному дистрибутиві та scratch, — вивід docker history для фінального образу з поясненням, який шар дав найбільший приріст розміру, і відповіді на контрольні питання) автотести не відкривають — він ведеться для захисту рівнів 2–3 і оцінюється викладачем.

Автотести (рівень 1) перевіряють шість умов, перелічених у завданні рівня 1 вище; рівень зараховується, лише коли пройшли всі шість. Поріг розміру — не орієнтовний: фінальний образ має бути строго менший за 50 % наївного, і відношення грейдер вимірює самостійно, у байтах.

Захист (рівень 2) — усне пояснення, який порядок інструкцій обрано і чому, та демонстрація повторної збірки з CACHED-шарами після зміни лише прикладного коду.

Розширення (рівень 3) перевіряється на захисті, а не автотестами: артефакт живе у власному репозиторії студента vtfk-sid, куди перевірка не дістає. Викладач дивиться на FROM scratch у фінальному етапі й на те, що зібраний образ успішно запускається (docker run завершується без помилки лінкера чи відсутньої бібліотеки).

🔗 ДЖЕРЕЛА
⌨️ КОМАНДИ НА ЦІЙ СТОРІНЦІ 12 · 3 нових▾

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

docker build
Збирає образ за Dockerfile (docker build -t ім'я:тег . — крапка = контекст збірки; -f — інший Dockerfile).
docker
Клієнт Docker: усі дії з образами й контейнерами — через його підкоманди (docker <підкоманда> --help — довідка).
docker images
Список локальних образів з тегами й розмірами.
grep
Фільтрує текст: виводить лише рядки, що містять шаблон (ps aux | grep bash).
docker historyнове
Показує шари образу: яка інструкція Dockerfile створила кожен і скільки він займає.
cargo newнове
Створює новий проєкт із Cargo.toml і src/main.rs (cargo new sidkit; --lib — бібліотека).
cargo
Система збірки й менеджер пакетів Rust: створює проєкт, тягне залежності (крейти), збирає, тестує, запускає.
cargo runнове
Збирає й одразу запускає програму; аргументи після -- йдуть програмі (cargo run -- staff.log).
docker run
Створює й запускає контейнер з образу (docker run -it ubuntu:24.04 bash — інтерактивно з терміналом; -d — у фоні; --rm — видалити після виходу; -p 8080:80 — прокинути порт).
git tag
Позначає поточний коміт іменем (git tag submit/lab01) — так ти «здаєш» лабораторну.
git
Система керування версіями: історія змін, гілки, теги; здача робіт у курсі — через теги submit/labNN.
git push
Відправляє коміти й теги у віддалений репозиторій (git push --tags).

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