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

OCI Distribution Spec: пишемо свій Docker Registry

OCI Distribution Spec: пишемо свій Docker Registry

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Docker Registry - це HTTP сервер з конкретним набором endpoints. OCI Distribution Spec описує, які URL-и повинен підтримувати реєстр. Meadow - наш реєстр - реалізує цю специфікацію за 370 рядків.

Endpoints

mux.HandleFunc("/v2/", s.handler)

Один handler розбирає URL і маршрутизує:

func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
    path := strings.TrimPrefix(r.URL.Path, "/v2/")

    // GET /v2/ - version check
    if path == "" || path == "/" {
        w.Header().Set("Docker-Distribution-API-Version",
            "registry/2.0")
        w.WriteHeader(http.StatusOK)
        return
    }

    // GET /v2/_catalog
    if path == "_catalog" {
        s.handleCatalog(w, r)
        return
    }

    // {name}/blobs/uploads - blob upload
    if i := strings.LastIndex(path, "/blobs/uploads"); i > 0 {
        repo := path[:i]
        s.handleBlobUpload(w, r, repo)
        return
    }
    // {name}/blobs/{digest} - blob operations
    if i := strings.LastIndex(path, "/blobs/"); i > 0 {
        repo := path[:i]
        digest := path[i+len("/blobs/"):]
        s.handleBlob(w, r, repo, digest)
        return
    }
    // {name}/manifests/{ref} - manifest operations
    if i := strings.LastIndex(path, "/manifests/"); i > 0 {
        repo := path[:i]
        ref := path[i+len("/manifests/"):]
        s.handleManifest(w, r, repo, ref)
        return
    }
}
graph TB
    subgraph "OCI Distribution Spec endpoints"
        V2["GET /v2/<br/>Version check"]
        CAT["GET /v2/_catalog<br/>List repositories"]
        TAGS["GET /v2/{name}/tags/list<br/>List tags"]
        BLOB_HEAD["HEAD /v2/{name}/blobs/{digest}<br/>Check blob exists"]
        BLOB_GET["GET /v2/{name}/blobs/{digest}<br/>Download blob"]
        BLOB_POST["POST /v2/{name}/blobs/uploads<br/>Start upload"]
        BLOB_PUT["PUT /v2/{name}/blobs/uploads?digest=<br/>Upload blob"]
        MAN_GET["GET /v2/{name}/manifests/{ref}<br/>Get manifest"]
        MAN_PUT["PUT /v2/{name}/manifests/{ref}<br/>Push manifest"]
    end

Blob upload

Monolithic upload - клієнт надсилає весь blob за один запит:

func (s *Server) doBlobUpload(w http.ResponseWriter,
    r *http.Request, repo, expectedDigest string) {
    actualDigest, size, err := s.storage.PutBlob(r.Body)
    if err != nil {
        registryError(w, http.StatusInternalServerError,
            "BLOB_UPLOAD_INVALID", err.Error())
        return
    }

    if expectedDigest != "" &&
        actualDigest != expectedDigest {
        s.storage.DeleteBlob(actualDigest)
        registryError(w, http.StatusBadRequest,
            "DIGEST_INVALID",
            fmt.Sprintf("expected %s, got %s",
                expectedDigest, actualDigest))
        return
    }

    w.Header().Set("Docker-Content-Digest", actualDigest)
    w.Header().Set("Location",
        fmt.Sprintf("/v2/%s/blobs/%s", repo, actualDigest))
    w.WriteHeader(http.StatusCreated)
}

Digest перевіряється після запису. Якщо не збігається - blob видаляється і повертається помилка.

Manifest push/pull

func (s *Server) handleManifest(w http.ResponseWriter,
    r *http.Request, repo, ref string) {
    switch r.Method {
    case http.MethodGet, http.MethodHead:
        data, ct, _ := s.storage.GetManifest(repo, ref)
        w.Header().Set("Content-Type", ct)
        if r.Method == http.MethodGet {
            w.Write(data)
        }

    case http.MethodPut:
        body, _ := io.ReadAll(r.Body)
        ct := r.Header.Get("Content-Type")
        if ct == "" {
            ct = "application/vnd.oci.image.manifest.v1+json"
        }
        digest, _ := s.storage.PutManifest(repo, ref, body, ct)
        w.Header().Set("Docker-Content-Digest", digest)
        w.WriteHeader(http.StatusCreated)
    }
}

OCI error format

