Logo
Overview
Un Talos européen de qualité - Part XI - CI/CD et OpenTelemetry

Un Talos européen de qualité - Part XI - CI/CD et OpenTelemetry

October 31, 2025
22 min read
part-11

Objectif 🎯

Dans cette dernière partie de la série, nous allons mettre en place un système d’intégration continue (CI) pour automatiser les tests et la validation de notre code avant son déploiement en mode continue (CD). Enfin, nous ferons un rapide aperçu de l’intégration de OpenTelemetry dans l’application au sein de notre infrastructure.

Continuous Integration

Le but suivant sera de générer notre application en image OCI correctement taguée sur le container registry de Gitea, directement via Gitea Action inclus par défaut.

Act Runner

Le 1er élément indispensable est la mise en place d’un runner. Le principe est de créer un VPS Hetzner ou autre indépendant du cluster kube qui se connectera à votre instance Gitea. Un VPS avec haute capacité de disque et haute performance CPU est conseillé. Assurez-vous d’avoir docker préinstallé via curl -fsSL https://get.docker.com | sh (n’utilisez pas cette commande en production !).

Puis créez les fichiers de config suivants :

/etc/act/config.yaml
cache:
host: 172.17.0.1
port: 8088
log:
level: info
runner:
capacity: 3
Tip

Activer le cache interne afin d’accélérer la récupération des artefacts de dépendances (nuget, npm, etc.)

~/runner/compose.yaml
services:
act:
environment:
CONFIG_FILE: /etc/act/config.yaml
GITEA_INSTANCE_URL: https://gitea.ohmytalos.io
GITEA_RUNNER_REGISTRATION_TOKEN: <token>
image: gitea/act_runner:nightly
ports:
- 8088:8088
restart: always
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /etc/act/config.yaml:/etc/act/config.yaml
- act_data:/data
- act_cache:/root/.cache
volumes:
act_data:
act_cache:
Explanation

Générer votre token d’inscription depuis votre instance Gitea sur admin/actions/runners.

Si vous choisissez d’héberger votre Gitea uniquement en interne sur gitea.dev.ohmytalos.io, il vous faudra brancher votre VPS sur le réseau tailnet du kube.

Un petit docker compose up -d et le runner devrait apparaître comme disponible sur votre interface Gitea dans l’administration des runners.

Gitea admin runners

Gitea Action

Depuis le projet ohmytalos/conduit, créons un workflow de base juste pour le build, lancement des tests et publication.

.gitea/workflows/build.yaml
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
cache: true
cache-dependency-path: "**/packages.lock.json"
- name: install
run: |
dotnet tool restore
dotnet restore
- name: lint
run: |
dotnet format --verify-no-changes
- name: build
run: |
dotnet build -c Release --no-restore
- name: test
run: |
dotnet coverlet Conduit.Tests/bin/Release/net10.0/Conduit.Tests.dll --target "dotnet" --targetargs "test -c Release --no-restore --no-build" -f=opencover -o="coverage.xml"
- name: publish
run: |
dotnet publish -c Release -o ./publish --no-restore --no-build

Les testcontainers utilisés pour les tests d’intégration devraient se lancer automatiquement sans problème, ce qui nous évite de les déclarer manuellement dans le workflow via jobs.build.services, que du bon en somme.

Vous pouvez apercevoir le résultat de la couverture en sortie des tests :

Gitea build

Note (Remarque)

Nous générons également un fichier coverage.xml au format opencover qui pourra être exploité par SonarQube juste après.

Les prochaines relances devraient aussi tenir compte du cache NuGet pour accélérer le processus à l’étape install.

Image OCI

Nous ne faisons ici que publier l’artifact sans rien derrière, il est temps de construire notre image OCI et de la pousser sur le registry Gitea.

Préparons quelques variables et secrets sur Gitea sur l’interface des variables globales :

Terminal window
CONTAINER_REGISTRY=gitea.ohmytalos.io
CONTAINER_REGISTRY_USERNAME=ohmytalos

Aller ensuite au niveau des secrets de l’organisation puis créer le secret suivant :

Terminal window
CONTAINER_REGISTRY_PASSWORD=<personal access token under ohmytalos with write:packages scope>

