Привет, Хабр!

Меня зовут Иван, я автор Telegram-канала и сайта "Код на салфетке". Уже три года я изучаю Python, а последний год занимаюсь фрилансом.

В разработке мне очень нравится Python, но в какой-то момент я понял, что пора двигаться "вширь" и изучать второй язык (при том, что я немного знаком с Java и JavaScript, но эти языки меня не устроили по ряду причин). По итогу я выбрал Rust, т.к. в сравнении с Python он показался мне одновременно сложным и увлекательным — именно это разожгло мой азарт. Но обо всём по порядку.


Первый проект на Rust

Во всех своих проектах я использую CI/CD — для линтеров, тестов и деплоя. Однако иногда возникают ситуации, когда workflow завершается с ошибкой. Чтобы не сидеть на странице репозитория в ожидании окончания сборки, я решил настроить уведомления в Telegram-чат.

Проблема существующих решений в том, что готовые Actions для этого не поддерживают "супергруппы". Это ограничивает отправку уведомлений либо в личные сообщения, либо в обычные чаты, что для меня было неудобно.

"Если нет готового — пиши своё!" — подумал я и приступил к разработке на Rust.

Изучать Rust я решил сразу "в бою" — работая над проектом, вооружившись учебником и гуглом. Было непросто: после привычного "питоновского сахара" Rust показался более глубоким, но в тоже время весьма дружелюбен, в плане информирования об ошибках.

На энтузиазме и с горящими глазами за несколько дней я написал проект Telegram Notify Action.

Считаю, что для первой "пробы пера" результат получился достойным. Возможно, код не идеален и далёк от оптимального, но Action работает так, как нужно. Теперь, если в процессе выполнения workflow что-то идёт не так, уведомление автоматически отправляется в специальный раздел Telegram-чата.

Оповещение в Telegram
Оповещение в Telegram

Если вас заинтересовал проект, подробная инструкция по использованию доступна на его странице: Telegram Notify Action.

Буду рад вашим отзывам и предложениям! Но мы немного отвлеклись от основной темы.


Проблема и предпосылки к ReBack

В конце декабря у хостинга TimeWeb произошёл то ли сбой, то ли масштабный DDoS на их зарубежные серверы — об этом мне сообщил заказчик. Нужно было срочно перенести проект на другой сервер. Однако возникла серьёзная проблема: бэкапов у нас не было, а по SSH сервер оказался недоступен.

После двух часов попыток пробиться на сервер всё-таки удалось, и все данные успешно перенесли. Но, как говорится, осадочек остался. Тогда заказчик предложил создать Bash-скрипт для автоматического бэкапа в S3-хранилище, даже предоставив пример.

Однако работа над скриптом, заточенным под что-то конкретное, показалась мне скучной и однообразной. И тут я задумался: "А что, если сделать универсальное решение?" У меня есть несколько собственных серверов с проектами, где настроен простой бэкап. Но он покрывает только самое необходимое. Хотелось создать что-то более универсальное, способное работать с базами данных и файлами.

Идея настолько меня захватила, что остановиться уже не было сил. Так появился ReBack (Restore/Backup Utility).


Начало разработки

Идея заказчика сохранять бэкапы в S3-хранилище сразу мне понравилась. Это удобно и позволяет хранить резервные копии отдельно от основного сервера/сервиса, исключая риск когда "все яйца в одной корзине" (имеется ввиду запущенный S3 на этом же самом сервере).

Требования к проекту

Я сформировал следующий список требований:

  • Универсальность: Утилита должна поддерживать создание резервных копий как для локально установленных баз данных, так и для тех, что работают в Docker. Кроме того, она должна архивировать указанные директории.

  • Простота настройки: Конфигурация должна быть максимально удобной и настраиваться через файл.

  • Интеграция с cron: Программа должна запускаться как cron-задача для автоматизации.

  • Управление временем жизни бэкапов: Необходимо реализовать удаление устаревших резервных копий из локального хранилища и S3-хранилища в соответствии с указанным в конфигурации сроком жизни. Это должно работать индивидуально для каждого элемента.

  • Восстановление: Должна быть возможность восстановить последний бэкап нужного сервиса.

  • Информативность: Программа должна вести логи всех своих действий.

  • Open Source: Проект должен быть доступен для всех — как в готовом виде, так и в виде исходного кода для доработок.

