Il y a quelques années, j’ai réduit mon infrastructure personnelle au strict
minimum. Jusqu’en 2018, elle représentait une douzaine de conteneurs tournant
sur un seul serveur Hetzner. J’ai migré mon courriel vers
Fastmail et mes zones DNS vers Gandi. Il ne me restait plus que mon blog
à héberger. À ce jour, ma petite infrastructure est composée de 4 machines
virtuelles exécutant NixOS sur Hetzner Cloud et Vultr, d’une poignée
de zones DNS sur Gandi et Route 53, et de quelques distributions
Cloudfront. Elle est gérée par CDK pour Terraform
(CDKTF), tandis que les déploiements de NixOS sont gérés par NixOps.
Dans cet article, je présente brièvement Terraform, CDKTF et l’écosystème
Nix. J’explique également comment utiliser Nix pour accéder à ces outils
dans votre shell afin de les utiliser rapidement.
CDKTF : infrastructure en tant que code
Terraform est un outil d’« infrastructure en tant que code ». Vous pouvez
définir votre infrastructure en déclarant des ressources avec le langage
HCL. Ce dernier possède quelques fonctionnalités supplémentaires,
comme des boucles permettant de déclarer plusieurs ressources à partir d’une
liste, des fonctions intégrées que vous pouvez appeler dans les expressions et
l’expansion de variables dans les chaînes de caractères. Terraform s’appuie
sur un large ensemble de fournisseurs pour gérer les
ressources.
Gérer des serveurs
Voici un court exemple utilisant le fournisseur pour Hetzner Cloud pour créer une machine virtuelle :
L’expressivité de HCL est assez limitée et je trouve qu’un langage généraliste
est plus pratique pour décrire les ressources. C’est là qu’intervient CDK pour
Terraform : vous pouvez gérer votre infrastructure à l’aide de votre langage de
programmation préféré, notamment TypeScript, Go et Python. Voici l’exemple
précédent utilisant CDKTF et TypeScript :
import { App, TerraformStack, Fn } from "cdktf";
import { HcloudProvider } from "./.gen/providers/hcloud/provider";
import * as hcloud from "./.gen/providers/hcloud";
class MyStack extends TerraformStack {
constructor(scope: Construct, name: string) {
super(scope, name);
const hcloudToken = new TerraformVariable(this, "hcloudToken", {
type: "string",
sensitive: true,
});
const hcloudProvider = new HcloudProvider(this, "hcloud", {
token: hcloudToken.value,
});
const web03 = new hcloud.server.Server(this, "web03", {
name: "web03",
serverType: "cpx11",
image: "debian-11",
datacenter: "nbg1-dc3",
provider: hcloudProvider,
});
new hcloud.rdns.Rdns(this, "rdns4-web03", {
serverId: Fn.tonumber(web03.id),
ipAddress: web03.ipv4Address,
dnsPtr: "web03.luffy.cx",
provider: hcloudProvider,
});
new hcloud.rdns.Rdns(this, "rdns6-web03", {
serverId: Fn.tonumber(web03.id),
ipAddress: web03.ipv6Address,
dnsPtr: "web03.luffy.cx",
provider: hcloudProvider,
});
}
}
const app = new App();
new MyStack(app, "cdktf-take1");
app.synth();
La commande de cdktf synth
génère un fichier de configuration pour
Terraform, terraform plan
prévisualise les changements et terraform apply
les applique. Maintenant que vous disposez d’un langage généraliste, vous pouvez
utiliser des fonctions.
Gérer des enregistrements DNS
Si l’utilisation de CDKTF pour 4 serveurs web peut sembler un peu exagérée, il
en va tout autrement lorsqu’il s’agit de gérer quelques zones DNS. Avec
DNSControl, qui utilise JavaScript comme langage, j’ai pu définir la zone
bernat.ch
avec ce bout de code :
D("bernat.ch", REG_NONE, DnsProvider(DNS_BIND, 0), DnsProvider(DNS_GANDI),
DefaultTTL('2h'),
FastMailMX('bernat.ch', {subdomains: ['vincent']}),
WebServers('@'),
WebServers('vincent');
Cela produit 38 enregistrements. Avec CDKTF, j’écris :
new Route53Zone(this, "bernat.ch", providers.aws)
.sign(dnsCMK)
.registrar(providers.gandiVB)
.www("@", servers)
.www("vincent", servers)
.www("media", servers)
.fastmailMX(["vincent"]);
Toute la magie est située dans les fonctions appelées. Vous pouvez regarder le
fichier dns.ts dans le dépôt cdktf-take1 pour comprendre comment cela
fonctionne. Rapidement :
Route53Zone()
crée une zone sur Route 53,
sign()
signe la zone avec une clef maître,
registrar()
inscrit la zone auprès du registre de domaines et configure DNSSEC,
www()
crée les enregistrements A
et AAAA
pour les serveurs web,
fastmailMX()
crée les enregistrements MX
et d’autres enregistrements liés
pour configurer Fastmail en tant que fournisseur de courriel.
Voici le contenu de la fonction fastmailMX()
. Elle génère quelques
enregistrements et retourne la zone en cours pour faciliter le chaînage :
fastmailMX(subdomains?: string[]) {
(subdomains ?? [])
.concat(["@", "*"])
.forEach((subdomain) =>
this.MX(subdomain, [
"10 in1-smtp.messagingengine.com.",
"20 in2-smtp.messagingengine.com.",
])
);
this.TXT("@", "v=spf1 include:spf.messagingengine.com ~all");
["mesmtp", "fm1", "fm2", "fm3"].forEach((dk) =>
this.CNAME(`${dk}._domainkey`, `${dk}.${this.name}.dkim.fmhosted.com.`)
);
this.TXT("_dmarc", "v=DMARC1; p=none; sp=none");
return this;
}
Je vous encourage à parcourir le dépôt pour plus de détails !
À propos de Pulumi
Ma première tentative autour de Terraform a été d’utiliser Pulumi. Vous
pouvez trouver cette tentative sur GitHub. C’est assez
similaire à ce que je fais actuellement avec CDKTF. La principale différence
est que j’utilise Python au lieu de TypeScript car ce dernier ne
m’était pas familier à l’époque.
Pulumi est antérieur à CDKTF et il utilise une approche légèrement
différente. CDKTF génère une configuration pour Terraform (au format JSON au
lieu de HCL), laissant la planification, la gestion des états et le déploiement
à ce dernier. Il est donc lié aux limites de ce qui peut être exprimé par
Terraform, notamment lorsque vous devez transformer des données obtenues d’une
ressource à une autre. Pulumi a besoin de fournisseurs spécifiques
pour chaque ressource. De nombreux fournisseurs encapsulent des fournisseurs
Terraform.
Bien que Pulumi offre une bonne expérience utilisateur, je suis passé à
CDKTF car écrire des fournisseurs pour Pulumi est une corvée. CDKTF ne
nécessite pas de passer par cette étape. En dehors des grands acteurs (AWS,
Azure et Google Cloud), l’existence, la qualité et la fraîcheur des fournisseurs
Pulumi sont inégales. La plupart des fournisseurs s’appuient sur un
fournisseur Terraform et il se peut qu’ils soient en retard de quelques
versions, qu’il leur manque quelques ressources ou qu’ils présentent des bogues
qui leur sont propres.
Lorsqu’un fournisseur n’existe pas, vous pouvez en écrire un à l’aide de la
bibliothèque pulumi-terraform-bridge. Le projet Pulumi fournit un
modèle à cet effet. J’ai eu une mauvaise expérience avec celui-ci
lors de l’écriture de fournisseurs pour Gandi et
Vultr : le Makefile
installe automatiquement
Pulumi en utilisant curl | sh
et ne fonctionne pas avec
/bin/sh
. Il y a un manque d’intérêt pour les contributions
communautaires ou même pour les fournisseurs pour les acteurs
tiers.
NixOS & NixOps
Nix est un langage de programmation purement fonctionnel.
Nix est aussi le nom du gestionnaire de paquets qui est construit
au-dessus du langage Nix. Il permet aux utilisateurs d’installer des paquets
de manière déclarative. nixpkgs est un dépôt de paquets. Vous pouvez
installer Nix au-dessus d’une distribution Linux ordinaire. Si
vous voulez plus de détails, une bonne ressource est le site
officiel, notamment la section « Apprendre ». La
courbe d’apprentissage est rude, mais la récompense est grande.
NixOS : distribution Linux déclarative
NixOS est une distribution Linux construite au-dessus du gestionnaire de
paquets Nix. Voici un bout de configuration pour ajouter quelques paquets :
environment.systemPackages = with pkgs;
[
bat
htop
liboping
mg
mtr
ncdu
tmux
];
Il est possible de modifier une dérivation existante pour utiliser une version
différente, activer une fonctionnalité spécifique ou appliquer un correctif.
Voici comment j’active et configure Nginx pour désactiver le module stream
,
ajouter le module de compression Brotli et ajouter
le module d’anonymisation des adresses IP. De
plus, au lieu d’utiliser OpenSSL 3, je continue à utiliser OpenSSL 1.1.
services.nginx = {
enable = true;
package = (pkgs.nginxStable.override {
withStream = false;
modules = with pkgs.nginxModules; [
brotli
ipscrub
];
openssl = pkgs.openssl_1_1;
});
Si vous avez besoin d’ajouter certaines modifications, c’est également possible.
À titre d’exemple, voici comment j’ai corrigé en avance les failles de sécurité
découvertes en 2019 dans Nginx en attendant que cela soit corrigé
dans NixOS :
services.nginx.package = pkgs.nginxStable.overrideAttrs (old: {
patches = oldAttrs.patches ++ [
# HTTP/2: reject zero length headers with PROTOCOL_ERROR.
(pkgs.fetchpatch {
url = https://github.com/nginx/nginx/commit/dbdd[…].patch;
sha256 = "a48190[…]";
})
# HTTP/2: limited number of DATA frames.
(pkgs.fetchpatch {
url = https://github.com/nginx/nginx/commit/94c5[…].patch;
sha256 = "af591a[…]";
})
# HTTP/2: limited number of PRIORITY frames.
(pkgs.fetchpatch {
url = https://github.com/nginx/nginx/commit/39bb[…].patch;
sha256 = "1ad8fe[…]";
})
];
});
Si cela vous intéresse, jetez un coup d’œil à ma configuration relativement
réduite : common.nix
contient la configuration à appliquer sur
tous les serveurs (SSH, utilisateurs, paquets communs), web.nix
contient la configuration pour les serveurs web et isso.nix
exécute Isso dans un conteneur systemd.
NixOps : outil de déploiement pour NixOS
Sur un seul nœud, la configuration de NixOS se trouve dans le fichier
/etc/nixos/configuration.nix
. Après l’avoir modifiée, vous devez exécuter la
commande nixos-rebuild switch
. Nix va chercher toutes les dépendances
possibles dans le cache binaire et construit le reste. Il crée une nouvelle
entrée dans le menu du chargeur de démarrage et active la nouvelle
configuration.
Pour gérer plusieurs nœuds, il existe plusieurs options, dont NixOps,
deploy-rs, Colmena et morph. Je ne les connais pas toutes, mais de
mon point de vue, les différences ne sont pas si importantes. Il est également
possible de construire un tel outil soi-même car Nix fournit les blocs de
construction les plus importants : nix build
et nix copy
. NixOps est l’un
des premiers outils publiés mais je vous encourage à explorer les alternatives.
La configuration de NixOps est écrite avec la langage Nix. Voici une
configuration simplifiée pour déployer znc01.luffy.cx
, web01.luffy.cx
et
web02.luffy.cx
, à l’aide des fonctions server
et web
:
let
server = hardware: name: imports: {
deployment.targetHost = "${name}.luffy.cx";
networking.hostName = name;
networking.domain = "luffy.cx";
imports = [ (./hardware/. + "/${hardware}.nix") ] ++ imports;
};
web = hardware: idx: imports:
server hardware "web${lib.fixedWidthNumber 2 idx}" ([ ./web.nix ] ++ imports);
in {
network.description = "Luffy infrastructure";
network.enableRollback = true;
defaults = import ./common.nix;
znc01 = server "exoscale" [ ./znc.nix ];
web01 = web "hetzner" 1 [ ./isso.nix ];
web02 = web "hetzner" 2 [];
}
Connecter le tout avec Nix
L’écosystème Nix est une solution unifiée aux différents problèmes liés à la
gestion des logiciels et des configurations. Les environnements de
développement déclaratifs et reproductibles en constituent une caractéristique très intéressante.
Cela ressemble aux environnements virtuels de Python, mais ils ne sont pas
spécifiques à un langage.
Courte introduction aux « flakes » Nix
J’utilise les « flakes », une nouvelle fonctionnalité de Nix qui améliore
la reproductibilité en fixant toutes les dépendances et en isolant le processus
de construction. Bien que cette fonctionnalité soit marquée comme
expérimentale, elle est de plus en plus populaire et vous pouvez
trouver flake.nix
et flake.lock
à la racine de certains dépôts.
A titre d’exemple, voici le contenu du flake.nix
livré avec Snimpy, un
outil SNMP interactif pour Python reposant sur libsmi, une bibliothèque C :
{
inputs = {
nixpkgs.url = "nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, ... }@inputs:
inputs.flake-utils.lib.eachDefaultSystem (system:
let
pkgs = inputs.nixpkgs.legacyPackages."${system}";
in
{
# nix build
packages.default = pkgs.python3Packages.buildPythonPackage {
name = "snimpy";
src = self;
preConfigure = ''echo "1.0.0-0-000000000000" > version.txt'';
checkPhase = "pytest";
checkInputs = with pkgs.python3Packages; [ pytest mock coverage ];
propagatedBuildInputs = with pkgs.python3Packages; [ cffi pysnmp ipython ];
buildInputs = [ pkgs.libsmi ];
};
# nix run + nix shell
apps.default = {
type = "app";
program = "${self.packages."${system}".default}/bin/snimpy";
};
# nix develop
devShells.default = pkgs.mkShell {
name = "snimpy-dev";
buildInputs = [
self.packages."${system}".default.inputDerivation
pkgs.python3Packages.ipython
];
};
});
}
Si Nix est installé sur votre système :
nix run github:vincentbernat/snimpy
exécute Snimpy,
nix shell github:vincentbernat/snimpy
fournit un shell avec Snimpy prêt à être utilisé,
nix build github:vincentbernat/snimpy
construit le paquet Python,
nix develop .
fournit un shell pour développer autour de Snimpy depuis un
clône du dépôt.
Pour plus d’informations sur les flakes, regardez le tutoriel de
Tweag.
Nix et CDKTF
A la racine du dépôt que j’utilise pour CDKTF, il y a un fichier
flake.nix
pour configurer un shell avec Terraform
et CDKTF installés et avec les variables d’environnement nécessaires pour
automatiser mon infrastructure.
Terraform est déjà présent dans nixpkgs, mais je dois appliquer une rustine sur
le fournisseur Gandi. Ce n’est pas un problème avec Nix !
terraform = pkgs.terraform.withPlugins (p: [
p.aws
p.hcloud
p.vultr
(p.gandi.overrideAttrs
(old: {
src = pkgs.fetchFromGitHub {
owner = "vincentbernat";
repo = "terraform-provider-gandi";
rev = "feature/livedns-key";
hash = "sha256-V16BIjo5/rloQ1xTQrdd0snoq1OPuDh3fQNW7kiv/kQ=";
};
}))
]);
CDKTF est écrit en TypeScript. J’ai un fichier
package.json
avec toutes les dépendances
nécessaires, y compris celles pour utiliser TypeScript comme langage cible :
{
"name": "cdktf-take1",
"version": "1.0.0",
"main": "main.js",
"types": "main.ts",
"private": true,
"dependencies": {
"@types/node": "^14.18.30",
"cdktf": "^0.13.3",
"cdktf-cli": "^0.13.3",
"constructs": "^10.1.151",
"eslint": "^8.27.0",
"prettier": "^2.7.1",
"ts-node": "^10.9.1",
"typescript": "^3.9.10",
"typescript-language-server": "^2.1.0"
}
}
J’utilise Yarn pour obtenir un fichier yarn.lock
qui peut ensuite être utilisé directement pour construire la dérivation
contenant toutes les dépendances :
nodeEnv = pkgs.mkYarnModules {
pname = "cdktf-take1-js-modules";
version = "1.0.0";
packageJSON = ./package.json;
yarnLock = ./yarn.lock;
};
L’étape suivant est de générer les fournisseurs CDKTF à partir des
fournisseurs Terraform et de les inclure dans une dérivation :
cdktfProviders = pkgs.stdenvNoCC.mkDerivation {
name = "cdktf-providers";
nativeBuildInputs = [
pkgs.nodejs
terraform
];
src = nix-filter {
root = ./.;
include = [ ./cdktf.json ./tsconfig.json ];
};
buildPhase = ''
export HOME=$(mktemp -d)
export CHECKPOINT_DISABLE=1
export DISABLE_VERSION_CHECK=1
export PATH=${nodeEnv}/node_modules/.bin:$PATH
ln -nsf ${nodeEnv}/node_modules node_modules
# Build all providers we have in terraform
for provider in $(cd ${terraform}/libexec/terraform-providers; echo */*/*/*); do
version=''${provider##*/}
provider=''${provider%/*}
echo "Build $provider@$version"
cdktf provider add --force-local $provider@$version | cat
done
echo "Compile TS → JS"
tsc
'';
installPhase = ''
mv .gen $out
ln -nsf ${nodeEnv}/node_modules $out/node_modules
'';
};
Enfin, nous définissons l’environnement de développement :
devShells.default = pkgs.mkShell {
name = "cdktf-take1";
buildInputs = [
pkgs.nodejs
pkgs.yarn
terraform
];
shellHook = ''
# No telemetry
export CHECKPOINT_DISABLE=1
# No autoinstall of plugins
export CDKTF_DISABLE_PLUGIN_CACHE_ENV=1
# Do not check version
export DISABLE_VERSION_CHECK=1
# Access to node modules
export PATH=$PWD/node_modules/.bin:$PATH
ln -nsf ${nodeEnv}/node_modules node_modules
ln -nsf ${cdktfProviders} .gen
# Credentials
for p in \
njf.nznmba.pbz/Nqzvavfgengbe \
urgmare.pbz/ivaprag@oreang.pu \
ihyge.pbz/ihyge@ivaprag.oreang.pu; do
eval $(pass show $(echo $p | tr 'A-Za-z' 'N-ZA-Mn-za-m') | grep '^export')
done
eval $(pass show personal/cdktf/secrets | grep '^export')
export TF_VAR_hcloudToken="$HCLOUD_TOKEN"
export TF_VAR_vultrApiKey="$VULTR_API_KEY"
unset VULTR_API_KEY HCLOUD_TOKEN
'';
};
Les dérivations listées dans buildInputs
sont disponibles dans le shell
fourni. Le contenu de shellHook
est exécuté lors du démarrage du shell. Il
établit des liens symboliques pour rendre disponible l’environnement JavaScript
construit à une étape précédente, ainsi que les fournisseurs CDKTF générés. Il
exporte également toutes les informations d’identification.
J’utilise également direnv avec un fichier .envrc
pour passer automatiquement dans l’environnement de développement. Cela permet
également à ce dernier d’être disponible depuis Emacs, notamment lors de
l’utilisation de lsp-mode pour obtenir les complétions. nix develop .
permet aussi d’activer manuellement l’environnement.
J’utilise les commandes suivantes pour déployer :
$ cdktf synth
$ cd cdktf.out/stacks/cdktf-take1
$ terraform plan --out plan
$ terraform apply plan
$ terraform output -json > ~-automation/nixops-take1/cdktf.json
La dernière commande produit un fichier JSON contenant les données nécessaires
pour finir le déploiement avec NixOps.
NixOps
Le fichier JSON exporté par Terraform contient la
liste des serveurs avec quelques attributs :
{
"hardware": "hetzner",
"ipv4Address": "5.161.44.145",
"ipv6Address": "2a01:4ff:f0:b91::1",
"name": "web05.luffy.cx",
"tags": [
"web",
"continent:NA",
"continent:SA"
]
}
Dans le fichier network.nix
, cette liste est
importée et transformée en un ensemble d’attributs décrivant les serveurs. Une
version simplifiée ressemble à cela :
let
lib = inputs.nixpkgs.lib;
shortName = name: builtins.elemAt (lib.splitString "." name) 0;
domainName = name: lib.concatStringsSep "." (builtins.tail (lib.splitString "." name));
server = hardware: name: imports: {
networking = {
hostName = shortName name;
domain = domainName name;
};
deployment.targetHost = name;
imports = [ (./hardware/. + "/${hardware}.nix") ] ++ imports;
};
cdktf-servers-json = (lib.importJSON ./cdktf.json).servers.value;
cdktf-servers = map
(s:
let
tags-maybe-import = map (t: ./. + "/${t}.nix") s.tags;
tags-import = builtins.filter (t: builtins.pathExists t) tags-maybe-import;
in
{
name = shortName s.name;
value = server s.hardware s.name tags-import;
})
cdktf-servers-json;
in
{
// […]
} // builtins.listToAttrs cdktf-servers
Pour web05
, on obtient ceci :
web05 = {
networking = {
hostName = "web05";
domainName = "luffy.cx";
};
deployment.targetHost = "web05.luffy.cx";
imports = [ ./hardware/hetzner.nix ./web.nix ];
};
Comme pour CDKTF, à la racine du dépôt que j’utilise pour
NixOps, il y a un fichier flake.nix
pour fournir un shell avec NixOps configuré. Comme NixOps ne supporte pas
les déploiements progessifs, j’utilise généralement ces commandes pour déployer
sur un unique serveur :
$ nix flake update
$ nixops deploy --include=web04
$ ./tests web04.luffy.cx
Si les tests se déroulent sans soucis, je déploie les autres nœuds un par un
avec la commande suivante :
$ (set -e; for h in web{03..06}; do nixops deploy --include=$h; done)
La commande nixops deploy
déploie tous les serveurs en parallèle et peut donc
provoquer une panne si tous les serveurs Nginx sont indisponibles au même
moment.
Cet article est en chantier depuis trois ans. Le contenu a été mis à jour et
affiné au fur et à mesure de mes expérimentations. Il y a encore beaucoup à
explorer, mais j’estime que le contenu est désormais suffisant pour être
publié ! 🎄