MinIo, как система объектного хранилища данных, заслуженно пользуется любовью разработчиков: инструмент приятный и, довольно, простой в использовании и освоении. Вот и для одного из наших крупных проектов на работе недавно возникла потребность в использовании S3 хранилища, мы, однако, по корпоративным соображениям выбрали для применения в продакшене другой инструмент, а именно - IONOS (компания у нас немецкая и на ионосе много еще чего завязано), но для тестов и для локального запуска скриптов ничего лучше MinIo в голову нам не пришло. Подобное сочетание при этом вызвало необходимость в использовании такой Python библиотеки, которая могла бы работать и "на наших, и на ваших", а в нашем случае и на MinIo, и на IONOS (поменял параметры в конфиге и тот же самый код, что работал локально, начинает работать и с продакшеном) и этой библиотекой стал Boto3 (стандартный пакет minio для этих целей не подходил). Именно об этой констелляции - Python, MinIo и Boto3 - дальше мне и хотелось бы рассказать, ну а если вместо MinIo вы захотите использовать что-то другое, то "поменял параметры в конфиге и тот же самый код, что работал локально, начинает работать и с продакшеном".

В начале был docker compose файл...

Итак, поскольку MinIo нам главным образом нужен для локальной разработки и тестирования, то с локального запуска и начнем. Для этого создадим в нашем проекте docker-compose.yml файл и поместим там следующий сервис:

services:
  minio:
    image: minio/minio
    entrypoint: sh
    command: > 
      -c 'mkdir -p /data/test-bucket #  Этой командой мы сразу создаем нужный
      && minio server /data'         #  нам бакет в MinIo (test-bucket)
    ports: 
      - 9000:9000
      - 9001:9001
    environment:                         #  Эта часть кода нам нужна, чтобы 
      MINIO_ROOT_USER: 'USERNAME'        #  запустить пользовательскую консоль
      MINIO_ROOT_PASSWORD: 'PASSWORD'
      MINIO_ADDRESS: ':9000'
      MINIO_CONSOLE_ADDRESS: ':9001'

Далее запускаем docker compose командой docker compose up и ждем пока подтянется имидж и запустится контейнер. После завершения этих процоессов переходим по адресу http://localhost:9001/login вводим логин и пароль (USERNAME и PASSWORD) и проверяем, что наш тестовый бакет создался. Должна получиться такая картинка:

S3 сервис на Boto3

Итак MinIo запущен, теперь можно перейти к созданию скрипта на библиотеке boto3, который даст нам возможность взаимодействовать с нашим бакетом. Установим интересующую нас библиотеку командой pip install boto3 (или какой-нибудь другой командой, которая используется вашим любимым менеджером зависимостей), а затем создадим файл s3_service.py и поместим в него следующий код:

from io import BytesIO
from pathlib import Path
from typing import Optional, Union

import boto3
from botocore.client import Config
from botocore.exceptions import ClientError
from botocore.response import StreamingBody


class S3BucketService:
    def __init__(
        self,
        bucket_name: str,
        endpoint: str,
        access_key: str,
        secret_key: str,
    ) -> None:
        self.bucket_name = bucket_name
        self.endpoint = endpoint
        self.access_key = access_key
        self.secret_key = secret_key

    def create_s3_client(self) -> boto3.client:
        client = boto3.client(
            "s3",
            endpoint_url=self.endpoint,
            aws_access_key_id=self.access_key,
            aws_secret_access_key=self.secret_key,
            config=Config(signature_version="s3v4"),
        )
        return client

    def upload_file_object(
        self,
        prefix: str,
        source_file_name: str,
        content: Union[str, bytes],
    ) -> None:
        client = self.create_s3_client()
        destination_path = str(Path(prefix, source_file_name))

        if isinstance(content, bytes):
            buffer = BytesIO(content)
        else:
            buffer = BytesIO(content.encode("utf-8"))
        client.upload_fileobj(buffer, self.bucket_name, destination_path)

    def list_objects(self, prefix: str) -> list[str]:
        client = self.create_s3_client()

        response = client.list_objects_v2(Bucket=self.bucket_name, Prefix=prefix)
        storage_content: list[str] = []

        try:
            contents = response["Contents"]
        except KeyError:
            return storage_content

        for item in contents:
            storage_content.append(item["Key"])

        return storage_content

    def delete_file_object(self, prefix: str, source_file_name: str) -> None:
        client = self.create_s3_client()
        path_to_file = str(Path(prefix, source_file_name))
        client.delete_object(Bucket=self.bucket_name, Key=path_to_file)
        

