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

Content-Addressable Storage: SHA256 як ключ

Content-Addressable Storage: SHA256 як ключ

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


У Meadow кожен blob зберігається за його SHA256 хешем. Ім'я файлу = хеш його вмісту. Це дає дедуплікацію (однаковий контент зберігається один раз) і верифікацію (хеш не збігається = дані пошкоджені).

Структура директорій

data/
  blobs/
    sha256/
      a1b2c3d4e5f6...   <- layer blob
      f6e5d4c3b2a1...   <- config blob
  repositories/
    myapp/
      manifests/
        latest           <- manifest JSON
        latest.content-type
        sha256_abc...    <- manifest by digest
  uploads/               <- temp files

Запис blob'у

func (s *Storage) PutBlob(r io.Reader) (string, int64, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    // Пишемо в temp файл і рахуємо хеш одночасно
    tmpFile, _ := os.CreateTemp(
        filepath.Join(s.baseDir, "uploads"), "blob-*")
    tmpPath := tmpFile.Name()

    h := sha256.New()
    size, err := io.Copy(io.MultiWriter(tmpFile, h), r)
    tmpFile.Close()
    if err != nil {
        os.Remove(tmpPath)
        return "", 0, err
    }

    digest := "sha256:" + hex.EncodeToString(h.Sum(nil))
    _, alg, hexStr := parseDigest(digest)
    blobPath := filepath.Join(s.baseDir, "blobs", alg, hexStr)

    os.MkdirAll(filepath.Dir(blobPath), 0755)
    // Atomic rename
    if err := os.Rename(tmpPath, blobPath); err != nil {
        copyFilePath(tmpPath, blobPath)
        os.Remove(tmpPath)
    }

    return digest, size, nil
}

Дивись, яка штука: io.MultiWriter(tmpFile, h) одночасно пише в файл і рахує SHA256. Не потрібно читати файл двічі.

Atomic write: спочатку пишемо в temp файл (в uploads/), потім os.Rename переміщує його на фінальне місце. Якщо процес впаде посеред запису - temp файл залишиться в uploads, але blob не буде пошкоджений.

graph LR
    A["HTTP Body"] --> B["io.MultiWriter"]
    B --> C["temp file<br/>uploads/blob-xxx"]
    B --> D["sha256 hasher"]
    D --> E["digest = sha256:a1b2c3..."]
    C -->|"os.Rename (atomic)"| F["blobs/sha256/a1b2c3..."]

Перевірка існування

func (s *Storage) HasBlob(digest string) bool {
    _, alg, hex := parseDigest(digest)
    path := filepath.Join(s.baseDir, "blobs", alg, hex)
    _, err := os.Stat(path)
    return err == nil
}

Перед upload клієнт робить HEAD запит. Якщо blob вже є - upload можна пропустити. Це дедуплікація: якщо два образи мають однаковий layer, він зберігається один раз.

Manifest storage

Manifest зберігається і за тегом, і за digest:

func (s *Storage) PutManifest(repo, ref string,
    data []byte, contentType string) (string, error) {
    h := sha256.Sum256(data)
    digest := "sha256:" + hex.EncodeToString(h[:])

    // Зберігаємо за тегом
    path := s.manifestPath(repo, ref)
    os.WriteFile(path, data, 0644)
    os.WriteFile(path+".content-type",
        []byte(contentType), 0644)

    // І за digest
    digestPath := s.manifestPath(repo, digest)
    os.WriteFile(digestPath, data, 0644)
    os.WriteFile(digestPath+".content-type",
        []byte(contentType), 0644)

    return digest, nil
}

Content-Type зберігається в sidecar файлі (.content-type). OCI manifests і Docker manifests мають різні media types.

Чому content-addressable краще

  1. Дедуплікація - однаковий layer = один файл
  2. Верифікація - завантажив blob, порахував хеш, порівняв з digest
  3. Іммутабельність - blob не можна "оновити", тільки додати новий
  4. Кешування - якщо digest однаковий, вміст гарантовано однаковий

Чесно про мінуси

