golang

Разбираемся, что такое S3 и делаем простое объектное хранилище на Go

  • пятница, 6 сентября 2024 г. в 00:00:07
https://habr.com/ru/companies/cdnnow/articles/840874/

Привет, Хабр! С вами снова Матвей Мочалов из cdnnow!, и в этом посте мы не будем разбираться с FFmpeg — в этот раз наша рубрика «Эээээксперименты!» будет затрагивать объектные хранилища. Разберёмся, чем S3 отличается от S3, а также почему не всё то S3, что называется S3. А заодно эксперимента ради сделаем своё собственное простенькое объектное хранилище на любимом языке всех DevOps и SRE-инженеров — Go.

Что такое вообще объектные хранилища?

Объектные хранилища — это способ хранения данных, созданный для работы с большими объемами неструктурированной информации. В отличие от обычных файловых систем, где файлы структурированы по папкам, они могут ссылаться друг на друга различными путями, иметь разные уровни доступа и владельцев и в целом имеют строгую иерархию. Объектные хранилища максимально просты и представлены собственно только «объектами», где каждый — это минимальный набор метаданных, главный из которых — это уникальный для объекта идентификатор. 

Объектом может быть что угодно: изображение, видео или текстовый документ. Помимо идентификатора метаданные также содержат дополнительную информацию об объекте, такую как: дата создания, тип файла, автор и другие атрибуты, почти не сказывающиеся на самих свойствах объекта, в отличие от аналогичных в файловой системе (например, тот же автор), почти не сказывающиеся.

Отличие объектных хранилищ от традиционных файловых систем

Привычные файловые системы NTFS, EXT4 и т.д. организуют файлы в виде иерархической структуры папок. Такая модель хорошо работает для небольших объемов данных и простых сценариев использования, когда количество файлов относительно невелико, к примеру, на личном устройстве или домашнем файловом сервере. Но, когда объём данных начинает переваливать за терабайты, а количество пользователей — это не несколько членов вашей семьи, подключающихся по WiFi к NAS, чтобы посмотреть вечером кино, пролистнуть фотки или скачать сканы загранпаспортов, уже начинаются заметные трудности по скорости работы и затрате ресурсов на выполнение операций.

Без жёсткой иерархической структуры все операции к доступу данных сводятся просто к использованию ключа для поиска нужного объекта: никаких долгих поисков по подпапкам подпапок и проверок прав доступа. К тому же, когда основное требование к объекту — это его уникальный идентификатор, система легко масштабируется: просто докидывайте объекты, пока хватает места на накопителе. А если не хватает, то расширьте на лету — капризов, как с файловыми системами, которые потребуют зачастую для подобного финта ушами форматирование или хотя бы отмонтировать раздел — не возникает. 

Особенности объектных хранилищ

Из преимуществ можно отметить:

Масштабируемость. Как уже было ранее упомянуто, легко масштабируются горизонтально путем добавления новых узлов и увеличения емкости хранилища. Только поспевайте подносить новые диски или стойки с СХД в серверную комнату.

Гибкость. В объектных хранилищах можно хранить любые типы данных: от небольших текстовых файлов до крупных видеороликов с тем, как 24 часа режут ножницами воду из-под крана. Всё в равной мере легко и просто контролируется через API.

Простота управления. Никаких заморочек с иерархией. Папки, подпапки, автор, права доступа, настраиваемые через chmod, — это всё не про объектные хранилища. Метаданные хранятся вместе с объектами, что позволяет их легко и быстро индексировать.

Надежность и доступность. Объектные хранилища достаточно просто децентрализовать на физически удалённых устройствах, где данные реплицируются на нескольких узлах, составляя при этом единую систему. Это гарантирует высокую надежность и доступность даже в случае отказа одного или нескольких узлов.

Но есть у объектных хранилищ и недостатки — там, где требуется максимально быстрое выполнение операций или работа с большим количеством маленьких файлов, лучше присмотреться к блочным хранилищам.
Кроме того, объектные хранилища по определению не подходят для работы с данными, где нужна строгая иерархия, различные права доступа, ссылки и т.п. Что, впрочем, никого не останавливает забивать гвозди микроскопом и превращать объектные хранилища в файловые системы. Зачем — история умалчивает.

Применение объектных хранилищ

Объектные хранилища широко используются в различных облачных сервисах и платформах, где зачастую требуется хранение и совершение операций с большим объёмом информации:

Резервное копирование и архивирование. Объектные хранилища идеально подходят для долгосрочного хранения данных, например, для резервных копий и архивов. Особенно, когда бэкапов много, и их нужно сделать и забыть до следующего обвала системы.

Хранение медиафайлов. Если вам требуется создать файлопомойку для вашего онлайн-кинотеатра, куда вы просто хотите скидывать все файлы, не заморачиваясь, объектные хранилища — ваш друг.

