Backup failed: Volume path does not exist: 7fe348ca-4f53-4257-a318-ef9fedc68374Cette erreur raconte toute l'histoire en une ligne. Le moteur de backup a reçu un UUID comme « chemin de volume » et a essayé de l'ouvrir comme un répertoire du système de fichiers. L'UUID était l'identifiant de l'application -- le frontend l'envoyait comme source_id pour un backup de volume. Le moteur le passait à backup_volume(Path::new(&path)), qui vérifiait path.exists() sur le système de fichiers hôte. Un UUID n'est pas un répertoire.
Même si le moteur avait reçu le bon chemin (/var/lib/postgresql/data), il aurait quand même échoué. Ce chemin existe à l'intérieur du conteneur Docker, pas sur l'hôte. Sur macOS, Docker s'exécute dans une VM Linux -- il n'y a pas de /var/lib/postgresql/data sur l'hôte. Sur Linux, les données des volumes Docker se trouvent dans /var/lib/docker/volumes/<nom_du_volume>/_data/, ce qui nécessite un accès root et contourne l'abstraction du driver de stockage de Docker.
La bonne approche consiste à ne jamais toucher au système de fichiers hôte. Il faut utiliser l'API de Docker pour copier les fichiers hors des conteneurs.
L'implémentation originale
rustpub fn backup_volume(path: &Path) -> Result<Vec<u8>> {
if !path.exists() {
return Err(BackupError::BackupFailed(format!(
"Volume path does not exist: {}",
path.display()
)));
}
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut builder = Builder::new(encoder);
builder.append_dir_all(".", path)?;
// ...
}Cette fonction traite le volume comme un répertoire local. Elle l'ouvre avec std::fs, lit chaque fichier et les écrit dans une archive tar. Pour les bind mounts où le chemin hôte est connu, cela fonctionne. Pour les volumes gérés par Docker (la section volumes: dans les templates), non.
Pourquoi exec + tar échoue aussi
Le premier réflexe a été d'utiliser docker exec pour exécuter tar à l'intérieur du conteneur :
rustlet cmd = vec!["tar", "cf", "-", "-C", path, "."];
let output = docker.exec_in_container(container_id, cmd).await?;Cela échoue pour une raison différente. L'API exec de Docker retourne un flux multiplexé où stdout et stderr sont entrelacés avec des en-têtes de trame de 8 octets. Notre parseur de flux convertit les trames stdout en un String Rust via str::from_utf8 :
rustfn parse_multiplexed_stream(data: &[u8]) -> (String, String) {
// ...
if let Ok(text) = std::str::from_utf8(&data[pos..pos + size]) {
match stream_type {
1 => stdout.push_str(text),
// ...
}
}
// ...
}Une archive tar est constituée de données binaires. Les données binaires ne sont pas du UTF-8 valide. L'appel à from_utf8 ignore silencieusement les trames contenant du contenu binaire, produisant une archive corrompue (quasi vide). Ce parseur fonctionne pour la sortie de pg_dump (qui est du texte SQL) mais pas pour les formats binaires.
L'API d'archive Docker
Docker possède une API spécialement conçue pour copier des fichiers dans et hors des conteneurs :
GET /containers/{id}/archive?path=/chemin-- Retourne une archive tar du chemin spécifiéPUT /containers/{id}/archive?path=/chemin-- Envoie une archive tar et l'extrait au chemin spécifié
Ces endpoints gèrent correctement les données binaires (le corps de la réponse est constitué d'octets bruts, pas un flux multiplexé), supportent tout type de fichier et fonctionnent indépendamment des outils installés à l'intérieur du conteneur (pas besoin que tar soit présent).
Nous avons ajouté deux méthodes au client Docker :
rustpub async fn copy_from_container(
&self,
id: &str,
src_path: &str,
) -> Result<Vec<u8>> {
let path = format!(
"/containers/{}/archive?path={}",
id,
urlencoding::encode(src_path)
);
let bytes = self.get_raw(&path).await?;
Ok(bytes.to_vec())
}
pub async fn copy_to_container(
&self,
id: &str,
dest_path: &str,
tar_data: Vec<u8>,
) -> Result<()> {
let path = format!(
"/containers/{}/archive?path={}",
id,
urlencoding::encode(dest_path)
);
self.put_raw(&path, "application/x-tar", Bytes::from(tar_data)).await
}copy_from_container retourne des octets bruts -- pas de conversion UTF-8, pas de parsing de flux, pas de perte de données. copy_to_container était déjà partiellement implémenté (en tant que copy_to_container pour la fonctionnalité d'explorateur de fichiers) mais n'était pas utilisé pour les backups.
Le pipeline de backup après la correction
Le moteur de backup utilise maintenant copy_from_container pour les backups de volumes :
rustBackupSource::Volume { container_id, path } => {
backup_volume_docker(&self.docker, container_id, path).await?
}Et copy_to_container pour les restaurations de volumes :
rustdocker.copy_to_container(container_id, path, data.to_vec()).await?;Les données retournées par copy_from_container sont déjà une archive tar. Le pipeline du moteur les compresse ensuite (gzip), les chiffre optionnellement (AES-256-GCM) et les stocke via le fournisseur de stockage configuré. À la restauration, le processus s'inverse : récupérer, déchiffrer, décompresser et envoyer l'archive tar dans le conteneur.
Résolution de l'identifiant source
Le code original avait aussi un problème d'identifiant. Quand le frontend sélectionnait une application pour un backup de volume, il envoyait l'UUID de l'application comme source_id. Le moteur traitait source_id comme le chemin du volume. La correction a consisté à chercher l'application par son identifiant pour obtenir le container_id, puis à consulter les montages de l'application pour trouver le chemin cible du volume :
rustlet app = App::find_by_id(&pool, &source_id)?;
let container_id = app.container_id.unwrap();
let mounts = AppMount::list_by_app_id(&pool, &app.id)?;
let volume_path = mounts.first()
.map(|m| m.target.clone())
.unwrap_or_else(|| default_volume_path(app.stack.as_deref().unwrap_or("")));Le source_id stocké dans l'enregistrement de backup est passé d'un simple chemin au format container_id:path (par exemple abc123:/var/lib/postgresql/data), pour que les restaurations puissent trouver le bon conteneur et le bon chemin sans nouvelle recherche d'application.
La leçon : utiliser l'API de la plateforme
Chaque runtime de conteneurs fournit des API pour le déplacement de données. Docker a archive. Podman a les mêmes endpoints. Kubernetes a kubectl cp (qui utilise la même approche tar-via-API). Utiliser exec + des commandes shell est tentant parce que cela semble familier, mais cela introduit des dépendances (est-ce que tar est installé ?), des problèmes d'encodage (la sortie est-elle sûre pour les données binaires ?) et des problèmes de permissions (l'utilisateur exec a-t-il les droits de lecture ?).
L'API de la plateforme gère tout cela. Elle fonctionne avec chaque image, chaque système de fichiers, chaque encodage. Pour déplacer des données dans ou hors des conteneurs, préférez toujours l'API native de déplacement de données de la plateforme plutôt que des solutions de contournement basées sur exec.
La fonction backup_volume originale existe toujours pour les chemins directs du système de fichiers (bind mounts où le chemin hôte est connu). Mais pour les volumes gérés par Docker -- qui sont le défaut dans chaque template sh0 -- copy_from_container est la seule approche correcte.