Dans notre dernier article, on a vu comment simplement simuler le réseau et les nœuds d’un cloud.
Mais nous l’avions dit (emoji avion), un cloud, c’est avant tout du logiciel. On ne va pas, dès qu’un nouveau client s’inscrit, aller créer et configurer nous-mêmes le réseau. Pour gérer cela, on va développer ce qu’on appelle un Control Plane.
Le Cloud
Le cloud se divise souvent en deux catégories : ceux qui ont le pistolet chargé, et ceux qui creusent… Non, attendez, ce n’est pas ça…
Le cloud se divise souvent en deux parties : le Control Plane et le Data Plane.
Le Control Plane
Il s’agit du point d’entrée des requêtes pour le cloud, aussi bien des requêtes utilisateurs que des requêtes du système. Il sert à organiser les actions sur le cloud.
Le Data Plane
L’autre partie est là où les données ou les ressources vivent. Le Data Plane, ce sont donc nos nœuds, nos VM, nos disques…
Actuellement, notre cloud est simplement un squelette. On n’a aucune donnée, on n’a même aucune machine, elles sont simplement simulées par des processus isolés au niveau réseau. Mais c’est suffisant pour commencer à créer un Control Plane pour gérer tout ça et permettre d’ajouter de nouveaux tenants, de modifier ceux qui existent et de les supprimer.
Le projet PandaCloud
On va créer un projet C# pour notre Control Plane. Il pourrait être écrit dans n’importe quel langage, donc si vous souhaitez le faire en Java ou Go, vous pouvez. Si vous souhaitez le faire en JavaScript, partez et ne revenez jamais ici.
Le modèle
Nous allons commencer par représenter nos classes de modèle, qui serviront à représenter :
- un tenant ;
- un nœud ;
- l’état général de notre cloud.
Les classes vont être assez parlantes par elles-mêmes, donc inutile de passer trop de temps dessus, mais voici quand même les structures pour ne pas vous perdre :
public class Tenant
{
public string Name { get; set; } = string.Empty;
public string BridgeName { get; set; } = string.Empty;
public string Subnet { get; set; } = string.Empty;
public string Gateway { get; set; } = string.Empty;
public int NextHostAddress { get; set; } = 10;
}
public enum NodeState
{
Available,
Allocated
}
public class Node
{
public string Name { get; set; } = string.Empty;
public int CpuCores { get; set; }
public NodeState State { get; set; } = NodeState.Available;
public string? TenantName { get; set; } = null;
public string? IpAddress { get; set; } = null;
}
public class CloudState
{
public List<Tenant> Tenants { get; init; } = [];
public List<Node> Nodes { get; init; } = [];
}
La prochaine étape est de pouvoir gérer l’infrastructure pour créer des nœuds, les allouer à des tenants, etc.
Actuellement, bien sûr, tout cela est fait sur un seul Linux, mais dans le futur, le Control Plane pourrait très bien être utilisé pour gérer des ressources sur Azure ou un autre cloud provider. Dans ce cas, le Control Plane ne devrait plus exécuter du Bash, mais pourrait utiliser des templates ARM, du Terraform ou directement communiquer avec un autre système d’infrastructure.
L’infrastructure
On va donc rendre cela assez flexible en commençant par définir une interface avec les actions que l’on doit pouvoir effectuer :
public interface IInfrastructureProvider
{
Task CreateTenantNetworkAsync(Tenant tenant);
Task DeleteTenantNetworkAsync(Tenant tenant);
Task CreateNodeAsync(Node node);
Task AttachNodeToTenantAsync(Node node, Tenant tenant, string ipAddress);
Task ReleaseNodeAsync(Node node);
Task DeleteNodeAsync(Node node);
}
L’interface est, je pense, assez claire pour ne pas passer trop de temps à l’expliquer, mais il y a quand même quelque chose d’important à mentionner.
Vous voyez que toutes les fonctions sont des tâches asynchrones. On pourrait se dire que c’est simplement une bonne pratique : on va exécuter des appels externes, donc c’est normal de ne pas bloquer le Control Plane pendant leur exécution.
Mais dans le cloud, les opérations qui servent à créer, configurer ou modifier des ressources peuvent devenir bien plus complexes et plus longues que de simples appels réseau. Certaines peuvent durer plusieurs heures, voire plusieurs jours. Il faut donc, dès le départ, penser à une architecture asynchrone.
« Hey mais attends, moi j’ai déjà créé des ressources sur Azure et ça n’a jamais duré plus de quelques minutes avant de réussir ou d’échouer. »
Bon, alors déjà, tu as eu de la chance, félicitations.
Mais le Control Plane ne gère pas uniquement les demandes des clients, qui sont en effet généralement effectuées assez rapidement. Il peut aussi recevoir des requêtes du système, donc du cloud lui-même, et ces requêtes peuvent être plus complexes que simplement allumer une VM et l’affecter. Elles peuvent éventuellement même faire appel à des actions humaines.
Ce qu’il faut retenir ici, c’est que nous parlons de tâches asynchrones pour le moment, pour commencer simplement. Plus tard, il faudra plutôt voir ces opérations comme de vrais workflows persistants. Un simple Task ne remplacera pas ce mécanisme, mais il nous permet déjà de ne pas bloquer le Control Plane sur les opérations que nous exécutons aujourd’hui.
Ce que l’on va exécuter maintenant sera du shell, on va donc créer une classe pour simplifier l’exécution de nos commandes :
public sealed class ShellRunner
{
public async Task RunAsync(string command)
{
Console.WriteLine($"> {command}");
var startInfo = new ProcessStartInfo
{
FileName = "wsl.exe",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
};
startInfo.ArgumentList.Add("-u");
startInfo.ArgumentList.Add("root");
startInfo.ArgumentList.Add("--");
startInfo.ArgumentList.Add("bash");
startInfo.ArgumentList.Add("-lc");
startInfo.ArgumentList.Add(command);
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("Unable to start shell process.");
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
Console.Write(await stdoutTask);
if (process.ExitCode != 0)
throw new InvalidOperationException(await stderrTask);
}
}
La classe est légèrement simplifiée, mais elle devrait s’exécuter correctement. Vous pouvez trouver la version complète sur le GitHub de PandaCloud :
https://github.com/MarieLePanda/PandaCloud
Maintenant, on va construire notre infrastructure provider pour notre environnement.
L’infrastructure
Si vous avez lu correctement le précédent blog post, on avait créé un tenant de la façon suivante :
sudo ip link add pc-alice type bridge
sudo ip link set pc-alice up
Nous allons donc reprendre ces commandes dans notre Control Plane avec une légère modification, mais importante.
Que se passe-t-il si le tenant d’Alice existe déjà ? Peu importe la raison pour laquelle cette commande serait exécutée alors que le tenant existe déjà.
Rien de bon. Une commande rejouée peut provoquer une erreur ou, selon l’opération, créer un doublon. On va donc devoir rendre nos opérations idempotentes, c’est-à-dire faire en sorte que les exécuter plusieurs fois conduise au même état final.
public async Task CreateTenantNetworkAsync(Tenant tenant)
{
await _shellRunner.RunAsync(
$"sudo ip link show {tenant.BridgeName} >/dev/null 2>&1 || " +
$"sudo ip link add {tenant.BridgeName} type bridge");
await _shellRunner.RunAsync(
$"sudo ip link set {tenant.BridgeName} up");
}
Même chose pour les nœuds, qui avaient été créés avec la commande :
sudo ip netns add node10
Là encore, il faut simplement être sûr que le nœud n’est pas déjà créé :
public async Task CreateNodeAsync(Node node)
{
await _shellRunner.RunAsync(
$"if ! sudo ip netns list | grep -qw {node.Name}; then " +
$"sudo ip netns add {node.Name}; " +
$"fi");
}
Dernière étape pour le moment, il faut attacher un nœud à un tenant.
Cette partie est un peu plus compliquée, car cela signifie attacher, avec un veth (nos câbles virtuels), notre nœud au réseau du tenant. Et, encore une fois, il faut le faire de manière idempotente.
private static string ShortInterfaceName(string name)
{
// Linux interface names are limited to 15 characters.
// Enhance the method in the future to generate unique names.
return name.Length <= 15 ? name : name[..15];
}
private static string GetHostInterfaceName(string nodeName)
{
var hostVeth = ShortInterfaceName($"v-{nodeName}-h");
return hostVeth;
}
private static string GetNodeInterfaceName(string nodeName)
{
var nodeVeth = ShortInterfaceName($"v-{nodeName}-n");
return nodeVeth;
}
public async Task AttachNodeToTenantAsync(
Node node,
Tenant tenant,
string ipAddress)
{
var hostVeth = GetHostInterfaceName(node.Name);
var nodeVeth = GetNodeInterfaceName(node.Name);
await _shellRunner.RunAsync(
$"if ! sudo ip link show {hostVeth} >/dev/null 2>&1; then " +
$"sudo ip link add {hostVeth} type veth peer name {nodeVeth} && " +
$"sudo ip link set {nodeVeth} netns {node.Name}; " +
$"fi");
await _shellRunner.RunAsync(
$"sudo ip link set {hostVeth} master {tenant.BridgeName}");
await _shellRunner.RunAsync(
$"sudo ip link set {hostVeth} up");
await _shellRunner.RunAsync(
$"sudo ip netns exec {node.Name} ip link set lo up");
await _shellRunner.RunAsync(
$"sudo ip netns exec {node.Name} ip link set {nodeVeth} up");
await _shellRunner.RunAsync(
$"sudo ip netns exec {node.Name} ip addr flush dev {nodeVeth}");
await _shellRunner.RunAsync(
$"sudo ip netns exec {node.Name} ip addr add {ipAddress}/24 dev {nodeVeth}");
}
Gérer le cycle de vie
On peut créer nos ressources et les attacher, mais il est aussi nécessaire de penser à ce qui peut se passer plus tard.
En l’occurrence, un nœud peut être créé, ça c’est bon. Il est ensuite alloué à un tenant, c’est bon aussi. Mais il sera très probablement libéré dans le futur, pour être réassigné ailleurs, voire détruit.
La dernière fois, on s’était arrêté à cette partie. Il est donc temps de continuer et de voir comment gérer cela au niveau Linux, puis de l’implémenter de la même manière dans notre Control Plane.
public async Task DeleteNodeAsync(Node node)
{
await _shellRunner.RunAsync(
$"if sudo ip netns list | grep -qw {node.Name}; then " +
$"sudo ip netns delete {node.Name}; " +
$"fi");
}
Même principe pour supprimer le réseau d’un tenant :
public async Task DeleteTenantNetworkAsync(Tenant tenant)
{
await _shellRunner.RunAsync(
$"if ip link show dev {tenant.BridgeName} >/dev/null 2>&1; then " +
$"sudo ip link delete dev {tenant.BridgeName} type bridge; " +
$"fi");
}
Et enfin pour libérer un nœud de son réseau :
public async Task ReleaseNodeAsync(Node node)
{
var hostInterface = GetHostInterfaceName(node.Name);
await _shellRunner.RunAsync(
$"if sudo ip link show dev {hostInterface} >/dev/null 2>&1; then " +
$"sudo ip link delete dev {hostInterface}; " +
$"fi");
}
Notre Control Plane est donc fini ?
Eh non, en fait, on n’a même pas commencé à le coder.
On a jusqu’à maintenant des opérations assez robustes pour gérer notre infrastructure, mais vous avez pu remarquer qu’il manque pas mal de vérifications.
Que se passe-t-il pour mes ressources quand le tenant est supprimé ? Comment puis-je demander une certaine capacité à mon tenant ?
Il manque en fait toute notre couche service, et c’est vraiment là que l’on va créer notre classe ControlPlane.
Le Control Plane, le vrai !
Toutes ces fonctions qui agissent sur l’infrastructure vont maintenant être appelées par une couche de service qui sera notre vrai Control Plane.
On va donc retrouver des fonctions similaires, mais avec cette fois-ci une validation métier avant que la commande soit réellement exécutée.
Encore une fois, les premières fonctions vont être la création de tenants et de nœuds :
public async Task CreateTenantAsync(string name)
{
if (_cloudState.Tenants.Any(t =>
t.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
{
throw new Exception($"Tenant with name {name} already exists.");
}
string networkId = FindNextNetworkId(_cloudState).ToString();
Tenant newTenant = new Tenant
{
Name = name,
BridgeName = $"br-{name.ToLower()}",
Subnet = $"10.{networkId}.0.0/24",
Gateway = $"10.{networkId}.0.1",
};
await _infrastructureProvider.CreateTenantNetworkAsync(newTenant);
_cloudState.Tenants.Add(newTenant);
Console.WriteLine(
$"Tenant {name} created with subnet {newTenant.Subnet} " +
$"and gateway {newTenant.Gateway}.");
}
Pour le moment, on réserve l’adresse .1 comme gateway dans notre modèle. Le routage sera ajouté plus tard.
La création d’un nœud suit la même logique :
public async Task CreateNodeAsync(string nodeName, int cpuCores)
{
if (_cloudState.Nodes.Any(n =>
n.Name.Equals(nodeName, StringComparison.OrdinalIgnoreCase)))
{
throw new Exception($"Node with name {nodeName} already exists.");
}
Node newNode = new Node
{
Name = nodeName,
CpuCores = cpuCores,
};
await _infrastructureProvider.CreateNodeAsync(newNode);
_cloudState.Nodes.Add(newNode);
Console.WriteLine($"Node {nodeName} created.");
}
Ici, CpuCores représente pour le moment une capacité déclarée dans le Control Plane. Nos network namespaces ne limitent pas réellement le CPU. On implémentera plus tard un provider capable d’appliquer réellement cette capacité.
Une fois cela fait, il faut encore gérer ce qui est légèrement plus compliqué : l’allocation d’un nœud à un tenant.
C’est plus compliqué parce qu’en plus de vérifier les différentes conditions valides, il faut aussi donner à ce nœud une IP libre :
public async Task AllocateNodeAsync(string nodeName, string tenantName)
{
Node? node = _cloudState.Nodes.FirstOrDefault(n =>
n.Name.Equals(nodeName, StringComparison.OrdinalIgnoreCase));
if (node == null)
throw new Exception($"Node {nodeName} does not exist.");
Tenant? tenant = _cloudState.Tenants.FirstOrDefault(t =>
t.Name.Equals(tenantName, StringComparison.OrdinalIgnoreCase));
if (tenant == null)
throw new Exception($"Tenant {tenantName} does not exist.");
if (node.State != NodeState.Available)
throw new Exception($"Node {nodeName} is not available for allocation.");
string networkId = tenant.Subnet.Split('.')[1];
string ipAddress = $"10.{networkId}.0.{tenant.NextHostAddress}";
tenant.NextHostAddress++;
await _infrastructureProvider.AttachNodeToTenantAsync(
node,
tenant,
ipAddress);
node.TenantName = tenantName;
node.IpAddress = ipAddress;
node.State = NodeState.Allocated;
Console.WriteLine(
$"Node {nodeName} allocated to tenant {tenantName} " +
$"with IP address {ipAddress}.");
}
Je vous passe le détail des autres fonctions qui servent à désallouer le nœud, le supprimer, etc. Cela ressemble beaucoup aux méthodes vues jusqu’ici, il y a donc peu d’intérêt à les détailler.
Encore une fois, elles sont disponibles sur le GitHub de PandaCloud :
https://github.com/MarieLePanda/PandaCloud
Testons notre Control Plane
Il est temps de voir notre Control Plane en action.
Pour cela, il manque néanmoins une brique : les points d’entrée permettant d’appeler ces fonctions. On pourrait créer un contrôleur pour recevoir des appels HTTP afin d’exécuter nos actions, mais on va rester simple en les exécutant via la ligne de commande, en passant des arguments.
CloudState cloudState = new CloudState();
IInfrastructureProvider provider = new LinuxProvider();
ControlPlane controlPlane = new ControlPlane(cloudState, provider);
try
{
switch (args)
{
case ["tenant", "create", var tenantName]:
await controlPlane.CreateTenantAsync(tenantName);
break;
case ["tenant", "list"]:
await controlPlane.ListTenantsAsync();
break;
case ["node", "create", var nodeName, var cpuCores]:
await controlPlane.CreateNodeAsync(nodeName, int.Parse(cpuCores));
break;
case ["node", "allocate", var nodeName, var tenantName]:
await controlPlane.AllocateNodeAsync(nodeName, tenantName);
break;
case ["node", "list"]:
await controlPlane.ListNodesAsync();
break;
case ["node", "release", var nodeName]:
await controlPlane.ReleaseNodeAsync(nodeName);
break;
case ["node", "delete", var nodeName]:
await controlPlane.DeleteNodeAsync(nodeName);
break;
case ["capacity", "request", var tenantName, var cpuCores]:
await controlPlane.RequestCapacity(tenantName, int.Parse(cpuCores));
break;
case ["reconcile"]:
await controlPlane.ReconcileAsync();
break;
default:
Console.Error.WriteLine("Unknown command.\n");
PrintHelp();
Environment.ExitCode = 1;
break;
}
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine($"Error: {ex.Message}");
Console.ResetColor();
Environment.ExitCode = 1;
}
Et on va pouvoir commencer à lancer nos premières commandes, comme :
dotnet run -- tenant list
No tenant.
« Comment ça, no tenant ? Où sont Bob et Alice ? »
Eh oui, jusqu’à maintenant, nos ressources avaient été créées directement en Bash. Elles sont donc absentes du CloudState, qui garde justement en mémoire l’état de notre cloud.
D’ailleurs, jusqu’ici, le CloudState ne fonctionne qu’en mémoire. Ce n’est pas idéal, surtout pour une application console.
On va donc rapidement corriger cela en sauvegardant notre CloudState après chaque modification.
public class StateStore
{
private readonly string _stateDirectory;
private readonly string _stateFile;
private readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
public StateStore()
{
_stateDirectory = Path.Combine(
Environment.CurrentDirectory,
".pandacloud");
_stateFile = Path.Combine(
_stateDirectory,
"state.json");
}
public async Task<CloudState> LoadAsync()
{
if (!File.Exists(_stateFile))
return new CloudState();
var json = await File.ReadAllTextAsync(_stateFile);
return JsonSerializer.Deserialize<CloudState>(json, _jsonOptions)
?? new CloudState();
}
public async Task SaveAsync(CloudState state)
{
Directory.CreateDirectory(_stateDirectory);
var json = JsonSerializer.Serialize(state, _jsonOptions);
await File.WriteAllTextAsync(_stateFile, json);
}
}
On va maintenant, à chaque exécution, charger l’état actuel et sauvegarder le nouvel état après chaque modification de notre infrastructure.
Sauvegarder l’état d’un cloud dans un fichier JSON n’est évidemment pas idéal, car c’est un état qui change constamment, mais à notre niveau c’est bien suffisant.
N’oubliez pas d’adapter le point d’entrée de votre Control Plane pour charger et sauvegarder l’état avec cette nouvelle classe. Le GitHub, toussa, toussa.
On teste pour de vrai cette fois
Allez, maintenant on a tout, on peut lancer.
Commençons par la création du tenant d’Alice :
PS C:\Users\lgirardin\source\repos\PandaCloud> dotnet run -- tenant create alice
> sudo ip link show br-alice >/dev/null 2>&1 || sudo ip link add br-alice type bridge
> sudo ip link set br-alice up
Tenant alice created with subnet 10.10.0.0/24 and gateway 10.10.0.1.
PS C:\Users\lgirardin\source\repos\PandaCloud> dotnet run -- tenant list
TENANT NETWORK BRIDGE
--------------------------------------------------------
alice 10.10.0.0/24 br-alice
Il semble bien créé, mais allons vérifier nous-mêmes sur la machine Linux plutôt que d’avoir une confiance aveugle dans le Data Plane :
lgirardin@lgirardin-pc:~$ ip link show br-alice
22: br-alice: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN mode DEFAULT group default qlen 1000
link/ether d2:5b:d8:9c:56:59 brd ff:ff:ff:ff:ff:ff
Notre bridge a donc bien été créé. Il est administrativement UP, mais son état opérationnel est encore DOWN car aucune interface active n’y est encore attachée.
Commençons donc à créer des nœuds pour Alice :
dotnet run -- node create Node25 12
dotnet run -- node create Node26 12
dotnet run -- node create Node27 6
dotnet run -- node create Node28 6
dotnet run -- node list
NODE STATE TENANT IP CPU
--------------------------------------------------------------------
Node25 Available - - 12
Node26 Available - - 12
Node27 Available - - 6
Node28 Available - - 6
Il ne reste plus qu’à les allouer à Alice :
PS C:\Users\lgirardin\source\repos\PandaCloud> dotnet run -- node allocate node25 alice
> if ! sudo ip link show v-Node25-h >/dev/null 2>&1; then sudo ip link add v-Node25-h type veth peer name v-Node25-n && sudo ip link set v-Node25-n netns Node25; fi
> sudo ip link set v-Node25-h master br-alice
> sudo ip link set v-Node25-h up
> sudo ip netns exec Node25 ip link set lo up
> sudo ip netns exec Node25 ip link set v-Node25-n up
> sudo ip netns exec Node25 ip addr flush dev v-Node25-n
> sudo ip netns exec Node25 ip addr add 10.10.0.10/24 dev v-Node25-n
Node node25 allocated to tenant alice with IP address 10.10.0.10.
Voilà pour un, je vous laisse faire les autres ! Le but est d’arriver à ce résultat :
PS C:\Users\lgirardin\source\repos\PandaCloud> dotnet run -- node list
NODE STATE TENANT IP CPU
--------------------------------------------------------------------
Node25 Allocated alice 10.10.0.10 12
Node26 Allocated alice 10.10.0.11 12
Node27 Allocated alice 10.10.0.12 6
Node28 Allocated alice 10.10.0.13 6
Encore une fois, ici c’est l’état donné par le Control Plane, mais allons quand même vérifier directement sur notre machine Linux.
Les namespaces d’abord :
sudo ip netns list
Node28 (id: 12)
Node27 (id: 11)
Node26 (id: 10)
Node25 (id: 9)
On vérifie au passage que notre bridge, donc le réseau de notre tenant, est maintenant UP puisque des interfaces actives y sont attachées :
ip link show br-alice
22: br-alice: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether d2:5b:d8:9c:56:59 brd ff:ff:ff:ff:ff:ff
Vérification côté host
On peut voir les interfaces veth attachées à notre bridge :
ip link show master br-alice
Ou utiliser directement :
bridge link show
On retrouve alors les interfaces côté host :
24: v-Node25-h@if23: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br-alice state forwarding priority 32 cost 2
26: v-Node26-h@if25: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br-alice state forwarding priority 32 cost 2
28: v-Node27-h@if27: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br-alice state forwarding priority 32 cost 2
30: v-Node28-h@if29: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br-alice state forwarding priority 32 cost 2
Vérification côté nœud
Pour regarder cette fois réellement à l’intérieur du namespace Node25 :
sudo ip netns exec Node25 ip addr show
On peut aussi regarder uniquement son interface réseau :
sudo ip netns exec Node25 ip addr show v-Node25-n
Et vérifier ses routes :
sudo ip netns exec Node25 ip route
Enfin, on peut vérifier que deux nœuds appartenant au même tenant communiquent bien entre eux :
sudo ip netns exec Node25 ping -c 3 10.10.0.11
Si tout fonctionne, Node25 doit pouvoir joindre Node26 directement via br-alice.
Nous arrivons au bout de notre Control Plane pour le moment. On peut continuer à tester les différents use cases, mais vous avez, je pense, compris la logique.
Notre Control Plane est cependant loin d’être terminé, et c’est ce que l’on verra dans une prochaine partie avec l’allocation de capacité !
Top comments (0)