23. Pipeline de production de bout en bout#

Ce chapitre est la synthèse du livre. Il assemble toutes les briques vues précédemment — intégration continue, sécurité, GitOps, canary deployment, observabilité, notifications — dans un pipeline cohérent et opérationnel. L’architecture cible est une application microservices avec un frontend React, un backend FastAPI, et une base de données PostgreSQL.

Architecture cible#

L’application est découpée en trois services :

  • frontend : application React servie par Nginx, image ~50 Mo

  • api : backend FastAPI avec authentification JWT, image ~120 Mo

  • worker : consommateur Celery pour les tâches asynchrones, image ~130 Mo

Les trois services partagent :

  • Une base PostgreSQL (StatefulSet ou RDS selon l’environnement)

  • Un broker Redis (StatefulSet ou ElastiCache)

  • Un service de tracing Jaeger (sidecar ou collector centralisé)

Trois environnements : dev (déploiement automatique à chaque merge), staging (approbation manuelle après les tests d’intégration), production (canary progressif avec SLO check).

Pipeline CI complet#

Étapes du pipeline CI#

Le pipeline CI s’exécute sur chaque pull request et sur chaque merge vers main.

# .github/workflows/ci.yml — Pipeline CI complet annoté

name: CI Pipeline

on:
  push:
    branches: [main, "release/**"]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # ─── Qualité du code ────────────────────────────────────────────────────────
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install ruff mypy
      - run: ruff check src/
      - run: mypy src/ --ignore-missing-imports

  # ─── Tests unitaires ────────────────────────────────────────────────────────
  test-unit:
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -r requirements-dev.txt
      - run: pytest tests/unit/ --cov=src --cov-report=xml -q
      - uses: codecov/codecov-action@v4
        with: { files: coverage.xml }

  # ─── Tests d'intégration ────────────────────────────────────────────────────
  test-integration:
    runs-on: ubuntu-latest
    needs: lint
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements-dev.txt
      - run: pytest tests/integration/ -q
        env:
          DATABASE_URL: postgresql://postgres:testpass@localhost/testdb

  # ─── SAST : analyse statique de sécurité ────────────────────────────────────
  sast:
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4
      - uses: returntocorp/semgrep-action@v1
        with:
          config: >-
            p/python
            p/owasp-top-ten
            p/secrets

  # ─── Build et push de l'image ───────────────────────────────────────────────
  build:
    runs-on: ubuntu-latest
    needs: [test-unit, test-integration, sast]
    permissions:
      contents: read
      packages: write
      id-token: write    # Pour Cosign keyless
    outputs:
      image-digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build et push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.ref == 'refs/heads/main' }}
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: true
          sbom: true       # Génère le SBOM automatiquement

  # ─── Scan de vulnérabilités ──────────────────────────────────────────────────
  scan:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: aquasecurity/trivy-action@master
        with:
          image-ref: >-
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.image-digest }}
          format: sarif
          output: trivy.sarif
          severity: CRITICAL,HIGH
          exit-code: 1    # Échoue le pipeline si des CVE CRITICAL sont trouvées

      - uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy.sarif

  # ─── Signature Cosign ───────────────────────────────────────────────────────
  sign:
    runs-on: ubuntu-latest
    needs: [build, scan]
    permissions:
      id-token: write
      packages: write
    steps:
      - uses: sigstore/cosign-installer@v3
      - name: Signer l'image avec Cosign keyless
        run: |
          cosign sign --yes \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.image-digest }}

Pipeline CD complet#

Gestion des environnements#

# .github/workflows/cd.yml — Pipeline CD complet

name: CD Pipeline

on:
  workflow_run:
    workflows: ["CI Pipeline"]
    types: [completed]
    branches: [main]