func registryError(w http.ResponseWriter,
    status int, code, message string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(map[string]any{
        "errors": []map[string]string{
            {"code": code, "message": message},
        },
    })
}

OCI spec вимагає конкретний формат помилок з кодами: BLOB_UNKNOWN, MANIFEST_UNKNOWN, DIGEST_INVALID.

Де це ламається

Meadow підтримує тільки monolithic blob upload. Chunked upload (для великих layers) не реалізований. Для layer'ів > 1GB це означає, що весь blob має поміститися в пам'яті під час upload.

  • Префікс /v2/ - не косметика. Саме перевірка GET /v2/ повертає 200 (і заголовок Docker-Distribution-API-Version) - так клієнт відрізняє OCI-реєстр від випадкового HTTP-сервера.
  • Маршрутизація через LastIndex на /blobs/, /manifests/ працює, бо ім'я репозиторію може містити слеші (library/nginx), а /blobs/ - ні. Але якщо тег раптом міститиме /manifests/, парсер зламається.

💡 Цікаві факти

  • Шлях /v2/ - це не випадковий номер версії. Це межа між старим Docker Registry v1 (де образи були ланцюжком parent-id, без content-addressing) і v2 (де все - SHA256). v1 досі мертвий, але /v2/ залишився як хендшейк назавжди.
  • OCI Distribution Spec з'явився не на порожньому місці - його буквально витягли з Docker Registry HTTP API v2 і стандартизували. Тому в коді й досі скрізь заголовок Docker-Distribution-API-Version і media types з vnd.docker.* поруч із vnd.oci.*.
  • HTTP-статус 202 Accepted у протоколі upload - не "успіх", а "продовжуй". Реєстр повертає Location із session URL, і клієнт ллє дані туди. Фінальний 201 Created приходить лише після PUT з digest.
  • Помилки в OCI - це не довільний текст, а перелічений набір кодів (BLOB_UNKNOWN, MANIFEST_UNKNOWN, NAME_UNKNOWN). Клієнти типу docker pull парсять саме код, а не повідомлення - тому "красива" помилка з неправильним кодом зламає клієнт.
  • Специфікація навмисно лише про транспорт: вона стандартизує як байти рухаються (blob'и, маніфести, дайджести по HTTP), але нічого не каже про те, що всередині маніфеста - це окрема OCI Image Spec. Саме цей чистий розподіл дозволив тому самому /v2/ API возити Helm-чарти, WASM-модулі та SBOM'и, а не лише образи - це рух "OCI artifacts".
  • Один docker pull nginx - це насправді три типи запитів під капотом: GET маніфеста, GET config-blob, потім GET кожного layer-blob - усе за digest. Реєстр ніколи не "знає", що віддає nginx; він просто повертає content-addressed байти.
  • Cross-repository blob mount - гарний трюк специфікації: POST .../blobs/uploads/?mount=<digest>&from=<repo> дозволяє push'у, що ділить layer із наявним образом, не копіювати нічого - реєстр просто лінкує наявний blob. Дедуплікація на рівні API, а не лише на диску.
  • Теги мутабельні, дайджести - ні. nginx:latest з часом може вказувати на різні маніфести; nginx@sha256:... заморожений назавжди. Саме тому продакшн-деплої пінять образи за digest, а не за тегом.

Що я зрозумів, поки розбирався з темою

Найбільше здивувало, наскільки реєстр - це "тупий" key-value store по HTTP. Я очікував складну логіку, а виявилось: blob'и за digest, маніфести за тегом/digest, і кілька статус-кодів. Уся "магія" Docker насправді живе в клієнті - реєстр лише віддає байти. Коли це дійшло, 370 рядків перестали здаватися підозріло малими.

Що можна покращити

  • Додати chunked upload (PATCH із session URL) - без нього великі layer'и не заллєш, бо весь blob тримається в пам'яті.
  • Реалізувати пагінацію для _catalog і tags/list (заголовок Link, параметри n і last) - на реальному реєстрі список репозиторіїв не влізе в одну відповідь.
  • Винести маршрутизацію з ручного LastIndex на нормальний регексп-роутер, що валідує ім'я репозиторію за грамою зі специфікації.

Спробуй сам

# Запусти Meadow:
./meadow -addr :5000 -data-dir /tmp/meadow-data
# Перевір:
curl -s localhost:5000/v2/
curl -s localhost:5000/v2/_catalog | jq .

Registry працює. Далі - Content-Addressable Storage: SHA256 як ключ для blob'ів.

Ресурси

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

Попередня: Event System