Немає garbage collection. Видалений manifest залишає blob'и на диску. З часом storage росте. Docker Registry має garbage collector, який видаляє unreferenced blob'и.

  • os.Rename атомарний лише в межах однієї файлової системи. Якщо uploads/ і blobs/ лежать на різних томах, rename провалиться - тому в коді є fallback на copy. Але copy вже не атомарний: краш посеред копіювання залишить пошкоджений blob.
  • Сам по собі content-addressing не захищає від collision-атак на слабкий хеш. SHA256 поки що безпечний, але SHA-1 (яким Git користувався роками) уже ламали - тому OCI прив'язаний саме до SHA256, а не до "будь-якого хешу".

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

  • Content-addressable storage - не винахід Docker. Git робить те саме з 2005 року: кожен об'єкт зберігається за хешем свого вмісту. Docker фактично взяв ту саму ідею і наклав на шари образів.
  • Розбивка хеша на підкаталоги (blobs/sha256/ab/abcd... у справжніх реєстрах) - це не естетика, а втеча від ліміту файлової системи: десятки тисяч файлів в одному каталозі вбивають продуктивність ext4 і багатьох інших ФС.
  • Та сама властивість, що дає дедуплікацію, унеможливлює "перезапис" тега на місці непомітно: змінив вміст - змінився digest. Саме тому атака на supply chain - це підміна тега, що вказує на інший digest, а не підміна самого blob'у.
  • Sidecar-файл .content-type потрібен, бо ФС не зберігає MIME-тип. Docker-маніфест і OCI-маніфест байтово різні навіть для одного образу - і клієнт обирає формат через заголовок Accept.
  • Сам термін «content-addressable» прийшов не зі storage, а з заліза: content-addressable memory (CAM) у мережевих комутаторах шукає рядок за його вмістом, а не за адресою. Ідея адресувати дані їхнім вмістом старша за Docker на десятиліття.
  • Docker перейшов на content-addressable image ID лише у версії 1.10 (лютий 2016). До того ID образів були випадковими UUID - звідси й легендарна плутанина «той самий образ, але інший ID» при docker save/load.
  • Та сама ідея масштабується до цілої мережі: IPFS адресує кожен файл через CID (хеш вмісту), Nix - кожен артефакт збірки через хеш його входів, а ZFS/Btrfs тримають checksum на кожен блок. Content-addressing - це не фіча реєстру, а окремий клас архітектур.
  • SHA256 - це 256 біт = 64 hex-символи. Імовірність колізії на реалістичній кількості blob'ів настільки мала (birthday bound), що дешевше припустити збій обладнання, ніж збіг хешів.

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

Спочатку я думав, що io.MultiWriter(tmpFile, h) - це просто акуратний трюк, щоб не читати файл двічі. А потім дійшло: без нього взагалі неможливо чесно порахувати digest стрімом - бо я мушу записати дані на диск ДО того, як дізнаюсь їхній хеш, тобто ДО того, як знаю фінальне ім'я файлу. Звідси й уся танець із temp-файлом і atomic rename. Це не оптимізація - це єдиний коректний порядок дій.

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

  • Додати garbage collection: mark-and-sweep по маніфестах, щоб видаляти unreferenced blob'и (як це робить registry garbage-collect).
  • Підрахунок reference count на blob, щоб дедуплікований layer не видалявся, поки на нього посилається хоч один образ.
  • Перевіряти digest і на читанні (GetBlob), а не лише на запис - bit rot на диску content-addressing сам не виявить, поки хтось явно не звірить хеш.

Спробуй сам

# Після push подивись storage:
ls /tmp/meadow-data/blobs/sha256/ | head -5
ls /tmp/meadow-data/repositories/
# Blob size:
du -sh /tmp/meadow-data/blobs/

# А ось той самий принцип у Git - подивись, як воно рахує digest:
echo -n "hello meadow" | git hash-object --stdin
# і дістань об'єкт назад за хешем:
git cat-file -p <hash>

Storage зрозумілий. Далі - Pull з Docker Hub: auth, manifest lists і multi-arch.

Ресурси

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

Попередня: OCI Distribution Spec | Наступна: Pull з Docker Hub