Перейти до змісту

Push в свій реєстр: layer creation і manifest upload

Push в свій реєстр: layer creation і manifest upload

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


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, і обробляти шлях 202PUT на 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.

Ресурси

Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow

Попередня: Pull з Docker Hub | Наступна: Desired State vs Actual State