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

Меня зовут Иван, я автор 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<Settings> {  
    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<Bucket> {  
    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<String> = 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<dyn Error>> {  
    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-канал, где регулярно публикуются материалы для новичков, или заходите на сайт "Код на салфетке".

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


  1. m1skam
    22.01.2025 10:30

    В качестве развлечения и изучения чего то нового - прикольно. Но в качестве решения даже для одного сервера, я наверное буду использовать проверенную временем Bacula или Bare OS. Не потому что я не доверяю вашему решению, а потому что больше опций и возможностей.

    Нет параметров для вызова утилит резервного копирования баз данных, у того же pg_dump их достаточно много и я хотел бы ими управлять (-с -С -F -x -s и тд)

    На определенных объемах, логичнее становится ежедневно делать инкрементальный бэкап и еженедельно полный. К примеру, если у вас сайт куда загружают большое кол-во pdf или картинок, сам по себе мощный сервер не нужен, потому что обращений мало, но весит все это много, а сжимается плохо и легко за пару лет, бекап переваливает за тцать гигабайт, каждый день создавать такой архив и переливать его в s3 - такое себе. И еще нужно следить, что бы на диске всегда было больше 50% свободного места, а то может случиться сюрприз )

    Жизненный цикл хранения бекапов, так же может быть более сложным чем "храним за последние 14 дней", например "Храним ежедневные бекапы за последние 2 недели и еженедельные бекапы за последние 2 месяца".


    1. proDream Автор
      22.01.2025 10:30

      Спасибо за такой развёрнутый комментарий! Подкинули идей на подумать)


  1. PetyaUmniy
    22.01.2025 10:30

    Изучение новых языков программирования, а особенно таких сложных, это весьма похвально.
    Но зачем все же делать тулзу в той области, где уже и так существует большое количество развитых утилит? Где фактически заранее известно что нет шансов, что этот код будет комуто полезен практически?
    Тот же restic: поддерживает из коробки штук 10 бекендов (и еще штук 40 через rclone, в котором реализовали интерфейс его хранилища), дедупликацию, шифрование, функционал append only, монитирование бекапов по fuse. Для того чтобы пользователь захотел только начать пользоваться ващей утилитой, вам, увы, придется реализавать большую часть из этого.


    1. proDream Автор
      22.01.2025 10:30

      В первую очередь это важно и полезно мне, и то, что существуют аналоги не означает, что нужно сложить лапки и писать то, чего ещё нет.

      Restic же, действительно предоставляет и способы хранения и удобное хранение только изменений а-ля git, но он хранит только файлы, он не делает бэкапы баз данных, например в докере. Да, он может забэкапить весь Docker Volume БДшки, но это не лучший выход и не всегда корректно восстанавливается.

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

      Спасибо за комментарий!


      1. PetyaUmniy
        22.01.2025 10:30

        А что плохого в том чтобы не сохранять неизменившиеся данные 2 и более раза? Это же никак не ограничивает область применения программы (а если вы используете в качестве железки для хранения продвинутую SAN с дедупликацией, то она сделает это за вас невидимо и совершенно так же (сославшись несколько раз по хешу на один чанк), оставляя лишь иллюзию избыточного хранения нескольких копий). Вместо того чтобы заливать одно и то же повторно, нагружая каналы и диски, можно потратить ресурсы на то чтобы перепроверить то что было залито ранее (сравнить хеши) например находясь поближе к хранилищу (=дешевле). А тестовое разбекапливание в обоих случаях никто не отменял.
        Это верно, что для бекапа баз лучше всего подходят специальные программы. В случае postgres и mysql - это скорее всего wal-g для pitr через непрерывное архивирование wal.
        А вот для архивирования БД не понимаю чем не угодили комбинации из 2 программ pg_dump | restic backup --stdin или restic dump | psql для архивирования и восстановления соответственно, при использовании которых еще и 2ное место под дамп не надо резервировать (программы написаные по философии unix должны уметь работать совместно с другими программами). Покрайней мере я делаю так уже годы и не вижу никаких проблем. Еще делаю рестиком файловые бекапы с lvm снепшотов и тоже без проблем.
        Кажется что чем менее программа заточена под конкретное применение (не имеет списка никаких поддерживаемых источников кроме стандартных stdin и файлов) тем более это расширяет её область применения и соотвественно потенциальный пользовательский охват. Так же делает пользовательскую базу более профессиональной.
        Алсо: ни на чем не настаиваю. Peace. :)