jobs:
  # ─── Déploiement dev : automatique ──────────────────────────────────────────
  deploy-dev:
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    environment: dev
    steps:
      - uses: actions/checkout@v4
        with:
          repository: myorg/k8s-manifests
          token: ${{ secrets.GIT_TOKEN }}

      - name: Mettre à jour le tag d'image (dev)
        run: |
          cd apps/api/overlays/dev
          kustomize edit set image \
            api=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          git config user.email "ci@myorg.com"
          git config user.name "CI Bot"
          git add .
          git commit -m "chore(dev): update api image to ${{ github.sha }}"
          git push

      - name: Attendre la sync ArgoCD
        run: |
          argocd app wait api-dev \
            --sync --health \
            --timeout 300 \
            --auth-token ${{ secrets.ARGOCD_TOKEN }} \
            --server argocd.myorg.com

  # ─── Déploiement staging : approbation manuelle ─────────────────────────────
  deploy-staging:
    needs: deploy-dev
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.myorg.com
    steps:
      - name: Mettre à jour le tag d'image (staging)
        run: |
          echo "Deploying ${{ github.sha }} to staging..."

      - name: Tests de smoke staging
        run: |
          sleep 30
          curl -f https://staging.myorg.com/health || exit 1
          curl -f https://staging.myorg.com/api/v1/status || exit 1

  # ─── Déploiement production : canary progressif ─────────────────────────────
  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myorg.com
    steps:
      - name: Déploiement canary (10 %)
        run: |
          kubectl patch rollout api-rollout \
            -n production \
            --type merge \
            -p '{"spec":{"steps":[{"setWeight":10}]}}'

      - name: Vérification SLO (10 min)
        run: |
          python scripts/check_slo.py \
            --error-rate-threshold 0.01 \
            --p99-threshold 500 \
            --duration 600

      - name: Promotion complète
        run: |
          kubectl argo rollouts promote api-rollout -n production

  # ─── Notifications Slack ────────────────────────────────────────────────────
  notify:
    needs: [deploy-production]
    runs-on: ubuntu-latest
    if: always()
    steps:
      - name: Notification Slack
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Déploiement production : ${{ github.sha }} — ${{ needs.deploy-production.result }}",
              "blocks": [{
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": "*Statut :* ${{ needs.deploy-production.result }}\n*SHA :* `${{ github.sha }}`\n*Auteur :* ${{ github.actor }}"
                }
              }]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Pipeline pour les librairies#

Les librairies (packages Python, npm) nécessitent un pipeline distinct axé sur le versioning sémantique et la publication.

# Pipeline librairie : semver automatique + publication PyPI
name: Release Library

on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }

      # semantic-release analyse les commits conventionnels
      # et détermine le prochain numéro de version
      - uses: python-semantic-release/python-semantic-release@v9
        id: release
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}

      # Publie sur PyPI uniquement si une nouvelle version a été créée
      - name: Publier sur PyPI
        if: steps.release.outputs.released == 'true'
        uses: pypa/gh-action-pypi-publish@release/v1

Conventional commits et semantic-release#

semantic-release analyse l’historique des commits pour déterminer la prochaine version :

  • fix: → patch (1.2.3 → 1.2.4)

  • feat: → minor (1.2.3 → 1.3.0)

  • feat!: ou BREAKING CHANGE: → major (1.2.3 → 2.0.0)

  • chore:, docs:, style: → pas de release

Le changelog est généré automatiquement depuis ces commits et inclus dans la GitHub Release.

Coût et dimensionnement#

Estimation pour une équipe de 10 développeurs#

Composant

Spécification

Coût mensuel estimé

Runners CI (GitHub-hosted)

3 000 min/mois

~24 €

Runners CI (self-hosted, 4 vCPU)

2× EC2 t3.xlarge

~130 €

Registry OCI (GHCR)

50 Go stockage

~5 €

Cluster Kubernetes (staging)

3× t3.medium

~90 €

Cluster Kubernetes (prod)

3× t3.large

~180 €

Observabilité (Grafana Cloud)

50 Go logs, 10k métriques

~45 €

Total self-hosted runners

~450 €/mois

Optimisation des coûts CI

Les runners self-hosted sur des instances Spot EC2 ou Preemptible GCE réduisent le coût des runners de 60 à 70 %. Le cache Buildx (GitHub Actions Cache) réduit les temps de build de 40 à 60 % et donc la consommation de minutes CI.

Théorie des files d’attente appliquée aux pipelines CI#

Un pipeline CI peut être modélisé comme une file d’attente M/M/c :

  • M (arrivées) : les commits suivent approximativement un processus de Poisson

  • M (service) : les durées de pipeline suivent une loi exponentielle

  • c : le nombre de runners en parallèle

La formule de Little donne le temps moyen en système : W = L / λL est le nombre moyen de pipelines en cours et λ le débit d’arrivée. Augmenter c (runners) réduit W selon une courbe hyperbolique : en dessous de 80 % d’utilisation, les temps d’attente restent faibles ; au-delà, ils explosent.


Visualisations#

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch
import networkx as nx
import seaborn as sns
import pandas as pd
from scipy.special import factorial

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
# Modèle M/M/c : temps d'attente en fonction du nombre de runners

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

def erlang_c(c, a):
    """Probabilité d'attente dans un système M/M/c (formule d'Erlang C)."""
    r = a / c
    if r >= 1.0:
        return 1.0
    sum_terms = sum((a**n) / factorial(n) for n in range(c))
    last_term = (a**c) / (factorial(c) * (1 - r))
    p0 = 1.0 / (sum_terms + last_term)
    return (last_term * p0)

def wq_mmс(c, lam, mu):
    """Temps d'attente moyen en file M/M/c (en unités de 1/mu)."""
    a = lam / mu
    if a / c >= 1.0:
        return float("inf")
    ec = erlang_c(int(c), a)
    return ec / (c * mu - lam)  # en heures

