Push в свій реєстр: layer creation і manifest upload¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
Pull - це завантаження. Push - створення OCI-образу з локального rootfs і відправка в реєстр. Чотири кроки: створити layer, створити config, зібрати manifest, відправити все.
graph LR
A["rootfs/"] -->|"tar.gz"| B["Layer blob"]
C["Image metadata"] -->|"JSON"| D["Config blob"]
B & D --> E["OCI Manifest"]
E --> F["Registry"]
Крок 1: Layer з rootfs¶
func createLayer(rootfs string, w io.Writer) (string, int64, error) {
h := sha256.New()
countWriter := &countingWriter{
w: io.MultiWriter(w, h),
}
gw := gzip.NewWriter(countWriter)
tw := tar.NewWriter(gw)
filepath.Walk(rootfs, func(path string, info os.FileInfo,
err error) error {
rel, _ := filepath.Rel(rootfs, path)
if rel == "." { return nil }
header, _ := tar.FileInfoHeader(info, "")
header.Name = "./" + rel
if info.Mode()&os.ModeSymlink != 0 {
link, _ := os.Readlink(path)
header.Linkname = link
header.Typeflag = tar.TypeSymlink
}
tw.WriteHeader(header)
if info.Mode().IsRegular() {
f, _ := os.Open(path)
io.Copy(tw, f)
f.Close()
}
return nil
})
tw.Close()
gw.Close()
digest := "sha256:" + hex.EncodeToString(h.Sum(nil))
return digest, countWriter.n, nil
}
Обходимо rootfs, пакуємо в tar, стискаємо gzip, рахуємо SHA256 на льоту.
Зверни увагу, де саме рахується хеш: MultiWriter стоїть після gzip, тобто ми хешуємо стиснуті байти. Це і є layer digest - ім'я, під яким blob житиме в реєстрі. Хеш нестиснутого tar - це інше число, воно називається diffID, і його місце в config'у. До того, чому потрібні обидва, ще повернемось.
Крок 2: Upload layer blob¶
func uploadBlob(client *http.Client, registryURL, repo,
filePath, digest string) error {
// Перевіряємо, чи blob вже є
headURL := fmt.Sprintf("%s/v2/%s/blobs/%s",
registryURL, repo, digest)
headResp, err := client.Head(headURL)
if err == nil && headResp.StatusCode == http.StatusOK {
return nil // вже є, пропускаємо
}
// Upload
f, _ := os.Open(filePath)
defer f.Close()
url := fmt.Sprintf(
"%s/v2/%s/blobs/uploads?digest=%s",
registryURL, repo, digest)
req, _ := http.NewRequest("POST", url, f)
req.Header.Set("Content-Type", "application/octet-stream")
client.Do(req)
return nil
}
HEAD перед upload - оптимізація. Якщо layer вже є в реєстрі (з іншого push), повторно завантажувати не потрібно.
Крок 3: Config blob¶
func createImageConfig(img *Image) ociImageConfig {
return ociImageConfig{
Created: img.CreatedAt.Format(time.RFC3339),
Architecture: "amd64",
OS: "linux",
Config: map[string]interface{}{},
RootFS: ociRootFS{
Type: "layers",
DiffIDs: []string{},
},
}
}
Config - JSON з метаданими образу. Теж завантажується як blob.
Крок 4: Manifest¶
manifest := map[string]any{
"schemaVersion": 2,
"mediaType":
"application/vnd.oci.image.manifest.v1+json",
"config": map[string]any{
"mediaType":
"application/vnd.oci.image.config.v1+json",
"digest": configDigest,
"size": len(configJSON),
},
"layers": []map[string]any{
{
"mediaType":
"application/vnd.oci.image.layer.v1.tar+gzip",
"digest": layerDigest,
"size": layerSize,
},
},
}
manifestJSON, _ := json.Marshal(manifest)
uploadManifest(client, registryURL, ref.Repo,
ref.Tag, manifestJSON)
Manifest зв'язує config і layers. Реєстр зберігає manifest за тегом (latest) і за digest.
А це означає, що конкретні байти manifestJSON мають значення: digest маніфесту рахується саме по них, байт за байтом. Виведи ту саму структуру з відступами - отримаєш валідний маніфест з іншим digest, і docker pull image@sha256:... за старим уже не працює. Саме тому реєстр повертає заголовок Docker-Content-Digest у відповіді на PUT, а не очікує, що ти перерахуєш його сам.
Повний push flow¶
$ sheep push localhost:5000/myapp:v1
pushing to localhost:5000/myapp:v1
creating layer from rootfs...
uploading layer sha256:a1b2c3d4... (45.2 MB)...
uploading config...
uploading manifest...
pushed localhost:5000/myapp:v1
Де граблі¶
Один layer на весь rootfs. Docker створює окремі layers для кожної інструкції Dockerfile. Це дає дедуплікацію і інкрементальні push'і. Наш підхід - весь rootfs як один layer кожного разу.
DiffIDsу config порожній, хоча OCI вимагає, щоб там були uncompressed-хеші шарів. Деякі реєстри й рантайми це проковтнуть, але строгіша валідація (наприклад,cosignчи Harbor) відкине такий маніфест.- Порядок push критичний: спершу всі blob'и (layers + config), маніфест - останнім. Якщо залити маніфест, що посилається на ще не завантажений blob, реєстр відповість
MANIFEST_BLOB_UNKNOWNі відхилить його. - В URL для upload'у немає слеша в кінці. За специфікацією маршрут -
/v2/<name>/blobs/uploads/, зі слешем. Без нього частина реєстрів відповідає404, і push падає з причини, яка взагалі не пов'язана з твоїми байтами. - Monolithic upload одним запитом (
POST .../uploads/?digest=) - це опціональна форма. Реєстр має право відповісти202 Acceptedзамість201 Created, тобто «я відкрив сесію, теперPUTбайти наLocation, який я дав». Наш код статус-код взагалі не дивиться, тому на такому реєстрі він звітує про успіх, хоч нічого не закомітилось - а помилка вилізе пізніше якMANIFEST_BLOB_UNKNOWNна маніфесті, далеко від справжньої причини. - Передаємо
*os.Fileяк body - і Go не знаєContent-Length.http.NewRequestвиводить довжину лише для*bytes.Buffer,*bytes.Readerі*strings.Reader; усе інше йде зTransfer-Encoding: chunked. А monolithic upload специфіковано з явнимContent-Length, і кілька реєстрів chunked-варіант просто відкидають. Виправляється одним рядком -req.ContentLengthзіStat(). tar.FileInfoHeader(info, "")викликається з порожнім link-таргетом, а потім патчиться руками. Працює, але сигнатура не просто так саме така: передати таргет одразу - це те, чого хоче API, а ручне присвоєнняTypeflag- це те, про що інакше забувають.- Тут немає нічого детермінованого. У tar-заголовках лежать mtime, uid/gid і (через Go) дробові частки часу як PAX-записи; вихід gzip залежить від рівня стиснення й версії бібліотеки. Запакуй той самий rootfs двічі - і можеш отримати два різні layer digest для однакового вмісту, що тихо вбиває ту саму дедуплікацію через HEAD, яку ми щойно побудували.
💡 Цікаві факти¶
- Config blob - це теж звичайний blob, який зберігається за digest нарівні з шарами. Тобто "метадані образу" живуть у тому самому content-addressable сховищі, що й гігабайтні layer'и - просто це маленький JSON.
- HEAD-перевірка перед upload (
mount/skip) - не наша вигадка, а частина спеки: саме так працює cross-repository blob mount. Запушив шар уlibrary/nginx- той самий шар уmyappможна "примонтувати" без повторного заливання байтів. - І цей mount - справжній ендпоінт, а не метафора:
POST /v2/<name>/blobs/uploads/?mount=<digest>&from=<інший-репо>.201 Createdозначає, що реєстр зв'язав наявний blob і не передав жодного байта;202 Accepted- що відмовився, і заливати треба довгим шляхом. Колиdocker pushпишеMounted from library/nginx- це саме цей виклик. - У шару два хеші, і вони ніколи не збігаються. Digest у маніфесті - це SHA256 стиснутого blob'у, а diffID у config'у - SHA256 нестиснутого tar. Два реєстри можуть тримати той самий вміст шару під різними digest'ами лише через те, що один перестиснув його іншим рівнем gzip - diffID при цьому однакові, і саме тому config їх носить.
mediaTypeу маніфесті - не косметика: саме за ним реєстр і клієнт розрізняють OCI-образ (vnd.oci.image.manifest.v1+json) і Docker schema 2 (vnd.docker.distribution.manifest.v2+json). Помилишся - іdocker pullне зрозуміє, що завантажив.schemaVersion: 2досі стирчить у кожному маніфесті, хоча schema 1 мертва роками. Це той самий рудимент сумісності, що й/v2/у URL реєстру.- Є один знаменитий константний blob, який ти зустрінеш по всій екосистемі:
sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a, розмір 2. Це SHA256 від двох символів{}. OCI 1.1 дав йому ім'я -application/vnd.oci.empty.v1+json- щоб не-образи (SBOM, Helm-чарти, підписи) могли заповнити обов'язкове полеconfigчимось умовно осмисленим, а не вигадувати payload. Той самий двобайтовий файл лежить у кожному реєстрі на планеті. - Push того самого тега двічі нічого не перезаписує. Вказівник тега переїжджає на новий маніфест, а попередній залишається у сховищі - без посилань, але все ще доступний за digest, аж поки не пройде garbage collection. Ось справжня причина, чому
:latestу production небезпечний, а digest - ні: тег змінний, вміст - ні. - Специфікація обмежує розмір маніфесту - приблизно 4 MiB - і це звучить абсурдно, поки не згадаєш, що маніфест - це список вказівників. Якщо ти в нього вперся, то в тебе не великий маніфест, а тисячі шарів. А overlay2 у Docker, до слова, впирається у 128 шарів.
- gzip уже не єдиний варіант. OCI визначає
application/vnd.oci.image.layer.v1.tar+zstd, а zstd розпаковується в кілька разів швидше - і це важливо, бо на сучасних мережах затримка pull'у визначається розпакуванням, а не пропускною здатністю. BuildKit уміє віддавати такі шари сьогодні; проблема лише в тому, що старі клієнти цього mediaType не знають. - Порядок push - blob'и першими, маніфест останнім - це не домовленість, а модель цілісності. Маніфест - єдине, що робить із набору blob'ів образ, тому реєстр може перевірити весь граф одним рухом на останньому кроці. Це ж означає, що обірваний push лишає осиротілі blob'и, але ніколи - напівзламаний образ.
- OCI 1.1 додав поле
subjectі Referrers API (GET /v2/<name>/referrers/<digest>), щоб підписи й SBOM можна було приєднати до наявного образу, не змінюючи його. До тогоcosignімітував це, пушачи в магічний тег, названий за digest'ом (sha256-<hex>.sig) - побічний канал, зібраний цілком із імен тегів, бо теги були єдиною змінною річчю в спеці.
Що я зрозумів, поки розбирався з темою¶
Поки писав push, дійшло, що "зібрати образ" - це переважно правильно порахувати хеші й розкласти посилання, а не "запакувати файли". Маніфест - просто JSON зі списком digest'ів і розмірів; реєстр навіть не дивиться всередину шарів. Найделікатніше - тримати порядок: blob'и перші, маніфест останній. Один раз переплутав - і отримав MANIFEST_BLOB_UNKNOWN, хоча всі дані фізично вже були на диску реєстру.
Друге, що здивувало, - наскільки push складається з читання статус-кодів, а не з передачі даних. 201 проти 202 на тому самому запиті - це різниця між «готово» і «ти лише почав», і клієнт, який це ігнорує, падає через три кроки з повідомленням про зовсім інший об'єкт. Content-addressing робить модель даних гарно простою і зсуває всю крихкість у протокольний handshake.
Що можна покращити¶
- Розбивати rootfs на окремі шари (хоч би "base + diff") - тоді HEAD-перевірка реально дедуплікуватиме, а не заливатиме весь образ щоразу.
- Заповнювати
DiffIDsсправжніми uncompressed-хешами, щоб маніфест проходив строгу валідацію і підписcosign. - Перевіряти статус-коди відповідей (
client.Doзараз ігнорує помилку) і додати chunked upload для великих шарів. - Виставляти
req.ContentLengthявно, а не дозволяти Go падати в chunked encoding, і обробляти шлях202→PUTнаLocation, щоб код працював із реєстрами без single-request upload'у. - Пробувати
?mount=&from=до того, як щось заливати - один запит, який або зв'яже blob безкоштовно, або скаже надсилати байти. - Нормалізувати tar-заголовки (обнулити mtime, uid/gid у
0, без PAX-записів), щоб той самий rootfs двічі давав той самий digest. Без цього і дедуплікація, і reproducible builds - це театр. - Довіряти заголовку
Docker-Content-Digestяк канонічному digest'у маніфесту, а не перераховувати його локально.
Спробуй сам¶
# Push в локальний Meadow:
sudo ./sheep tag minimal localhost:5000/myapp:v1
sudo ./sheep push localhost:5000/myapp:v1
# Перевір:
curl -s localhost:5000/v2/_catalog | jq .
curl -s localhost:5000/v2/myapp/tags/list | jq .
А тепер те саме повністю руками - повноцінний OCI-образ, запушений лише curl, tar і shasum:
docker run -d -p 5000:5000 --name meadow registry:2
# 1. Шар: один файл, запакований у tar.gz.
mkdir -p rootfs && echo hello > rootfs/hello.txt
tar -C rootfs -czf layer.tar.gz .
# Два хеші одного шару. Це та сама різниця, що й у цікавих фактах:
LAYER_DIGEST="sha256:$(shasum -a 256 layer.tar.gz | cut -d' ' -f1)" # стиснутий
DIFF_ID="sha256:$(gzip -dc layer.tar.gz | shasum -a 256 | cut -d' ' -f1)" # нестиснутий
LAYER_SIZE=$(wc -c < layer.tar.gz | tr -d ' ')
echo "digest=$LAYER_DIGEST"
echo "diffID=$DIFF_ID" # інше число, той самий шар
# 2. Upload у два кроки: відкриваємо сесію, потім PUT байти.
# У Location вже є ?_state=, тому digest дописуємо через '&', а не '?'.
# distribution повертає його абсолютним; спека дозволяє й відносний шлях,
# тому нормалізуємо.
LOC=$(curl -s -X POST -D - -o /dev/null \
http://localhost:5000/v2/myapp/blobs/uploads/ \
| tr -d '\r' | awk '/^[Ll]ocation:/ {print $2}')
case "$LOC" in /*) LOC="http://localhost:5000$LOC";; esac
curl -s -X PUT --data-binary @layer.tar.gz \
-H "Content-Type: application/octet-stream" \
"${LOC}&digest=${LAYER_DIGEST}" \
-o /dev/null -w 'layer: %{http_code}\n' # очікуємо 201
# 3. Config blob - зверни увагу, у diff_ids лежить НЕстиснутий хеш.
cat > config.json <<EOF
{"architecture":"amd64","os":"linux","config":{},
"rootfs":{"type":"layers","diff_ids":["${DIFF_ID}"]}}
EOF
CONFIG_DIGEST="sha256:$(shasum -a 256 config.json | cut -d' ' -f1)"
CONFIG_SIZE=$(wc -c < config.json | tr -d ' ')
LOC=$(curl -s -X POST -D - -o /dev/null \
http://localhost:5000/v2/myapp/blobs/uploads/ \
| tr -d '\r' | awk '/^[Ll]ocation:/ {print $2}')
case "$LOC" in /*) LOC="http://localhost:5000$LOC";; esac
curl -s -X PUT --data-binary @config.json \
-H "Content-Type: application/octet-stream" \
"${LOC}&digest=${CONFIG_DIGEST}" \
-o /dev/null -w 'config: %{http_code}\n'
# 4. Маніфест ОСТАННІМ - і дивимось, як повертається Docker-Content-Digest.
cat > manifest.json <<EOF
{"schemaVersion":2,
"mediaType":"application/vnd.oci.image.manifest.v1+json",
"config":{"mediaType":"application/vnd.oci.image.config.v1+json",
"digest":"${CONFIG_DIGEST}","size":${CONFIG_SIZE}},
"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip",
"digest":"${LAYER_DIGEST}","size":${LAYER_SIZE}}]}
EOF
curl -s -X PUT --data-binary @manifest.json \
-H "Content-Type: application/vnd.oci.image.manifest.v1+json" \
-D - -o /dev/null http://localhost:5000/v2/myapp/manifests/v1 \
| grep -i 'HTTP/\|docker-content-digest'
# 5. Справжній клієнт спокійно з'їдає те, що зібрав curl:
docker pull localhost:5000/myapp:v1
# Перестав поля в маніфесті вище (або просто виведи його з відступами) і запуш ще раз:
# той самий образ, інший Docker-Content-Digest. Digest - це хеш байтів.
# Cross-repository blob mount - той самий шар в інший репозиторій, нуль байтів:
curl -s -X POST -D - -o /dev/null \
"http://localhost:5000/v2/other/blobs/uploads/?mount=${LAYER_DIGEST}&from=myapp" \
| head -1
# 201 Created = примонтовано безкоштовно. 202 Accepted = відмова, заливай довгим шляхом.
# І той знаменитий порожній blob, перевірка в один рядок:
printf '{}' | shasum -a 256
# 44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
Серія Image Registry завершена. Далі - Distributed Systems Patterns.
Ресурси¶
- OCI Distribution Spec — chunked та monolithic uploads
- Distribution Spec docs — відрендерена специфікація
- Pushing blobs — handshake
POST→201/202→PUTдослівно - Cross-repository blob mounting —
?mount=&from=, безкоштовний upload - distribution/distribution — референсна реалізація реєстру
- OCI Image Spec: manifest — усі поля, які ми зібрали руками, включно з
subject - OCI Image Spec: config — де живе
rootfs.diff_idsі навіщо - OCI Image Spec: шари і media types —
tar+gzip,tar+zstdі розділення digest vs diffID - OCI Image Spec: descriptors — трійка
mediaType/digest/sizeі константа порожнього дескриптора - Referrers API — як приєднати підписи й SBOM, не змінюючи образ
- net/http.NewRequest — для яких типів body Go сам ставить
Content-Length, а для яких падає в chunked - archive/tar.FileInfoHeader — той самий аргумент
link, який ми передали як"" - Reproducible builds: SOURCE_DATE_EPOCH — конвенція, якою BuildKit гасить вплив mtime на digest
- google/go-containerregistry — production-рівня Go-клієнт саме для цього флоу
- crane push / crane append — push і інспекція образів без Docker-демона
- ORAS — push довільних артефактів через ті самі ендпоінти
- cosign — підпис образів і трюк із тегом
sha256-<hex>.sig, що передував Referrers API - registry garbage collection — що стається з маніфестом, який лишив по собі тег після переїзду
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: Pull з Docker Hub | Наступна: Desired State vs Actual State