В приведенном коде мы создали класс S3BucketService, который позволяет нам настроить соединение с нашим хранилищем, дает возможность добавления и удаления объектов, а также получения их списка. Самое время настроить параметры соединения с MinIo и проверить, правильно ли все работает. Создадим файл конфигурации (вы можете использовать любой привычный вам формат, лично я буду применять .ini файл и конфигпарсер) и назовем его default.ini. Запишем внутрь такой конфиг:

[s3_storage]
bucket_name = test-bucket
endpoint = http://localhost:9000
access_key = USERNAME
secret_key = PASSWORD

Затем добавим в уже созданный файл s3_service.py следующую функцию:

def s3_bucket_service_factory(config: configparser.ConfigParser) -> S3BucketService:
    return S3BucketService(
        config["s3_storage"]["bucket_name"],
        config["s3_storage"]["endpoint"],
        config["s3_storage"]["access_key"],
        config["s3_storage"]["secret_key"],
    )

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

import configparser

from s3_service import s3_bucket_service_factory

config = configparser.ConfigParser()
config.read('default.ini')

s3 = s3_bucket_service_factory(config)
s3.upload_file_object("test", "test.txt", "test")

Запустим получившийся скрипт, подождем завершения его выполнения и перейдем в пользовательскую консоль MinIo, нажмем на бакет и порадуемся появившейся директории test, с файлом test.txt и его содержанием "test" (ну тут, конечно, прежде чем радоваться, придется сначала файл скачать, но это также можно сделать через GUI, предоставляемое MinIo)!

Тоже самое для любопытства можно проделать и с другими, определенными в классе S3BucketService методами, такими как получение списка объектов (тут проще всего будет просто принтовать результат в консоль) и удаление объекта.

Тесты (ну или что-то типа того)

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

import configparser

from s3_service import s3_bucket_service_factory


OBJECTS_TO_UPLOAD = [1, 2, 3]
config = configparser.ConfigParser()
config.read("default.ini")
S3 = s3_bucket_service_factory(config)


def test_object_is_created():
    for obj in OBJECTS_TO_UPLOAD:
        S3.upload_file_object("test", f"{obj}.txt", "")

    objects_in_bucket = S3.list_objects("test")
    assert len(objects_in_bucket) == len(OBJECTS_TO_UPLOAD)


def test_object_is_deleted():
    for obj in OBJECTS_TO_UPLOAD:
        S3.delete_file_object("test", f"{obj}.txt")

    objects_in_bucket = S3.list_objects("test")
    assert len(objects_in_bucket) == 0

Запустим pytest и вуаля, все работает.

Gitlab-ci для pytest и Minio

В завершении картины приведу также скрипт для gitlab-ci, который запускает pipeline с pytest, может кому-то пригодится.

test-pytest:
  image: 'python:3.9-slim-bullseye'
  stage: test
  needs: []
  variables:
    MINIO_BASE_URL: http://minio:9000
  services:
    - name: minio/minio
      alias: minio
      entrypoint: ['sh']
      command:
        - -c
        - >
          mkdir -p /data/test-bucket
          && minio server /data
      variables:
        MINIO_ROOT_USER: 'USERNAME'
        MINIO_ROOT_PASSWORD: 'PASSWORD'
  before_script:    # тут нам нужно убедиться, что MinIo запущен 
    - apt update    # и лишь потом стартовать тесты
    - apt install -y curl
    - | 
      until curl --output /dev/null --silent --head --fail $MINIO_BASE_URL/minio/health/live; do
        printf '.'
        sleep 1
      done
    - pip install -r requirements.txt
  script:
    - |
      pytest -v .

PS:
Код также можно найти в репозитории.

Комментарии (3)


  1. trabl
    18.08.2024 20:59
    +1

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


  1. minaevmike
    18.08.2024 20:59

    Мне кажется что это нарушает большое количество лучших практик про то что тестовое окружение должно быть приближенно к проду. Это примерно тоже самое что если бы в породе вы использовали MsSql а локально postgres


    1. AlexanderZug Автор
      18.08.2024 20:59

      За ионос нужно отдельно платить и использовать его в тестах, с этой точки зрения, не очень приятно. Не думаю, что приведено удачное сравнение, между MinIo и Ionos какой-то значительной разницы я не заметил, задача была протестировать работу бизнес логики в ее взаимодействие с S3 хранилищем и создавать для этого отдельный бакет именно в Ionos представлялось избыточным.