# Paramètres : équipe de 10 développeurs
# 8 commits/heure, durée pipeline ~10 min → mu = 6 pipelines/heure/runner
lam = 8.0
mu  = 6.0

runners = np.arange(2, 16)
temps_attente_min = []
utilisation_pct   = []

for c in runners:
    wq = wq_mmс(int(c), lam, mu)
    temps_attente_min.append(min(wq * 60, 65))
    utilisation_pct.append(min(lam / (int(c) * mu) * 100, 105))

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5))

ax1.plot(runners, temps_attente_min, "o-", color="#4c72b0",
         linewidth=2, markersize=7)
ax1.fill_between(runners, temps_attente_min, alpha=0.15, color="#4c72b0")
ax1.axhline(y=5, color="#2ca02c", linestyle="--",
            linewidth=1.5, label="Cible : < 5 min d'attente")
ax1.set_xlabel("Nombre de runners CI")
ax1.set_ylabel("Temps d'attente moyen (min)")
ax1.set_title("Temps d'attente moyen\n(modèle M/M/c, λ=8 commits/h, μ=6 pipelines/h)")
ax1.legend()
ax1.set_ylim(0, 70)

ax2.bar(runners, utilisation_pct, color="#dd8452", alpha=0.85,
        label="Utilisation des runners (%)")
ax2.axhline(y=80, color="#d62728", linestyle="--",
            linewidth=1.5, label="Seuil critique (80 %)")
ax2.set_xlabel("Nombre de runners CI")
ax2.set_ylabel("Utilisation (%)")
ax2.set_title("Utilisation des runners\n(au-delà de 80 % : files longues)")
ax2.legend()
ax2.set_ylim(0, 115)

plt.suptitle("Dimensionnement des runners CI — Modèle M/M/c",
             fontsize=13, y=1.02)
plt.show()
_images/4db2bc5b379eae8ac46b5f9e59b64c24539b7ec060e37fe18a2d24ef2dffb213.png
# DAG du pipeline complet : CI + Security + CD + Notify

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

G = nx.DiGraph()

noeuds = {
    "Checkout":          "ci",
    "Lint":              "ci",
    "Test\nunitaires":   "ci",
    "Test\nintégra.":    "ci",
    "SAST\nSemgrep":     "security",
    "Build\nimage":      "ci",
    "Scan\nTrivy":       "security",
    "Sign\nCosign":      "security",
    "Update\nmanifests": "cd",
    "ArgoCD\ndev":       "cd",
    "Smoke\nstaging":    "cd",
    "Canary\n10 %":      "cd",
    "SLO\ncheck":        "cd",
    "Promote\n100 %":    "cd",
    "Slack\nnotify":     "notify",
}

for n in noeuds:
    G.add_node(n, type=noeuds[n])

deps = [
    ("Checkout", "Lint"),
    ("Lint", "Test\nunitaires"),
    ("Lint", "Test\nintégra."),
    ("Lint", "SAST\nSemgrep"),
    ("Test\nunitaires", "Build\nimage"),
    ("Test\nintégra.", "Build\nimage"),
    ("SAST\nSemgrep", "Build\nimage"),
    ("Build\nimage", "Scan\nTrivy"),
    ("Scan\nTrivy", "Sign\nCosign"),
    ("Sign\nCosign", "Update\nmanifests"),
    ("Update\nmanifests", "ArgoCD\ndev"),
    ("ArgoCD\ndev", "Smoke\nstaging"),
    ("Smoke\nstaging", "Canary\n10 %"),
    ("Canary\n10 %", "SLO\ncheck"),
    ("SLO\ncheck", "Promote\n100 %"),
    ("Promote\n100 %", "Slack\nnotify"),
]
G.add_edges_from(deps)

pos = {
    "Checkout":          (0,    0),
    "Lint":              (1.5,  0),
    "Test\nunitaires":   (3,    1),
    "Test\nintégra.":    (3,    0),
    "SAST\nSemgrep":     (3,   -1),
    "Build\nimage":      (4.8,  0),
    "Scan\nTrivy":       (6.2,  0.5),
    "Sign\nCosign":      (6.2, -0.5),
    "Update\nmanifests": (7.8,  0),
    "ArgoCD\ndev":       (9.3,  0),
    "Smoke\nstaging":    (10.8, 0),
    "Canary\n10 %":      (12.3, 0),
    "SLO\ncheck":        (13.8, 0),
    "Promote\n100 %":    (15.3, 0),
    "Slack\nnotify":     (16.8, 0),
}

couleurs_types = {
    "ci":       "#4c72b0",
    "security": "#c44e52",
    "cd":       "#55a868",
    "notify":   "#ff7f0e",
}
node_colors = [couleurs_types[G.nodes[n]["type"]] for n in G.nodes()]