Облачные приложения. Облачные сервисы и приложения по модели SaaS или PaaS зачастую используют объектные хранилища для хранения пользовательских данных, логов, отчетов и других неупорядоченных данных, которые, как правило, будут лежать бесхозно до второго пришествия.

Контейнеры и микросервисы. В контейнеризованных средах микросервисов объектные хранилища используются для хранения и передачи данных между различными сервисами, обеспечивая переносимость и децентрализованность архитектуры системы.

Что такое S3?

С основами мы разобрались, а теперь поговорим о S3 — сервисе, протоколе и технологии, который, по сути, стал синонимичным для словосочетания “объектные хранилища”.

S3 предлагает пользователям простой и масштабируемый способ хранения данных через веб-интерфейс. Он поддерживает различные протоколы доступа, включая REST API, и интегрируется с другими сервисами AWS, такими как: EC2, Lambda и RDS. Благодаря своей надежности, доступности и гибкости, S3 стал стандартом де-факто для облачного хранения данных.

Интересно отметить, что, появившись 14 марта 2006 года, S3 в итоге стал не просто очередным сервисом от AWS, а эталоном для всех объектных хранилищ. Это привело к тому, что многие компании и разработчики начали создавать свои решения совместимые с S3 API, чтобы обеспечить пользователям возможность использовать те же инструменты и приложения, что и с S3, но на других платформах.

Как S3, но не S3

Когда мы говорим "S3", то чаще всего подразумевается не сервис от AWS. S3 постепенно превратилось в то, чем стал "ксерокс" для сканеров или "гугл" для поисковых систем — именем нарицательным. Термин "S3" стал обозначать целый класс объектных хранилищ совместимых с оригинальным стандартом API от Amazon.

Причина тому простая: S3 появился достаточно давно, уже 18 лет прошло, то есть сыграл эффект первопроходца. И ко всему прочему первопроходец в лице Amazon: мало того, что это одна из самых богатых мегакорпораций, так она и на облачном рынке без устали претендует на то, чтобы поджать весь рынок под себя. Из плюсов для простых смертных —API S3 оказался крайне простым и понятным в освоении, что способствовало его широкой адаптации. В результате S3 API превратился в своего рода универсальный язык для взаимодействия с объектными хранилищами.

Однако по сравнению с "ксероксом", где термин просто стал синонимом любого сканера, в случае с S3 — ситуация посложнее. S3-совместимые хранилища следуют общему стандарту. Они реализуют тот же API, что и оригинал от Amazon. То есть, будучи знакомым с оригиналом в экосистеме AWS, вы сможет с легкостью работать и с любыми другими S3-совместимыми хранилищами, будь-то Ceph, MinIO и т.п.

В результате эта стандартизация объектных хранилищ по лекалу S3 привела к интересному эффекту на рынке. Компании, не желавшие полностью зависеть от Amazon, чего, кажется, не хочет даже сама Amazon, или ищущие более экономичные альтернативы, чего также, кажется, хочет и сама Amazon, начали разрабатывать свои собственные объектные хранилища совместимые с S3, но которые просто называют S3-хранилищами. Хотя, если бы речь была не о IT, а о пищевых продуктах, то такую историю бы назвали скорее S3-продукт идентичный натуральному, либо S3-продукт имитация. Это, как если бы производители сканеров не просто назывались "ксероксами", а в значительной мере опирались на документацию и стандарты, используемые в оригиналах от самой Xerox.

Ceph, например, через свой RADOS Gateway (RGW) так хорошо имитирует S3, что большая часть приложений, изначально заточенных для AWS, может спокойно работать с Ceph, как с родным. MinIO пошел еще дальше и сделал совместимость с S3 API своим основным преимуществом, делая миграцию из AWS на собственное self-host решение или к провайдеру, использующим MinIO для S3-хранилищ ещё более бесшовным.

Но стоит понимать, что пусть все эти имитации и используют общий API и стандарт S3, они могут серьёзно различаться в своём бэкенде. Ceph и MinIO — это две совершенно разные истории, с как минимум различной производительностью и уровнем потребления ресурсов.

Рубрика Эээксперименты

Теория — это, конечно, здорово, но давайте перейдем к практике и попробуем написать своё объектное хранилище на Go. Почему? А почему бы и да?

Шаг 1: Создание и настройка проекта

Начнем с основ. Создадим директорию для нашего проекта и инициализируем Go-модуль:

```bash
mkdir go-object-storage
cd go-object-storage
go mod init go-object-storage
```

Шаг 2: Написание кода

Теперь самое интересное. Создадим файл main.go и приступаем к написанию кода:

Архитектура приложения

Наше приложение представляет собой простое объектное хранилище. Давайте разберем его ключевые компоненты:

