Постановка задачи

Начнём с потребности: Я хочу загрузить файл в систему, чтобы потом его обработать. Здесь вроде всё понятно. Дальше формируем требования:

  • Нужна версионность загруженных файлов.

  • GUI не обязательно, основное взаимодействие будет по API.

  • Файлы доступны другим сервисам по внутренней сети.

  • Загрузка файлов должна быть через относительный URL сервиса (без открытия порта напрямую).

  • ...

Из OpenSource решили выбрать MinIO: потребность закрывает, с настройками разберёмся в процессе. ????

Разворачиваем

Для теста решения будем использовать простенькое приложение, имитирующее сервер. И на его примере посмотрим сложности реализации. Весь код для самостоятельного тестирования можно взять здесь.

Архитектура

Возьмём для теста простейший http.server из Python, который будет хостить веб страничку для загрузки файлов. В первом приближении получили архитектуру как на картинке ниже.

Два докер контейнера, у которых открыты порты наружу. Пользователь обращается к серверу, сервер получает у MinIO ссылку на загрузку, пользователь по этой ссылке загружает файл. Все счастливы.

server.py
import http.server
import socketserver
import os
from minio import Minio

PORT = 8080

remote_url = os.getenv('REMOTE_URL')
minio_host = os.getenv('MINIO_HOST')
minio_port = os.getenv('MINIO_PORT')
minio_url = f'{minio_host}:{minio_port}'

minio_user = os.getenv('MINIO_USER')
minio_password = os.getenv('MINIO_PASSWORD')

bucket_name = 'uploads'

client = Minio(
    minio_url,
    access_key=minio_user,
    secret_key=minio_password,
    secure=False
)

if not client.bucket_exists(bucket_name):
    client.make_bucket(bucket_name)


class Handler(http.server.SimpleHTTPRequestHandler):
    def do_GET(self):
        if '/presignedUrl' in self.path:
            self.send_response(200, 'OK')
            self.send_header('Content-type', 'text/html')
            self.end_headers()

            filename = self.path.split('=')[1]
            original_minio_str = client.presigned_put_object(bucket_name, filename)
            self.wfile.write(str.encode(original_minio_str))
            return
        elif '/files' in self.path:
            self.send_response(200, 'OK')
            self.send_header('Content-type', 'text/html')
            self.end_headers()

            files = list(client.list_objects(bucket_name, recursive=True))
            files_str = '<br>'.join(
                [f'<p>{file.object_name} &emsp; {client.stat_object(bucket_name, file.object_name).metadata}</p>' for
                 file in files])

            self.wfile.write(str.encode(files_str))

            return
        else:
            with open('index.html', 'r') as web_page_file:
                web_page = web_page_file.read()

            self.send_response(200, 'OK')
            self.send_header('Content-type', 'text/html')
            self.end_headers()

            self.wfile.write(str.encode(web_page))

            return


with socketserver.TCPServer(("", PORT), Handler) as httpd:
    print("serving at port", PORT)
    httpd.serve_forever()

index.html
 <input type="file" id="selector" multiple>
<button onclick="upload()">Upload</button>

<div id="status">No uploads</div>

<script type="text/javascript">
  // `upload` iterates through all files selected and invokes a helper function called `retrieveNewURL`.
  function upload() {
        // Get selected files from the input element.
        var files = document.querySelector("#selector").files;
        for (var i = 0; i < files.length; i++) {
            var file = files[i];
            // Retrieve a URL from our server.
            retrieveNewURL(file, (file, url) => {
                // Upload the file to the server.
                uploadFile(file, url);
            });
        }
    }

    // `retrieveNewURL` accepts the name of the current file and invokes the `/presignedUrl` endpoint to
    // generate a pre-signed URL for use in uploading that file: 
    function retrieveNewURL(file, cb) {
        fetch(`/presignedUrl?name=${file.name}`).then((response) => {
            response.text().then((url) => {
                cb(file, url);
            });
        }).catch((e) => {
            console.error(e);
        });
    }

    // ``uploadFile` accepts the current filename and the pre-signed URL. It then uses `Fetch API`
    // to upload this file to S3 at `play.min.io:9000` using the URL:
    function uploadFile(file, url) {
        if (document.querySelector('#status').innerText === 'No uploads') {
            document.querySelector('#status').innerHTML = '';
        }
        fetch(url, {
            method: 'PUT',
            body: file
        }).then(() => {
            // If multiple files are uploaded, append upload status on the next line.
            document.querySelector('#status').innerHTML += `<br>Uploaded ${file.name}.`;
        }).catch((e) => {
            console.error(e);
        });
    }
