FastAPI Hexagonal Code source

02. Les workloads

Une seule image pour trois rôles, des pods durcis, et un piège d'interpolation qui coûte une heure la première fois.

TL;DR — task-manager-api:latest sert l'API, le worker et les migrations : seule la command change. Les pods tournent en non-root, système de fichiers racine en lecture seule, sans capability. Et $(VAR) ne s'interpole que dans un env[].value — jamais dans un envFrom.

Une image, trois rôles#

C'est la traduction directe de ce que montre le chapitre 07 du cours : trois adaptateurs entrants appellent les mêmes use cases. Au niveau infrastructure, ça donne trois workloads qui partagent la même image.

task-manager-api:latest Deployment api Deployment mail-worker Job db-migrate gunicorn task_manager.main:app python -m …presentation.worker alembic upgrade head ×2 → 6 (HPA) ×2 une fois, avant tout seule la command change

La même image peut fournir l’API, le worker et les migrations. Cela simplifie la construction, mais ne garantit pas que tous les pods exécutent la même version à un instant donné : les rolling updates sont indépendants et le tag latest est mutable. Des tags immuables ou digests et des messages compatibles entre versions restent nécessaires.

Durcissement : deux images, deux stratégies#

Le durcissement se décline à deux niveaux — le pod et le conteneur — et il ne s'écrit pas pareil selon ce que l'image fournit déjà.

# k8s/api-deployment.yaml — niveau POD
securityContext:
  runAsNonRoot: true
  runAsUser: 1000        # l'image backend n'a pas de USER → sinon root
  runAsGroup: 1000
  fsGroup: 1000
  seccompProfile:
    type: RuntimeDefault

# … niveau CONTENEUR
securityContext:
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]
API / worker / migrateFrontend
Image de basepython:3.14-slim-bookwormnginxinc/nginx-unprivileged:1.27-alpine
USER dans le Dockerfileabsentuid 101
Le manifeste doit forcer…runAsUser: 1000rien — runAsNonRoot suffit
Port d'écoute80008080 (non privilégié)

C'est une leçon générale : le durcissement se fait dans l'image quand c'est possible, dans le manifeste quand il faut rattraper. Ici l'image nginx-unprivileged fait le travail en amont ; l'image Python officielle, non — alors le manifeste compense, avec le commentaire qui explique pourquoi.

Le système de fichiers en lecture seule#

readOnlyRootFilesystem: true est la mesure la plus efficace du lot — et celle qui casse le plus de choses si on la pose sans regarder ce que le processus écrit. Gunicorn a besoin d'un répertoire temporaire pour les fichiers de heartbeat de ses workers. D'où le trio :

env:
  - name: HOME
    value: /tmp
  - name: TMPDIR
    value: /tmp
volumeMounts:
  - name: tmp
    mountPath: /tmp
volumes:
  - name: tmp
    emptyDir: {}

Un emptyDir monté sur /tmp, plus HOME et TMPDIR redirigés dessus : tout ce qui veut écrire le fait dans un volume éphémère, et le reste de l'arborescence reste immuable. Le volume disparaît avec le pod — ce qui est exactement ce qu'on veut d'un répertoire temporaire.

Ce que le durcissement ne couvre pas, et c'est écrit Seuls les pods dont le projet maîtrise l'image sont durcis : api, worker, migrate, frontend. Les instances PostgreSQL et le pooler sont déjà non-root (uid 26) via CNPG. Valkey, RabbitMQ, Mailpit, Prometheus et RedisInsight restent aux réglages par défaut : imposer readOnlyRootFilesystem à une image tierce demande de vérifier, image par image, ce qu'elle écrit. L'annoncer vaut mieux que de laisser croire à une couverture totale.

Configuration : ce qui va dans la ConfigMap, ce qui n'y va pas#

Les variables statiques partagées par l'API et le worker vivent dans une ConfigMap unique :

# k8s/api-config.yaml
data:
  APP_INIT_DB: "false"          # le schéma est piloté par Alembic
  APP_REDIS_URL: "redis://valkey:6379/0"
  APP_EXCHANGE_NAME: "task_events"
  APP_SMTP_HOST: "mailpit"
  APP_SMTP_PORT: "1025"
  APP_CACHE_TTL: "300"