fig, ax = plt.subplots(figsize=(18, 5))
ax.axis("off")

nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=1300,
                       ax=ax, alpha=0.92)
nx.draw_networkx_labels(G, pos, font_size=7.5, font_color="white",
                        font_weight="bold", ax=ax)
nx.draw_networkx_edges(G, pos, ax=ax, arrows=True, arrowsize=14,
                       edge_color="#555555", width=1.5,
                       connectionstyle="arc3,rad=0.05")

legend_elements = [
    mpatches.Patch(color="#4c72b0", label="CI"),
    mpatches.Patch(color="#c44e52", label="Sécurité"),
    mpatches.Patch(color="#55a868", label="CD / GitOps"),
    mpatches.Patch(color="#ff7f0e", label="Notifications"),
]
ax.legend(handles=legend_elements, loc="upper left", fontsize=10)
ax.set_title("Pipeline de production complet : CI → Sécurité → CD → Notify",
             fontsize=13, pad=10)

plt.show()
_images/0269849c06428a7ae01e07aed5f8b2b95410188923b2d7767df5be624e08c0c8.png
# Time-to-production selon le type de changement (violin plot)

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

np.random.seed(42)
n = 150

types_changement = {
    "hotfix":   np.random.lognormal(mean=np.log(25),  sigma=0.3, size=n),
    "feature":  np.random.lognormal(mean=np.log(90),  sigma=0.4, size=n),
    "refactor": np.random.lognormal(mean=np.log(155), sigma=0.5, size=n),
}

donnees = []
for type_chg, durees in types_changement.items():
    for d in durees:
        donnees.append({"type": type_chg, "time_to_production_min": d})

df = pd.DataFrame(donnees)

fig, ax = plt.subplots(figsize=(10, 6))

sns.violinplot(
    data=df,
    x="type", y="time_to_production_min",
    palette={"hotfix": "#c44e52", "feature": "#4c72b0", "refactor": "#dd8452"},
    inner="box",
    ax=ax
)

ax.set_xlabel("Type de changement")
ax.set_ylabel("Time-to-production (minutes)")
ax.set_title("Distribution du time-to-production\nselon le type de changement")

medians = df.groupby("type")["time_to_production_min"].median()
for i, (type_chg, med) in enumerate(medians.items()):
    ax.text(i, med + 10, f"médiane\n{med:.0f} min",
            ha="center", fontsize=9, color="#333333", fontweight="bold")

plt.show()
/tmp/ipykernel_10117/4022486463.py:23: FutureWarning: 

Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `x` variable to `hue` and set `legend=False` for the same effect.

  sns.violinplot(
_images/f8cdac4315c32185d6ed6a6306a100c3573db5fb93a245027e05362e5851426f.png

Résumé#

  1. L’architecture microservices (frontend, API, worker) impose un pipeline CI par service mais une orchestration CD unifiée via GitOps pour garantir la cohérence des versions déployées simultanément.

  2. Le pipeline CI suit un ordre non négociable : lint → tests → SAST → build → scan Trivy → sign Cosign ; les étapes de sécurité sont bloquantes et non-optionnelles en production.

  3. La stratégie d’environnements (dev automatique, staging avec approbation, production en canary) équilibre vélocité et sécurité : les régressions sont détectées en staging avant d’atteindre l’intégralité des utilisateurs.

  4. Le canary deployment avec SLO check est la protection finale avant la promotion complète ; un taux d’erreur ou une latence P99 dépassant les seuils déclenche un rollback automatique sans intervention humaine.

  5. Les notifications Slack sur le job notify avec if: always() assurent la visibilité des succès et des échecs, y compris des rollbacks, sans configuration supplémentaire dans chaque pipeline.

  6. Le pipeline des librairies avec semantic-release automatise le versioning sémantique, le changelog et la publication sur PyPI ou npm à partir des conventional commits, sans décision humaine sur le numéro de version.

  7. Le modèle M/M/c démontre qu’en dessous de 80 % d’utilisation des runners, les temps d’attente restent faibles ; au-delà, ils croissent hyperboliquement — la capacité CI doit être dimensionnée avec une marge de sécurité.

  8. Le cache Buildx est le levier d’optimisation le plus accessible pour réduire les temps de build de 40 à 60 % sans infrastructure supplémentaire ; il doit être activé dès le premier pipeline.

  9. Le coût total d’un pipeline de production complet pour une équipe de 10 développeurs se situe entre 300 et 500 €/mois avec des runners self-hosted Spot, observabilité incluse.

  10. La visualisation du pipeline comme DAG révèle le chemin critique (la séquence la plus longue sans parallélisation possible) et identifie les opportunités de réduction du lead time sans augmenter les ressources.