Rajouter les actions spécifiques au build et push de l’image OCI dans le workflow build.yaml :

.gitea/workflows/build.yaml
# ...
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ vars.CONTAINER_REGISTRY }}/${{ gitea.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
- uses: docker/login-action@v3
with:
registry: ${{ vars.CONTAINER_REGISTRY }}
username: ${{ vars.CONTAINER_REGISTRY_USERNAME }}
password: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

Le fichier Dockerfile de production à la racine du repo pour embarquer l’application .NET publiée précédemment situé dans le répertoire ./publish :

Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:10.0
USER app
COPY --chown=app:app /publish /app
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "Conduit.WebApi.dll"]

Commiter et pousser les modifications, le workflow devrait se déclencher et aboutir à la création de l’image OCI dans le registry Gitea, sous les tags latest et main.

L’image OCI étant générée, ajoutez-la directement dans le repo en tant que package via l’onglet Packages du repo.

Versionning

Nous sommes en capacité de builder et pousser automatiquement notre application en package OCI, prêt à l’emploi pour le déploiement en production, mais il nous manque encore un élément important : le versionning sémantique. L’image est systématiquement taguée main, ce qui rendrait tout déploiement via flux compliqué sans suivi de version.

Pour faire propre et efficient, une release Gitea devrait être automatiquement créée à chaque commit dans main, avec incrémentation patch automatique en restant dans la logique semver. Nous allons utiliser GitVersion pour cela, qui s’appuiera sur les tags Git existants.

L’idée est donc de créer un token personnel avec accès en écriture aux repos sous l’organisation ohmytalos, puis de l’utiliser dans le workflow Gitea Action pour créer la release automatiquement en la liant à un tag git suivant la convention semver.

Retourner sur la gestion des secrets de l’organisation, puis créer un token avec accès en écriture au repo cible.

Terminal window
RELEASE_TOKEN=<personal access token under ohmytalos with write:repository scope>

On s’attelle maintenant à l’installation de GitVersion :

Terminal window
dotnet tool install GitVersion.Tool
GitVersion.yml
mode: ContinuousDeployment
next-version: 1.0.0
branches:
main:
increment: Patch
Explanation

On choisit le mode ContinuousDeployment pour que chaque commit dans main incrémente la version patch automatiquement, tout en démarrant à 1.0.0.

Tester localement avec dotnet gitversion /output json, la version FullSemVer devrait démarrer à 1.0.0. Plus qu’à adapter notre workflow :

.gitea/workflows/build.yaml
# ...
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
# ...
- name: install
run: |
dotnet tool restore
dotnet restore
- name: version
id: gitversion
run: |
echo "version=$(dotnet gitversion /output | jq -r .FullSemVer)" >> $GITHUB_OUTPUT
# ...
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ vars.CONTAINER_REGISTRY }}/${{ gitea.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
type=raw,value=v${{ steps.gitversion.outputs.version }}
# ...
- uses: akkuman/gitea-release-action@v1
with:
token: ${{ secrets.RELEASE_TOKEN }}
name: Release v${{ steps.gitversion.outputs.version }}
tag_name: v${{ steps.gitversion.outputs.version }}
Explanation

Les points importants sont d’abord de faire un fetch-depth: 0 lors du checkout pour que GitVersion ait accès à l’historique complet des tags, puis de récupérer le tag généré lors de la création de l’image OCI, via type=raw,value=v${{ steps.gitversion.outputs.version }}.

La création de la release via l’API Gitea se fera à l’étape finale grâce au plugin akkuman/gitea-release-action. Il générera dans le même temps le même tag Git correspondant et sera réutilisé par GitVersion pour déterminer la version suivante lors de la prochaine release.

Poussez les modifications, le workflow devrait se déclencher et aboutir à la création d’une release Gitea avec le tag v1.0.0, ainsi qu’une image OCI taguée v1.0.0 dans le registry. Chaque nouveau commit dans main générera une nouvelle release avec incrémentation automatique du patch (v1.0.1, v1.0.2, etc.). On est nickel.

Gitea release

Tip

Pour incrémenter une version minor, indiquer simplement les mots clés +semver: minor dans un commit.

SonarQube

Allons encore un peu plus loin sur l’analyse statique de code avec SonarQube.

Indiquer dans les variables globales de l’administration Gitea l’URL de l’instance SonarQube :

Terminal window
SONAR_HOST_URL=https://sonarqube.ohmytalos.io

Ensuite aller sur l’interface SonarQube normalement hébergée sur https://sonarqube.ohmytalos.io/ puis créer un nouveau projet ohmytalos/conduit. Ajouter dans les variables propres au repo Gitea l’ID du projet SonarQube :

Terminal window
SONAR_PROJECT_ID=ohmytalos-conduit

Enfin générer un token d’analyse projet dans l’onglet security, puis revenez sur Gitea et rajouter le token dans les secrets spécifiques au repo :

Terminal window
SONAR_TOKEN=sqp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Ceci étant fait il ne nous reste plus qu’à rajouter quelques étapes dans le workflow Gitea Action pour analyser le code source avec l’outil SonarScanner for .NET déjà installé au début.

.gitea/workflows/build.yaml
# ...
steps:
# ...
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
cache: true
cache-dependency-path: "**/packages.lock.json"
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- name: Cache SonarQube packages
uses: actions/cache@v5
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
# ...
- name: build
run: |
dotnet sonarscanner begin /k:"${{ vars.SONAR_PROJECT_ID }}" /d:sonar.host.url="${{ vars.SONAR_HOST_URL }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.cs.opencover.reportsPaths=coverage.xml
dotnet build -c Release --no-restore
- name: test
run: |
dotnet coverlet Conduit.Tests/bin/Release/net10.0/Conduit.Tests.dll --target "dotnet" --targetargs "test -c Release --no-restore --no-build" -f=opencover -o="coverage.xml"
dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
Explanation

Sonar oblige, il faut ajouter le setup java avant d’exécuter l’analyse. On ajoute également une étape de cache pour les paquets SonarQube.

L’essentiel ensuite réside dans dotnet sonarscanner begin et dotnet sonarscanner end pour effectuer l’analyse entre le build et les tests. Notez l’option /d:sonar.cs.opencover.reportsPaths=coverage.xml pour indiquer le rapport de couverture généré précédemment.

Aller sur l’interface SonarQube après le push des modifications, l’analyse devrait apparaître dans le projet avec les métriques de niveau de qualité/sécurité global du code, couverture de test incluse.

SonarQube analyze

Continuous Deployment

Nous en avons fini avec la CI, passons au CD avec FluxCD pour déployer automatiquement notre application sur le cluster Talos à chaque nouvelle release. Le schéma complet du flux CI/CD sera le suivant :

flux deploy

Nous sommes donc sur un modèle pull, où FluxCD surveille les releases Gitea et déploie automatiquement la nouvelle version de l’application sur le cluster Talos. A aucun moment la CI n’a connaissance de l’environnement de run, et se limite à la génération d’artifacts (images OCI) versionnés.

Note

Par défaut, l’accès à l’image docker est publique si lié à une organisation configuré en visibilité publique sur Gitea (paramètre par défaut lors de la création). Pour simplifier le processus de déploiement, nous allons laisser cela comme ça.

Si vous souhaitez restreindre l’accès au registry Gitea, en mettant l’organisation en accès privée, il vous faudra configurer un imagePullSecret dans Kubernetes avec les bonnes informations d’authentification.

Kubernetes manifests

Commencer par créer une base de données conduit PostgreSQL dédiée à l’app à déployer, directement sur pgAdmin, avec un utilisateur de même nom conduit et un mot de passe solide. Créer ensuite un secret Kubernetes pour stocker le mot de passe de connexion.

clusters/dev/ohmytalos/secret-conduit-database.yaml
apiVersion: v1
kind: Secret
metadata:
name: conduit-database
namespace: ohmytalos
type: Opaque
data:
postgres-password: <votre-mot-de-passe-postgres | base64>

Chiffrez immédiatement ce fichier via la commande sops -e -i clusters/dev/ohmytalos/secret-conduit-database.yaml.

Préparer ensuite le déploiement Kubernetes en 2 replicas de l’application Conduit, toujours constitué au minimum d’un deployment, un service, et un ingress :

clusters/dev/ohmytalos/deployment-conduit.yaml
apiVersion: v1
kind: Namespace
metadata:
name: ohmytalos
labels:
pod-security.kubernetes.io/enforce: privileged
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: conduit
namespace: ohmytalos
spec:
replicas: 2
selector:
matchLabels:
app: conduit
template:
metadata:
labels:
app: conduit
spec:
containers:
- name: conduit
image: gitea.ohmytalos.io/ohmytalos/conduit:main
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: conduit-database
key: postgres-password
- name: ConnectionStrings__DefaultConnection
value: Host=ohmytalos-dev-rw.postgres;Username=conduit;Password='$(DB_PASSWORD)';Database=conduit;
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
app: conduit
---
apiVersion: v1
kind: Service
metadata:
name: conduit
namespace: ohmytalos
labels:
app: conduit
spec:
selector:
app: conduit
ports:
- name: http
port: 8080
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: conduit
namespace: ohmytalos
spec:
routes:
- match: Host(`conduit.ohmytalos.io`)
kind: Rule
services:
- name: conduit
port: http
Explanation

Il est de bonne pratique de rajouter un podAntiAffinity pour s’assurer de ne jamais avoir de pods d’une même application sur un même nœud.

Puis le fichier Kustomization pour regrouper le tout :

clusters/dev/ohmytalos/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment-conduit.yaml
- secret-conduit-database.yaml

On commit tout ça et checker le status du déploiement via kgp -n ohmytalos. Si tout est bon go sur https://conduit.ohmytalos.io/scalar/ et admirer. Tester l’endpoint /article, vous devriez avoir une erreur 500. Utiliser kl -n ohmytalos deploy/conduit -c conduit pour voir les logs, qui devrait indiquer relation "Articles" does not exist. La base de données n’a effectivement pas été migrée. Nous traiterons ce cas plus tard.

Images manifests

Bien, l’application est déployée, mais sans aucun système de déploiement continue. Il est temps de s’appuyer sur les composants ImageReflector et ImageAutomation de FluxCD pour automatiser tout ça.

Pour fonctionner ces 2 composants utilisent 3 CRDs essentiels :

  • ImageRepository : Indique à image reflector quel repository OCI à surveiller et d’y injecter tous les tags trouvés.
  • ImagePolicy : Indique à image reflector quelle politique de versionning à appliqué pour obtenir le dernier tag à utiliser. Le dernier tag retenu y est stocké.
  • ImageUpdateAutomation : Indique à image automation comment mettre à jour le repo git source selon le dernier tag récupéré par l’ensemble des ImagePolicy défini pour chaque image.

Nous allons commencer par créer les manifests Kubernetes nécessaires au déploiement automatisé de l’application Conduit.

clusters/dev/ohmytalos/images.yaml
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: image-conduit
namespace: flux-system
spec:
image: gitea.ohmytalos.io/ohmytalos/conduit
interval: 1m0s
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: image-conduit
namespace: flux-system
spec:
imageRepositoryRef:
name: image-conduit
namespace: flux-system
policy:
semver:
range: ">=1.0.0"
clusters/dev/ohmytalos/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
# ...
- images.yaml

Quelques commandes utiles :

  • k describe -n flux-system imgrepo image-conduit pour voir les tags récupérés.
  • k describe -n flux-system imgpol image-conduit pour vous voir le dernier tag actif selon la politique définie.

Définir ensuite le CRD ImageUpdateAutomation pour automatiser la mise à jour du repo git source, avec un template de commit + author. Peut être défini une seule fois par repo git source.

clusters/dev/images/image-update-automation.yaml
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageUpdateAutomation
metadata:
name: flux-system
namespace: flux-system
spec:
interval: 1m0s
sourceRef:
kind: GitRepository
name: flux-system
git:
checkout:
ref:
branch: main
commit:
author:
email: fluxcdbot@users.noreply.github.com
name: fluxcdbot
messageTemplate: |-
Automated image update
Changes:
{{ range .Changed.Changes -}}
- {{ .OldValue }} -> {{ .NewValue }}
{{ end -}}
Files:
{{ range $filename, $_ := .Changed.FileChanges -}}
- {{ $filename }}
{{ end -}}
push:
branch: main
update:
strategy: Setters

Enfin, il ne reste plus qu’à appliquer l’ImagePolicy définie précédemment juste au niveau de l’image de déploiement. C’est qui indiquera à ImageAutomation l’emplacement exact de l’image à mettre à jour dans le manifest selon la policy cible.

clusters/dev/ohmytalos/deployment-conduit.yaml
# ...
spec:
# ...
template:
# ...
spec:
containers:
- name: conduit
image: gitea.ohmytalos.io/ohmytalos/conduit:main # {"$imagepolicy": "flux-system:image-conduit"}

Une fois commité, utiliser k describe -n flux-system iua flux-system pour vérifier que cette ImagePolicy a bien été pris en compte dans la section Observed Policies :

Status:
Observed Policies:
Image - Conduit:
Name: gitea.ohmytalos.io/ohmytalos/conduit
Tag: vX.Y.Z

À cet instant précis, ImageAutomation va récupérer le dernier tag dans la policy puis mettre à jour le manifest deployment-conduit.yaml dans le repo git source en conséquence, en remplaçant :main par :vX.Y.Z. Un commit sera automatiquement créé et poussé sur la branche main.

Et voilà, le déploiement continu est en place. À chaque nouvelle release Gitea, FluxCD détectera le nouveau tag, mettra à jour le manifest dans le repo git source, puis KustomizeController appliquera automatiquement la nouvelle version de l’application sur le cluster Talos, en Zero Downtime. Il nous reste plus qu’un dernier détail à régler : les migrations de la base de données.

Migrations DB

Comme vu précédemment, l’application nécessite une base de données PostgreSQL avec les bonnes migrations appliquées pour fonctionner. Il n’est évidemment pas envisageable de migrer la base de données au démarrage de l’application, du fait du risque de concurrence entre les replicas. Même si EF Core gère bien ce cas en verrouillant la table de migration, il est préférable de séparer les responsabilités.

EF Core nous simplifie bien la vie en supportant la génération d’un bundle de migration exécutable en ligne de commande, parfait pour notre cas d’usage. Nous allons donc modifier le workflow Gitea Action pour générer ce bundle via la commande dotnet ef migrations bundle au moment de la publication de l’application.

.gitea/workflows/build.yaml
# ...
jobs:
build:
runs-on: ubuntu-latest
steps:
# ...
- name: publish
run: |
dotnet publish -c Release -o ./publish --no-restore --no-build
dotnet ef migrations bundle --project Conduit.Business --startup-project Conduit.WebApi

Ne reste plus qu’à inclure cet exécutable dans notre image OCI de production.

Dockerfile
# ...
COPY --chown=app:app /efbundle /app/efbundle
WORKDIR /app
# ...

Committer cette modification, le workflow Gitea Action se chargera de reconstruire et pousser la nouvelle image OCI avec le bundle de migration inclus. La partie CD se chargera ensuite du déploiement automatique.

Une fois l’app déployée, il est possible de lancer cet exécutable directement via la commande keti -n ohmytalos deploy/conduit -c conduit -- ./efbundle.

Dans le cadre où nous souhaiterions l’automatiser au démarrage de chaque nouveau déploiement, la mise en place d’un Job Kubernetes reste l’approche recommandée. Ce job s’exécutera une seule fois à chaque mise à jour de l’application.

clusters/dev/ohmytalos/job-conduit-migrate.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: conduit
namespace: ohmytalos
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 60
template:
spec:
restartPolicy: Never
containers:
- name: conduit
image: gitea.ohmytalos.io/ohmytalos/conduit:main # {"$imagepolicy": "flux-system:image-conduit"}
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: conduit-database
key: postgres-password
- name: ConnectionStrings__DefaultConnection
value: Host=ohmytalos-dev-rw.postgres;Username=conduit;Password='$(DB_PASSWORD)';Database=conduit;
command:
- /app/efbundle

Ajouter le job :

clusters/dev/ohmytalos/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
# ...
- job-conduit-migrate.yaml

Puis commiter. À chaque nouvelle mise à jour de l’application, FluxCD déploiera le job de migration qui s’exécutera une seule fois avant de se supprimer automatiquement. Tester un rajout de propriété dans une entité EF Core, générer une nouvelle release Gitea, et observer le bon déroulement du processus de migration automatique.

Pour seeder les données initiales, vous pouvez utiliser keti -n ohmytalos deploy/conduit -c conduit -- ./Conduit.Console.

Run

La CI/CD c’est bien, mais le boulot continue une fois l’application en production. L’observabilité est le dernier aspect crucial à appliquer.

Probes

La première chose à faire est de configurer les probes Kubernetes pour s’assurer que l’application est bien vivante et prête à recevoir du trafic. C’est cet élément qui permettra à Kubernetes de redémarrer un pod en cas de problème, et d’éviter tout trafic vers un pod non prêt, permettant ainsi un déploiement en zero-downtime.

Le plus courant est de mettre à dispo un endpoint healthz dans l’application, qui retournera un code 200 si tout va bien. Nous allons juste installer 2 packages simples pour cela.

Terminal window
dotnet add Conduit.WebApi package Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore

Rajouter les health checks dans le programme principal de l’application.

Conduit.WebApi/Program.cs
// ...
builder.Services
// ...
.AddHealthChecks()
.AddDbContextCheck<AppDbContext>();
// ...
app.MapHealthChecks("/healthz");
await app.RunAsync();
// ...

Plus qu’à rajouter les probes dans le manifest de déploiement.

clusters/dev/ohmytalos/deployment-conduit.yaml
# ...
spec:
# ...
template:
# ...
spec:
containers:
- name: conduit
# ...
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
# ...
---
apiVersion: traefik.io/v1alpha1
# ...
spec:
routes:
- match: Host(`conduit.ohmytalos.io`) && !PathRegexp(`^/(healthz|metrics)`)
# ...
Explanation

Ici, nous choisissons de garder /healthz et /metrics accessibles uniquement en interne.

Le Zero Downtime est maintenant réellement effectif. Vous pouvez rajouter autant de HealthChecks personnalisé que nécessaire dans l’application pour vérifier l’état de santé des dépendances critiques.

Métriques

Depuis la dernière version .NET 8, ASP.NET fourni des métriques exploitables par Prometheus. De nos jours, il y a 2 façons de faire au choix :

  • La méthode classique, en mode pull, qui consiste à exposer un endpoint /metrics compatible Prometheus, scrappable via un CRD ServiceMonitor.
  • La méthode télémétrique, beaucoup plus récente, en mode push, qui utilise l’exporter OTLP pour envoyer les métriques vers un collecteur OpenTelemetry, qui se chargera de les exporter vers Prometheus.

À vous de choisir votre préférence. Je présente ici les 2. L’avantage principal de la méthode OTLP est de centraliser toutes les télémétries (métriques, logs, traces) via un collecteur unique sans besoin de configuration supplémentaire avec un ServiceMonitor. C’est l’approche recommandée par OpenTelemetry.

Méthode pull (scraping)

Terminal window
dotnet add Conduit.WebApi package OpenTelemetry.Exporter.Prometheus.AspNetCore --prerelease
dotnet add Conduit.WebApi package OpenTelemetry.Extensions.Hosting
dotnet add Conduit.WebApi package OpenTelemetry.Instrumentation.AspNetCore
Conduit.WebApi/Program.cs
// ...
builder.Services
.AddOpenTelemetry()
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddPrometheusExporter()
);
var app = builder.Build();
// ...
app.MapHealthChecks("/healthz");
app.MapPrometheusScrapingEndpoint();
// ...

Aller sur /metrics pour confirmer puis committer pour déploiement. La dernière étape est de rajouter le CRD ServiceMonitor à notre déploiement pour que prometheus puisse aller scrapper les métriques.

clusters/dev/ohmytalos/deployment-conduit.yaml
# ...
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: conduit
namespace: ohmytalos
spec:
endpoints:
- port: http
selector:
matchLabels:
app: conduit

Retourner sur les targets Prometheus pour vérifier que le scraping est bien effectif.

Conduit prometheus targets

Méthode push (OTLP)

Au lieu d’utiliser l’exporter Prometheus, on utilise l’exporter OTLP OpenTelemetryProtocol pour envoyer les métriques vers le collecteur OpenTelemetry Alloy installé précédemment.

Terminal window
dotnet add Conduit.WebApi package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add Conduit.WebApi package OpenTelemetry.Extensions.Hosting
dotnet add Conduit.WebApi package OpenTelemetry.Instrumentation.AspNetCore
Conduit.WebApi/Program.cs
// ...
builder.Services
.AddOpenTelemetry()
.UseOtlpExporter()
.ConfigureResource(r => r.AddService(builder.Environment.ApplicationName))
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
);
var app = builder.Build();
// ...
Explanation

