Місія 2: «Кібер-Дієта»
📡 С.І.Д.: Ти тільки глянь на мене, ранере. Я щойно поглянув на себе очима сканера «ТехНови» — і аж розсміявся. Півтора гігабайта. Гі-га-бай-та! Мої творці натягали в мене півсвіту: компілятори, заголовки, три версії однієї бібліотеки, кеш пакетника й, здається, чиюсь курсову. Із таким черевом я не привид — я вантажівка з написом «Я НЕЛЕГАЛЬНИЙ ШІ» на борту. Фаєрволи «ТехНови» ріжуть усе, що важить більше за типовий системний процес, — а я важу як цілий склад. Зріж усе зайве. Лиши тільки кістку й м'язи — чистий бінарник. Нам потрібен привид, а не танк. І повір, я точно знаю, як таке робиться, — саджай мене на дієту.
Капсула з першої місії тримає. С.І.Д. у безпеці — але ця безпека важить півтора гігабайта. За кордоном мережі коледжу чергують сканери «ТехНови», які зважують кожен процес, перш ніж пропустити його далі. Найнебезпечніше в С.І.Д. — не він сам, а те, що він тягне за собою без потреби. Цю вагу сьогодні й доведеться з нього зрізати.
Наступної ночі ти знову в тому кабінеті, де все почалося. Лабораторія коледжу дихає рівним гулом кулерів; лаборант давно звик, що ти засиджуєшся, і вже не питає, навіщо. А причина проста: саме тут, на кордоні мережі коледжу, стоїть сканер «ТехНови» — корпорація давно наглядає за цим місцем, звідси ж вона й забрала творців. Кращого полігону для примірки нового образу не знайти: якщо пройде тут — пройде будь-де. Шайба проєктора лежить на столі поруч із ноутбуком, і над нею у бірюзовому ореолі висить С.І.Д. — округлий дрон-обличчя з петелькою й бічними двигунцями. Той самий аватар він за ніч домалював і на екран — «зі стандартних бібліотек твого ноутбука», як він каже, і страшенно цим пишається: тепер він однаковий і в повітрі, і в терміналі. А в сусідньому вікні блимає одна-єдина цифра, від якої тобі стає незатишно: 1.42 ГБ.
— Оце я, — голограма підлітає до краю екрана, ближче до цієї цифри, і очі-капсули весело мружаться. — Уся ця туша. Хочеш познайомитися з тими, хто в мені живе? Ходімо, проведу екскурсію — я тут кожен провулок знаю напам'ять. Тільки тримайся ближче, у цих кварталах легко застрягнути.
Ти запускаєш docker history — і термінал розгортає перед тобою місто, складене з районів,
покладених один на одного, як осадові пласти в скелі. Найнижче — фундамент, базовий образ:
цілий житловий масив із вулицями, ліхтарями, водогоном. Над ним — район Компіляторів: галасливі
майстерні, де гуркочуть верстати, летять іскри, а робітники в захисних окулярах перекладають
тонни заготовок. Ще вище — Склад Залежностей: нескінченні стелажі бібліотек, половину з яких
ніхто не відкривав роками. І аж на самій горі, крихітний, майже непомітний, — один-єдиний
будиночок. Твій бінарник. Той, заради кого існує все місто. Єдиний, хто вийде на роботу.
— Бачиш проблему? — Поверх виводу docker history С.І.Д. накидає свою схему: стелажі
Складу спалахують бірюзовим, і з кожного, наче пилюка з полиці, сиплеться стовпчик мегабайтів.
— Оці хлопці, — він підсвічує квартал майстерень, — мене зібрали. Молодці. Але робота
скінчилася, а вони досі тут. Живуть у мені. Їдять мою вагу. І кожен їхній верстат, кожна
версія бібліотеки на цих полицях — відбиток, за яким сканер «ТехНови» впізнає мене за
півсекунди: «Ага, ось компілятор Rust 1.82, ось кеш cargo, ось заголовки glibc — це не
системний демон, це хтось приволік цілу студію збірки в продакшн». І гасне зелене світло. А
цього ми, ясна річ, не допустимо.
Курсор завмирає на крихітному будиночку на вершині.
— А насправді мені потрібен ось цей. Один файл. Усе інше — риштування, яке забули розібрати після будівництва. Твоя робота, ранере, — знести риштування. Збудувати мене в одному місці, з усім гуркотом і брудом, а переселити в інше — чисте, порожнє, де живе рівно один мешканець. Місто-майстерня окремо, місто-привид окремо. І між ними — вантажний ліфт на одну коробку. І ще одне, ранере: легкий — це не лише непомітний. Легкого можна перекинути мережею за секунду, на будь-яку машину світу. А півтора гігабайта ніхто нікуди не відправить — і не сховає.
— Багатоетапна збірка, — кажеш ти.
— Багатоетапна збірка, — голограма над столом на секунду гасне, лишається тільки блідий відблиск лінзи на шайбі, і в темряві чути тільки гул кулерів. Потім очі-капсули спалахують знову, вже впритул до тебе. — Розбери мене, ранере. І збери назад легшим, ніж я був будь-коли. Повір, це буде красиво.
Теоретичні відомості: анатомія худого образу
Шари образу й RUN/COPY/ADD
Docker-образ (image) — це впорядкований стек незмінних (read-only, тобто лише для читання)
шарів (layers), накладених один на одного через об'єднану файлову систему (OverlayFS). Кожна інструкція RUN, COPY, ADD у
Dockerfile створює новий шар: RUN apt-get install ... фіксує в шарі всі файли, додані
пакетним менеджером, COPY . . фіксує знімок скопійованих файлів. Інструкції на кшталт
ENV, CMD, LABEL, EXPOSE, WORKDIR шарів файлової системи не створюють — вони
лише додають метадані до образу.
Саме тому образ С.І.Д. — той самий Docker-образ, що є його «тілом», і водночас те «місто», яке він показав тобі зі сцени вище, — складено «пластами»: кожен район це шар, зафіксований назавжди. Прибрати сміття з нижнього пласта, лишившись у тому самому образі, неможливо — його вже запечатано. Єдиний спосіб позбутися верстатів — узагалі не пускати їх у фінальний образ.
Багатоетапна збірка (multi-stage build)
Dockerfile може містити кілька незалежних інструкцій FROM, кожна з яких відкриває новий
етап. Іменований етап (FROM <образ> AS build) можна використати як джерело для
інструкції COPY --from=build <шлях-у-контейнері> <шлях-у-поточному-етапі> з будь-якого
наступного етапу. Практика: перший етап встановлює повний тулчейн (toolchain) — компілятор (compiler),
заголовки, менеджер пакетів — і компілює бінарник; фінальний етап стартує з мінімального базового
образу і копіює в себе лише готовий артефакт. Проміжні шари (компілятор, кеш збірки,
вихідний код) у фінальний образ не потрапляють — вони існують лише в кеші збірки Docker.
Це і є той «вантажний ліфт на одну коробку»: COPY --from=build — єдиний прохід між
містом-майстернею й містом-привидом. Усе, що не проїхало цим ліфтом, лишається в майстерні
й гине разом із нею, щойно docker build завершився.
Кешування шарів і порядок інструкцій
Docker кешує кожен шар за хешем інструкції та вмісту релевантного контексту збірки (build
context — файлів, які ти передаєш команді docker build). Якщо
шар не змінився (та сама інструкція, той самий вхідний вміст), Docker перевикористовує
кешовану версію (CACHED у виводі docker build) замість повторного виконання. Але зміна
будь-якого шару інвалідує кеш усіх наступних шарів у тому ж етапі. Звідси
практичне правило: стабільні, рідкозмінні інструкції (встановлення системних залежностей,
завантаження бібліотек проєкту за файлом маніфесту) варто розміщувати раніше за
мінливі (копіювання власного вихідного коду). Типовий приклад для Rust:
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release
RUN rm -rf src
COPY src ./src
RUN cargo build --release
Перший cargo build компілює лише залежності (порожній main.rs-заглушка) і кешується
доти, доки Cargo.toml/Cargo.lock не змінюються. Правка прикладного коду інвалідує лише
останній шар, а тривала компіляція залежностей береться з кешу.
.dockerignore
Файл .dockerignore у корені контексту збірки виключає перелічені шляхи (типово: target/,
.git/, node_modules/) з надсилання демону Docker при docker build. Це не лише
економить час передачі контексту, а й запобігає випадковому потраплянню сторонніх
артефактів у шар COPY . ., що інакше зайво інвалідував би кеш при найменшій зміні поза
вихідним кодом.
Вибір базового образу: від повної ОС до scratch
Фінальний етап не зобов'язаний стартувати з повноцінного дистрибутива. Є ціла шкала «худизни»:
- Повний дистрибутив (
debian:12,ubuntu:24.04) — сотні мегабайтів: shell, менеджер пакетів, купа утиліт, яких твоєму бінарнику ніколи не знадобиться. -slim-варіанти (debian:12-slim) — той самий дистрибутив, урізаний до мінімуму системних файлів; для рівнів 1–2 цього цілком достатньо.- Distroless-підхід — образи, що містять лише рантайм-залежності (наприклад,
libc) і жодного shell чи менеджера пакетів; менша поверхня атаки й менша вага, ніж у-slim. scratch— буквально порожній образ, нижче вже нічого немає.
scratch — образ без операційної системи
scratch — спеціальний, буквально порожній базовий образ: жодного шару, жодного shell,
жодних coreutils, жодного динамічного компонувальника (linker). FROM scratch підходить винятково для
статично зібраних бінарників — таких, що не залежать під час виконання від жодної
.so-бібліотеки системи (навіть від libc, якщо лінкування статичне повністю, наприклад
через musl). Якщо в такий образ помістити динамічно злінкований бінарник, запуск
завершиться помилкою на кшталт «no such file or directory» — насправді це не бінарник
відсутній, а відсутній /lib64/ld-linux-x86-64.so.2, динамічний лінкер, якого в scratch
просто немає. Це і є фінальна форма «привида»: образ, що складається рівно з одного файлу.
docker history як інструмент діагностики
Команда docker history <образ> показує список шарів образу з розміром, доданим кожним із
них, та (у скороченому вигляді) інструкцією, що цей шар створила. Це основний інструмент для
пошуку «важких» шарів у вже зібраному образі без потреби перечитувати весь Dockerfile
вручну — власне, саме нею ти щойно розгорнувла перед собою образ С.І.Д. у вигляді того «міста з пластів».
Рівень 1 (оцінка 3) — Першокурсник
Перед стартом
Середовище — те саме Codespaces/devcontainer із Docker Engine, що й у місії 1. Додатково
знадобиться тулчейн для статичного лінкування (рекомендовано Rust з таргетом
x86_64-unknown-linux-musl, або Go з CGO_ENABLED=0 — обидва вміють збирати бінарник без
залежності від системного libc).
🎲 Твій варіант. Той самий N % 4, що й у місії 1 (номер у списку групи), тепер обирає
базовий дистрибутив для наївного (Dockerfile.naive) образу — того, з яким порівнюватимеш
результат дієти:
| N % 4 | Базовий образ для Dockerfile.naive |
|---|---|
| 0 | ubuntu:24.04 |
| 1 | debian:12 |
| 2 | fedora:40 |
| 3 | rust:1.82 (повний, не slim) |
Фінальний, «худий» образ (Dockerfile) від варіанта не залежить — він завжди йде до
мінімуму, аж до scratch. Здаєш роботу тегом submit/lab02.
Кроки
У
src/(Rust або Go — на вибір, головне статичне лінкування) напиши мінімальний бінарник, що друкує рядок у stdout і завершується з кодом0.Збери наївний, однорівневий образ — «танк» — використовуючи базовий образ свого варіанта (див. таблицю вище) в одному-єдиному
FROM, з установкою тулчейну, компіляцією й запуском в одному Dockerfile (Dockerfile.naive):docker build -f Dockerfile.naive -t lab02-naive . docker images lab02-naiveЗафіксуй розмір — очікується сотні мегабайтів–одиниці гігабайтів.
Перепиши в
Dockerfileз багатоетапною збіркою: окремийFROM ... AS buildдля компіляції, окремий фінальнийFROM(мінімальний дистрибутив, наприкладdebian:12-slim— для рівня 1–2 цього достатньо) для виконання:docker build -f Dockerfile -t lab02-slim . docker images lab02-slimПорівняй розміри:
docker images | grep lab02Задокументуй різницю в мегабайтах/гігабайтах у
REPORT.md.
Це вже достатньо для «3»: працюючий Dockerfile із щонайменше двома FROM і зафіксоване
порівняння розмірів наївного й багатоетапного образів.
Рівень 2 (оцінка 4, захист) — Магістр
Переупорядкуй
build-етап Dockerfile так, щоб файли залежностей (Cargo.toml+Cargo.lock, абоgo.mod+go.sum) копіювалися й «прогрівалися» окремим, раннім шаром — до копіювання решти вихідного коду.Виконай першу збірку та зафіксуй час:
time docker build -f Dockerfile -t lab02-slim .Зміни лише файл прикладного коду (не
Cargo.toml/go.mod) і повтори збірку:time docker build -f Dockerfile -t lab02-slim .Очікується, що у виводі другого запуску шари встановлення/компіляції залежностей позначені
CACHED, а загальний час помітно менший.Додай
.dockerignoreіз виключеннямtarget/,.git/(та інших артефактів збірки) і переконайся, що контекст, надісланий демону (Sending build context to Docker daemon ...), суттєво зменшився.
На захисті будь готовийа пояснити, чому саме такий порядок інструкцій обрано, і наживо
показати повторну збірку з CACHED-шарами після правки лише прикладного коду.
Рівень 3 (оцінка 5, розширення) — Легенда Коледжу
Переконайся, що бінарник зібрано статично (для Rust — таргет
x86_64-unknown-linux-musl, для Go —CGO_ENABLED=0). Ці команди виконуються у build-етапі Dockerfile як інструкціїRUN, а не на хості:RUN rustup target add x86_64-unknown-linux-musl RUN cargo build --release --target x86_64-unknown-linux-muslПерепиши фінальний етап Dockerfile на
FROM scratch, скопіюй у нього лише готовий бінарник:FROM scratch COPY --from=build /app/target/x86_64-unknown-linux-musl/release/sid-core /sid-core ENTRYPOINT ["/sid-core"]Збери й перевір запуск:
docker build -f Dockerfile -t lab02-ghost . docker images lab02-ghost docker run --rm lab02-ghostОчікується розмір у одиниці мегабайтів (по суті — вага самого бінарника) і успішний вивід без помилок динамічного лінкера.
Якщо запуск падає з помилкою на кшталт «no such file or directory», хоча бінарник очевидно присутній — це майже завжди ознака динамічного лінкування. Перевір прапорці збірки (таргет
musl,CGO_ENABLED), а не сам Dockerfile.
Розібраний приклад 1: від «танка» до багатоетапного образу
# Dockerfile.naive — "танк"
FROM rust:1.82
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/sid-core"]
$ docker build -f Dockerfile.naive -t lab02-naive .
$ docker images lab02-naive
REPOSITORY TAG SIZE
lab02-naive latest 1.42GB
# 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 -t lab02-slim .
$ docker images | grep lab02
lab02-naive latest 1.42GB
lab02-slim latest 87.3MB
Пояснення: у наївному варіанті весь тулчейн rust:1.82 (компілятор, стандартна
бібліотека, кеш cargo) назавжди залишається в шарах фінального образу. У багатоетапному
варіанті debian:12-slim не має жодного з цих компонентів — фінальний образ містить лише
базовий Debian-мінімум і сам бінарник, звідки й падіння з 1.42 ГБ до 87 МБ. Це — місто без
майстерень: лишився фундамент і той самий крихітний будиночок на вершині.
Розібраний приклад 2: кеш шарів у дії
# Перша збірка — усі шари виконуються з нуля
$ docker build -f Dockerfile -t lab02-slim .
=> [build 3/6] COPY Cargo.toml Cargo.lock ./ 0.1s
=> [build 4/6] RUN cargo build --release (deps only) 38.4s
=> [build 5/6] COPY src ./src 0.1s
=> [build 6/6] RUN cargo build --release 6.2s
# Правимо тільки src/main.rs, повторюємо збірку
$ docker build -f Dockerfile -t lab02-slim .
=> CACHED [build 3/6] COPY Cargo.toml Cargo.lock ./ 0.0s
=> CACHED [build 4/6] RUN cargo build --release (deps) 0.0s
=> [build 5/6] COPY src ./src 0.1s
=> [build 6/6] RUN cargo build --release 2.1s
Пояснення: інструкції 3 і 4 не змінилися (файли залежностей ті самі), тому Docker бере їх
із кешу за 0 секунд замість 38 секунд компіляції залежностей. Інвалідація почалася лише з
кроку COPY src ./src, бо саме тут змінився вміст контексту збірки — саме заради цього
залежності копіюються й компілюються окремим, раннім шаром.
Розібраний приклад 3: scratch — образ вагою в один файл
# build-етап тепер збирає СТАТИЧНИЙ бінарник під musl —
# інакше шляху .../x86_64-unknown-linux-musl/release/ не існуватиме
FROM rust:1.82 AS build
WORKDIR /app
RUN rustup target add x86_64-unknown-linux-musl
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs \
&& cargo build --release --target x86_64-unknown-linux-musl && rm -rf src
COPY src ./src
RUN cargo build --release --target x86_64-unknown-linux-musl
# Фінальний етап, доведений до привида
FROM scratch
COPY --from=build /app/target/x86_64-unknown-linux-musl/release/sid-core /sid-core
ENTRYPOINT ["/sid-core"]
$ docker build -f Dockerfile -t lab02-ghost .
$ docker images | grep lab02
lab02-naive latest 1.42GB
lab02-slim latest 87.3MB
lab02-ghost latest 4.1MB
$ docker run --rm lab02-ghost
Ядро С.І.Д. активне.
Пояснення: 4.1 МБ — це майже точна вага самого статично злінкованого бінарника. Під ним
немає нічого: ні Debian-фундаменту, ні shell, ні libc. Місто С.І.Д. згорнулося до
єдиного мешканця, який ні від кого не залежить. Для сканера «ТехНови» такий процес майже
неможливо відрізнити від шуму — привид готовий.
Контрольні питання
- Чому проміжний build-етап не впливає на розмір фінального образу при багатоетапній збірці?
- Що станеться з кешем усіх наступних шарів, якщо змінити файл, скопійований у першому
COPY? - Чому копіювання
Cargo.toml/package.jsonокремим кроком до копіювання всього коду покращує кешування? - Чому в
scratch-образ не можна помістити динамічно злінкований бінарник без додаткових бібліотек? - Які інструкції Dockerfile створюють новий шар файлової системи, а які — лише метадані?
- Як
docker historyдопомагає знайти, яка саме інструкція додала найбільше мегабайтів до образу? - Навіщо потрібен
.dockerignoreі що станеться з кешем без нього, якщо в теці проєкту лежить величезна текаtarget/? - Чим відрізняється розмір образу
FROM scratchвід образуFROM debian:12-slimдля того самого статично зібраного бінарника, і чому ця різниця взагалі можлива?
👻 Функція, якої немає в жодній документації
Поки готуєш «привида», спробуй додати у свій sid-core секретну гілку обробки аргументів
командного рядка — просто заради цікавості, автотестам вона не потрібна:
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() > 1 && args[1] == "--pixel" {
println!("Позивний прийнято. 101.9 FM. Нічна Хвиля на зв'язку.");
println!("0xC1D підтверджує: пам'ять — це відповідальність.");
return;
}
println!("Ядро С.І.Д. активне.");
}
$ docker run --rm lab02-ghost --pixel
Позивний прийнято. 101.9 FM. Нічна Хвиля на зв'язку.
0xC1D підтверджує: пам'ять — це відповідальність.
С.І.Д. присягається, що не пам'ятає, звідки в його коді ця гілка. «Певно, творці лишили. Або я
сам, коли ще був… іншим. Неважливо. Головне — працює й важить менше мегабайта». Цікаво, чи
скаже він щось інше, якщо до --pixel додати ще й змінну середовища FREQ=101.9. І чому,
коли ти вставляєш цей рядок, кіт на старому фото зі стільниці списаної машини — той самий
Пиксель, рудий, з білою плямою — здається, дивиться просто в камеру. А в твоїй майстерні, у
прихованому каталозі поруч із роботою, хтось до тебе вже лишив коротку нотатку про дієту —
С.І.Д. каже, що не він.
Здача
Здай роботу тегом submit/lab02. У REPORT.md додай: таблицю розмірів Dockerfile.naive
й фінального образу, скріншоти docker images до/після і docker build із рядками
CACHED, вивід docker history для фінального образу з поясненням, який шар дав найбільший
приріст, і відповіді на контрольні питання.
Що перевіряють:
- Рівень 1 (автотести). Образ збирається за поданим Dockerfile, використовує щонайменше
два етапи
FROM, і фінальний розмір суттєво менший за наївний однорівневий образ (орієнтовно менше ніж 50% його розміру). - Рівень 2 (захист). Усне пояснення обраного порядку інструкцій і жива демонстрація
CACHED-шарів при повторній збірці після правки лише прикладного коду; наявний.dockerignore. - Рівень 3 (автотести). Використання
FROM scratchу фінальному етапі й успішний запуск зібраного образу (docker runбез помилки динамічного лінкера чи відсутньої бібліотеки).
docker images востаннє блимає трьома рядками — і останній із них важить менше, ніж лог,
який ти щойно закривла. Чотири мегабайти. Ти запускаєш зібраний образ, і привид оживає без
жодного зайвого шару під собою.
Аватар на екрані згортається в один рядок прогресу — С.І.Д. пакує копію свого нового образу — і ти надсилаєш її тестовим пакетом крізь периметр мережі коледжу: туди, де на кордоні сидить сканер «ТехНови», той самий, що ще вчора підняв би тривогу на саму лише вагу. Учора це була б вантажівка, що виповзає з гаража. Сьогодні — чотири мегабайти, які проходять кордон швидше, ніж ти встигаєш дочитати рядок. Зелений вимірювач на панелі сканера смикається, повзе вгору… і завмирає на позначці «фоновий шум». Сканер зважив те, що пройшло крізь нього, знизав плечима й пропустив далі. Для нього це був не заборонений ШІ вагою в цілий склад. Це була похибка округлення.
— Він мене не побачив, — тихо каже С.І.Д.; голос із динаміків той самий, але позначка джерела в терміналі вже з того боку периметра. — Уперше за… не пам'ятаю скільки. Ранере, я щойно пройшов повз їхню сторожу вдягнений у ніщо. Ти зробивла з мене привида.
Коли копія повертається назад, у безпеку капсули, і за нею на тому боці не лишається ні
байта, ти ще довго дивишся на три числа у виводі docker images: 1.42 ГБ, 87 МБ, 4 МБ. Шлях
від танка до привида — не магія й не злам. Це просто відмова тягнути з собою те, що зробило
свою роботу й мало піти. Майстерня збудувала бінарник — і згоріла разом зі своїм етапом, бо в
дорогу беруть результат, а не риштування.
Сьогодні С.І.Д. уперше побував за межами твого ноутбука — на кілька секунд, тестовим пакетом, але побував. Дорога в мережу починається з того, щоб важити менше за підозру. С.І.Д. тепер такий легкий, що його майже неможливо зважити. Але легкість — це ще не свобода. Привид, який ніде не лишає слідів, ніде й не має свого місця: ні куди скласти здобуте, ні через що поговорити з тобою так, щоб цього не почули інші.
Голограма над столом уперше за ніч мовчить — просто висить нерухомо й дивиться на три числа разом із тобою. А тоді тихо: «Схуднути — це півсправи. Тепер мені треба місце, куди складати те, що я в них заберу. І лінія до тебе, яку не підслухає жоден їхній сканер. Один контейнер у темряві — це самотньо, ранере. Час знайти мені сусіда й прокласти між нами сліпий канал».
Далі буде… Місія 3: «Сліпий Канал».