Ожидаемый функционал

Исходя из требований к проекту, в результате ожидался следующий функционал:

  • Backup: Создание резервных копий баз данных и файлов.

  • Restore: Восстановление из резервных копий.

Немного о названии

Существует же мнение, что самое сложное в программировании - придумать название (переменной, класса, метода или функции) и назвать утилиту ReBack, действительно, оказалось не просто

Изначально я назвал проект скучно, но, как мне казалось, предельно понятно: "Universal Backup Restore Utility". Согласитесь, такое и не прочитать с первого раза, чтобы не забыть

Но устроив "мозговой штурм" и замучив несколько человек, оказалось, что решение было на поверхности.

Собственно, отсюда и появилось название проекта Re(store)Back(up)

Структура проекта

На этот раз я решил подойти к структуре проекта более серьёзно. Если в Telegram Notify Action весь код был написан в одном файле main.rs, то для ReBack такой подход уже не подходил.

Я решил разделить проект на файлы и "пакеты" (модули), чтобы улучшить читаемость и упрощение разработки. В процессе работы я обнаружил, что Rust значительно отличается от Python в подходе к областям видимости. Каждую функцию и файл нужно явно объявлять с указанием уровня доступа: только внутри файла, модуля или на уровне всего проекта.

Структура проекта
Структура проекта

Чтение JSON-файла

Разобравшись с особенностями областей видимости, я приступил к чтению файла конфигурации. Для удобства я выбрал формат JSON, так как он широко известен, обладает простой структурой и легко читается.

Формат файла выглядит так:

{  
  "s3_endpoint": "https://s3.example.com",  
  "s3_region": "us-east-1",  
  "s3_bucket": "my-bucket",  
  "s3_access": "access-key",  
  "s3_secret": "secret-key",  
  "s3_path_style": "path",  
  "backup_dir": "/tmp/backups",  
  "elements": [  
    {  
      "element_title": "my_pg_docker_db",  
      "s3_folder": "postgres_docker_backups",  
      "backup_retention_days": 30,  
      "s3_backup_retention_days": 90,  
      "params": {  
        "type": "postgresql_docker",  
        "docker_container": "my_postgres_container",  
        "db_name": "my_database",  
        "db_user": "user",  
        "db_password": "password"  
      }  
    }  
  ]  
}

Этот файл задаёт основные параметры:

  • Адрес S3-хранилища.

  • Регион.

  • Бакет.

  • Access и Secret ключи.

  • Способ доступа к S3: path или virtual-host.

  • Директория для хранения локальных бэкапов.

  • Список элементов для резервного копирования или восстановления.

Для работы с конфигурацией я создал соответствующие структуры данных. Для меня, как питониста, это чем-то похоже на использование dataclasses или Pydantic-моделей.

Структуры для конфигурации
Структуры для конфигурации
Структура элемента
Структура элемента
Энум типов элементов
Энум типов элементов

Чтобы загружать данные из файла конфигурации, я написал метод from_file в структуре Settings:

pub fn from_file() -> io::Result {  
    let exe_path = env::current_exe()?;  
    let exe_dir = exe_path.parent().unwrap();  
  
    let settings_path = exe_dir.join("settings.json");  
  
    let file_content = fs::read_to_string(settings_path)?;  
  
    let settings: Settings = match serde_json::from_str(&file_content) {  
        Ok(data) => data,  
        Err(err) => {  
            error!("Error parsing JSON file: {}", err);  
            return Err(io::Error::new(io::ErrorKind::InvalidData, err));  
        }  
    };  
  
    Ok(settings)  
}

Метод выполняет следующие шаги:

  1. Определяет путь до исполняемого файла программы. Это важно для того, чтобы всегда использовать актуальный путь к файлу конфигурации, независимо от того, откуда запускается программа.

  2. Открывает файл конфигурации и считывает его содержимое.

  3. Парсит содержимое файла из формата JSON в структуру данных с помощью serde_json.

Создание объекта бакета

После формирования объекта настроек я решил, что лучше создавать объект бакета в одном месте и затем использовать его по коду, вместо того чтобы инициализировать его "на месте". Для этого я добавил к структуре Settings метод get_bucket:

pub fn get_bucket(&self) -> Option {  
    let credentials = Credentials::new(  
        Some(&self.s3_access),  
        Some(&self.s3_secret),  
        None,  
        None,  
        None,  
    )  
    .map_err(|err| {  
        error!("Error creating credentials: {}", err);  
        err  
    })  
    .ok()?;  
  
    let region = Region::Custom {  
        region: self.s3_region.clone(),  
        endpoint: self.s3_endpoint.clone(),  
    };  
  
    let bucket_result = Bucket::new(self.s3_bucket.as_str(), region, credentials);  
  
    match bucket_result {  
        Ok(bucket) => match self.s3_path_style {  
            S3PathStyle::VirtualHost => Some(*bucket),  
            S3PathStyle::Path => Some(*bucket.with_path_style()),  
        },  
        Err(err) => {  
            error!("Error creating bucket: {}", err);  
            None  
        }  
    }  
}

Для работы с S3-хранилищем я использовал библиотеку rust-s3. Признаюсь, на первых порах разобраться с ней оказалось непросто, но благодаря попыткам и тестам всё встало на свои места.

Код выполняет следующие шаги:

  1. Создание объекта Credentials: Здесь используются параметры из конфигурационного файла — s3_access и s3_secret. Если что-то идёт не так, ошибка логируется, и метод завершает работу.

  2. Инициализация объекта Region: В этом объекте указываются регион и endpoint S3-хранилища.

  3. Создание объекта Bucket: На основе региона и учётных данных создаётся объект бакета. Если создание завершилось успешно, проверяется тип подключения (VirtualHost или Path), указанный в конфигурации. В зависимости от этого возвращается соответствующий объект.

Если на каком-то из этапов возникает ошибка (например, некорректные учётные данные или неверный регион), она логируется, и метод возвращает None.

Аргументы запуска

Чтобы разделить функционал резервного копирования и восстановления, я решил использовать аргументы запуска программы. Логика следующая:

  • Если программа запускается с аргументом backup, выполняется процесс резервного копирования.

  • Если используется аргумент restore , запускается процесс восстановления всех элементов, либо конкретных элементов, если они указаны в аргументах.

Реализация получилась такой:

let args: Vec = env::args().collect();  
  
if args.len() < 2 {  
    error!("No command argument provided. Exiting.");  
    return;  
}

match args[1].as_str() {  
    "backup" => {  
        start_backup_process(&settings, &bucket).await;  
    }  
    "restore" => {  
        if args.len() > 2 {  
            restore_selected_process(&settings, &bucket, &args).await  
        } else {  
            restore_all_process(&settings, &bucket).await;  
        }  
    }  
    _ => {  
        error!("Unknown argument provided. Exiting.");  
        return;  
    }  
}

Здесь:

  1. Получение аргументов: Использую env::args().collect() для получения всех аргументов запуска программы в виде вектора строк.

  2. Проверка аргументов: Если аргументов меньше двух (не указана команда), программа логирует ошибку и завершает выполнение.

  3. Обработка команд:

    • Если команда backup, вызывается функция start_backup_process.

    • Если команда restore и указаны дополнительные аргументы, вызывается restore_selected_process для восстановления указанных элементов. Если дополнительных аргументов нет, выполняется restore_all_process, восстанавливающая все элементы.

    • Для неизвестных команд логируется ошибка, и программа завершает выполнение.

Процесс бэкапа

Запуская процесс бэкапа, мы попадаем в функцию start_backup_process, где итерируемся по списку элементов, указанных в файле конфигурации.

В ходе итерации сначала создаём объект пути, по которому будет сохранён локальный бэкап. Если директория для локальных бэкапов отсутствует, она создаётся рекурсивно:

let path_str = format!("{}/{}", settings.backup_dir, element.element_title);  
let path = Path::new(&path_str);  
  
if !path.exists() {  
    if let Err(e) = fs::create_dir_all(path) {  
        error!("Failed to create backup dir {}: {}", path.display(), e);  
        continue;  
    }  
    info!("Created backup dir {}", path.display());  
}

После этого вызывается метод структуры элемента perform_backup, который отвечает за создание локального бэкапа для конкретного типа элемента.