L’utilisation de UseOtlpExporter permet de configurer une seule fois l’exporter OTLP pour tous les types de télémétrie avec les variables d’environnement adéquates (à configurer juste après). Il remplace AddOtlpExporter.

Si besoin utiliser AddConsoleExporter pour tester le fonctionnement puis committer pour déploiement. Enfin, s’agissant d’une méthode push, il faut configurer le service où renvoyer les requêtes OTLP. OpenTelemetry permet de configurer cela via des variables d’environnement, de manière séparée pour chaque niveau de télémétrie (métriques, logs, traces), ou de manière unifiée.

clusters/dev/ohmytalos/deployment-conduit.yaml
# ...
spec:
# ...
template:
# ...
spec:
containers:
- # ...
env:
# ...
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: http://alloy.telemetry:4317
- name: OTEL_EXPORTER_OTLP_PROTOCOL
value: grpc
# ...
Explanation

L’utilisation du collecteur Alloy permet de grandement simplifier la configuration côté applicatif, en utilisant un endpoint unique pour toutes les télémétries.

Le but est de déplacer la charge de la configuration des backends (Prometheus, Loki, Tempo) vers le collecteur.

Si toute la partie collecteur et backend prometheus est bien configurée (ce qui est normalement le cas en suivant scrupuleusement ce guide), il n’y a rien de plus à faire. Vérifier sur prometheus que des métriques remontent bien pour le service Conduit.WebApi.

