Привет, Хабр!
Меня зовут Иван, я автор Telegram-канала и сайта "Код на салфетке". Уже три года я изучаю Python, а последний год занимаюсь фрилансом.
В разработке мне очень нравится Python, но в какой-то момент я понял, что пора двигаться "вширь" и изучать второй язык (при том, что я немного знаком с Java и JavaScript, но эти языки меня не устроили по ряду причин). По итогу я выбрал Rust, т.к. в сравнении с Python он показался мне одновременно сложным и увлекательным — именно это разожгло мой азарт. Но обо всём по порядку.
Первый проект на Rust
Во всех своих проектах я использую CI/CD — для линтеров, тестов и деплоя. Однако иногда возникают ситуации, когда workflow завершается с ошибкой. Чтобы не сидеть на странице репозитория в ожидании окончания сборки, я решил настроить уведомления в Telegram-чат.
Проблема существующих решений в том, что готовые Actions для этого не поддерживают "супергруппы". Это ограничивает отправку уведомлений либо в личные сообщения, либо в обычные чаты, что для меня было неудобно.
"Если нет готового — пиши своё!" — подумал я и приступил к разработке на Rust.
Изучать Rust я решил сразу "в бою" — работая над проектом, вооружившись учебником и гуглом. Было непросто: после привычного "питоновского сахара" Rust показался более глубоким, но в тоже время весьма дружелюбен, в плане информирования об ошибках.
На энтузиазме и с горящими глазами за несколько дней я написал проект Telegram Notify Action.
Считаю, что для первой "пробы пера" результат получился достойным. Возможно, код не идеален и далёк от оптимального, но Action работает так, как нужно. Теперь, если в процессе выполнения workflow что-то идёт не так, уведомление автоматически отправляется в специальный раздел 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)
}
Метод выполняет следующие шаги:
Определяет путь до исполняемого файла программы. Это важно для того, чтобы всегда использовать актуальный путь к файлу конфигурации, независимо от того, откуда запускается программа.
Открывает файл конфигурации и считывает его содержимое.
Парсит содержимое файла из формата 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. Признаюсь, на первых порах разобраться с ней оказалось непросто, но благодаря попыткам и тестам всё встало на свои места.
Код выполняет следующие шаги:
Создание объекта
Credentials
: Здесь используются параметры из конфигурационного файла —s3_access
иs3_secret
. Если что-то идёт не так, ошибка логируется, и метод завершает работу.Инициализация объекта
Region
: В этом объекте указываются регион и endpoint S3-хранилища.Создание объекта
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;
}
}
Здесь:
Получение аргументов: Использую
env::args().collect()
для получения всех аргументов запуска программы в виде вектора строк.Проверка аргументов: Если аргументов меньше двух (не указана команда), программа логирует ошибку и завершает выполнение.
-
Обработка команд:
Если команда
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 последовательно запускаются две функции:
check_outdated_local_backups
— проверяет локальное хранилище на наличие устаревших бэкапов путём анализа метаданных файлов.check_outdated_s3_backups
— выполняет аналогичную проверку для S3-хранилища, используя полеlast_modified
при получении списка объектов.
Если находятся устаревшие бэкапы, они удаляются, чтобы сохранить место в локальном и удалённом хранилищах.
Процесс восстановления
В процессе работы над функционалом восстановления данных возник вопрос: "Как организовать процесс?" Решение оказалось достаточно простым и эффективным:
Получаем список элементов для восстановления — либо все, либо конкретные, если они переданы в аргументах.
По каждому элементу обращаемся к S3-хранилищу и получаем список доступных файлов.
Среди доступных файлов находим самый актуальный (по дате модификации) и скачиваем его в локальную директорию.
Выполняем команду восстановления данных из скачанного бэкапа.
Почему восстановление только из S3-хранилища?
Решение использовать только S3-хранилище для восстановления данных обусловлено несколькими факторами:
Универсальность: При миграции на новый сервер локальные файлы могут отсутствовать, а восстановление напрямую из S3 гарантирует доступность данных.
Снижение риска ошибок: Подкидывание локальных файлов вручную чревато ошибками или несовместимостью версий.
Надёжность: S3-хранилище служит центральным и защищённым местом хранения, что позволяет избежать проблем, связанных с локальной потерей данных.
Итоги
В этой статье я поделился процессом разработки проекта на Rust — ReBack. Конечно, я не описал весь код и не охватил все аспекты, так как во время разработки возникало множество проблем, связанных с отсутствием опыта и глубокого понимания языка. Но, несмотря на все трудности, процесс работы и изучение Rust доставили мне большое удовольствие. Это был вызов самому себе, который я, как мне кажется, смог преодолеть.
Работа над проектом ещё не завершена. Уже поступили запросы на улучшения функционала:
Добавить возможность отправки бэкапов не только в S3-хранилище, но и в Telegram или WebDav.
Реализовать возможность восстановления конкретного бэкапа, а не только самого последнего.
Надеюсь, что программа будет полезна другим пользователям и, возможно, кому-то из вас она пригодится. В моём окружении нет Rust-разработчиков, поэтому я буду рад вашим комментариям, отзывам и критике относительно кода и программы в целом.
Спасибо за прочтение!
Подписывайтесь на мой Telegram-канал, где регулярно публикуются материалы для новичков, или заходите на сайт "Код на салфетке".