1 Структура Storage:

```
type Storage struct {
    mu    sync.Mutex
    files map[string][]byte
}
```

Это ядро нашего хранилища. Оно использует хэш-таблицу (map) для хранения объектов в памяти, где ключ — это имя файла, а значение — его содержимое в виде байтов. Мьютекс (sync.Mutex) обеспечивает потокобезопасность при одновременном доступе.

2 — Методы Save и Load:

```go
func (s *Storage) Save(key string, data []byte)
func (s *Storage) Load(key string) ([]byte, bool)
```

Эти методы отвечают за сохранение и загрузку объектов. Save сохраняет данные как в оперативной памяти, так и на файловой системе, обеспечивая персистентность данных. Load загружает данные сначала из памяти, а при отсутствии данных там — с диска.

  • В текущей версии приложение сохраняет данные на диск, но при перезапуске сервера они не загружаются назад в память. Так что это лишь заготовка для реализации на будущее.

3 — HTTP-обработчики:

```go
func HandleUpload(w http.ResponseWriter, r *http.Request, storage *Storage)
func HandleDownload(w http.ResponseWriter, r *http.Request, storage *Storage)
func HandleList(w http.ResponseWriter, r *http.Request, storage *Storage)
```

Эти функции обрабатывают HTTP-запросы для загрузки, скачивания и листинга объектов:

  • HandleUpload загружает данные на сервер и сохраняет их в хранилище.

  • HandleDownload предоставляет клиенту данные из хранилища по запросу.

  • HandleList возвращает список всех объектов, хранящихся в системе.

4 — Функция main:

```go

func main() {...}
```

Инициализирует хранилище и запускает HTTP-сервер.

Само приложение

```go
package main

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"sync"
)

const (
	STORAGE_DIR         = "./storage"        // ДИРЕКТОРИЯ ДЛЯ ХРАНЕНИЯ ОБЪЕКТОВ
	UPLOAD_PREFIX_LEN   = len("/upload/")    // ДЛИНА ПРЕФИКСА ДЛЯ МАРШРУТА ЗАГРУЗКИ
	DOWNLOAD_PREFIX_LEN = len("/download/")  // ДЛИНА ПРЕФИКСА ДЛЯ МАРШРУТА ЗАГРУЗКИ
)

// Storage — структура для хранения объектов в памяти
type Storage struct {
	mu    sync.Mutex            // Мьютекс для обеспечения потокобезопасности
	files map[string][]byte      // Хэш-таблица для хранения данных объектов
}

// NewStorage — конструктор для создания нового хранилища
func NewStorage() *Storage {
	return &Storage{
		files: make(map[string][]byte),
	}
}

// Save — метод для сохранения объекта в хранилище
func (s *Storage) Save(key string, data []byte) {
	s.mu.Lock()         // Захватываем мьютекс перед записью
	defer s.mu.Unlock() // Освобождаем мьютекс после записи

	// Сохраняем данные в памяти
	s.files[key] = data

	// Также сохраняем данные на диск
	err := ioutil.WriteFile(STORAGE_DIR+"/"+key, data, 0644)
	if err != nil {
		log.Printf("Ошибка при сохранении файла %s: %v", key, err)
	}
}

// Load — метод для загрузки объекта из хранилища
func (s *Storage) Load(key string) ([]byte, bool) {
	s.mu.Lock()         // Захватываем мьютекс перед чтением
	defer s.mu.Unlock() // Освобождаем мьютекс после чтения

	// Проверяем наличие объекта в памяти
	data, exists := s.files[key]
	if exists {
		return data, true
	}

	// Если объект не найден в памяти, пытаемся загрузить его с диска
	data, err := ioutil.ReadFile(STORAGE_DIR + "/" + key)
	if err != nil {
		return nil, false
	}

	// Если загрузка с диска успешна, кэшируем объект в памяти
	s.files[key] = data
	return data, true
}

// HandleUpload — обработчик для загрузки объектов
func HandleUpload(w http.ResponseWriter, r *http.Request, storage *Storage) {
	if r.Method != http.MethodPost {
		http.Error(w, "Метод не поддерживается", http.StatusMethodNotAllowed)
		return
	}

	// Получаем ключ (имя объекта) из URL
	key := r.URL.Path[UPLOAD_PREFIX_LEN:]

	// Читаем тело запроса (данные объекта)
	data, err := ioutil.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Ошибка чтения данных", http.StatusInternalServerError)
		return
	}

	// Сохраняем объект в хранилище
	storage.Save(key, data)

	// Отправляем ответ клиенту
	w.WriteHeader(http.StatusOK)
	fmt.Fprintf(w, "Объект %s успешно сохранен", key)
}

// HandleDownload — обработчик для загрузки объектов
func HandleDownload(w http.ResponseWriter, r *http.Request, storage *Storage) {
	if r.Method != http.MethodGet {
		http.Error(w, "Метод не поддерживается", http.StatusMethodNotAllowed)
		return
	}

	// Получаем ключ (имя объекта) из URL
	key := r.URL.Path[DOWNLOAD_PREFIX_LEN:]

	// Загружаем объект из хранилища
	data, exists := storage.Load(key)
	if !exists {
		http.Error(w, "Объект не найден", http.StatusNotFound)
		return
	}

	// Отправляем данные объекта клиенту
	w.WriteHeader(http.StatusOK)
	w.Write(data)
}

// HandleList — обработчик для вывода списка всех объектов
func HandleList(w http.ResponseWriter, r *http.Request, storage *Storage) {
	if r.Method != http.MethodGet {
		http.Error(w, "Метод не поддерживается", http.StatusMethodNotAllowed)
		return
	}

	// Захватываем мьютекс для доступа к хэш-таблице объектов
	storage.mu.Lock()
	defer storage.mu.Unlock()

	// Создаем список ключей (имен объектов)
	keys := make([]string, 0, len(storage.files))
	for key := range storage.files {
		keys = append(keys, key)
	}

	// Кодируем список ключей в формат JSON и отправляем клиенту
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(keys)
}

func main() {
	// Проверяем наличие директории для хранения объектов
	if _, err := os.Stat(STORAGE_DIR); os.IsNotExist(err) {
		err := os.Mkdir(STORAGE_DIR, 0755)
		if err != nil {
			log.Fatalf("Ошибка создания директории %s: %v", STORAGE_DIR, err)
		}
	}

	// Создаем новое хранилище
	storage := NewStorage()

	// Настраиваем маршруты для обработки HTTP-запросов
	http.HandleFunc("/upload/", func(w http.ResponseWriter, r *http.Request) {
		HandleUpload(w, r, storage)
	})
	http.HandleFunc("/download/", func(w http.ResponseWriter, r *http.Request) {
		HandleDownload(w, r, storage)
	})
	http.HandleFunc("/list", func(w http.ResponseWriter, r *http.Request) {
		HandleList(w, r, storage)
	})

	// Запускаем HTTP-сервер на порту 8080
	log.Println("Сервер запущен на порту 8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}
```

