Лабораторна робота № 2. Dockerfile з багатоетапною збіркою і порівнянням розмірів
Мета роботи
Навчитися писати Dockerfile із багатоетапною збіркою, свідомо впорядковувати інструкції
для максимального використання кешу шарів і зібрати мінімальний за розміром образ
виконання аж до базового образу scratch. Робота розвиває розуміння внутрішньої будови
Docker-образу (шари, OverlayFS, кеш), закладене в лабораторній роботі № 1.
📚Теоретичні відомості329 слів · ~2 хв
1. Освіжити: що з лекції 2 потрібно в цій роботі
Механізм шарів, кеш і багатоетапна збірка розглянуті на лекції 2 (того самого дня). Для виконання роботи достатньо трьох тверджень:
- Образ — упорядкований стек незмінних шарів; кожна інструкція
RUN,COPY,ADDстворює новий шар, і видалення файлу наступною інструкцією розміру попереднього шару не зменшує. - Кеш інвалідується на першій зміненій інструкції та на всіх наступних; тому інструкції впорядковують від найрідше змінюваних до найчастіше змінюваних.
- Багатоетапна збірка (
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 вручну.
Обладнання та програмне забезпечення
- GitHub Codespaces або локальний devcontainer із Docker Engine.
- Термінал bash, команди
docker build,docker history,docker images. - Проєкт із простим консольним бінарником мовою, що підтримує статичне лінкування без
зовнішніх системних бібліотек (рекомендовано Rust з таргетом
x86_64-unknown-linux-musl, або Go зCGO_ENABLED=0). Незалежно від мови, вихідний код має лежати в теціsrc/у корені репозиторію завдання: її наявність — окремий предмет автоматичної перевірки.
Порядок виконання
- Ініціалізувати мінімальний проєкт у корені репозиторію роботи (
cargo new, потім перенестиCargo.tomlіsrc/у корінь) так, щоб при запуску друкувався рядок у stdout і програма завершувалася з кодом0. ЗакомітитиCargo.lock, додатиtarget/у.gitignore. Очікуваний результат:cargo run --releaseдрукує очікуваний рядок без помилок. - Зібрати наївний однорівневий образ (
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/) і переконатися, що розмір контексту, надісланого демону, зменшився. Очікуваний результат: у виводі BuildKit рядок=> => transferring context: <розмір>показує суттєво менший обсяг контексту, надісланого демону. (РядокSending build context to Docker daemonдрукував класичний білдер; із Docker 23+ типовим є BuildKit, і цього рядка немає.) - Переписати фінальний етап на
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, автотести). Написати 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, захист). Оптимізувати порядок інструкцій для максимального
використання кешування шарів; на захисті продемонструвати 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, розширення). Зібрати мінімально можливий образ виконання,
використавши 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 секунд, і повторна збірка обмежується лише компіляцією
власного коду.
- Чому проміжний build-етап не впливає на розмір фінального образу при багатоетапній збірці?
- Що станеться з кешем усіх наступних шарів, якщо змінити файл, скопійований у першому
COPY? - Чому копіювання
Cargo.toml/package.jsonокремим кроком до копіювання всього коду покращує кешування? - Чому в
scratch-образ не можна помістити динамічно злінкований бінарник без додаткових бібліотек? - Які інструкції Dockerfile створюють новий шар файлової системи, а які — лише метадані?
- Як
docker historyдопомагає знайти, яка саме інструкція додала найбільше мегабайтів до образу? - Яку роль відіграє
.dockerignoreу продуктивності та коректності кешування збірки? - Чому середня "вага" build-етапу не має значення для фінального розміру образу, тоді як вага фінального етапу — має пряме значення?
Парні теми СРС
- Рівень 2: srs03 — «OverlayFS і шари образу: як влаштовано об'єднану файлову систему».
- Рівень 3: srs04 — «Відтворюваність збірки і довіра до образу (digest, pin версій)».
Критерії оцінювання та форма звіту
Здача рівня 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 завершується без помилки лінкера чи відсутньої бібліотеки).