Il y a un moment dans tout projet de programmation système où l'on réalise que le chemin facile n'existe pas. Pour sh0.dev, ce moment est arrivé quand nous avons essayé de communiquer avec Docker.
Le Docker Engine expose une API REST. Elle accepte du JSON, retourne du JSON, et se comporte comme n'importe quel autre service HTTP -- avec une différence critique. Elle écoute sur un socket de domaine Unix à /var/run/docker.sock, pas sur un port TCP. Ce seul détail architectural nous a forcés à écrire notre propre client Docker de zéro, et le résultat est devenu l'un des morceaux de code les plus satisfaisants de toute la base de code sh0.
Pourquoi ne pas simplement exécuter des commandes shell ?
L'approche évidente : appeler le CLI docker depuis Rust avec std::process::Command. Lancer docker ps, parser la sortie, terminé.
Cette approche est un piège.
Le CLI Docker est conçu pour les humains. Son format de sortie change entre les versions. Son mode de sortie JSON (--format '{{json .}}') est inconsistant d'une commande à l'autre. La gestion des erreurs devient du parsing de chaînes. Et chaque invocation du CLI lance un nouveau processus, se connecte au daemon, s'authentifie, exécute la commande, sérialise la sortie et se termine. Quand on gère des dizaines de conteneurs, qu'on tire des images, qu'on streame des logs et qu'on collecte des statistiques en temps réel, les coûts s'accumulent.
Plus fondamentalement, passer par le shell signifie que votre PaaS dépend du CLI Docker installé, à la bonne version, et dans le PATH. sh0 est un binaire unique. Nous ne voulons pas dire aux utilisateurs « installez aussi le CLI Docker version 24.0.7 ou ultérieure ».
L'API Docker Engine est la bonne interface. Elle est versionnée, stable, documentée, et retourne du JSON structuré. La seule question était de savoir comment l'appeler via un socket Unix.
Pourquoi pas reqwest ?
Le client HTTP par défaut de l'écosystème Rust est reqwest. Il est excellent pour appeler des API web en TCP. Mais il ne supporte pas les sockets de domaine Unix. Il n'y a pas d'option de configuration, pas de feature flag, pas de contournement. reqwest utilise hyper en interne, et sa couche de connexion est codée en dur pour le TCP.
Nous aurions pu utiliser la crate bollard, une bibliothèque client Docker en Rust. Mais bollard apporte sa propre couche d'abstraction, son propre système de types, sa propre opinion sur la façon dont la gestion des conteneurs devrait fonctionner. Quand on construit un PaaS, on a besoin d'un contrôle précis sur chaque appel API Docker -- timeouts, streaming, gestion d'erreurs, logique de retry. Les abstractions d'une autre bibliothèque deviennent des contraintes.
Nous sommes donc descendus d'un niveau : hyper 1.x, l'implémentation HTTP sur laquelle reqwest lui-même est construit, avec un connecteur personnalisé qui parle les sockets Unix.
Le UnixConnector : 40 lignes qui ont tout rendu possible
L'intégration complète du socket Unix est une seule struct implémentant tower::Service<Uri>. Voici l'essentiel :
rustuse hyper::Uri;
use hyper_util::rt::TokioIo;
use tokio::net::UnixStream;
use tower::Service;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
#[derive(Clone)]
pub struct UnixConnector {
path: String,
}
impl UnixConnector {
pub fn new(path: impl Into<String>) -> Self {
Self { path: path.into() }
}
}
impl Service<Uri> for UnixConnector {
type Response = TokioIo<UnixStream>;
type Error = std::io::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _uri: Uri) -> Self::Future {
let path = self.path.clone();
Box::pin(async move {
let stream = UnixStream::connect(&path).await?;
Ok(TokioIo::new(stream))
})
}
}C'est le connecteur dans son intégralité. Il ignore l'URI (parce qu'il n'y a pas de résolution DNS ni de port à gérer -- nous nous connectons toujours au même chemin de socket) et retourne un UnixStream tokio enveloppé dans l'adaptateur TokioIo de hyper.
Le DockerClient utilise ensuite ce connecteur avec le pool de connexions de hyper-util :
rustpub struct DockerClient {
client: Client<UnixConnector, Full<Bytes>>,
base: String,
}
impl DockerClient {
pub fn new() -> Self {
let connector = UnixConnector::new("/var/run/docker.sock");
let client = Client::builder(TokioExecutor::new())
.pool_idle_timeout(Duration::from_secs(30))
.build(connector);
Self {
client,
base: "http://localhost/v1.44".to_string(),
}
}
}L'URL base utilise http://localhost comme hôte fictif -- le routage réel passe par le socket Unix, donc l'hôte n'a pas d'importance. Le suffixe /v1.44 nous fixe à la version 1.44 de l'API Docker Engine, garantissant un comportement cohérent quelle que soit la version de Docker installée sur l'hôte.
Les helpers HTTP internes
Chaque appel API Docker passe par un petit ensemble de méthodes internes du DockerClient :
rustimpl DockerClient {
async fn get(&self, path: &str) -> Result<Bytes, DockerError> {
let uri = format!("{}{}", self.base, path).parse::<Uri>()?;
let req = Request::builder()
.method(Method::GET)
.uri(uri)
.header("Host", "localhost")
.body(Full::new(Bytes::new()))?;
let resp = self.client.request(req).await?;
let status = resp.status();
let body = resp.into_body().collect().await?.to_bytes();
if !status.is_success() {
return Err(DockerError::Api {
status: status.as_u16(),
message: String::from_utf8_lossy(&body).to_string(),
});
}
Ok(body)
}
async fn post<T: Serialize>(&self, path: &str, body: &T) -> Result<Bytes, DockerError> {
let json = serde_json::to_vec(body)?;
let uri = format!("{}{}", self.base, path).parse::<Uri>()?;
let req = Request::builder()
.method(Method::POST)
.uri(uri)
.header("Host", "localhost")
.header("Content-Type", "application/json")
.body(Full::new(Bytes::from(json)))?;
let resp = self.client.request(req).await?;
// ... même pattern de vérification de statut
}
}Simple, explicite, pas de magie. Chaque requête inclut le header Host (requis par HTTP/1.1), et chaque réponse vérifie le code de statut avant de retourner le corps. Le type d'erreur transporte à la fois le statut HTTP et le message d'erreur du daemon Docker, offrant ainsi des diagnostics exploitables aux appelants.
Parsing de flux multiplexés : la partie difficile
L'API Docker a une complexité subtile qui piège tout le monde. Quand on demande les logs d'un conteneur ou la sortie d'un exec, le corps de la réponse n'est pas du texte brut. C'est un flux multiplexé où stdout et stderr sont entrelacés, chaque bloc préfixé d'un en-tête de 8 octets :
[stream_type: 1 octet] [0x00: 3 octets] [size: 4 octets big-endian] [payload: size octets]Le type de flux 1 est stdout. Le type de flux 2 est stderr. Si vous essayez de lire cela comme du texte brut, vous obtenez des déchets binaires mélangés à votre sortie de logs.
La logique de parsing gère cela trame par trame :
rustpub fn parse_multiplexed_stream(raw: &[u8]) -> (String, String) {
let mut stdout = String::new();
let mut stderr = String::new();
let mut pos = 0;
while pos + 8 <= raw.len() {
let stream_type = raw[pos];
let size = u32::from_be_bytes([
raw[pos + 4],
raw[pos + 5],
raw[pos + 6],
raw[pos + 7],
]) as usize;
pos += 8; // sauter l'en-tête
if pos + size > raw.len() {
break; // trame incomplète
}
let payload = String::from_utf8_lossy(&raw[pos..pos + size]);
match stream_type {
1 => stdout.push_str(&payload),
2 => stderr.push_str(&payload),
_ => {} // ignorer les autres types de flux
}
pos += size;
}
(stdout, stderr)
}Cette fonction est pure -- pas d'I/O, pas d'async, pas d'état. Elle prend une tranche d'octets et retourne deux chaînes. Cela la rendait trivialement testable :
rust#[test]
fn test_parse_multiplexed_stdout_stderr() {
let mut data = Vec::new();
// trame stdout : "hello"
data.push(1); // type de flux
data.extend_from_slice(&[0, 0, 0]); // padding
data.extend_from_slice(&5u32.to_be_bytes()); // taille
data.extend_from_slice(b"hello");
// trame stderr : "error"
data.push(2);
data.extend_from_slice(&[0, 0, 0]);
data.extend_from_slice(&5u32.to_be_bytes());
data.extend_from_slice(b"error");
let (out, err) = parse_multiplexed_stream(&data);
assert_eq!(out, "hello");
assert_eq!(err, "error");
}Pas de daemon Docker requis. Pas de conteneurs en cours d'exécution. Juste des octets en entrée, des chaînes en sortie. Ce test s'exécute en microsecondes et ne sera jamais instable.
Pourcentage CPU : reproduire la formule de Docker
Les statistiques de conteneurs sont un autre domaine où l'API Docker est d'une complexité trompeuse. Le point de terminaison /containers/{id}/stats retourne un blob JSON avec des compteurs CPU -- mais ce sont des valeurs cumulatives en nanosecondes depuis le démarrage du conteneur, pas des pourcentages. Les convertir en un pourcentage CPU lisible par un humain nécessite le même calcul basé sur les deltas que le CLI de Docker utilise lui-même :
rustpub fn compute_cpu_percent(stats: &ContainerStats) -> f64 {
let cpu_delta = stats.cpu_stats.cpu_usage.total_usage as f64
- stats.precpu_stats.cpu_usage.total_usage as f64;
let system_delta = stats.cpu_stats.system_cpu_usage.unwrap_or(0) as f64
- stats.precpu_stats.system_cpu_usage.unwrap_or(0) as f64;
if system_delta <= 0.0 || cpu_delta < 0.0 {
return 0.0;
}
let num_cpus = stats.cpu_stats.online_cpus
.or_else(|| {
stats.cpu_stats.cpu_usage.percpu_usage
.as_ref()
.map(|v| v.len() as u64)
})
.unwrap_or(1) as f64;
(cpu_delta / system_delta) * num_cpus * 100.0
}La formule est : (cpu_delta / system_delta) <em> num_cpus </em> 100. Les deltas sont calculés entre l'instantané de statistiques actuel et le précédent (Docker fournit les deux dans une seule réponse sous cpu_stats et precpu_stats). La gestion des cas limites -- deltas à zéro, online_cpus manquant, repli sur la longueur de percpu_usage -- reproduit exactement ce que docker stats fait en interne.
Nous avons testé les cas limites explicitement :
rust#[test]
fn test_cpu_percent_zero_delta() {
// Quand system_delta est zéro, CPU% doit être 0.0, pas NaN ou l'infini
let stats = make_stats(1000, 1000, 5000, 5000, 4);
assert_eq!(compute_cpu_percent(&stats), 0.0);
}La division par zéro dans un système de monitoring signifie NaN qui se propage dans vos tableaux de bord. Nous avons fait en sorte que cela ne puisse pas arriver.
Cycle de vie des conteneurs : la surface API complète
Une fois la plomberie bas niveau en place, l'API de gestion des conteneurs était directe mais exhaustive. La surface complète :
- create -- Accepter une configuration de conteneur, POST vers
/containers/create, retourner l'ID du conteneur - start / stop / restart -- POST vers le point de terminaison approprié avec les paramètres de timeout
- remove -- DELETE avec les options de suppression forcée et de suppression des volumes
- inspect -- GET l'état complet du conteneur (en cours d'exécution, code de sortie, adresse IP, montages)
- list -- GET tous les conteneurs avec des filtres optionnels (statut, label)
- wait -- Bloquer jusqu'à ce qu'un conteneur se termine et retourner le code de sortie
- logs -- Récupérer les logs avec parsing de flux multiplexés
- exec -- Créer une instance exec, la démarrer, capturer stdout/stderr séparément
Le helper sh0_container_config() mérite une mention. Il génère une configuration de conteneur Docker avec les valeurs par défaut standard de sh0 :
rustpub struct Sh0ContainerParams {
pub image: String,
pub name: String,
pub port: u16,
pub env: Vec<String>,
pub network: Option<String>,
pub memory_limit: Option<u64>,
pub cpu_quota: Option<u64>,
}Cette struct existe parce que la configuration brute de conteneur de l'API Docker comporte des dizaines de champs, et Clippy se plaint à juste titre des fonctions avec plus de 8 paramètres. La struct est le pattern builder sans le cérémonial.
Gestion des réseaux et des volumes
Deux modules plus petits mais essentiels complétaient le client Docker.
La pièce maîtresse du module réseau est ensure_sh0_network() -- une fonction idempotente qui crée le réseau bridge sh0 s'il n'existe pas déjà. Chaque conteneur géré par sh0 rejoint ce réseau, leur donnant la découverte de services par DNS (le conteneur A peut atteindre le conteneur B par son nom) sans rien exposer au réseau hôte.
Le module volume gère le stockage persistant avec des labels sh0.managed, afin que sh0 puisse distinguer ses propres volumes de ceux créés par l'utilisateur et les nettoyer de manière appropriée.
Le système de types : 40 structs Serde
L'API Docker Engine retourne du JSON profondément imbriqué. Nous avons défini environ 40 structs Rust avec les macros derive de serde pour désérialiser chaque type de réponse dont nous avions besoin :
rust#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ContainerInspect {
pub id: String,
pub name: String,
pub state: ContainerState,
pub config: ContainerConfig,
pub network_settings: NetworkSettings,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ContainerState {
pub status: String,
pub running: bool,
pub exit_code: i64,
pub started_at: String,
pub finished_at: String,
}L'attribut #[serde(rename_all = "PascalCase")] gère la convention Docker des clés JSON en PascalCase (un artefact du fait que Docker est écrit en Go, où les champs exportés sont en majuscules). Sans cela, chaque champ nécessiterait une annotation #[serde(rename = "...")] individuelle.
Tester sans Docker
Un objectif de conception clé : les tests unitaires doivent fonctionner sans Docker installé. Nous y sommes parvenus en gardant la logique pure -- parsing de flux, calcul CPU, génération de configuration -- dans des fonctions séparées des opérations d'I/O.
Les six tests unitaires couvrent :
- Parsing de flux multiplexés (séparation stdout/stderr)
- Parsing de flux de logs
- Calcul du pourcentage CPU (cas normal)
- Pourcentage CPU avec delta à zéro (cas limite)
- Agrégation des I/O réseau sur plusieurs interfaces
- Valeurs par défaut de
sh0_container_config
Ces tests s'exécutent avec cargo test sur n'importe quelle machine, y compris les environnements CI sans Docker.
Les cinq fichiers de tests d'intégration sont protégés par #[cfg(feature = "docker-tests")] et nécessitent un daemon Docker en cours d'exécution. Ils testent l'API réelle : ping du daemon, création et destruction de conteneurs, pull d'images, création et suppression de réseaux, gestion des volumes. On les exécute explicitement avec cargo test --features docker-tests quand Docker est disponible.
Ce que nous avons appris
Écrire un client Docker from scratch nous a enseigné trois choses.
Les sockets Unix ne sont pas spéciaux. Le UnixConnector fait 40 lignes parce que se connecter à un socket Unix est, fondamentalement, la même chose que se connecter à un port TCP -- on obtient un flux d'octets bidirectionnel. hyper ne se soucie pas du type de flux sur lequel il écrit des trames HTTP. La frontière d'abstraction est exactement au bon endroit.
L'API Docker est meilleure que son CLI. Des réponses JSON structurées, des codes de statut HTTP appropriés, le support du streaming, des points de terminaison versionnés. Le CLI est une couche de commodité pour les humains ; l'API est la vraie interface pour les machines.
Les flux multiplexés sont un vrai problème. Chaque bibliothèque client Docker doit résoudre ce problème. Beaucoup se trompent, surtout autour des trames incomplètes et de la séparation des flux d'erreurs. En écrivant notre propre parser, nous avons compris exactement ce que les octets signifiaient, et nous avons pu tester chaque cas limite.
Le client Docker a été la pièce de code la plus difficile que nous ayons écrite le Jour Zéro. Mais c'était aussi la fondation de tout ce qui a suivi : le pipeline de déploiement construit les images à travers lui, le système de monitoring collecte les statistiques à travers lui, le gestionnaire de proxy inspecte les réseaux de conteneurs à travers lui. Chaque conteneur que sh0 gère passe par ces 40 lignes de code socket Unix.
Ceci est la Partie 2 de la série « Comment nous avons construit sh0.dev ».
Navigation de la série : - [1] Jour Zéro : 10 crates Rust en 24 heures - [2] Écrire un client Docker Engine from scratch en Rust (vous êtes ici) - [3] Détection automatique de 19 stacks technologiques depuis le code source - [4] 34 règles pour détecter les erreurs de déploiement avant qu'elles ne surviennent