conduit-prometheus-query

Bien plus simple que la méthode pull pour le coup, en plus de s’intégrer déjà aux logs et traces que l’on attaquera ensuite.

Dashboard

La team .NET fourni un dashboard Grafana pour visualiser tout ça. Plus qu’à aller sur Grafana puis importer un nouveau dashboard en utilisant l’ID 19924 et admirer.

Conduit Grafana Dashboard

Le menu Drilldown Metrics permet également de visualiser par mal de métriques intéressantes.

Conduit Grafana Metrics

Logs 💝 Traces

Nous allons enfin continuer d’exploiter OpenTelemetry pour centraliser les logs et traces de l’application, toujours en passant par le collecteur Alloy installé précédemment avec ses backends Loki et Tempo. Assurer-vous dans un premier temps d’avoir rajouté les variables d’environnement OTLP nécessaires (cf. ci-dessus) au niveau du déploiement.

On rajoute quelques packages OpenTelemetry supplémentaires dans l’application, dont notamment les télémétries spécifiques aux bases de données. Si pas déjà fait au niveau des métriques, n’oubliez pas de rajouter le package OpenTelemetry.Exporter.OpenTelemetryProtocol.

Terminal window
dotnet add Conduit.WebApi package OpenTelemetry.Instrumentation.EntityFrameworkCore --prerelease
dotnet add Conduit.WebApi package Npgsql.OpenTelemetry

