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

Pull з regestry: auth, manifest lists, multi-arch

Pull з regestry: auth, manifest lists, multi-arch

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


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. Тому наш fallback ml.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 образ і відправити в свій реєстр.

Ресурси

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

Попередня: Content-Addressable Storage | Наступна: Push в свій реєстр