Mais APP_DATABASE_URL et APP_RABBITMQ_URL n'y sont pas, et ce n'est pas un oubli.

Le piège : $(VAR) ne s'interpole pas dans un envFrom Kubernetes n'expanse $(VAR) que dans les env[].value d'un conteneur. Une valeur injectée via envFrom (ConfigMap ou Secret) arrive littéralement. Mettre "postgresql+asyncpg://user:$(DB_PASSWORD)@…" dans la ConfigMap donnerait une URL contenant les six caractères $(DB_P… — et une erreur d'authentification incompréhensible.

Ces deux URLs sont donc déclarées dans le env de chaque Deployment, après les variables secrètes qu'elles référencent — l'ordre compte :

env:
  # 1. les secrets d'abord
  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef: { name: task-manager-db-app, key: password }
  - name: RABBITMQ_USER
    valueFrom:
      secretKeyRef: { name: rabbitmq-default-user, key: username }
  - name: RABBITMQ_PASSWORD
    valueFrom:
      secretKeyRef: { name: rabbitmq-default-user, key: password }
  # 2. les URLs qui les interpolent ensuite
  - name: APP_DATABASE_URL
    value: "postgresql+asyncpg://task_manager:$(DB_PASSWORD)@task-manager-pooler:5432/task_manager?prepared_statement_cache_size=0"
  - name: APP_RABBITMQ_URL
    value: "amqp://$(RABBITMQ_USER):$(RABBITMQ_PASSWORD)@rabbitmq:5672/"
envFrom:
  - configMapRef: { name: api-config }

La règle de partage qui en découle : ce qui est statique et partagé va dans la ConfigMap ; ce qui compose un secret reste dans le Deployment. Le worker duplique donc ces deux lignes — une duplication assumée, imposée par le mécanisme d'interpolation.

Sondes et ressources#

resources:
  requests: { cpu: 50m,  memory: 300Mi }
  limits:   { cpu: 500m, memory: 612Mi }
readinessProbe:
  httpGet: { path: /health, port: 8000 }
  initialDelaySeconds: 5
  periodSeconds: 10
livenessProbe:
  httpGet: { path: /health, port: 8000 }
  initialDelaySeconds: 15
  periodSeconds: 30
WorkloadRequestsLimitsSondes
api50m / 300Mi500m / 612Mireadiness + liveness sur /health
mail-worker128m / 128Mi500m / 256Miaucune — pas de port HTTP
frontend10m / 24Mi100m / 64Mireadiness sur /

Deux remarques :

Readiness actuelle limitée. Le manifeste utilise /health pour readiness et liveness ; cette route ne consulte aucune dépendance. Un pod peut donc rester Ready quand PostgreSQL est inaccessible. /ready existe mais exige aussi le cache ; avant de l’utiliser, aligner ce choix avec le repli sans cache de l’application.

Des images locales, jamais un registry#

# k8s/deploy.sh
docker build -t task-manager-api:latest ./backend
docker build -t task-manager-frontend:latest --target production \
  --build-arg VITE_MAIL_UI_URL="http://mailpit.localhost" \
  --build-arg VITE_REDIS_INSIGHT_URL="http://redisinsight.localhost" \
  ./frontend
kind load docker-image task-manager-api:latest task-manager-frontend:latest --name task-manager

Les images sont injectées dans les nœuds Kind par kind load, d'où imagePullPolicy: IfNotPresent dans chaque manifeste. Avec le Always implicite du tag :latest, Kubernetes chercherait l'image sur Docker Hub et échouerait en ImagePullBackOff.

Noter aussi que les URLs des consoles sont des arguments de build du frontend : elles sont figées dans le bundle JavaScript au moment de la compilation, pas lues à l'exécution. Changer un hostname de console impose donc de reconstruire l'image — une contrainte du front, pas du cluster.

Le durcissement n'est qu'une porte sur trois

Tout ce chapitre protège contre une évasion du conteneur. Il ne dit rien de ce qu'un attaquant peut faire depuis l'intérieur : parler à l'API Kubernetes, déployer un pod privilégié, exfiltrer vers Internet. Ces trois portes-là se ferment au chapitre 07.

À retenir#