Le système est testé et automatisé — mais il vit dans un seul conteneur, sur une seule machine. Trois jours pour le faire tourner en prod : répliqué sur un cluster, mis à jour sans coupure, et surveillé jusqu'au drift du modèle. The system is tested and automated — but it lives in one container, on one machine. Three days to run it in production: replicated on a cluster, updated with zero downtime, and watched all the way to model drift.
Bloc 6 : chaque commit est testé, validé, versionné. Mais au bout de la chaîne, un seul conteneur fait tout le travail. Qu'il meure — panne, pic de trafic, mise à jour — et plus aucune fraude n'est détectée. Block 6: every commit is tested, validated, versioned. But at the end of the line, a single container does all the work. If it dies — failure, traffic spike, update — no fraud gets detected anymore.
Regardez la carte : la boîte s'est multipliée. Un load balancer répartit le trafic sur des réplicas ; un pod meurt, un autre renaît — personne ne s'en aperçoit. C'est l'idée déclarative : on décrit l'état désiré, le cluster corrige l'écart. Look at the map: the box has multiplied. A load balancer spreads traffic across replicas; one pod dies, another is reborn — nobody notices. That's the declarative idea: describe the desired state, the cluster closes the gap.
Le monitoring veille : latence et erreurs côté infra, distribution des scores et drift des features côté modèle. Quand le monde change trop, l'alerte part — et la flèche du réentraînement referme la boucle du MLOps. Monitoring is on watch: latency and errors on the infra side, score distribution and feature drift on the model side. When the world shifts too far, the alert fires — and the retraining arrow closes the MLOps loop.
2 h du matin, Black Friday, trafic ×50, un serveur meurt — et personne n'est réveillé : le cluster a réparé seul. Le secret n'est pas la magie, c'est l'idée déclarative : on ne donne pas des ordres, on décrit un état désiré — « je veux 3 réplicas en bonne santé » — et le chef d'orchestre corrige l'écart, en continu. 2 a.m., Black Friday, traffic ×50, a server dies — and nobody gets woken up: the cluster healed itself. The secret isn't magic, it's the declarative idea: you don't give orders, you describe a desired state — "I want 3 healthy replicas" — and the conductor closes the gap, continuously.
Tout s'écrit en manifests YAML versionnés dans Git — l'infrastructure se relit et se revoit comme du code. Et cinq commandes kubectl suffisent : apply, get, describe, logs, rollout undo. Déclarer, constater, enquêter, lire, reculer.
Everything is written as YAML manifests versioned in Git — infrastructure gets read and reviewed like code. And five kubectl commands are enough: apply, get, describe, logs, rollout undo. Declare, observe, investigate, read, step back.
Le process tourne, le port répond… et l'application est morte. Kubernetes ne peut pas le deviner — sauf si on lui donne des yeux. Les probes sont trois questions posées en boucle à chaque pod : The process runs, the port answers… and the application is dead. Kubernetes cannot guess it — unless you give it eyes. Probes are three questions asked of every pod, on a loop:
Nos modèles sont longs à charger : des centaines de Mo en RAM, 30 à 90 secondes. Sans startup probe, la liveness tue le pod avant la fin du chargement — redémarrage, rechargement, re-mise à mort : la boucle de crash infinie, le grand classique du déploiement ML. Une startup probe généreuse + un /ready qui ne dit OK que si le modèle est en mémoire, et le problème disparaît.
Our models are slow to load: hundreds of MB into RAM, 30 to 90 seconds. Without a startup probe, liveness kills the pod before loading finishes — restart, reload, killed again: the infinite crash loop, the great classic of ML deployments. A generous startup probe + a /ready that only says OK once the model is in memory, and the problem vanishes.
La v13 est prête. Comment la mettre en face de vrais clients sans couper le service — et sans parier toute la prod sur elle ? v13 is ready. How do you put it in front of real customers without cutting the service — and without betting all of production on it?
Pour un nouveau modèle, le verdict est sans appel : canary. Un modèle peut passer tous les tests offline et échouer en prod — la seule vérité est le trafic réel, et on préfère qu'il le rencontre à 5 %. For a new model, the verdict is final: canary. A model can pass every offline test and fail in production — the only truth is real traffic, and we'd rather it meet it at 5 %.
Prometheus collecte, Grafana montre : latence, erreurs, saturation — la santé de la machine. Mais votre API peut être parfaitement verte et votre modèle parfaitement faux : 200 OK, 40 ms… et un détecteur qui laisse tout passer. C'est la partie que 90 % des équipes oublient. Prometheus collects, Grafana shows: latency, errors, saturation — the machine's health. But your API can be perfectly green and your model perfectly wrong: 200 OK, 40 ms… and a detector that lets everything through. It's the part 90 % of teams forget.
Le modèle ne lève jamais d'exception quand il se trompe : il faut instrumenter ses opinions. Et quand l'alerte de drift déclenche le réentraînement, la boucle du MLOps se ferme — c'est exactement pour ça qu'on a construit tout le reste. The model never raises an exception when it's wrong: you must instrument its opinions. And when the drift alert triggers retraining, the MLOps loop closes — which is exactly why we built everything else.
Un incident ML a toujours deux suspects : le code et le modèle. La marche arrière se prépare — et se répète — aux deux niveaux, à froid. An ML incident always has two suspects: the code and the model. Reverse gear is prepared — and rehearsed — on both levels, in calm weather.
kubectl rollout undo — la version précédente revient en secondes. Côté modèle : repointer l'alias Production du registry MLflow, v13 → v12. Un geste chacun.App side: kubectl rollout undo — the previous version is back in seconds. Model side: repoint the MLflow registry's Production alias, v13 → v12. One move each.« Le détecteur tourne répliqué sur un cluster : il encaisse les pannes et la charge, se met à jour sans coupure — et prévient dès qu'il dérive. » "The detector runs replicated on a cluster: it absorbs failures and load, updates with zero downtime — and warns the moment it drifts."
Le système cesse d'être une démo sur un laptop et devient un service — celui qu'on assemblera et présentera de bout en bout au bloc 8. The system stops being a laptop demo and becomes a service — the one we'll assemble and present end to end in block 8.
Cinq missions sur trois jours — 1 h de concepts le jour 3, la grosse journée de 8 h le jour 4, et 3 h le jour 5 pour le rollback et l'observabilité. Les durées viennent du plan de cours : Five missions across three days — 1 h of concepts on day 3, the big 8-hour day on day 4, and 3 h on day 5 for rollback and observability. Durations come straight from the course plan:
L'API servie par le cluster (un kubectl delete pod en pleine charge sans requête perdue), un canary mené 5 % → 100 % et son retour maîtrisé, le HPA vu en action, un dashboard à deux étages — infra et modèle — avec une alerte de drift qui part, et un post-mortem d'une page. Il ne reste qu'à tout assembler et le prouver : bloc 8.
The API served by the cluster (a kubectl delete pod under full load without a lost request), a canary run 5 % → 100 % with a controlled return, the HPA seen in action, a two-storey dashboard — infra and model — with a drift alert that fires, and a one-page post-mortem. All that's left is to assemble it all and prove it: block 8.
Ce guide reprend les cinq missions du lab, commande par commande, pour tout refaire seul. Prérequis : les blocs 4 à 6 terminés — Docker en marche, l'image fraud-api:0.1 construite au bloc 4, le serveur MLflow du bloc 5 lancé (mlflow server --host 0.0.0.0 --port 5000, alias @production posé sur fraud-detector), et le dépôt fraud-detection/. À la fin : l'API répliquée sur un cluster kind, mise à jour sans coupure, autoscalée, surveillée par Prometheus — et un script de drift qui crie quand le monde change.
This guide replays the lab's five missions, command by command, so you can redo everything on your own. Prerequisites: blocks 4 to 6 finished — Docker running, the fraud-api:0.1 image built in block 4, block 5's MLflow server up (mlflow server --host 0.0.0.0 --port 5000, the @production alias set on fraud-detector), and the fraud-detection/ repo. At the end: the API replicated on a kind cluster, updated with zero downtime, autoscaled, watched by Prometheus — and a drift script that shouts when the world changes.
kind (« Kubernetes in Docker ») fait tourner un vrai cluster dans des conteneurs Docker — parfait pour apprendre sans facture cloud. kubectl est la télécommande universelle de n'importe quel cluster Kubernetes. kind ("Kubernetes in Docker") runs a real cluster inside Docker containers — perfect for learning without a cloud bill. kubectl is the universal remote for any Kubernetes cluster.
# macOS
brew install kind kubectl
# Linux
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.23.0/kind-linux-amd64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind
curl -LO https://dl.k8s.io/release/v1.30.0/bin/linux/amd64/kubectl
chmod +x kubectl
sudo mv kubectl /usr/local/bin/kubectl
kind version
kubectl version --client
Une seule commande crée un nœud Kubernetes complet dans un conteneur et configure kubectl pour lui parler. One single command creates a complete Kubernetes node inside a container and configures kubectl to talk to it.
kind create cluster --name mlops
kubectl get nodes
Le cluster kind ne voit pas les images de votre Docker local : il faut les charger explicitement dans ses nœuds. L'oublier est LA cause n° 1 du fameux ImagePullBackOff. The kind cluster cannot see your local Docker images: you must load them into its nodes explicitly. Forgetting this is THE number 1 cause of the infamous ImagePullBackOff.
docker images fraud-api
kind load docker-image fraud-api:0.1 --name mlops
L'URL de MLflow ne vit pas dans l'image : elle vit dans une ConfigMap. Depuis un pod kind, la machine hôte s'appelle host.docker.internal (macOS / Windows) ; sur Linux, remplacez par http://172.17.0.1:5000 (l'IP du pont docker0).
The MLflow URL does not live in the image: it lives in a ConfigMap. From a kind pod, the host machine is called host.docker.internal (macOS / Windows); on Linux, replace it with http://172.17.0.1:5000 (the docker0 bridge IP).
cd fraud-detection
mkdir -p k8s
cat > k8s/configmap.yaml <<'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
name: fraud-api-config
data:
MLFLOW_TRACKING_URI: "http://host.docker.internal:5000"
EOF
kubectl apply -f k8s/configmap.yaml
Le contrat complet : 3 réplicas, ressources bornées, et les trois probes. La startupProbe est généreuse (30 × 2 s = 60 s de grâce) parce que le modèle est long à charger — sans elle, la liveness tue le pod avant la fin du chargement, en boucle. Gardez le serveur MLflow du bloc 5 allumé : les pods chargent le modèle au démarrage. The full contract: 3 replicas, bounded resources, and the three probes. The startupProbe is generous (30 × 2 s = 60 s of grace) because the model is slow to load — without it, liveness kills the pod before loading finishes, in a loop. Keep block 5's MLflow server on: pods load the model at startup.
cat > k8s/deployment.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: fraud-api
labels:
app: fraud-api
spec:
replicas: 3
selector:
matchLabels:
app: fraud-api
track: stable
template:
metadata:
labels:
app: fraud-api
track: stable
spec:
containers:
- name: fraud-api
image: fraud-api:0.1
imagePullPolicy: Never
ports:
- containerPort: 8000
env:
- name: MLFLOW_TRACKING_URI
valueFrom:
configMapKeyRef:
name: fraud-api-config
key: MLFLOW_TRACKING_URI
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 500m
memory: 1Gi
startupProbe:
httpGet:
path: /health
port: 8000
failureThreshold: 30
periodSeconds: 2
readinessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 10
failureThreshold: 3
EOF
kubectl apply -f k8s/deployment.yaml
Le Service sélectionne app: fraud-api sans regarder track — ce détail servira au canary. On observe les pods démarrer, puis on appelle l'API servie par le cluster.
The Service selects app: fraud-api without looking at track — that detail will matter for the canary. Watch the pods start, then call the API served by the cluster.
cat > k8s/service.yaml <<'EOF'
apiVersion: v1
kind: Service
metadata:
name: fraud-api
spec:
type: ClusterIP
selector:
app: fraud-api
ports:
- port: 80
targetPort: 8000
EOF
kubectl apply -f k8s/service.yaml
kubectl get pods -w
# Ctrl-C once the 3 pods are Running and READY 1/1, then:
kubectl port-forward service/fraud-api 8000:80 &
sleep 2
curl -s http://localhost:8000/health
Tuez un pod : le Deployment constate l'écart avec l'état désiré (3 réplicas) et en recrée un, sans vous demander la permission. C'est l'idée déclarative en action. Kill a pod: the Deployment notices the gap with the desired state (3 replicas) and recreates one, without asking your permission. The declarative idea, in action.
kubectl get pods
kubectl delete pod $(kubectl get pods -l app=fraud-api -o jsonpath='{.items[0].metadata.name}')
kubectl get pods
Construisez la v0.2 (le contenu peut être identique — c'est le mécanisme qu'on observe), chargez-la dans kind, puis déployez-la en canary : mêmes labels app=fraud-api, mais track=canary. Le Service route vers les 4 pods → le canary reçoit ~25 % du trafic.
Build v0.2 (the content can be identical — the mechanism is what we observe), load it into kind, then deploy it as a canary: same app=fraud-api labels, but track=canary. The Service routes to all 4 pods → the canary receives ~25% of the traffic.
docker build -t fraud-api:0.2 .
kind load docker-image fraud-api:0.2 --name mlops
cat > k8s/deployment-canary.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: fraud-api-canary
labels:
app: fraud-api
spec:
replicas: 1
selector:
matchLabels:
app: fraud-api
track: canary
template:
metadata:
labels:
app: fraud-api
track: canary
spec:
containers:
- name: fraud-api
image: fraud-api:0.2
imagePullPolicy: Never
ports:
- containerPort: 8000
env:
- name: MLFLOW_TRACKING_URI
valueFrom:
configMapKeyRef:
name: fraud-api-config
key: MLFLOW_TRACKING_URI
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 500m
memory: 1Gi
startupProbe:
httpGet:
path: /health
port: 8000
failureThreshold: 30
periodSeconds: 2
readinessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 10
failureThreshold: 3
EOF
kubectl apply -f k8s/deployment-canary.yaml
kubectl get pods -l app=fraud-api
Envoyez 40 requêtes depuis l'intérieur du cluster (on vise /docs, que les probes ne touchent pas, pour un comptage propre), puis comptez ce qui est arrivé au canary dans ses logs. Send 40 requests from inside the cluster (we target /docs, which the probes never hit, for a clean count), then count what reached the canary in its logs.
kubectl run traffic --rm -it --restart=Never --image=busybox -- /bin/sh -c 'for i in $(seq 1 40); do wget -q -O /dev/null http://fraud-api/docs; done; echo done'
kubectl logs deployment/fraud-api-canary | grep -c 'GET /docs'
Les métriques tiennent ? Promotion : le Deployment stable passe en v0.2 par rolling update — un pod remplacé à la fois, jamais sous l'effectif — puis le canary est retiré. Si ça s'était mal passé : kubectl delete -f k8s/deployment-canary.yaml seul, et retour instantané à 100 % v0.1.
Metrics hold? Promotion: the stable Deployment moves to v0.2 through a rolling update — one pod replaced at a time, never below strength — then the canary is withdrawn. Had it gone wrong: kubectl delete -f k8s/deployment-canary.yaml alone, and you are instantly back to 100% v0.1.
kubectl set image deployment/fraud-api fraud-api=fraud-api:0.2
kubectl rollout status deployment/fraud-api
kubectl delete -f k8s/deployment-canary.yaml
kubectl get deployment fraud-api -o wide
Le HPA a besoin de mesures CPU. Sur kind, metrics-server refuse les certificats des kubelets — le patch --kubelet-insecure-tls est le réglage standard pour un cluster local.
The HPA needs CPU measurements. On kind, metrics-server rejects the kubelets' certificates — the --kubelet-insecure-tls patch is the standard tweak for a local cluster.
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
kubectl patch -n kube-system deployment metrics-server --type=json \
-p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'
kubectl -n kube-system rollout status deployment/metrics-server
kubectl top nodes
Déclarez l'élastique (3 à 6 réplicas, cible 60 % de CPU), générez de la charge, et regardez. Si TARGETS ne décolle pas, lancez un second pod de charge (load2). La redescente, elle, prend ~5 minutes : l'HPA est prudent par conception. Declare the elastic (3 to 6 replicas, 60% CPU target), generate load, and watch. If TARGETS will not take off, start a second load pod (load2). Scaling down takes ~5 minutes: the HPA is cautious by design.
kubectl autoscale deployment fraud-api --cpu-percent=60 --min=3 --max=6
kubectl run load --image=busybox -- /bin/sh -c "while true; do wget -qO- http://fraud-api/health; done"
kubectl get hpa fraud-api -w
# Ctrl-C once REPLICAS goes above 3, then stop the load:
kubectl delete pod load
prometheus-fastapi-instrumentator instrumente toutes les routes et publie /metrics au format Prometheus. Une ligne dans requirements.txt, deux dans le code — c'est tout l'extrait. prometheus-fastapi-instrumentator instruments every route and publishes /metrics in Prometheus format. One line in requirements.txt, two in the code — that is the whole excerpt.
# requirements.txt: add this line
prometheus-fastapi-instrumentator
# src/serve/app.py: add these lines right after "app = FastAPI(...)"
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator().instrument(app).expose(app)
Le circuit devient un réflexe : build, kind load, set image, rollout. Si le port-forward de l'étape 6 est tombé pendant le rolling update (c'est normal), relancez kubectl port-forward service/fraud-api 8000:80 &.
The circuit becomes a reflex: build, kind load, set image, rollout. If step 6's port-forward dropped during the rolling update (that is normal), rerun kubectl port-forward service/fraud-api 8000:80 &.
docker build -t fraud-api:0.3 .
kind load docker-image fraud-api:0.3 --name mlops
kubectl set image deployment/fraud-api fraud-api=fraud-api:0.3
kubectl rollout status deployment/fraud-api
curl -s http://localhost:8000/metrics | head -20
Trois objets dans un seul fichier : la ConfigMap de scrape (toutes les 15 s sur le Service fraud-api), le Deployment, le Service. Pas de Helm, pas de magie — juste du YAML lisible. Three objects in one file: the scrape ConfigMap (every 15 s against the fraud-api Service), the Deployment, the Service. No Helm, no magic — just readable YAML.
cat > k8s/prometheus.yaml <<'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-config
data:
prometheus.yml: |
global:
scrape_interval: 15s
scrape_configs:
- job_name: fraud-api
metrics_path: /metrics
static_configs:
- targets: ["fraud-api:80"]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: prometheus
spec:
replicas: 1
selector:
matchLabels:
app: prometheus
template:
metadata:
labels:
app: prometheus
spec:
containers:
- name: prometheus
image: prom/prometheus:v2.53.0
args:
- --config.file=/etc/prometheus/prometheus.yml
ports:
- containerPort: 9090
volumeMounts:
- name: config
mountPath: /etc/prometheus
volumes:
- name: config
configMap:
name: prometheus-config
---
apiVersion: v1
kind: Service
metadata:
name: prometheus
spec:
type: ClusterIP
selector:
app: prometheus
ports:
- port: 9090
targetPort: 9090
EOF
kubectl apply -f k8s/prometheus.yaml
kubectl rollout status deployment/prometheus
kubectl port-forward service/prometheus 9090:9090 &
Le script compare la distribution des scores récents à une référence (test de Kolmogorov-Smirnov) et sort en code 1 si elle a bougé — exactement ce qu'un cron ou une CI sait transformer en alerte. On le teste avec une dérive fabriquée ; régénérez recent_scores avec beta(2, 20) pour voir le cas « OK ». The script compares the recent score distribution to a reference (Kolmogorov-Smirnov test) and exits with code 1 if it moved — exactly what a cron job or a CI can turn into an alert. We test it with a manufactured drift; regenerate recent_scores with beta(2, 20) to see the "OK" case.
pip install scipy numpy
mkdir -p scripts data
cat > scripts/check_drift.py <<'EOF'
"""Compare recent prediction scores against a reference distribution (KS test)."""
import json
import sys
from pathlib import Path
import numpy as np
from scipy import stats
REFERENCE = Path("data/reference_scores.json")
RECENT = Path("data/recent_scores.json")
P_THRESHOLD = 0.05
def load_scores(path):
if not path.exists():
print(f"missing file: {path}")
sys.exit(2)
return np.array(json.loads(path.read_text()))
def main():
ref = load_scores(REFERENCE)
recent = load_scores(RECENT)
ks_stat, p_value = stats.ks_2samp(ref, recent)
print(f"reference: n={len(ref)} mean={ref.mean():.3f} q95={np.quantile(ref, 0.95):.3f}")
print(f"recent: n={len(recent)} mean={recent.mean():.3f} q95={np.quantile(recent, 0.95):.3f}")
print(f"KS statistic={ks_stat:.3f} p-value={p_value:.4f}")
if p_value < P_THRESHOLD:
print("ALERT: score distribution has drifted")
return 1
print("OK: no significant drift detected")
return 0
if __name__ == "__main__":
sys.exit(main())
EOF
python - <<'EOF'
import json
import numpy as np
rng = np.random.default_rng(0)
json.dump(rng.beta(2, 20, 1000).tolist(), open("data/reference_scores.json", "w"))
json.dump(rng.beta(4, 10, 500).tolist(), open("data/recent_scores.json", "w"))
EOF
python scripts/check_drift.py
echo "exit code: $?"
Suspect n° 1 : le code. Kubernetes garde l'historique des rollouts — la marche arrière est une commande, à répéter à froid pour qu'elle soit un réflexe le jour J. Suspect number 1: the code. Kubernetes keeps the rollout history — reverse gear is one command, to be rehearsed in calm weather so it is a reflex on the day.
kubectl rollout history deployment/fraud-api
kubectl rollout undo deployment/fraud-api
kubectl rollout status deployment/fraud-api
kubectl get deployment fraud-api -o wide
Suspect n° 2 : le modèle. Le geste du bloc 5 : repointer l'alias @production du registry vers la version précédente — aucun rebuild, aucun manifest. Un rollout restart force les pods à recharger models:/fraud-detector@production.
Suspect number 2: the model. Block 5's move: repoint the registry's @production alias to the previous version — no rebuild, no manifest. A rollout restart forces the pods to reload models:/fraud-detector@production.
python - <<'EOF'
from mlflow import MlflowClient
client = MlflowClient(tracking_uri="http://localhost:5000")
current = client.get_model_version_by_alias("fraud-detector", "production")
print("in production before:", current.version)
previous = str(int(current.version) - 1)
client.set_registered_model_alias("fraud-detector", "production", previous)
after = client.get_model_version_by_alias("fraud-detector", "production")
print("in production after:", after.version)
EOF
kubectl rollout restart deployment/fraud-api
kubectl rollout status deployment/fraud-api
ImagePullBackOff — l'image n'est pas dans le cluster : rejouez kind load docker-image … --name mlops, et vérifiez imagePullPolicy: Never (sinon Kubernetes part la chercher sur Docker Hub). Pods tués en boucle pendant le chargement du modèle (RESTARTS qui monte, CrashLoopBackOff) — startupProbe absente ou trop courte : 30 × 2 s = 60 s de grâce, augmentez failureThreshold si votre modèle est plus lent. kubectl top ou HPA en « unknown » — metrics-server sans --kubelet-insecure-tls sur kind : rejouez le patch de l'étape 11. Le port-forward tombe (« lost connection to pod ») — normal quand le pod derrière meurt (rolling update, delete pod) : relancez simplement la commande. ImagePullBackOff — the image is not in the cluster: rerun kind load docker-image … --name mlops, and check imagePullPolicy: Never (otherwise Kubernetes goes looking on Docker Hub). Pods killed in a loop while the model loads (climbing RESTARTS, CrashLoopBackOff) — startupProbe missing or too short: 30 × 2 s = 60 s of grace, raise failureThreshold if your model is slower. kubectl top or HPA stuck on "unknown" — metrics-server without --kubelet-insecure-tls on kind: replay step 11's patch. The port-forward drops ("lost connection to pod") — normal whenever the pod behind it dies (rolling update, delete pod): just rerun the command.