Documentation · version du 17 octobre 2026
Documentation · as of 17 October 2026
La documentation du lab
The lab documentation
Un petit cluster Kubernetes à deux nœuds, quelques services qui tournent à côté, et un seul point d'entrée HTTPS. Cette page décrit chaque pièce, pourquoi elle est là, et comment l'exploiter.
A small two-node Kubernetes cluster, a few services running beside it, and a single HTTPS entry point. This page describes each piece, why it is there, and how to operate it.
Vue d'ensembleOverview
Le cluster est installé avec Kubespray (Kubernetes 1.35) sur deux machines Debian :
- master1 : le plan de contrôle (API, etcd, scheduler). Il est protégé par un taint, donc aucune charge applicative n'y tourne.
- worker1 : 4 vCPU, 16 Go de RAM. Il exécute toutes les applications, le contrôleur d'ingress, et héberge aussi deux services installés directement sur la machine : GitLab et Zabbix.
Les briques de base : Calico pour le réseau, ingress-nginx pour l'entrée HTTP(S), cert-manager pour les certificats, kube-prometheus-stack pour la supervision, Rancher pour la gestion, et le registry de GitLab pour les images. Les versions et le rôle de chaque brique sont sur la page Stack du portfolio.
The cluster is installed with Kubespray (Kubernetes 1.35) on two Debian machines:
- master1: the control plane (API, etcd, scheduler). It carries a taint, so no application workload runs there.
- worker1: 4 vCPU, 16 GB RAM. It runs every application and the ingress controller, and also hosts two services installed directly on the machine: GitLab and Zabbix.
The building blocks: Calico for networking, ingress-nginx for HTTP(S) entry, cert-manager for certificates, kube-prometheus-stack for monitoring, Rancher for management, and the GitLab registry for images. Versions and the role of each piece are on the portfolio's Stack page.
Domaines et servicesDomains and services
Tous les noms pointent vers l'IP publique du worker. Aucun port n'est à saisir : tout passe par 443.
Every name points to the worker's public IP. No port to type: everything goes through 443.
| Domaine | Ce que c'est | What it is | Où ça tourne | Where it runs |
|---|---|---|---|---|
| hoortrend.com | Cette page d'accueil, sa documentation et la page « Comment ça marche » | This home page, its documentation and the “How it works” page | Kubernetes · ns landing · 2 pods nginx | |
| portfolio.ilyes-litim.fr | Le portfolio SRE (FastAPI + JavaScript) et sa page de contact | The SRE portfolio (FastAPI + JavaScript) and its contact page | Kubernetes · ns portfolio · 2 pods portfolio-web + 2 pods contact-api | |
| grafana | Tableaux de bord Prometheus | Prometheus dashboards | Kubernetes · ns monitoring | |
| rancher | Console de gestion du cluster | Cluster management console | Kubernetes · ns cattle-system | |
| gitlab | Forge Git et CI/CD (GitLab 17.9, installation Omnibus) | Git forge and CI/CD (GitLab 17.9, Omnibus install) | Machine worker1 · via upstream | worker1 host · via upstream |
| registry | Registre d'images : toutes les images du lab | Container registry: every image of the lab | Machine worker1 · via upstream | worker1 host · via upstream |
| zabbix | Supervision Zabbix 6.4 | Zabbix 6.4 monitoring | Machine worker1 · via upstream | worker1 host · via upstream |
Le www.hoortrend.com n'existe pas : seul le domaine nu est configuré.
www.hoortrend.com does not exist: only the bare domain is configured.
Le portfolio a son propre domaine, ilyes-litim.fr : son enregistrement A pointe vers le même worker et son certificat Let's Encrypt est obtenu comme les autres. L'ancien nom portfolio.hoortrend.com redirige de façon permanente vers le nouveau.
The portfolio has its own domain, ilyes-litim.fr: its A record points to the same worker and its Let's Encrypt certificate is obtained like the others. The old name portfolio.hoortrend.com permanently redirects to the new one.
Le chemin d'une requêteRequest path
DNS, ingress-nginx (qui termine le TLS et choisit la destination d'après le nom d'hôte), Service, puis pod. Pour les services hors cluster, l'ingress envoie directement vers la machine (section suivante). Le détail, avec un schéma et un test en direct, est sur la page Comment ça marche.
DNS, ingress-nginx (which terminates TLS and picks the destination from the host name), Service, then pod. For services outside the cluster, the ingress sends straight to the host (next section). The detail, with a diagram and a live test, is on the How it works page.
Le formulaire de contactThe contact form
La page /contact du portfolio envoie le message à POST /api/contact, sur le même domaine. L'ingress du portfolio route ce seul chemin vers un petit service Python (contact-api) qui valide les champs puis relaie le message par SMTP authentifié (STARTTLS) vers ma boîte mail. Le message n'est stocké nulle part.
Ce service est séparé du site exprès : il détient le mot de passe SMTP, que portfolio-web (qui parle à l'API Kubernetes) ne doit jamais voir, et il n'a aucun jeton Kubernetes. Le mot de passe vit dans un Secret créé à la main, dans le namespace portfolio, jamais dans Git.
Protections : champ piège contre les robots, vérification de l'origine, limite de débit (par visiteur et pour tout le site), taille maximale, refus des retours à la ligne dans les en-têtes (injection), et un journal qui ne contient jamais le nom, l'adresse ni le message.
The portfolio's /contact page posts the message to POST /api/contact on the same domain. The portfolio's ingress routes only that path to a small Python service (contact-api) which validates the fields and relays the message over authenticated SMTP (STARTTLS) to my mailbox. The message is stored nowhere.
The service is separate from the site on purpose: it holds the SMTP password, which portfolio-web (which talks to the Kubernetes API) must never see, and it has no Kubernetes token. The password lives in a Secret created by hand, in the portfolio namespace, never in Git.
Protections: a honeypot field against robots, origin check, rate limits (per visitor and site-wide), a size cap, rejection of line breaks in headers (injection), and a log that never contains the name, address or message.
Services hors KubernetesServices outside Kubernetes
GitLab et Zabbix sont installés directement sur worker1. Ils ne peuvent pas écouter sur 80 et 443, déjà pris par l'ingress. Chacun écoute donc sur un port interne, lié à une adresse privée de la boucle locale (jamais à l'adresse publique), et l'ingress joue le rôle de reverse proxy avec upstream :
GitLab and Zabbix are installed directly on worker1. They cannot listen on 80 and 443, which the ingress already owns. Each one listens on an internal port, bound to a private loopback address (never the public one), and the ingress acts as a reverse proxy with an upstream:
| Service | Port interne | Internal port | Particularité | Note |
|---|---|---|---|---|
| GitLab | 8929 | external_url sans port, nginx Omnibus en listen_port 8929 | external_url without a port, Omnibus nginx on listen_port 8929 | |
| Registry | 5443 | Même principe pour registry_nginx | Same principle for registry_nginx | |
| Zabbix | 8444 | Site nginx dédié + PHP-FPM ; le serveur Zabbix garde le port 10051 pour les agents | Dedicated nginx site + PHP-FPM; the Zabbix server keeps port 10051 for agents |
Dans Kubernetes, chaque service externe est un Service sans sélecteur, dont l'Endpoints pointe vers la machine. L'Ingress l'utilise ensuite comme n'importe quel autre :
In Kubernetes, each external service is a selector-less Service whose Endpoints points at the host. The Ingress then uses it like any other:
# namespace external-hosts apiVersion: v1 kind: Service metadata: {name: gitlab, namespace: external-hosts} spec: {ports: [{name: https, port: 8929, targetPort: 8929}]} --- kind: Endpoints metadata: {name: gitlab, namespace: external-hosts} subsets: [{addresses: [{ip: 10.255.255.1}], ports: [{name: https, port: 8929}]}] --- kind: Ingress metadata: annotations: cert-manager.io/cluster-issuer: letsencrypt-prod nginx.ingress.kubernetes.io/backend-protocol: "HTTPS" nginx.ingress.kubernetes.io/proxy-body-size: "0" # pas de limite sur les push Git nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
Ces ports ne sont donc pas joignables depuis Internet : seul l'ingress, qui tourne sur la même machine, peut les atteindre. L'adresse est créée au démarrage par une unité systemd.
These ports are therefore not reachable from the Internet: only the ingress, running on the same machine, can reach them. The address is created at boot by a systemd unit.
Ajouter un nouveau service de ce type : un Service + Endpoints + Ingress, rien d'autre. Le certificat est demandé tout seul.
Adding another service of this kind takes a Service + Endpoints + Ingress, nothing else. The certificate is requested automatically.
Certificats TLSTLS certificates
Un seul ClusterIssuer, letsencrypt-prod, utilise le challenge HTTP-01. L'annotation cert-manager.io/cluster-issuer sur un Ingress suffit : cert-manager crée le certificat, le stocke dans le Secret nommé par tls.secretName, et le renouvelle avant l'expiration (environ 30 jours avant).
Le certificat vu par les visiteurs est toujours celui de l'ingress. Les services de la machine ont leur propre certificat interne, qui n'est utilisé que pour la liaison entre l'ingress et eux : l'ingress ne le valide pas.
A single ClusterIssuer, letsencrypt-prod, uses the HTTP-01 challenge. The cert-manager.io/cluster-issuer annotation on an Ingress is enough: cert-manager creates the certificate, stores it in the Secret named by tls.secretName, and renews it before expiry (about 30 days ahead).
The certificate visitors see is always the ingress one. The host services have their own internal certificate, used only for the link between the ingress and them: the ingress does not validate it.
kubectl get certificate -A kubectl -n external-hosts describe certificate gitlab-hoortrend-com-tls
Construire et déployerBuild and deploy
Tout le code vit dans GitLab (groupe hoortrend : portfolio, landing, demo-app, sre-demo, infra). Chaque git push sur main lance un pipeline, et les images sont publiées dans le registry de GitLab.
- validate : syntaxe du code, des manifestes et des fichiers référencés par les pages.
- build : l'image est construite avec Kaniko dans un pod, sans accès privilégié, puis publiée sous le tag du commit.
- deploy :
kubectl set imagesur le Deployment, puis attente de la fin du déploiement progressif. - verify : un test de fumée interroge le site public.
Sur une merge request, seules les étapes de validation et de build tournent, sans publier ni déployer. Avec maxUnavailable: 0, un nouveau pod doit être prêt avant qu'un ancien soit arrêté : le site reste disponible pendant la mise à jour.
Les jobs s'exécutent dans un runner Kubernetes. Son compte de service ne peut changer que l'image d'un seul Deployment nommé par namespace : la CI n'a pas de droits d'administration du cluster. Les pods tirent leurs images avec un jeton en lecture seule.
All code lives in GitLab (group hoortrend: portfolio, landing, demo-app, sre-demo, infra). Every git push to main starts a pipeline, and images are published to the GitLab registry.
- validate: syntax of the code, manifests and the files pages reference.
- build: the image is built with Kaniko inside a pod, without privileged access, then published under the commit tag.
- deploy:
kubectl set imageon the Deployment, then wait for the rolling update to finish. - verify: a smoke test queries the public site.
On a merge request, only validate and build run, without publishing or deploying. With maxUnavailable: 0, a new pod must be ready before an old one is stopped: the site stays up during the update.
Jobs run in a Kubernetes runner. Its service account can only change the image of one named Deployment per namespace: CI has no cluster-admin rights. Pods pull their images with a read-only token.
git push origin main # lance le pipeline / starts the pipeline kubectl -n landing rollout status deploy/landing-web kubectl -n landing rollout undo deploy/landing-web # retour arrière / rollback
Fichiers de configurationConfiguration files
Ces fichiers sont copiés tels quels dans l'image au moment du build : ce sont exactement ceux qui font tourner la page d'accueil.
These files are copied as-is into the image at build time: they are exactly what runs the home page.
- cert-manager.io/cluster-issuer demande le certificat Let's Encrypt, émis et renouvelé automatiquement.
- cert-manager.io/cluster-issuer requests the Let's Encrypt certificate, issued and renewed automatically.
- ssl-redirect renvoie tout le trafic HTTP vers HTTPS.
- ssl-redirect sends all HTTP traffic to HTTPS.
- limit-rps limite le débit par client pour protéger la page.
- limit-rps rate-limits each client to protect the page.
- La règle envoie hoortrend.com vers le Service, sans exposer les pods.
- The rule routes hoortrend.com to the Service without exposing the pods.
- Image nginx-unprivileged : le processus tourne en UID 101 et écoute sur 8080, donc aucun privilège requis.
- nginx-unprivileged image: the process runs as UID 101 and listens on 8080, so no privileges are needed.
- La configuration et le site sont copiés dans l'image : aucun volume, aucun état à gérer.
- The configuration and the site are baked into the image: no volume, no state to manage.
- Image minimale (Alpine) : peu de paquets, peu de vulnérabilités à suivre.
- Minimal (Alpine) image: few packages, few vulnerabilities to track.
- X-Served-By annonce le nom du pod sur chaque réponse : c'est ce que les tests en direct utilisent.
- X-Served-By announces the pod name on every response: the live tests rely on it.
- Un en-tête Content-Security-Policy strict : seuls ce site et le portfolio sont autorisés.
- A strict Content-Security-Policy: only this site and the portfolio are allowed.
- Les en-têtes de sécurité sont répétés dans chaque location : nginx ignore les en-têtes hérités dès qu'une location en définit.
- Security headers are repeated in every location: nginx drops inherited headers as soon as a location defines its own.
- Les parties communes (menu, pied de page) sont incluses par nginx (SSI) : une seule copie pour toutes les pages.
- The shared parts (menu, footer) are included by nginx (SSI): one copy for every page.
- 2 réplicas avec maxUnavailable à 0 : une mise à jour ne coupe jamais le site.
- 2 replicas with maxUnavailable at 0: an update never takes the site down.
- runAsNonRoot, système de fichiers en lecture seule, capabilities supprimées, profil seccomp : le pod a le minimum de droits.
- runAsNonRoot, read-only filesystem, dropped capabilities, seccomp profile: the pod has the bare minimum of rights.
- automountServiceAccountToken: false : un site statique n'a aucune raison de parler à l'API Kubernetes.
- automountServiceAccountToken: false: a static site has no reason to talk to the Kubernetes API.
- Des requests et limits de ressources, et une sonde sur /healthz.
- Resource requests and limits, and a probe on /healthz.
Workflow de chaque projetEach project's workflow
Tous les projets suivent le même chemin, du poste du développeur jusqu'au site public. Choisis un projet, puis rejoue son pipeline : les étapes, les jobs et les images sont les vrais, et le .gitlab-ci.yml exact est affiché dessous.
Every project follows the same path, from the developer's machine to the public site. Pick a project, then replay its pipeline: the stages, jobs and images are the real ones, and the exact .gitlab-ci.yml is shown below.
- Branche. Le changement est fait sur une branche, jamais directement sur
main. - Branch. The change is made on a branch, never directly on
main. - Merge request. Le pipeline valide et construit l'image sans la publier : on sait que ça passe avant de fusionner.
- Merge request. The pipeline validates and builds the image without publishing it: we know it passes before merging.
- Fusion dans main. Le pipeline complet démarre : validation, construction, publication, déploiement.
- Merge into main. The full pipeline starts: validate, build, publish, deploy.
- Déploiement progressif. Kubernetes remplace les pods un par un, et ne coupe un ancien pod qu'une fois le nouveau prêt.
- Rolling update. Kubernetes replaces pods one by one, and only stops an old pod once the new one is ready.
- Vérification. Un test de fumée interroge le site public. En cas de problème :
rollout undo, retour à la version précédente en quelques secondes. - Verification. A smoke test queries the public site. If something is wrong:
rollout undo, back to the previous version in seconds.
Le code affiché est une copie du fichier du dépôt, mise à jour avec cette page. Les durées sont l'ordre de grandeur mesuré le 17 octobre 2026 (démarrage du pod du job compris).
The code shown is a copy of the repository file, updated along with this page. Durations are the order of magnitude measured on 17 October 2026 (job pod start-up included).
SécuritéSecurity
Dans les pods
- Les namespaces
landingetplaygroundimposent Pod Security « restricted » : le cluster refuse un pod non conforme. Seulgitlab-runnerest en « baseline » (le build d'images). - Utilisateur non-root, système de fichiers en lecture seule, toutes les capabilities supprimées, profil seccomp.
- Aucun jeton d'API Kubernetes monté dans les pods qui n'en ont pas besoin.
- Limites de CPU et de mémoire sur chaque conteneur.
In the pods
- The
landingandplaygroundnamespaces enforce Pod Security “restricted”: the cluster rejects a non-compliant pod. Onlygitlab-runneris “baseline” (image builds). - Non-root user, read-only file system, all capabilities dropped, seccomp profile.
- No Kubernetes API token mounted in pods that do not need one.
- CPU and memory limits on every container.
Sur le réseau
- HTTPS partout, redirection automatique depuis HTTP, HSTS.
- En-têtes de sécurité et Content-Security-Policy stricte sur chaque page.
- Limitation de débit par client sur l'ingress.
- Le bac à sable du portfolio est isolé par une NetworkPolicy : ses pods ne peuvent joindre que le strict nécessaire.
On the network
- HTTPS everywhere, automatic redirect from HTTP, HSTS.
- Security headers and a strict Content-Security-Policy on every page.
- Per-client rate limiting at the ingress.
- The portfolio sandbox is isolated by a NetworkPolicy: its pods can only reach what they strictly need.
Dans l'API du portfolio
- Compte de service en lecture seule ; les actions du bac à sable ne visent que son propre namespace, avec une liste d'actions autorisées.
- Le relais Slack refuse les destinations qui ne sont pas Slack (protection SSRF), limite le débit, et ne stocke ni ne journalise jamais l'URL du webhook.
- CORS ouvert uniquement à cette origine, pour deux routes en lecture.
In the portfolio API
- Read-only service account; sandbox actions only target their own namespace, from an allow-list of actions.
- The Slack relay rejects any non-Slack destination (SSRF protection), rate-limits, and never stores or logs the webhook URL.
- CORS opened only to this origin, for two read routes.
SupervisionMonitoring
Deux outils complémentaires :
- Prometheus + Alertmanager + Grafana (kube-prometheus-stack) pour Kubernetes : nœuds, pods, API. Les alertes sont aussi lues par le portfolio, qui les affiche en direct et peut les relayer vers Slack.
- Zabbix pour la machine worker1 et ses services. Les agents parlent au serveur sur le port 10051.
Two complementary tools:
- Prometheus + Alertmanager + Grafana (kube-prometheus-stack) for Kubernetes: nodes, pods, the API. Alerts are also read by the portfolio, which shows them live and can relay them to Slack.
- Zabbix for the worker machine and its services. Agents talk to the server on port 10051.
ProcéduresRunbook
Un site ne répond plus
A site stopped answering
kubectl get nodes kubectl get pods -A | grep -v Running kubectl -n ingress-nginx logs ds/ingress-nginx-controller --tail=50 kubectl get ingress -A
GitLab ou Zabbix : 502 ou 504
GitLab or Zabbix: 502 or 504
L'ingress est vivant, c'est l'upstream sur la machine qui ne répond pas. Sur worker1 :
The ingress is alive; the upstream on the host is not answering. On worker1:
sudo gitlab-ctl status # GitLab : tous les services en "run"
sudo systemctl status nginx php7.4-fpm zabbix-server
ss -ltn | grep -E ':(8929|5443|8444) '
Après un redémarrage de GitLab, la page « Waiting for GitLab to boot » (502) est normale pendant une à deux minutes.
After a GitLab restart, the “Waiting for GitLab to boot” page (502) is normal for a minute or two.
Un certificat ne se renouvelle pas
A certificate does not renew
kubectl get certificate,order,challenge -A kubectl -n cert-manager logs deploy/cert-manager --tail=50
Cause fréquente : le port 80 ne mène plus à l'ingress, ou l'enregistrement DNS ne pointe plus vers le worker.
Common cause: port 80 no longer reaches the ingress, or the DNS record no longer points to the worker.
Exposer un nouveau sous-domaine
Expose a new subdomain
- Créer l'enregistrement DNS A vers l'IP du worker.
- Create the DNS A record to the worker's IP.
- Écrire un Ingress avec l'annotation
cert-manager.io/cluster-issuer: letsencrypt-prodet un bloctls. - Write an Ingress with the
cert-manager.io/cluster-issuer: letsencrypt-prodannotation and atlsblock. - Vérifier :
kubectl get certificatepasse àTrueen quelques secondes. - Check:
kubectl get certificateturnsTruewithin seconds.
Limites connuesKnown limits
Ce lab est une démonstration, pas une production à haute disponibilité :
- Un seul worker. Les deux pods de chaque application tournent sur la même machine : elle protège d'un crash de pod, pas d'une panne de machine.
- Un seul plan de contrôle (pas de haute disponibilité de l'API ni d'etcd).
- GitLab et Zabbix vivent sur la machine, hors du cycle de vie Kubernetes : mises à jour et sauvegardes sont manuelles.
- Le terminal k9s du portfolio est une émulation alimentée par l'API, pas un vrai terminal distant.
This lab is a demonstration, not a highly available production:
- One worker. Both pods of each application run on the same machine: that guards against a pod crash, not a machine failure.
- One control plane (no API or etcd high availability).
- GitLab and Zabbix live on the host, outside the Kubernetes lifecycle: updates and backups are manual.
- The portfolio's k9s terminal is an emulation fed by the API, not a real remote terminal.