Plus qu’à les activer dans le programme principal de l’application.

Conduit.WebApi/Program.cs
// ...
builder.Services
.AddOpenTelemetry()
.UseOtlpExporter()
.ConfigureResource(r => r.AddService(builder.Environment.ApplicationName))
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.SetExemplarFilter(ExemplarFilterType.TraceBased)
)
.WithLogging()
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddNpgsql()
);
// ...
Important

Nous rajoutons SetExemplarFilter(ExemplarFilterType.TraceBased) pour lier les métriques aux traces, ce qui permet d’avoir des métriques enrichies avec le trace_id associé.

Cela permet d’exploiter correctement la fonctionnalité exemplarTraceIdDestinations que l’on a défini dans la datasource Prometheus dans le chapitre dédié aux métriques.

Enrichissons l’API par quelques logs.

Conduit.WebApi/Extensions/LoggerExtensions.cs
namespace Conduit.WebApi.Extensions;
internal static partial class LoggerExtensions
{
[LoggerMessage(LogLevel.Information, "Fetching items with offset {offset} and limit {limit}.")]
public static partial void FetchPaginated(this ILogger logger, int offset, int limit);
[LoggerMessage(LogLevel.Information, "Fetched {count} items out of {total} total.")]
public static partial void FetchedPaginated(this ILogger logger, int count, int total);
[LoggerMessage(LogLevel.Information, "Fetching item identified by {slug}.")]
public static partial void FetchSlug(this ILogger logger, string slug);
[LoggerMessage(LogLevel.Information, "Item fetched with id {id}.")]
public static partial void FetchedSlug(this ILogger logger, int id);
[LoggerMessage(LogLevel.Information, "Creating new item.")]
public static partial void CreateItem(this ILogger logger);
[LoggerMessage(LogLevel.Information, "New item {id} created.")]
public static partial void CreatedItem(this ILogger logger, int id);
}
Conduit.WebApi/Controllers/ArticlesController.cs
// ...
public static class ArticlesEndpoints
{
public static void MapArticlesEndpoints(this WebApplication app)
{
app.MapGet("/articles", async (AppDbContext dbContext, int offset = 0, int limit = 20) =>
{
app.Logger.FetchPaginated(offset, limit);
// ...
app.Logger.FetchedPaginated(articles.Count, total);
// ...
})
// ...
app.MapGet("/articles/{slug}", async (AppDbContext dbContext, string slug) =>
{
app.Logger.FetchSlug(slug);
// ...
app.Logger.FetchedSlug(article.Id);
// ...
})
// ...
app.MapPost("/articles", async (AppDbContext dbContext, NewArticleDto articleDto, IValidator<NewArticleDto> validator) =>
{
app.Logger.CreateItem();
// ...
app.Logger.CreatedItem(article.Id);
// ...
})
// ...
}
}