</script>

Dockerfile
FROM python:alpine

RUN pip install minio

WORKDIR /server

COPY server.py index.html ./

ENTRYPOINT ["python", "/server/server.py"]

docker-compose.yml

version: "3.8"

services:
  minio:
    image: minio/minio
    command: server /data/minio
    ports:
      - 9000:9000
    environment:
      - MINIO_ROOT_USER=minioadmin
      - MINIO_ROOT_PASSWORD=minioadmin
    networks:
      - minio-local
  server:
    container_name: server
    ports:
      - 8080:8080
    build: .
    environment:
      - MINIO_HOST=192.168.44.76
      - MINIO_PORT=9000
      - MINIO_USER=minioadmin
      - MINIO_PASSWORD=minioadmin
    networks:
      - minio-local


networks:
  minio-local:
    driver: bridge

Запуск (не забудьте поменять значение переменной MINIO_HOST на своё).

 docker-compose up -d --build

Проверить загрузку можно по айпишнику и порту в браузере (ниже везде будет http://192.168.44.76, поменяйте на свой)

http://192.168.44.76:8080
http://192.168.44.76:8080

Проверить, что файлы действительно загрузились можно через относительный путь /files, где произойдёт обращение к MinIO и вернётся список файлов с метаданными.

Прячем сервер за Nginx

Сервер спрячем за Nginx по пути /server. MinIO пока оставим на порту 9000.

Просто добавляем nginx в docker-file.yml и монтируем конфиг nginx. Ещё на веб страничке надо поменять путь обращения к серверу для генерации ссылки на загрузку: /presignedUrl -> /server/presignedUrl.

Запускаем, проверяем.

Загрузка и проверка
http://192.168.44.76/server
http://192.168.44.76/server

http://192.168.44.76/server/files
http://192.168.44.76/server/files

Почти у цели

Теперь, собственно, остаётся спрятать MinIO за Nginx и задача решена. Но это часть оказалась самой сложной... Есть несколько issue на гитхабе, которые частично решены, но доступ всё равно через порт. Даже в родных клиентах MinIO при попытке подключения с относительным путём возникает ошибка. Но нам нужна просто ссылка на загрузку файла, поэтому можно "сгенерировать ссылку" во внутренней сети докера, видоизменить её, добавив относительный путь, отдать пользователю, а при загрузке файла на Nginx проделать обратную операцию. Попробуем.

Берём рекомендованные настройки Nginx отсюда, добавляем location /minio-api. Получаем:

nginx.conf
 user  nginx;
worker_processes auto;
worker_priority -20;

error_log  /var/log/nginx/error.log;
pid        /var/run/nginx.pid;

events {
    worker_connections  4000;
}

http {
    server {
        listen 80;
        resolver 127.0.0.11 valid=5s ipv6=off;

        # To allow special characters in headers
        ignore_invalid_headers off;
        # Allow any size file to be uploaded.
        # Set to a value such as 1000m; to restrict file size to a specific value
        client_max_body_size 0;
        # To disable buffering
        proxy_buffering off;

        proxy_redirect off;

        set $server http://server:8080;

        location /server {
                rewrite ^/server(.*) /$1 break;
                proxy_pass $server;
        }

        set $minio_port 9000;
        set $minio_host minio;
        set $minio_url http://$minio_host:$minio_port;

        location ~* ^/minio-api {
           proxy_set_header X-Real-IP $remote_addr;
           proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
           proxy_set_header X-Forwarded-Proto $scheme;
           proxy_set_header Host $http_host;

           proxy_connect_timeout 300;
           # Default is HTTP/1, keepalive is only enabled in HTTP/1.1
           proxy_http_version 1.1;
           proxy_set_header Connection "";
           chunked_transfer_encoding off;

           proxy_pass $minio_url;
        }
    }
}

Перезапускаем, пробуем загрузить файл и..... ФИАСКО... Возвращается 403 ошибка.

Сделаем через curl запрос и посмотрим ответ (привёл к читаемому виду):

 curl -X 'PUT' 'http://192.168.44.76/minio-api/uploads/sausage.drawio?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20230214%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20230214T124222Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=83d00d24d975f6c6263129d85f9a820b319d78d7d6738513a86429a2441b5f03'

<?xml version="1.0" encoding="UTF-8"?>
<Error>
	<Code>SignatureDoesNotMatch</Code>
	<Message>The request signature we calculated does not match the signature you provided. Check your key and signing method.</Message>
	<Key>uploads/sausage.drawio</Key>
	<BucketName>minio-api</BucketName>
	<Resource>/minio-api/uploads/sausage.drawio</Resource>
	<RequestId>1743B1AB6535B5E1</RequestId>
	<HostId>e96154ee-0e6d-422b-8102-feea5d741fae</HostId>
</Error>

Видим ошибку, что забыли добавить rewrite, чтобы убрать minio-api из пути, добавляем, перезапускаем.

rewrite ^/minio-api(.*) $1 break;

Загружаем файл, проверяем на странице с файлами - информация обновилась.

Итого

Нам удалось спрятать сервисы за Nginx без открытия портов напрямую, а пользователю отдавать ссылку, по которой можно грузить файлы.

А что там с HTTPS? Всё будет корректно работать, если осуществить SSL Termination на Nginx. Плюс в том, что это не надо делать отдельно для minIO. Единственное, надо менять в ссылке для пользователя http на https. Оставлю это для самостоятельной работы.

Код файлов можно найти тут.

PS

Во время написания статьи мне удалось всё структурировать и самый "костыль", ради которого я и хотел написать статью, ушёл... В ходе моего тестирования у меня не работала загрузка файлов без переделки http загловка Host на значение minio:9000. Оказалось, что обычного rewrite достаточно.

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


  1. zartdinov
    00.00.0000 00:00

    У нас отдельный поддомен просто


    1. Zvava Автор
      00.00.0000 00:00

      Да, с поддоменом прокатит.

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


  1. PbIXTOP
    00.00.0000 00:00
    +2

    Так и не понял зачем эти rewrite, если можно в proxy_pass указывать заменяемый путь https://nginx.org/ru/docs/http/ngx_http_proxy_module.html#proxy_pass

    set $server http://server:8080/;
    location /server {
      proxy_pass $server;
    }
    set $minio_port 9000;
    set $minio_host minio;
    set $minio_url http://$minio_host:$minio_port/;
    
    location ~* ^/minio-api {
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto $scheme;
      proxy_set_header Host $http_host;
    
      proxy_connect_timeout 300;
      proxy_http_version 1.1;
      proxy_set_header Connection "";
      chunked_transfer_encoding off;
      proxy_pass $minio_url;
    }


    1. Zvava Автор
      00.00.0000 00:00

      С proxy_pass надо быть очень аккуратным.

      Конкретно в вашей конфигурации на страничке по ссылке http://192.168.44.76/server/files открывается как http://192.168.44.76/server

      Я как-то больше привык к rewrite. Для меня он понятнее работает.