1. Base image як контракт збирання
У Dockerfile рядок FROM ... часто виглядає як невинне «ну так, звідкись же треба стартувати». Насправді це один із найважливіших рядків у всьому файлі: він визначає, які саме файли й який runtime потрапляють до вашого образу ще до того, як ви щось скопіювали. Якщо це посилання «плаває», результат збирання може змінитися без зміни коду. І саме так з’являється нове видання класики «works on my machine», тільки вже в контейнерному виконанні.
Коли ми контейнеризуємо Container-Ready Catalog Service, то хочемо, щоб двоє різних людей або одна людина сьогодні і через місяць зібрали один і той самий фінальний образ з одного й того самого Dockerfile. Інакше в команді починається магія: в одного сервіс стартує, в другого — ні, і обидва мають рацію, бо в них справді різні базові шари, різні системні бібліотеки, різне Java-збирання, а іноді навіть різні сертифікати й часові пояси. Так, це теж буває.
Щоб говорити про посилання на образ акуратно, уведемо термін image reference: це «адреса» образу, яку ви пишете у FROM. Вона буває зрозумілою для людини через tag і точною через digest. Сьогодні ми якраз розберемо, чим вони відрізняються, чому tag зручний, але не завжди стабільний, і як digest перетворює базовий образ на точно зафіксований вхід для вашого збирання.
2. tag: зручна, але рухома мітка
tag — це те, що ви зазвичай бачите після двокрапки: імʼя:tag. Його суперсила проста: він зрозумілий людині. Коли ви пишете :25-jre або :25-jdk, ви одразу «на око» розумієте, що це за варіант образу і яку роль він відіграє. За тегом зручно говорити, писати документацію, пояснювати новачкам.
Але є й зворотний бік: у більшості реальних registry тег — це вказівник, який може почати вказувати на інший вміст. Часто це роблять з добрих причин: оновлення безпеки, нові патчі, зміни базової ОС. Однак для відтворюваності це означає одне: «один і той самий рядок у Dockerfile» не гарантує «один і той самий вміст».
Є ще одна деталь, яку новачки майже завжди пропускають: коли tag не вказано, Docker використовує latest. Тобто FROM some-image і FROM some-image:latest — фактично одне й те саме. І це рішення зазвичай невдале, бо latest — це максимально рухома точка.
Подивімося на мініприклад. Він навмисно схематичний, щоб сфокусуватися саме на посиланні:
# Важливо: тег не вказано — отже Docker підставить :latest (це нестабільно для відтворюваних збирань)
FROM eclipse-temurin
WORKDIR /app
COPY app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Тут «тег не вказано» виглядає як «ну й гаразд». Але фактично це означає «візьми latest», а latest — це як «візьми найсвіжіший хліб із полиці» в магазині, тільки ви берете його на початку місяця й наприкінці місяця. Тож не дивуйтеся, якщо склад буде різним.
Для навчального проєкту й для нормальної командної дисципліни значно краще хоча б явно вказати тег, щоб було зрозуміло, що саме ви мали на увазі:
# Тег допомагає людині: одразу видно, який саме варіант базового образу беремо
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Це вже доросліше: Dockerfile читається як інженерний документ. Але тег усе одно лишається «міткою», а не «відбитком вмісту». І саме тут на сцену виходить digest.
3. digest: фіксує вміст образу
Коли tag — це кличка, то digest — це паспортний номер. Він виглядає страхітливо, бо це sha256-хеш, але сенс у нього дуже спокійний: digest вказує на конкретний вміст. Зазвичай формат такий: sha256:<64 hex символів>. У Dockerfile digest пишеться через @, а не через :.
Важливий практичний ефект такий: посилання виду image@sha256:... не можна «перепризначити» на інший вміст. Коли ви використовуєте digest, то фіксуєте immutable reference і захищаєтеся від drift у часі: один і той самий Dockerfile не почне раптово тягнути інший base image через тиждень.
Але тут важливо не переобіцяти. У випадку multi-platform образу pinned digest не завжди означає буквально один і той самий platform-specific бінарний шар на amd64 і arm64: однаковим лишається саме посилання, а platform-specific варіант усе ще залежить від вибраної платформи. Якщо потрібна однаковість ще й за архітектурою, платформу доведеться контролювати окремо.
Ось як виглядає FROM із digest:
# Digest фіксує конкретний вміст базового образу (відтворюваність збирання на різних машинах)
FROM eclipse-temurin@sha256:4f2c000000000000000000000000000000000000000000000000000000009ab1
WORKDIR /app
COPY app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Читати це очима важко — і це нормально. Digest не про красу, а про точність. Тому в командах зазвичай поєднують читабельність і фіксацію: або залишають людині підказку коментарем, або використовують посилання виду image:tag@sha256:.... Сенс не змінюється — збирання все одно закріплюється за digest.
Ще один момент, який часто плутають: digest не дорівнює IMAGE ID, який ви бачите у docker images. IMAGE ID — локальний ідентифікатор конкретного образу на вашій машині, і він може відрізнятися залежно від того, як саме ви його отримали. Digest — це ідентифікатор вмісту, який приходить із registry і потрібен для «однаковості» між машинами. Коли ви новачок, просто запам’ятайте: якщо мова про відтворюваність посилання у FROM, вас цікавить саме digest.
Для поточної розмови досить ще одного факту: у світі amd64/arm64 один і той самий зрозумілий для людини тег може вести на multi-platform образ. У нього буває спільний digest для manifest list і окремі platform-specific варіанти всередині. Не лякайтеся: digest допомагає зафіксувати reference, а platform усе одно потрібно тримати в голові окремо.
4. tag drift: як пливе FROM ...:25-jre
Зараз буде дуже життєвий сценарій. Уявіть: ви і ваш колега берете один і той самий репозиторій docker-java-catalog-service. У Dockerfile фінальний stage виглядає приблизно так, якщо спростити:
# Тег читабельний, але сам по собі не гарантує незмінність вмісту з часом
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=builder /workspace/build/libs/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
У понеділок ви робите docker build, і все працює. У вівторок ваш колега робить docker build, а в нього раптово інші базові шари, бо тег 25-jre у registry оновився на новий патч. І це може проявитися по-різному: іноді ви навіть нічого не помітите, бо все працює, а це, відверто кажучи, непоганий результат. Але іноді з’являються дуже дивні симптоми.
Наприклад, змінюється набір кореневих сертифікатів. У результаті ваш застосунок починає інакше ходити в HTTPS — не тому, що «Java зламалась», а тому, що базовий образ оновив системні компоненти. Або змінюється базова ОС усередині образу, і в когось у контейнері з’являється чи зникає якась системна бібліотека. Або оновлюється Java minor version, і ви раптом бачите в логах інший рядок java.version. Усе це — не «кошмар Docker», а звичайна ціна рухомого вказівника.
Найнеприємніше, що такі відмінності виглядають як випадковість і погано пояснюються новачку. Саме тому освітні репозиторії й template-проєкти зазвичай хочуть мати tested baseline: один перевірений набір базових образів, який не змінюється раптово. Digest — один із найпряміших способів зробити цю базу стабільною.
Важливо не впадати в крайність і не проголошувати, що «теги — зло». Теги корисні, просто вони розв’язують інше завдання: бути зручними для читання і частіше оновлюватися. Digest розв’язує завдання «однаково на всіх машинах». А добрий інженер уміє вибирати інструмент під задачу, а не навпаки.
5. Отримуємо digest для тегу
Зараз буде практична механіка. Вона виглядає трохи CLI-шно, але в ній немає магії: ми просто просимо Docker показати, який digest відповідає конкретному тегу.
Починаємо зазвичай із pull, щоб гарантовано мати цей образ локально або принаймні отримати його метадані:
# 1) Завантажуємо образ за тегом (або оновлюємо локальні метадані)
docker pull eclipse-temurin:25-jre
# 2) Витягуємо точне посилання (RepoDigests) — його можна вставляти у FROM
docker image inspect eclipse-temurin:25-jre \
--format '{{ index .RepoDigests 0 }}'
# Приклад виводу (готовий image reference для Dockerfile):
# eclipse-temurin@sha256:19ffeab6f8bc9293ac2c3fdf94ebe28396254c993aea0b5a542cfb02e0883fa3
Тут важливі дві речі. По-перше, RepoDigests — це якраз список точних посилань, які Docker знає для цього локального образу. По-друге, вивід уже готовий для вставки в Dockerfile: імʼя@sha256:....
Коли вам не подобаються «шаблони» у --format або ви просто хочете спершу побачити все, можна зробити inspect без формату. Він величезний, так, але корисно хоча б один раз побачити, що це звичайний JSON:
# Inspect повертає великий JSON — іноді корисно подивитися структуру хоча б один раз
docker image inspect eclipse-temurin:25-jre | head -n 8
# [
# {
# "Id": "sha256:...",
# "RepoTags": ["eclipse-temurin:25-jre"],
# "RepoDigests": ["eclipse-temurin@sha256:19ffe...83fa3"],
# ...
Ще один інструмент, який часто допомагає, особливо коли далі починається тема платформ, — docker buildx imagetools inspect. Він показує інформацію, яку Docker може отримати з registry, зокрема й multi-arch деталі. Ми не будемо зараз заглиблюватися, але хоча б покажу вигляд команди, щоб ви знали, що така можливість існує:
# Команда корисна, щоб побачити digest маніфесту й multi-arch деталі просто з registry
docker buildx imagetools inspect eclipse-temurin:25-jre | head -n 10
# Name: docker.io/library/eclipse-temurin:25-jre
# MediaType: application/vnd.oci.image.index.v1+json
# Digest: sha256:...
# ...
Якщо цього інструмента у вас немає або він поводиться інакше — не трагедія. Для нашої задачі, тобто отримати digest під FROM, достатньо зв’язки pull + image inspect.
6. Закріплюємо digest у Dockerfile
Коли люди вперше бачать FROM ...@sha256:..., вони зазвичай кажуть щось на кшталт: «Я тепер ніколи це не запам’ятаю». І це нормально. Digest не повинен запам’ятовуватися. Він має лежати в репозиторії і працювати як стабілізатор.
Проблема тут інша: Dockerfile має залишатися читабельним. Тому зазвичай поєднують читабельність і фіксацію: або залишають людині підказку коментарем, або використовують посилання виду image:tag@sha256:.... В обох випадках збирання закріплюється за digest.
У нашому проєкті це може виглядати так. Покажу в контексті multi-stage, бо ми вже живемо з ним з учора:
# syntax=docker/dockerfile:1
# Стадія збирання: тут нам потрібен JDK, щоб зібрати jar
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /workspace
COPY . .
RUN ./gradlew bootJar --no-daemon
# Runtime base image: тег для читабельності, digest для відтворюваності збирання
FROM eclipse-temurin:25-jre@sha256:19ffeab6f8bc9293ac2c3fdf94ebe28396254c993aea0b5a542cfb02e0883fa3
WORKDIR /app
# Забираємо артефакт зі builder-стадії (у runtime не потрібні ні Gradle, ні вихідники)
COPY --from=builder /workspace/build/libs/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Зверніть увагу, що зараз ми не обговорюємо, «який саме vendor кращий» і «яка саме OS краща» — це було в лекції 1 і буде в лекції 5 цього дня як порівняння стратегій. Тут фокус лише на посиланні: builder stage лишено з тегом просто заради компактності прикладу, а runtime stage вже має pinned reference. У реальному проєкті ви можете закріпити digest і там, і там — це вже питання policy команди. Головне, щоб правило було єдиним і зрозумілим.
Ще один корисний психологічний трюк: коли ви закріплюєте digest, ви перестаєте «плисти» за оновленнями випадково. Оновлення базового образу перетворюється на явну дію: ви свідомо змінюєте digest і точно знаєте, що змінили вхідні дані збирання. Це помітно полегшує налагодження й супровід.
7. Перевіряємо, що запустилося саме те, що потрібно
Після того як ви закріпили digest, виникає просте питання: «А як мені переконатися, що я справді на потрібній Java і потрібному образі?» Це хороше питання, бо воно повертає нас до інженерної звички: не вірити на слово, а перевіряти спостережуване.
Найпростіша перевірка — вивести версію Java всередині контейнера. Для цього навіть не потрібно запускати весь сервіс; можна зробити мінімальний java -version. Наприклад, коли ваш runtime stage має entrypoint java -jar ..., можна тимчасово або окремою командою запустити контейнер із перевизначенням команди:
# Запускаємо контейнер і перевизначаємо команду, щоб просто подивитися версію Java
docker run --rm eclipse-temurin@sha256:19ffeab6f8bc9293ac2c3fdf94ebe28396254c993aea0b5a542cfb02e0883fa3 \
java -version
# openjdk version "25" ...
Коли ви хочете прив’язати це до нашого застосунку, можна зробити маленький Java-клас, суто навчальний і не обов’язковий у prod-коді, який друкує ключові властивості JVM. Він допомагає зрозуміти, що «образ реально задає середовище»:
import java.util.Properties;
public class JavaRuntimeInfo {
public static void main(String[] args) {
// Знімаємо "знімок" системних властивостей JVM усередині контейнера
Properties p = System.getProperties();
// Ці значення залежать від того, який runtime ви поклали у FROM
System.out.println("java.version = " + p.getProperty("java.version")); // наприклад: 25
System.out.println("java.vendor = " + p.getProperty("java.vendor")); // наприклад: Eclipse Adoptium
}
}
Так, наш Spring Boot сервіс так не влаштований, і так і має бути. Але цей приклад корисний як мисленнєвий інструмент: код той самий, а відповіді java.version/vendor залежать від того, що ви поклали у FROM.
І нарешті, можна перевірити, який digest реально «прилип» до вашого зібраного образу. Коли ви зібрали docker-java-catalog-service:dev, можна зробити так:
# RootFS показує набір шарів, з яких складається підсумковий образ
docker image inspect docker-java-catalog-service:dev \
--format '{{ json .RootFS }}' | head -n 2
# {"Type":"layers","Layers":["sha256:...","sha256:..."]}
Це вже більше про шари, ніж про digest base image, але корисно хоча б побачити: підсумковий образ — це набір шарів, і базовий образ теж входить туди своїм шаром. Ми не перетворюємо лекцію на forensic-аналіз образів, але звичка дивитися, що зібралося, дуже допомагає не губитися в разі проблем.
8. Типові помилки під час роботи з tag і digest
Помилка №1: залишати FROM some-image без тегу й говорити «ну це ж просто образ».
Коли ви не вказуєте тег, ви не «нічого не вказали», а обрали latest. Це максимально нестабільний варіант: він зручний лише в одному випадку — коли ви хочете сюрпризів. У навчальних проєктах і командних шаблонах краще відразу вважати implicit latest багом, а не стилем.
Помилка №2: вважати, що тег — це «завжди одне й те саме».
Теги зручні, але після оновлення в registry вони можуть вказувати на новий вміст. Іноді це чудово, коли йдеться про патчі безпеки. Іноді це ламає збирання або змінює поведінку. Проблема не в тегах, а в очікуваннях: коли ви хочете однаковий результат, вам потрібен digest або інша дисципліна фіксації.
Помилка №3: закріпити digest і забути про читабельність Dockerfile.
FROM image@sha256:... без коментарів і без пояснення перетворюється на «заклинання». Через місяць ви самі дивитиметеся на нього як на археологічну знахідку. Залишайте поруч коментар із зрозумілим для людини тегом і змістом: «pinned for reproducibility». Це дрібниця, але вона знижує рівень болю в команді.
Помилка №4: плутати digest з локальним IMAGE ID.
IMAGE ID з docker images — це локальна штука, а digest — ідентифікатор вмісту з registry. Коли ви обговорюєте «щоб у всіх було однаково», вам потрібен digest. Коли ви обговорюєте «що в мене лежить локально», ви дивитеся на image id. Це дві різні розмови, які легко переплутати.
Помилка №5: думати, що digest — це «завжди краще», і ігнорувати сенс оновлень.
Digest робить збирання стабільним, але водночас він робить оновлення базового образу не випадковими: вони тепер відбуваються лише тоді, коли ви явно змінили digest. Це добре, але вимагає звички свідомо оновлювати базові образи. У навчальному репозиторії це особливо важливо: baseline має бути один і перевірений, а не «як оновилося, так оновилося».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