Hay una clase particular de fallo de infraestructura que ningún panel de monitoreo puede prevenir: aquel en el que tus datos simplemente dejan de existir. Un disco corrupto. Una migración mal ejecutada. Un DROP TABLE ejecutado contra producción a las 2 AM por alguien que creía estar en staging. La única defensa son los respaldos, y los respaldos son tan buenos como el almacenamiento donde aterrizan y el cifrado que los protege.
Construimos un motor de respaldos que podía volcar bases de datos PostgreSQL, MySQL y MongoDB, archivar volúmenes Docker, comprimir todo con gzip, cifrarlo con AES-256-GCM y enviarlo a cualquiera de 13 proveedores de almacenamiento -- desde un directorio local hasta AWS S3 o un Hetzner Storage Box sobre FTPS. Luego descubrimos que FTP sobre IPv6 está roto de formas que nos obligaron a escribir nuestro propio cliente.
El pipeline de respaldos
El motor de respaldos seguía un pipeline lineal: volcado, compresión, cifrado, almacenamiento, registro. Cada etapa era un módulo separado con una sola responsabilidad:
Base de datos/Volumen -> volcado -> comprimir (gzip) -> cifrar (AES-256-GCM) -> almacenar -> registro en BDEl orquestador BackupEngine llamaba a cada etapa en secuencia. Si alguna etapa fallaba, el pipeline se detenía y el registro de respaldo se marcaba como fallido con el mensaje de error. No se dejaban respaldos parciales en el almacenamiento. No se escribían datos sin cifrar a disco.
rustpub async fn execute_backup(
&self,
source: &BackupSource,
destination: &BackupDestination,
master_key: &MasterKey,
) -> Result<BackupRecord> {
let raw_data = match source {
BackupSource::Database { app_id, db_type } =>
self.dump.execute(app_id, db_type).await?,
BackupSource::Volume { app_id, volume } =>
self.volume.archive(app_id, volume).await?,
};
let compressed = self.compress.gzip(&raw_data)?;
let encrypted = self.encryption.encrypt(&compressed, master_key)?;
let path = self.storage.upload(&encrypted, destination).await?;
Ok(BackupRecord {
source: source.clone(),
destination: destination.clone(),
path,
size_bytes: encrypted.len() as i64,
encrypted: true,
created_at: Utc::now(),
})
}Volcados de base de datos vía Docker Exec
Para los respaldos de bases de datos, no instalamos binarios cliente de PostgreSQL o MySQL en el host. En su lugar, ejecutamos comandos de volcado dentro de los contenedores de base de datos en ejecución usando la API exec de Docker:
rust// PostgreSQL: pg_dump vía Docker exec
let output = docker.exec_in_container(
container_id,
&["pg_dump", "-U", &user, "-d", &database, "--format=custom"],
).await?;
// MySQL: mysqldump vía Docker exec
let output = docker.exec_in_container(
container_id,
&["mysqldump", "-u", &user, &format!("-p{}", password), &database],
).await?;
// MongoDB: mongodump con --archive para salida de stream único
let output = docker.exec_in_container(
container_id,
&["mongodump", "--archive", "--db", &database],
).await?;Este enfoque tenía dos ventajas. Primero, sin dependencias en el host -- el motor de respaldos funcionaba en cualquier servidor sin instalar clientes de base de datos. Segundo, coincidencia de versiones -- la herramienta de volcado dentro del contenedor siempre coincidía con la versión de la base de datos, eliminando el problema común donde una discrepancia de versión de pg_dump produce respaldos corruptos.
Los respaldos de volúmenes usaban tar para crear archivos comprimidos de puntos de montaje completos de volúmenes Docker. El archivo se transmitía al mismo pipeline: comprimir, cifrar, almacenar.
AES-256-GCM: Cifrado que no puede degradarse
Cada respaldo era cifrado antes de salir del servidor. Elegimos AES-256-GCM porque proporciona tanto confidencialidad como verificación de integridad en una sola operación. Si un archivo de respaldo es manipulado en el almacenamiento, el descifrado fallará en lugar de producir silenciosamente datos corruptos.
Para respaldos grandes, implementamos cifrado por fragmentos de 4 MB, cada uno con su propio nonce generado aleatoriamente:
rustpub fn upload_encrypted(
data: &[u8],
master_key: &MasterKey,
backend: &StorageBackend,
path: &str,
) -> Result<()> {
const CHUNK_SIZE: usize = 4 * 1024 * 1024; // 4 MB
let mut offset = 0;
let mut chunk_index: u32 = 0;
while offset < data.len() {
let end = (offset + CHUNK_SIZE).min(data.len());
let chunk = &data[offset..end];
// Nonce por fragmento evita reutilización de nonce entre fragmentos
let nonce = generate_nonce();
let encrypted_chunk = aes_256_gcm_encrypt(chunk, master_key, &nonce)?;
let chunk_path = format!("{}.chunk_{:06}", path, chunk_index);
backend.write(&chunk_path, &encrypted_chunk).await?;
offset = end;
chunk_index += 1;
}
Ok(())
}Los nonces por fragmento eran esenciales. AES-GCM se rompe catastróficamente si se reutiliza el mismo nonce con la misma clave. Al generar un nonce aleatorio fresco para cada fragmento de 4 MB, eliminamos el riesgo por completo, incluso para respaldos de varios gigabytes.
13 proveedores de almacenamiento vía OpenDAL
El motor de respaldos necesitaba soportar diversos backends de almacenamiento. Algunos usuarios quieren S3. Algunos quieren Backblaze B2 por el ahorro de costos. Algunos tienen un Hetzner Storage Box por el que ya están pagando. Algunos quieren mantener los respaldos en el mismo servidor en un directorio diferente.
Usamos Apache OpenDAL como capa de abstracción unificada. OpenDAL proporciona una interfaz Operator única para leer, escribir, listar y eliminar archivos a través de docenas de backends de almacenamiento:
rustpub fn build_operator(config: &StorageConfig) -> Result<Operator> {
match config {
StorageConfig::Local { path } => {
let builder = Fs::default().root(path);
Ok(Operator::new(builder)?.finish())
}
StorageConfig::S3 { provider, bucket, region, access_key, secret_key, .. } => {
let mut builder = S3::default()
.bucket(bucket)
.region(region)
.access_key_id(access_key)
.secret_access_key(secret_key);
// Sobreescrituras de endpoint específicas por proveedor
match provider {
S3Provider::Cloudflare => builder = builder
.endpoint(&format!("https://{}.r2.cloudflarestorage.com", account_id)),
S3Provider::DigitalOcean => builder = builder
.endpoint(&format!("https://{}.digitaloceanspaces.com", region)),
S3Provider::Backblaze => builder = builder
.endpoint(&format!("https://s3.{}.backblazeb2.com", region)),
S3Provider::Wasabi => builder = builder
.endpoint(&format!("https://s3.{}.wasabisys.com", region)),
S3Provider::Hetzner => builder = builder
.endpoint("https://fsn1.your-objectstorage.com"),
S3Provider::Aws | S3Provider::MinIO | S3Provider::Generic => {}
}
Ok(Operator::new(builder)?.finish())
}
StorageConfig::Sftp { host, port, username, .. } => {
let builder = Sftp::default()
.endpoint(&format!("{}:{}", host, port))
.user(username);
Ok(Operator::new(builder)?.finish())
}
// FTP y FTPS manejados por separado (ver abajo)
_ => Err(anyhow!("Unsupported provider")),
}
}Los 13 proveedores se agrupaban en tres categorías:
| Categoría | Proveedores |
|---|---|
| Local | Sistema de archivos local |
| Compatible S3 | AWS S3, Cloudflare R2, DigitalOcean Spaces, Backblaze B2, Wasabi, MinIO, Hetzner Object Storage, S3 genérico |
| Transferencia de archivos | SFTP, FTP, FTPS |
| Almacenamiento en la nube | Dropbox, Google Drive |
Cada proveedor tenía su propio endpoint y configuración de región por defecto. El enum S3Provider codificaba las diferencias para que los usuarios solo necesitaran proporcionar su nombre de bucket y credenciales -- la URL del endpoint se derivaba automáticamente.
La pesadilla FTP: IPv6, PASV y TLS SNI
Todo funcionaba maravillosamente hasta que probamos las subidas FTP a un Hetzner Storage Box. La conexión falló con un error críptico:
421 Could not listen for passive connection: invalid passive IP "[2a01"La causa raíz fue una intersección de tres problemas:
Problema 1: IPv6 y PASV. El DNS del Hetzner Storage Box resolvía a una dirección IPv6. El backend FTP de OpenDAL usaba el comando PASV, que es solo IPv4. El servidor intentaba devolver una dirección IPv6 en formato PASV, que la truncaba en los primeros dos puntos, produciendo la basura [2a01.
Problema 2: Sin soporte EPSV. La corrección para PASV en IPv6 es EPSV (Extended Passive Mode). Pero el backend FTP de OpenDAL no exponía una forma de habilitar EPSV. La biblioteca suppaftp subyacente lo soportaba, pero la capa de abstracción de OpenDAL no pasaba la opción.
Problema 3: Hostname TLS SNI. Incluso si forzábamos la resolución a IPv4, OpenDAL usaba la misma cadena tanto para la dirección de conexión TCP como para el hostname de Server Name Indication (SNI) de TLS. Si resolvíamos el hostname a una dirección IPv4 y pasábamos la IP directamente, la verificación del certificado TLS fallaría porque el certificado estaba emitido para u563760.your-storagebox.de, no para 123.45.67.89.
Transmit (un cliente FTP de macOS) funcionaba bien porque usaba EPSV por defecto y manejaba SNI correctamente. Nuestro código no podía porque la abstracción de OpenDAL nos impedía controlar estos detalles de bajo nivel.
La solución: nuestro propio cliente FTP
Eludimos OpenDAL completamente para FTP y FTPS. Usando la biblioteca suppaftp directamente, construimos un cliente FTP dedicado que manejaba los tres problemas:
rustpub struct FtpClient {
host: String,
port: u16,
username: String,
password: String,
use_tls: bool,
}
impl FtpClient {
pub async fn connect(&self) -> Result<AsyncNativeTlsFtpStream> {
let addr = format!("{}:{}", self.host, self.port);
let mut stream = AsyncNativeTlsFtpStream::connect(&addr).await?;
if self.use_tls {
// TLS con hostname SNI correcto
stream = stream.into_secure(
AsyncNativeTlsConnector::from(TlsConnector::new()?),
&self.host, // Hostname original para SNI
).await?;
}
stream.login(&self.username, &self.password).await?;
// Modo EPSV -- funciona con IPv4 e IPv6
stream.set_mode(Mode::ExtendedPassive);
Ok(stream)
}
}El StorageBackend fue refactorizado con un enum interno Engine que enrutaba operaciones a OpenDAL o al cliente FTP personalizado:
rustenum Engine {
OpenDal(Operator),
Ftp(FtpClient),
}
impl StorageBackend {
pub async fn write(&self, path: &str, data: &[u8]) -> Result<()> {
match &self.engine {
Engine::OpenDal(op) => op.write(path, data.to_vec()).await?,
Engine::Ftp(client) => client.write(path, data).await?,
}
Ok(())
}
}El panel también se actualizó: cuando el usuario cambiaba el tipo de proveedor de almacenamiento a FTP, el campo de puerto por defecto cambiaba de 22 (SFTP) a 21 (FTP). Un pequeño detalle, pero uno que prevenía un fallo de conexión garantizado para cada usuario de FTP que no notara el valor por defecto incorrecto.
Programación y retención
Los respaldos sin programación son respaldos que no ocurren. El BackupScheduler usaba expresiones cron para disparar respaldos en intervalos configurados por el usuario:
0 2 <em> </em> *-- diario a las 2 AM0 <em>/6 </em> <em> </em>-- cada 6 horas0 0 <em> </em> 0-- semanal los domingos
Cada programación tenía un conteo de retención. Cuando un nuevo respaldo se completaba, el podador de retención listaba los respaldos existentes para esa programación, los ordenaba por marca de tiempo y eliminaba los más antiguos que excedían el límite. Si configurabas "mantener últimos 7 respaldos diarios," el octavo respaldo dispararía la eliminación del primero.
El programador se ejecutaba como una tarea en segundo plano con seguimiento de próxima ejecución. En cada ciclo, verificaba qué programaciones estaban pendientes, ejecutaba sus respaldos y calculaba el próximo tiempo de ejecución. Los registros de respaldo se almacenaban en la base de datos con seguimiento de estado (pendiente, ejecutando, completado, fallido) para que el panel pudiera mostrar progreso en tiempo real.
La API del proveedor de almacenamiento
Los proveedores de almacenamiento se gestionaban a través de una API CRUD completa con almacenamiento de configuración cifrado. Cuando un usuario añadía un proveedor S3, la clave de acceso y la clave secreta se cifraban con la clave maestra de la instancia antes de almacenarse en la base de datos:
POST /api/v1/storage-providers -- Crear proveedor
POST /api/v1/storage-providers/test -- Probar conexión
GET /api/v1/storage-providers -- Listar proveedores
GET /api/v1/storage-providers/:id -- Obtener proveedor
PATCH /api/v1/storage-providers/:id -- Actualizar proveedor
DELETE /api/v1/storage-providers/:id -- Eliminar proveedor
POST /api/v1/storage-providers/:id/default -- Establecer como predeterminadoEl endpoint de prueba realizaba una sonda de escritura/lectura/eliminación -- subiendo un pequeño archivo de prueba, leyéndolo de vuelta y eliminándolo. Esto verificaba no solo la conectividad sino los permisos reales de lectura/escritura. El DTO de respuesta nunca exponía config_encrypted, asegurando que las credenciales fueran de solo escritura a través de la API.
El conteo final
El sistema de respaldos abarcó tres crates (sh0-backup, sh0-db, sh0-api), una migración de base de datos, 33 tests pasando y una página de panel con tarjetas de proveedores, botones de prueba de conexión y selección de proveedor predeterminado. Soportaba 13 proveedores de almacenamiento, cifrado AES-256-GCM con subidas fragmentadas, programación basada en cron con poda de retención y volcados de base de datos para tres motores de bases de datos.
Y tenía un cliente FTP personalizado -- porque a veces, la única forma de hacer algo funcionar es rodear la abstracción que se supone debería hacerlo fácil.
Siguiente en la serie: Autoescalado en Rust: umbrales de CPU, enfriamientos y balanceo de carga -- cómo construimos escalado horizontal con gestión de réplicas, balanceo de carga Caddy y un autoescalador que vigila métricas de CPU y memoria.