Коротко процесс внутри perform_backup:

  • Определяется тип элемента и извлекаются его данные.

  • Создаётся путь для файла бэкапа.

  • Выполняется команда для создания бэкапа.

  • Возвращается объект пути к созданному файлу.

Загрузка в S3

Когда локальный бэкап создан, его необходимо отправить в S3-хранилище. Для этого используется функция upload_file_to_s3:

pub async fn upload_file_to_s3(  
    bucket: &Bucket,  
    path: &Path,  
    s3_folder: &String,  
) -> Result<(), Box> {  
    let file_name = path  
        .file_name()  
        .ok_or_else(|| format!("Failed to extract file name from {}", path.display()))?;  
    let file_name = file_name.to_string_lossy();  
  
    let s3_path = format!("/{}/{}", s3_folder, file_name);  
  
    let file = File::open(path).await?;  
    let mut reader = BufReader::new(file);  
  
    bucket  
        .put_object_stream(&mut reader, s3_path.clone())  
        .await  
        .map_err(|e| format!("Failed to upload file to S3: {}", e))?;  
  
    info!("File uploaded successfully to {}", s3_path);  
    Ok(())  
}

Здесь интересен способ чтения файла. Бэкапы могут быть как маленькими, так и довольно большими по объёму. Изначально я написал простую загрузку файла в оперативную память и его отправку в S3. Однако, при тестировании на файле размером ~500 МБ на сервере, где доступно ~350 МБ оперативной памяти, программа завершалась с ошибкой Out of Memory.

Проблему удалось решить, перейдя на асинхронное потоковое чтение файла через BufReader и отправку данных в S3 таким же образом. Это улучшило производительность и исключило проблему с нехваткой памяти.

Удаление устаревших бэкапов

После успешной загрузки файла в S3 последовательно запускаются две функции:

  1. check_outdated_local_backups — проверяет локальное хранилище на наличие устаревших бэкапов путём анализа метаданных файлов.

  2. check_outdated_s3_backups — выполняет аналогичную проверку для S3-хранилища, используя поле last_modified при получении списка объектов.

Если находятся устаревшие бэкапы, они удаляются, чтобы сохранить место в локальном и удалённом хранилищах.

Процесс восстановления

В процессе работы над функционалом восстановления данных возник вопрос: "Как организовать процесс?" Решение оказалось достаточно простым и эффективным:

  1. Получаем список элементов для восстановления — либо все, либо конкретные, если они переданы в аргументах.

  2. По каждому элементу обращаемся к S3-хранилищу и получаем список доступных файлов.

  3. Среди доступных файлов находим самый актуальный (по дате модификации) и скачиваем его в локальную директорию.

  4. Выполняем команду восстановления данных из скачанного бэкапа.

Почему восстановление только из S3-хранилища?

Решение использовать только S3-хранилище для восстановления данных обусловлено несколькими факторами:

  • Универсальность: При миграции на новый сервер локальные файлы могут отсутствовать, а восстановление напрямую из S3 гарантирует доступность данных.

  • Снижение риска ошибок: Подкидывание локальных файлов вручную чревато ошибками или несовместимостью версий.

  • Надёжность: S3-хранилище служит центральным и защищённым местом хранения, что позволяет избежать проблем, связанных с локальной потерей данных.


Итоги

В этой статье я поделился процессом разработки проекта на Rust — ReBack. Конечно, я не описал весь код и не охватил все аспекты, так как во время разработки возникало множество проблем, связанных с отсутствием опыта и глубокого понимания языка. Но, несмотря на все трудности, процесс работы и изучение Rust доставили мне большое удовольствие. Это был вызов самому себе, который я, как мне кажется, смог преодолеть.

Работа над проектом ещё не завершена. Уже поступили запросы на улучшения функционала:

  • Добавить возможность отправки бэкапов не только в S3-хранилище, но и в Telegram или WebDav.

  • Реализовать возможность восстановления конкретного бэкапа, а не только самого последнего.

Надеюсь, что программа будет полезна другим пользователям и, возможно, кому-то из вас она пригодится. В моём окружении нет Rust-разработчиков, поэтому я буду рад вашим комментариям, отзывам и критике относительно кода и программы в целом.

Спасибо за прочтение!

Подписывайтесь на мой Telegram-канал, где регулярно публикуются материалы для новичков, или заходите на сайт "Код на салфетке".

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