OCI Distribution Spec: пишемо свій Docker Registry¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
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'ів.
Ресурси¶
- OCI Distribution Spec — довідник API реєстру
- Distribution Spec docs — відрендерена специфікація
- OCI Image Spec — половина "що всередині маніфеста", що доповнює цю транспортну специфікацію
- distribution/distribution — референсна реалізація (Docker registry)
- ORAS & OCI Artifacts — push Helm-чартів, SBOM'ів і WASM через той самий
/v2/API - Distribution Spec conformance suite — тести, які реєстр має пройти, щоб зватися OCI-сумісним
- HTTP Range requests — як pull відновлює недовантажений layer
- Registry HTTP API v2 — історична Docker-документація
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: Event System