Et voilà pousser tout ça. Une fois déployé fait de multiples requêtes sur l’API pour générer des logs et traces, puis aller sur Grafana voir si les logs remontent bien pour sur le service Conduit.WebApi, qui est le nom du service définir avec builder.Environment.ApplicationName. Ils devraient être correctement enrichies avec le trace_id (l’intérêt principal d’OTLP).

conduit-otlp-logs

Cliquer sur le derivated field Tempo pour accéder à la trace associée.

conduit-otlp-traces

Vous pouvez y visualiser le cheminement complet de la requête, remontant jusqu’au reverse proxy Traefik, et descendant jusqu’aux 2 appels à la base de données (pagination liste + compteur), que ce soit au niveau de l’ORM EF Core et même le driver Npgsql avec la requête SQL brute sous-jacente.

En bonus le graph node est fourni pour visualiser le cheminement entre chaque requête, le tout enrichi de métriques.

conduit-otlp-node-graph

Il est aussi possible d’accéder aux traces directement depuis les métriques, si les exemplars sont bien actifs.

conduit-otlp-exemplars

Ici, View traces vient du lien défini dans la datasource au chapitre des métriques lors de l’installation de Prometheus, permettant d’accéder directement à la trace associée à cet évenement sur Tempo. Extrêmement pratique pour investiguer les pics de charge dans les métriques pour en naviguant dans le détail d’une requête en particulier.

En dehors des outils de monitoring et de tracing, il existe toujours les outils de visualisation temps réel comme Hubble pour explorer rapidement les requêtes et les dépendances entre les services.

conduit-hubble

Conclusion

Nous avons vu comment mettre en place une chaîne complète de CI/CD pour une application .NET sur Gitea et FluxCD, en intégrant des outils d’analyse statique de code, de monitoring, de logging et de tracing distribué via OpenTelemetry. Cette approche moderne permet de maintenir au mieux la qualité du code, la fiabilité des déploiements et la bonne observabilité de l’application en production.