Pull з regestry: auth, manifest lists, multi-arch¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
sheep pull nginx - одна команда, а під капотом: парсинг reference, отримання токену, обробка manifest list для multi-arch, завантаження шарів. Розберемо кожен крок.
Парсинг image reference¶
func ParseImageRef(s string) ImageRef {
ref := ImageRef{Tag: "latest"}
// "nginx" → registry-1.docker.io/library/nginx:latest
// "user/repo:v2" → registry-1.docker.io/user/repo:v2
// "ghcr.io/user/repo:tag" → ghcr.io/user/repo:tag
if i := strings.LastIndex(s, ":"); i > 0 &&
!strings.Contains(s[i:], "/") {
ref.Tag = s[i+1:]
s = s[:i]
}
parts := strings.Split(s, "/")
switch {
case len(parts) == 1:
ref.Registry = "registry-1.docker.io"
ref.Repo = "library/" + parts[0]
case len(parts) == 2 && !strings.Contains(parts[0], "."):
ref.Registry = "registry-1.docker.io"
ref.Repo = s
default:
ref.Registry = parts[0]
ref.Repo = strings.Join(parts[1:], "/")
}
return ref
}
nginx без слешів - це Docker Hub official image (library/nginx). user/repo без домену - теж Docker Hub. Якщо є крапка в першій частині (ghcr.io) - це інший реєстр.
Auth - Bearer Token¶
Docker Hub вимагає токен для pull. Ось як його отримати:
func (rc *RegistryClient) getToken(ref ImageRef) (string, error) {
if ref.Registry == defaultRegistry {
url := fmt.Sprintf(
"%s?service=%s&scope=repository:%s:pull",
dockerAuthURL, dockerService, ref.Repo)
resp, _ := rc.client.Get(url)
var tokenResp struct {
Token string `json:"token"`
}
json.NewDecoder(resp.Body).Decode(&tokenResp)
return tokenResp.Token, nil
}
return "", nil // anonymous для інших реєстрів
}
sequenceDiagram
participant C as Sheep
participant A as auth.docker.io
participant R as registry-1.docker.io
C->>A: GET /token?service=registry.docker.io&scope=repository:library/nginx:pull
A->>C: {"token": "eyJhbGci..."}
C->>R: GET /v2/library/nginx/manifests/latest<br/>Authorization: Bearer eyJhbGci...
R->>C: manifest list (multi-arch)
Manifest list - multi-arch¶
Сучасні образи на Docker Hub мають manifest list - індекс з маніфестами для різних платформ:
func (rc *RegistryClient) getManifest(registryURL string,
ref ImageRef, token string) (*manifestResponse, error) {
accepts := []string{
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
}
body, mediaType, _ := rc.registryGet(url, token,
strings.Join(accepts, ", "))
if strings.Contains(mediaType, "list") ||
strings.Contains(mediaType, "index") {
var ml manifestList
json.Unmarshal(body, &ml)
// Шукаємо linux/amd64
digest := ""
for _, m := range ml.Manifests {
if m.Platform.OS == "linux" &&
m.Platform.Architecture == "amd64" {
digest = m.Digest
break
}
}
if digest == "" {
digest = ml.Manifests[0].Digest
}
// Завантажуємо конкретний manifest
body, _, _ = rc.registryGet(
registryURL+"/v2/"+ref.Repo+"/manifests/"+digest,
token, singleAccepts)
}
var manifest manifestResponse
json.Unmarshal(body, &manifest)
return &manifest, nil
}
Завантаження шарів¶
func (rc *RegistryClient) pullLayer(registryURL string,
ref ImageRef, token, digest, rootfs string) error {
url := fmt.Sprintf("%s/v2/%s/blobs/%s",
registryURL, ref.Repo, digest)
req, _ := http.NewRequest("GET", url, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, _ := rc.client.Do(req)
defer resp.Body.Close()
return extractLayer(resp.Body, rootfs)
}
Кожен шар - це gzip-стиснутий tar-архів. Шари застосовуються послідовно, кожен додає або видаляє файли (через whiteout).
Результат¶
$ sheep pull nginx
pulling manifest for nginx:latest
pulling layer 1/7 a1b2c3d4e5f6
pulling layer 2/7 f6e5d4c3b2a1
...
pulled nginx:latest (180.5 MB)
Що тут не ідеально¶
Шари завантажуються послідовно. Docker завантажує їх паралельно, що суттєво швидше для образів з багатьма шарами. Також немає кешування шарів між pull'ами різних тегів одного образу.
- Токен анонімний і короткоживучий. На великому образі він може протухнути посеред pull'у - і реєстр поверне
401на середині завантаження шарів. Наш клієнт цього не обробляє: треба ловити401і перезапитувати токен. - Вибір
linux/amd64захардкоджений. На ARM-машині (Apple Silicon, Graviton) це тихо завантажить чужу архітектуру, і контейнер впаде зexec format errorзамість зрозумілої помилки. - Адреса auth-сервера захардкоджена під Docker Hub. За специфікацією її не треба знати заздалегідь: реєстр сам повертає
401із заголовкомWWW-Authenticate: Bearer realm="...",service="...",scope="...", і клієнт має піти саме за тимrealm. Наш код працює з Docker Hub і мовчки віддає anonymous для решти - тому приватний GHCR або ECR через нього не потягнеш. - Порівняння платформи тільки за
OSіArchitectureнеповне. В індексі є щеvariant(v7для 32-бітного ARM,v8для arm64) іos.versionдля Windows. Безvariantна 32-бітному ARM можна вибрати не той образ і знову впасти наexec format error. - Fallback
digest = ml.Manifests[0].Digestвиглядає безпечним, але ним не є: у сучасних індексах поруч з образами лежать attestation-маніфести (SBOM, provenance) зplatform: unknown/unknown. Замість «візьмемо перший, якщо свого не знайшли» треба або чесно повертати помилку, або хоча б відфільтруватиunknown.
💡 Цікаві факти¶
docker pull nginxніколи не звертається доdocker.ioнапряму - він йде наregistry-1.docker.io, а за токеном на окремийauth.docker.io. Самdocker.io- це лише вебсайт. Поділ на auth-сервер і реєстр - частина схеми Bearer-токенів із RFC 6750.- Префікс
library/для офіційних образів - історичний рудимент. Томуnginxнасправдіlibrary/nginx, і саме тому в коді окрема гілка для образу без слешів. - Manifest list (OCI image index) - це чому один тег
nginx:latestпрацює і на x86, і на ARM, і навіть на Windows. Клієнт сам обирає правильний digest за платформою; "образ" під тегом - це насправді список образів. - Docker Hub запровадив rate limits на анонімні pull'и (листопад 2020) - і саме тому анонімний токен, який ми отримуємо безкоштовно, рахується за IP. Багато CI-падінь "з нізвідки" - це якраз вичерпаний ліміт pull'ів: один NAT офісу або один пул runner'ів = один IP на всіх.
- Токен, який ми отримали, - це звичайний JWT, і його можна розкодувати. Всередині claim
accessіз точним переліком дозволів - і, найцікавіше, самі ліміти:"pull_limit":"100","pull_limit_interval":"21600". Тобто анонімний токен не просто рахується за IP - він носить свою квоту в собі: 100 pull'ів на 6 годин, підписані реєстром. - Не всі записи в manifest list - образи. У
nginx:latestполовина записів маєplatform: {os: unknown, architecture: unknown}і анотаціюvnd.docker.reference.type: attestation-manifest- це SBOM і provenance, які додає BuildKit. Тому наш fallbackml.Manifests[0].Digest- міна: якщо потрібної платформи в індексі немає, він може вибрати не образ, а attestation, який запустити неможливо в принципі. - Blob'и Docker Hub не віддає сам: на
GET /blobs/він відповідає307на CDN із підписаним URL у query. І ось тонкість, через яку наш наївний код працює: Go з версії 1.8 при редіректі на інший хост сам викидає заголовокAuthorization. Якби він його тягнув далі, CDN відповів би помилкою - два способи авторизації в одному запиті. - Windows-образи мають шари, які реєстр взагалі не зберігає. У маніфесті вони позначені як
foreign(nondistributable) і несуть полеurls- клієнт тягне їх напряму з серверів Microsoft, бо ліцензія не дозволяє реєстру їх роздавати. Так один формат маніфесту переживає юридичні обмеження. - Видалення файлу в шарі - це насправді створення файлу. Шар не може нічого «прибрати», тому OCI кодує видалення як whiteout-файл
.wh.<ім'я>, а очищення цілого каталогу - як.wh..wh..opq. Тому образ, з якого «видалили» секрет наступним шаром, усе одно містить його в попередньому шарі. - Ранній Docker Registry v2 підписував маніфести через JWS (schema 1), і digest доводилося рахувати по канонізованій формі - будь-яке переформатування JSON ламало підпис. Schema 2 підписи просто викинула, а цілісність тримає той самий content-addressing, що ми розбирали в попередній частині.
Що я зрозумів, поки розбирався з темою¶
Я роками вважав, що "image" - це один файл. А коли почав парсити відповідь Docker Hub, дійшло: тег вказує на manifest list, той - на manifest для платформи, той - на config blob і список layer-digest'ів. Чотири рівні непрямості, і жоден шар не знає свого тега. Тег - це просто рухомий вказівник на самій верхівці, а все під ним - незмінне і адресоване хешем.
Що можна покращити¶
- Завантажувати шари паралельно через
errgroupз обмеженням конкурентності - найшвидший виграш для багатошарових образів. - Визначати платформу через
runtime.GOOS/runtime.GOARCHзамість захардкодженогоlinux/amd64, і повертати зрозумілу помилку, якщо потрібної в індексі немає. - Додати retry з backoff і обробку
401(перезапит токена) - без цього pull великих образів нестабільний. - Дістати auth-сервер із заголовка
WWW-Authenticateпісля першого401замість захардкодженого URL - і той самий код почне працювати з GHCR, ECR і приватними реєстрами. - Перевіряти digest завантаженого шару, а не довіряти реєстру: порахувати SHA256 на льоту через
io.MultiWriter(як у попередній частині) і зіставити з тим, що в маніфесті. - Кешувати шари за digest у спільному blob-store замість розпакування в rootfs кожного образу окремо - тоді
nginx:1.25іnginx:1.26перевикористають спільні шари.
Спробуй сам¶
# Pull nginx з Docker Hub:
sudo ./sheep pull nginx
sudo ./sheep pull alpine:3.19
sudo ./sheep images
# Подивись rootfs:
ls /var/lib/sheep/images/*/rootfs/ | head -10
# А тепер той самий танець вручну, без Docker - спочатку токен:
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/nginx:pull" | jq -r .token)
# Що реально всередині токена. JWT - це три base64url-частини через точку,
# тому спочатку переводимо в звичайний base64 і докидаємо padding:
P=$(echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+')
printf '%s' "$P$(printf '=%.0s' $(seq $(( (4 - ${#P} % 4) % 4 ))))" | base64 -d | jq .access
# Зверни увагу на parameters: pull_limit і pull_limit_interval - твоя квота лежить у токені.
# Manifest list: скільки платформ живе під одним тегом latest -
# і які записи насправді не образи, а attestation:
curl -s -H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.oci.image.index.v1+json" \
https://registry-1.docker.io/v2/library/nginx/manifests/latest \
| jq -c '.manifests[] | {platform, type: .annotations["vnd.docker.reference.type"]}'
Pull працює. Далі - Push: як створити OCI образ і відправити в свій реєстр.
Ресурси¶
- Docker Hub — найпопулярніший публічний реєстр
- OCI Distribution Spec — протокол pull/push
- Token authentication specification — повний танець
401→WWW-Authenticate→ token → повтор запиту - RFC 6750: Bearer Token Usage — стандарт, з якого виріс цей auth-флоу
- OCI Image Index — структура manifest list і поля
platform(включно зvariant) - OCI Layer spec: whiteouts — як шар кодує видалення файлів через
.wh. - Build attestations — звідки в індексі беруться записи з
platform: unknown/unknown - Docker Hub usage and rate limits — актуальні ліміти на анонімні й авторизовані pull'и
- net/http Client: обробка редіректів — чому Go викидає
Authorizationпри переході на інший хост - golang.org/x/sync/errgroup — інструмент для паралельного завантаження шарів
- google/go-containerregistry — production-рівня Go-клієнт
- crane — CLI, щоб дивитись маніфести й шари без Docker
- skopeo — інспекція і копіювання образів між реєстрами
- Registry HTTP API v2 — історичний довідник Docker
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: Content-Addressable Storage | Наступна: Push в свій реєстр
