Content-Addressable Storage: SHA256 як ключ¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
У 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 краще¶
- Дедуплікація - однаковий layer = один файл
- Верифікація - завантажив blob, порахував хеш, порівняв з digest
- Іммутабельність - blob не можна "оновити", тільки додати новий
- Кешування - якщо 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.
Ресурси¶
- Content-addressable storage — концепт
- OCI Image Spec — як шари/маніфести використовують SHA256-дайджести
- google/go-containerregistry — Go-бібліотека для CAS у OCI
- Pro Git: Git Objects — канонічне пояснення content-addressing на прикладі Git
- Docker 1.10: content-addressable image IDs — момент, коли Docker перейшов на адресацію образів за хешем
- IPFS: Content addressing — та сама ідея на масштабі розподіленої мережі
- registry garbage collection — як реальний реєстр прибирає unreferenced blob'и
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: OCI Distribution Spec | Наступна: Pull з Docker Hub
