JavaRush /Курси /Docker for Spring /tag,

tag, digest і стабільний базовий образ

Docker for Spring
Рівень 7 , Лекція 1
Відкрита

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

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