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 :
cache: host: 172.17.0.1 port: 8088log: level: inforunner: capacity: 3Tip
Activer le cache interne afin d’accélérer la récupération des artefacts de dépendances (nuget, npm, etc.)
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 Action
Depuis le projet ohmytalos/conduit, créons un workflow de base juste pour le build, lancement des tests et publication.
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-buildLes 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 :

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 :
CONTAINER_REGISTRY=gitea.ohmytalos.ioCONTAINER_REGISTRY_USERNAME=ohmytalosAller ensuite au niveau des secrets de l’organisation puis créer le secret suivant :
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 :
# ...
- 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 :
FROM mcr.microsoft.com/dotnet/aspnet:10.0USER app
COPY --chown=app:app /publish /appWORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080ENTRYPOINT ["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.
RELEASE_TOKEN=<personal access token under ohmytalos with write:repository scope>On s’attelle maintenant à l’installation de GitVersion :
dotnet tool install GitVersion.Toolmode: ContinuousDeploymentnext-version: 1.0.0branches: main: increment: PatchExplanation
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 :
# ...
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.

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 :
SONAR_HOST_URL=https://sonarqube.ohmytalos.ioEnsuite 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 :
SONAR_PROJECT_ID=ohmytalos-conduitEnfin 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 :
SONAR_TOKEN=sqp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxCeci é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.
# ...
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.

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 :
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.
apiVersion: v1kind: Secretmetadata: name: conduit-database namespace: ohmytalostype: Opaquedata: 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 :
apiVersion: v1kind: Namespacemetadata: name: ohmytalos labels: pod-security.kubernetes.io/enforce: privileged---apiVersion: apps/v1kind: Deploymentmetadata: name: conduit namespace: ohmytalosspec: 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: v1kind: Servicemetadata: name: conduit namespace: ohmytalos labels: app: conduitspec: selector: app: conduit ports: - name: http port: 8080---apiVersion: traefik.io/v1alpha1kind: IngressRoutemetadata: name: conduit namespace: ohmytalosspec: routes: - match: Host(`conduit.ohmytalos.io`) kind: Rule services: - name: conduit port: httpExplanation
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 :
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources: - deployment-conduit.yaml - secret-conduit-database.yamlOn 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 desImagePolicydéfini pour chaque image.
Nous allons commencer par créer les manifests Kubernetes nécessaires au déploiement automatisé de l’application Conduit.
apiVersion: image.toolkit.fluxcd.io/v1kind: ImageRepositorymetadata: name: image-conduit namespace: flux-systemspec: image: gitea.ohmytalos.io/ohmytalos/conduit interval: 1m0s---apiVersion: image.toolkit.fluxcd.io/v1kind: ImagePolicymetadata: name: image-conduit namespace: flux-systemspec: imageRepositoryRef: name: image-conduit namespace: flux-system policy: semver: range: ">=1.0.0"apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources: # ... - images.yamlQuelques commandes utiles :
k describe -n flux-system imgrepo image-conduitpour voir les tags récupérés.k describe -n flux-system imgpol image-conduitpour 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.
apiVersion: image.toolkit.fluxcd.io/v1kind: ImageUpdateAutomationmetadata: name: flux-system namespace: flux-systemspec: 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: SettersEnfin, 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.
# ...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.
# ...
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.WebApiNe reste plus qu’à inclure cet exécutable dans notre image OCI de production.
# ...
COPY --chown=app:app /efbundle /app/efbundleWORKDIR /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.
apiVersion: batch/v1kind: Jobmetadata: name: conduit namespace: ohmytalosspec: 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/efbundleAjouter le job :
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources: # ... - job-conduit-migrate.yamlPuis 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.
dotnet add Conduit.WebApi package Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCoreRajouter les health checks dans le programme principal de l’application.
// ...
builder.Services // ... .AddHealthChecks() .AddDbContextCheck<AppDbContext>();
// ...
app.MapHealthChecks("/healthz");
await app.RunAsync();
// ...Plus qu’à rajouter les probes dans le manifest de déploiement.
# ...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
/metricscompatible Prometheus, scrappable via un CRDServiceMonitor. - 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)
dotnet add Conduit.WebApi package OpenTelemetry.Exporter.Prometheus.AspNetCore --prereleasedotnet add Conduit.WebApi package OpenTelemetry.Extensions.Hostingdotnet add Conduit.WebApi package OpenTelemetry.Instrumentation.AspNetCore// ...
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.
# ...---apiVersion: monitoring.coreos.com/v1kind: ServiceMonitormetadata: name: conduit namespace: ohmytalosspec: endpoints: - port: http selector: matchLabels: app: conduitRetourner sur les targets Prometheus pour vérifier que le scraping est bien effectif.

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.
dotnet add Conduit.WebApi package OpenTelemetry.Exporter.OpenTelemetryProtocoldotnet add Conduit.WebApi package OpenTelemetry.Extensions.Hostingdotnet add Conduit.WebApi package OpenTelemetry.Instrumentation.AspNetCore// ...
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.
# ...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.

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.

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

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.
dotnet add Conduit.WebApi package OpenTelemetry.Instrumentation.EntityFrameworkCore --prereleasedotnet add Conduit.WebApi package Npgsql.OpenTelemetryPlus qu’à les activer dans le programme principal de l’application.
// ...
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.
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);}// ...
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).

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

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.

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

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.

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.