Компилируем и тестируем

Теперь скомпилируем наше приложение:

```bash
go build -o object-storage
```

И запустим наш свежеиспечённый сервер:

```bash
./object-storage
```

Давайте проверим, как работает наше творение. Используем curl для тестирования:

  1. Загрузка объекта:

```bash
curl -X POST -d "Hello, World!" http://localhost:8080/upload/hello.txt
```
  1. Скачивание объекта:

```bash
curl -O http://localhost:8080/download/hello.txt
```
  1. Получение списка всех объектов:

```bash
curl http://localhost:8080/list
```

Вуаля! Мы создали простое объектное хранилище на Go. Конечно, это лишь базовая реализация, и в реальном мире вам понадобится гораздо больше дополнительного функционала поверх. Но это достаточно хорошая отправная точка для дальнейших экспериментов и изучения объектных хранилищ в рамках пэт-проекта.

P.S. Как можно заметить, в проекте активно используется файловая система Linux, хотя ранее весь пост я распинался про то, как объектные хранилища отличаются от файловых систем и вообще «Это другое»(тм). Всё дело в том, что здесь есть нюанс. То была теория, а на практике, объектные хранилища — это *барабанная дробь* – абстракция. Да, не опять, а снова. И если заглянуть в их суть, основу и базу, на нулевом уровне они будут использовать файловые системы для хранения информации.

Итог

История S3 и объектных-хранилищ в целом наглядно показывает старую и регулярно повторяющуюся историю, как технология за счёт возникновения в нужное время и в нужной компании, за счёт эффекта первопроходца и вкупе с монструозными размерами рынка, становится отраслевым стандартом и именем нарицательным для своих сородичей по цеху.
Впрочем, это было бы принижением достижений S3. Успех как сервиса, так и стандарта в значительной мере был также обеспечен простотой и понятной API и экосистемы в целом, что позволило легко и быстро даже новичками на лету интегрировать его в инфраструктуру своих сервисов и приложений. В итоге и без того укрепив позиции S3, как де-факто стандарта облачных хранилищ.

В cdnnow!, как можно догадаться после прочтения этой статьи, мы предоставляем клиентам доступ к различным хранилищам, включая S3-совместимые, на основе нашей реализации с помощью Ceph. Это позволяет гибко управлять данными, используя знакомые инструменты и процессы. А также не бояться, что из-за очередного пакета санкций придётся сказать “пока” вашему S3 хранилищу в рамках экосистемы AWS.