Kubernetes vs Docker : Compose, Swarm ou cluster, que choisir en 2026 ?
L'essentiel en une minute
Kubernetes vs Docker est une comparaison trompeuse : Docker construit des images et lance des conteneurs, Kubernetes décide sur quelles machines ils tournent, les remplace quand ils tombent et les met à jour par étapes. La vraie question est ailleurs : Docker Compose sur un serveur suffit-il, ou faut-il un orchestrateur comme Docker Swarm, k3s ou un cluster Kubernetes géré ?
Cet article tranche avec un exemple concret : l'API de tâches de notre tutoriel Docker pour débutants, écrite une fois en compose.yaml et une fois en manifestes Kubernetes. Il s'appuie sur les documentations de Kubernetes, de Docker, de Kompose et de k3s, et sur les pages de tarifs d'AWS, de Google Cloud et d'Azure, consultées le 1er novembre 2026. Les fichiers YAML sont validés syntaxiquement, mais nous ne les avons pas exécutés sur un cluster.
> Points clés
>
> - Docker et Kubernetes ne font pas le même travail. Depuis la version 1.24, Kubernetes ne prend plus en charge Docker Engine nativement comme moteur, mais les images construites avec docker build fonctionnent toujours.
> - Au 1er novembre 2026, la version courante est Kubernetes 1.37, maintenue jusqu'au 28 octobre 2027. La 1.38 est prévue le 16 décembre 2026.
> - Docker Compose reste l'outil officiel pour un seul serveur. Docker Swarm et Kubernetes servent à répartir les conteneurs sur plusieurs machines.
> - Kompose 1.38.0 convertit un compose.yaml en manifestes, mais ignore depends_on et place les variables d'environnement en clair : le résultat est un point de départ.
> - Un plan de contrôle géré coûte 0,10 $ par heure chez AWS, soit environ 73 $ par mois selon notre calcul. Google Cloud applique le même tarif, mais un crédit mensuel couvre un cluster zonal ou Autopilot par compte de facturation. Azure propose un niveau gratuit, sans SLA.
---
Pourquoi « Kubernetes vs Docker » est une fausse opposition
Les deux outils travaillent à des étages différents. Docker fabrique une image à partir d'un Dockerfile, la publie dans un registre et lance des conteneurs sur une machine. Kubernetes, selon sa documentation, est une plateforme qui gère des charges de travail conteneurisées sur un cluster : il redémarre les conteneurs défaillants, tue ceux qui ne répondent pas au test de santé et ne leur envoie pas de trafic tant qu'ils ne sont pas prêts (présentation de Kubernetes, consultée le 1er novembre 2026).
La même page précise ce que Kubernetes ne fait pas : il ne compile pas votre code et ne construit pas vos images. Dans une chaîne réelle, Docker construit l'image, un registre la stocke et Kubernetes la déploie. C'est ce que fait notre tutoriel GitHub Actions, qui publie l'image de l'API sur ghcr.io à chaque modification.
L'idée que « Kubernetes a abandonné Docker » vient d'un changement réel mais étroit. Pendant des années, Kubernetes s'est appuyé sur Docker Engine par l'intermédiaire d'un adaptateur intégré appelé dockershim. Ce composant a été retiré dans la version 1.24, car Docker Engine n'implémente pas l'interface CRI que Kubernetes utilise pour parler aux moteurs de conteneurs (FAQ sur le retrait de dockershim, blog de Kubernetes, 17 février 2022).
La même FAQ répond aux deux inquiétudes habituelles :
- Vos images. Celles produites par docker build fonctionnent avec toutes les implémentations de CRI, comme containerd ou CRI-O.
- Votre poste. Si vous utilisez Docker pour développer et tester, rien ne change. Pour garder Docker Engine sur les nœuds d'un cluster, Mirantis et Docker se sont engagés à maintenir un adaptateur séparé, cri-dockerd, selon la FAQ et le dépôt de cri-dockerd.
---
Compose, Swarm, k3s, Kubernetes géré : le tableau comparatif
Le choix réel se fait entre quatre façons de faire tourner des conteneurs Docker en production. Le tableau résume ce que disent leurs documentations officielles, lues le 1er novembre 2026.
| Critère | Docker Compose | Docker Swarm | k3s | Kubernetes géré (EKS, GKE, AKS) |
|---|---|---|---|---|
| Nombre de machines | Une seule | Plusieurs | Une ou plusieurs | Plusieurs |
| Fichier de description | compose.yaml | compose.yaml via docker stack deploy | Manifestes Kubernetes | Manifestes Kubernetes |
| Remplacement d'un conteneur tombé | Politique restart sur la machine | Oui, y compris sur un autre nœud | Oui, comme Kubernetes | Oui |
| Mise à jour progressive et retour arrière | Non, recréation du service | Oui (update_config, rollback_config) | Oui, via le Deployment | Oui, via le Deployment |
| Mise à l'échelle automatique | Non documentée | Non documentée dans les pages lues | Oui, Metrics Server installé d'office | Oui, Metrics Server à ajouter sur EKS, à vérifier ailleurs |
| Plan de contrôle à gérer | Aucun | Nœuds managers Swarm | Un binaire, à mettre à jour vous-même | Fourni par le cloud, facturé ou non |
| Versions au 1er novembre 2026 | Compose 5.6.1 (21 octobre) | Intégré à Docker Engine 29.9.2 | Canal stable 1.36.5+k3s1 | Selon le fournisseur |
Sources : Compose en production, mode Swarm, spécification deploy de Compose, documentation de k3s, mise à l'échelle horizontale, versions relevées sur les pages de publication de Docker Compose, de Docker Engine et sur le canal de mise à jour de k3s.
Quelques nuances que les tableaux concurrents oublient :
- Compose est fait pour une machine. La documentation de Docker indique que Compose prend en charge la production sur un seul hôte, et que la façon la plus simple de déployer reste un seul serveur. Lancé sur un nœud Swarm, Compose prévient qu'il place tous les conteneurs sur la machine courante (déploiement d'une pile sur Swarm).
- Swarm demande un registre. Le même tutoriel montre que docker stack deploy ignore la clé build : l'image doit être poussée dans un registre accessible à tous les nœuds. La page du mode Swarm conseille d'ailleurs Compose si vous ne comptez pas déployer avec Swarm.
- k3s est un vrai Kubernetes. Sa documentation le décrit comme une distribution entièrement conforme, livrée en un binaire de moins de 100 Mo, avec containerd, CoreDNS, Traefik et un fournisseur de volumes locaux. Elle demande au minimum 2 cœurs et 2 Go de mémoire pour un serveur (prérequis de k3s).
- Metrics Server n'est pas partout. Le HorizontalPodAutoscaler lit le processeur et la mémoire des Pods via l'API de métriques, fournie en général par le module Metrics Server, qui doit être lancé séparément (mise à l'échelle horizontale). k3s le déploie d'office parmi ses composants intégrés (composants de k3s). Amazon EKS ne le déploie pas par défaut (documentation d'EKS). La documentation d'AKS indique seulement que les offres cloud peuvent le préinstaller (HPA sur AKS). Vérifiez avec kubectl get deployment metrics-server -n kube-system.
Côté usage, l'écart reste net. Dans l'enquête Stack Overflow 2026, 59,1 % des 10 889 répondants à la rubrique « Cloud development » ont travaillé avec Docker ou Docker Compose dans l'année, contre 26,0 % pour Kubernetes et 13,0 % pour Podman, soit 6 440, 2 836 et 1 415 répondants (Stack Overflow Developer Survey 2026, consulté le 1er novembre 2026).
!Kubernetes vs Docker en barres horizontales : dans l'enquête Stack Overflow 2026, 59,1 % des répondants ont utilisé Docker ou Docker Compose, 26,0 % Kubernetes et 13,0 % Podman
---
Kubernetes vs Docker Compose : quand passer le cap
Docker Compose sur un serveur suffit tant qu'une panne de cette machine est acceptable et que la charge tient sur elle. Un orchestrateur devient utile dès que l'une de ces deux conditions tombe. Nous ne donnons pas de seuil en utilisateurs ou en requêtes par seconde : aucune des sources officielles consultées n'en fixe, et les chiffres qui circulent ne sont pas sourcés.
Restez sur Compose si :
- Une seule machine porte l'application. Une API, sa base et un Nginx reverse proxy devant tiennent dans un fichier, avec restart: unless-stopped pour relancer un conteneur arrêté.
- Une courte coupure au déploiement est tolérable. docker compose up -d recrée le service : c'est simple, mais ce n'est pas une mise à jour progressive.
- Personne n'est disponible pour exploiter un cluster. Compose se maintient avec les commandes Linux de base, Docker et un peu de supervision.
Les signaux généraux en faveur d'un orchestrateur (plusieurs nœuds, mises à jour sans coupure, charge variable, équipes multiples) sont détaillés dans la section quand Kubernetes vaut le coup de notre guide pour débutants. Le choix se joue ensuite entre trois options :
- Docker Swarm si vous voulez garder compose.yaml et la ligne de commande Docker. Swarm répartit les services sur plusieurs machines et sait revenir en arrière après une mise à jour, mais il exige un registre et nous n'avons trouvé aucune mise à l'échelle automatique dans sa documentation.
- k3s si vous avez vos propres serveurs ou quelques machines virtuelles. Vous obtenez l'API Kubernetes complète, avec Traefik, des volumes locaux et Metrics Server déjà installés, mais le plan de contrôle et ses mises à jour sont à votre charge.
- Un Kubernetes géré si la disponibilité prime et que le budget suit. Le fournisseur exploite le plan de contrôle. Par défaut, un Deployment garde au moins 75 % des Pods voulus disponibles et n'en crée pas plus de 125 % pendant une mise à jour (documentation des Deployments).
Un piège revient souvent : Kubernetes ne rend pas une application capable de monter en charge. Notre API stocke ses données dans un fichier SQLite. Deux copies qui écrivent dans le même fichier posent problème, quel que soit l'orchestrateur : il faut d'abord passer à PostgreSQL, comme le recommande notre tutoriel pour créer une API REST avec Node.js.
Verdict : dans le duel Kubernetes vs Docker Compose, le premier critère est le nombre de machines nécessaires ; le partage de l'infrastructure entre plusieurs équipes vient ensuite. Dans tous les cas, Docker reste l'outil pour construire les images et développer en local.
---
La même API en compose.yaml puis en manifestes Kubernetes
L'API de nos tutoriels écoute le port 3000, refuse de démarrer sans un JWT_SECRET d'au moins 32 octets, écrit sa base SQLite dans /app/data et répond {"statut":"ok"} sur /health. L'image est celle que publie le workflow GitHub Actions, ghcr.io/<compte>/api-taches.
Le compose.yaml
Ce fichier reprend les choix de nos guides Docker et Nginx : port publié sur l'adresse locale seulement, secret lu dans le .env, test de santé sur /health.
services:
api:
build: .
image: ghcr.io/votre-compte/api-taches:main
ports:
- "127.0.0.1:3000:3000"
environment:
JWT_SECRET: ${JWT_SECRET:?definir JWT_SECRET dans .env}
DB_PATH: /app/data/data.db
volumes:
- api-data:/app/data
init: true
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
volumes:
api-data:
Vingt et une lignes, une commande (docker compose up -d --build), et aucun composant à installer en dehors de Docker.
Les manifestes Kubernetes
Côté Kubernetes, le secret vit dans un objet séparé. La documentation prévient que les Secrets sont stockés sans chiffrement par défaut dans etcd, la base du cluster, et que toute personne ayant accès à l'API peut les lire (documentation des Secrets). Générez d'abord une valeur de 32 octets, comme dans notre guide Nginx, puis créez le Secret en ligne de commande plutôt que de l'écrire dans un fichier versionné :
export JWT_SECRET=$(docker run --rm node:24-alpine node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))")
kubectl create secret generic api-taches --from-literal=JWT_SECRET="$JWT_SECRET"
Le fichier équivalent, à ne jamais placer dans Git avec une vraie valeur. Le texte de remplacement fait moins de 32 octets : tant qu'il n'est pas changé, l'API refuse de démarrer, ce qui est voulu.
apiVersion: v1
kind: Secret
metadata:
name: api-taches
type: Opaque
stringData:
JWT_SECRET: "A-REMPLACER"
Puis le fichier api-taches.yaml, avec le volume, le Deployment et le Service :
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: api-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-taches
labels:
app: api-taches
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: api-taches
template:
metadata:
labels:
app: api-taches
spec:
shareProcessNamespace: true
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
containers:
- name: api
image: ghcr.io/votre-compte/api-taches:sha-1a2b3c4
ports:
- containerPort: 3000
env:
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: api-taches
key: JWT_SECRET
- name: DB_PATH
value: /app/data/data.db
volumeMounts:
- name: data
mountPath: /app/data
readinessProbe:
httpGet:
path: /health
port: 3000
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 30
volumes:
- name: data
persistentVolumeClaim:
claimName: api-data
---
apiVersion: v1
kind: Service
metadata:
name: api-taches
spec:
selector:
app: api-taches
ports:
- port: 80
targetPort: 3000
Appliquez-le avec kubectl apply -f api-taches.yaml, puis suivez le déploiement avec kubectl rollout status deployment/api-taches. La taille de 1 Gi est un exemple : ajustez-la. Le tag sha-1a2b3c4 remplace le commit abrégé que produit notre workflow GitHub Actions : avec un tag autre que latest, Kubernetes règle par défaut imagePullPolicy sur IfNotPresent, et un nœud qui a déjà une image main en cache ne téléchargerait pas la nouvelle (documentation des images). Un tag par commit rend chaque déploiement explicite. Si le paquet GHCR est privé, le Pod aura besoin d'un imagePullSecrets. Pour les bases de ces objets, notre guide Kubernetes pour débutants les présente un par un.
Ce qui change entre les deux
Quatre choix de ces manifestes découlent de SQLite et de la documentation :
- replicas: 1 et strategy: Recreate. Un volume en ReadWriteOnce ne peut être monté en écriture que par un seul nœud (volumes persistants). La stratégie Recreate arrête l'ancien Pod avant de lancer le nouveau : il y a donc une courte coupure, comme avec Compose. Pour une vraie mise à jour progressive, passez à PostgreSQL et à la stratégie par défaut.
- fsGroup: 1000. L'image de notre guide Docker tourne sous l'utilisateur node, d'identifiant 1000. Pour les types de volumes qui le permettent, fsGroup attribue ce groupe aux fichiers du volume pour que SQLite puisse écrire ; fsGroupChangePolicy: OnRootMismatch évite de refaire ce changement à chaque démarrage si la racine du volume est déjà correcte (contexte de sécurité).
- shareProcessNamespace: true, une alternative à init: true. Nous n'avons trouvé dans les pages consultées aucun champ de Pod qui lance un processus d'init comme le fait Compose ; avec ce réglage, les conteneurs d'un même Pod voient aussi les processus et les fichiers des autres, ce qui compte si vous ajoutez un second conteneur. Or Node.js n'est pas conçu pour tourner avec le PID 1 et ne répond alors pas à des signaux comme SIGINT (bonnes pratiques de l'image Node.js). Sans correctif, chaque arrêt du Pod attendrait la fin du délai de grâce, 30 secondes par défaut (cycle de vie des Pods). Avec un espace de processus partagé, le processus du conteneur n'a plus le PID 1 (partage de l'espace de processus). L'autre solution, que recommandent aussi les bonnes pratiques de l'image Node.js, consiste à ajouter Tini au Dockerfile : RUN apk add --no-cache tini et ENTRYPOINT ["/sbin/tini", "--"], selon le README de Tini.
- Deux sondes au lieu d'un test de santé. La sonde de disponibilité (readiness) décide si le Pod reçoit du trafic, la sonde de vie (liveness) le redémarre s'il échoue. Une sonde de vie trop sévère peut provoquer des redémarrages en cascade sous forte charge, prévient la documentation des sondes.
| Dans compose.yaml | Dans Kubernetes | Remarque |
|---|---|---|
| services.api | Deployment + Service | Le Service donne une adresse stable |
| ports: 127.0.0.1:3000:3000 | Service ClusterIP, port 80 vers 3000 | Exposition publique : Ingress ou Gateway |
| environment + .env | Secret + secretKeyRef | Secret non chiffré par défaut dans etcd |
| volumes: api-data | PersistentVolumeClaim | Suppose une classe de stockage par défaut |
| healthcheck | readinessProbe et livenessProbe | Deux rôles distincts |
| restart: unless-stopped | restartPolicy: Always, seule valeur permise dans un Deployment | Le kubelet relance le conteneur ; le Deployment recrée le Pod, au besoin ailleurs |
| build: . | Aucun équivalent | L'image doit venir d'un registre |
Comptées de la même façon, sans les lignes vides, le fichier Compose fait 21 lignes et les manifestes Kubernetes 82 avec le Secret, soit plus du triple. Dans ce match Kubernetes vs Docker Compose, ce surcoût en YAML n'est rentable que si vous avez besoin de ce qu'il apporte.
---
Migrer avec Kompose : ce qu'il convertit, ce qu'il laisse
Kompose, un projet officiel du groupe Kubernetes, sert à passer d'un fichier Compose à des manifestes. Son README le dit lui-même : la transformation « peut ne pas être exacte », mais elle aide beaucoup pour un premier déploiement (notre traduction, dépôt de Kompose, consulté le 1er novembre 2026).
La dernière version publiée est la 1.38.0, du 15 janvier 2026, selon la page des versions. Le README annonce un rythme de sortie toutes les trois semaines : aucune version n'est pourtant parue depuis plus de neuf mois. La commande de base est la suivante :
kompose convert -f compose.yaml
Le tableau de conversion de Kompose et son guide d'utilisation détaillent ce qui passe et ce qui ne passe pas :
| Clé Compose | Ce que produit Kompose |
|---|---|
| image, ports | Deployment et Service |
| environment | Variables en clair dans le Deployment |
| volumes | PersistentVolumeClaim de 100 Mi par défaut, avec stratégie Recreate |
| healthcheck | Sonde de vie (liveness) qui exécute la commande test |
| depends_on | Non applicable, ignoré |
| build | Construction seulement avec --build, envoi avec --push-image |
| build: args | Pas encore implémenté |
Trois limites comptent pour notre API. D'abord, le secret : environment devient une variable du Deployment, donc JWT_SECRET se retrouve en clair dans un fichier YAML. Remplacez-le par un Secret avant tout commit. Ensuite, l'ordre de démarrage : depends_on n'a pas d'équivalent, et chaque service doit savoir attendre ses dépendances.
Enfin, les sondes : le healthcheck de Compose devient une sonde de vie qui lance la même commande, mais aucune sonde de disponibilité n'est créée sans étiquettes propres à Kompose. Ajoutées au service api, celles-ci produisent une sonde de disponibilité HTTP sur /health :
labels:
kompose.service.healthcheck.readiness.http_get_path: /health
kompose.service.healthcheck.readiness.http_get_port: "3000"
La taille par défaut de 100 Mi et le passage du healthcheck en sonde de vie se lisent dans le code source de la version 1.38.0, l'étiquette kompose.volume.size permettant de changer la taille.
Verdict : utilisez Kompose pour obtenir un brouillon, puis relisez chaque fichier. Les points ci-dessus sont ceux que nos manifestes écrits à la main traitent différemment.
---
Coûts et charge d'exploitation
Le logiciel est gratuit, son exploitation ne l'est pas : il faut payer le plan de contrôle géré et suivre les versions.
Le rythme des versions
Le projet maintient les trois dernières versions mineures, soit 1.37, 1.36 et 1.35, avec environ un an de correctifs pour chacune, suivi de deux mois en mode maintenance (page des versions et politique des correctifs, consultées le 1er novembre 2026). La 1.37, nom de code Garhwal, est sortie le 26 août 2026 avec 67 améliorations (annonce de la 1.37).
| Version | Sortie | Mode maintenance | Fin de vie |
|---|---|---|---|
| 1.37 | 26 août 2026 | 28 août 2027 | 28 octobre 2027 |
| 1.36 | 22 avril 2026 | 28 avril 2027 | 28 juin 2027 |
| 1.35 | 17 décembre 2025 | 28 décembre 2026 | 28 février 2027 |
La 1.38 est prévue le 16 décembre 2026, selon le calendrier de la version 1.38. Un cluster installé aujourd'hui devra donc être mis à jour au moins une fois par an, en passant d'une version mineure à la suivante. Dernier correctif relevé : 1.37.2, publié le 28 octobre 2026 sur GitHub.
!Diagramme de Gantt : versions de Kubernetes 1.35, 1.36 et 1.37 avec leurs périodes de correctifs et de maintenance jusqu'à leur fin de vie, et sortie de la 1.38 prévue le 16 décembre 2026
Le prix d'un plan de contrôle géré
Les trois grands clouds facturent différemment le plan de contrôle, hors machines, stockage et réseau. Tarifs relevés le 1er novembre 2026 :
| Offre | Plan de contrôle | Condition |
|---|---|---|
| Amazon EKS, support standard | 0,10 $ par cluster et par heure | 14 premiers mois d'une version dans EKS |
| Amazon EKS, support étendu | 0,60 $ par cluster et par heure | 12 mois supplémentaires |
| Google Kubernetes Engine | 0,10 $ par cluster et par heure | Crédit de 74,40 $ par mois et par compte de facturation |
| Azure Kubernetes Service, niveau Free | Gratuit | Sans SLA, déconseillé au-delà de 10 nœuds |
| Azure Kubernetes Service, niveau Standard | Prix non relevé, illisible sur la page | Avec SLA sur le serveur d'API |
| Azure Kubernetes Service, niveau Premium | Prix non relevé, illisible sur la page | SLA et support à long terme |
Sources : tarifs d'Amazon EKS, tarifs de GKE et tarifs d'AKS. Google précise que son crédit gratuit équivaut à un cluster zonal ou Autopilot par mois.
> Notre calcul. 0,10 $ sur 730 heures donne environ 73 $ par mois pour le seul plan de contrôle chez AWS. Une version laissée en support étendu chez EKS passe à 0,60 $, soit environ 438 $ par mois. Ne pas mettre à jour son cluster a donc un prix direct.
Ce surcoût place Kubernetes géré au-dessus des offres plus simples présentées dans notre guide de l'hébergement cloud pour développeurs. Pour comparer les trois fournisseurs au-delà de Kubernetes, notre duel AWS vs Azure vs Google Cloud détaille les autres services.
---
Ce que nous n'avons pas pu vérifier
Cet article est une synthèse documentaire. Les points suivants restent ouverts :
- L'exécution des fichiers. Le compose.yaml et les manifestes ont été validés comme YAML, pas exécutés sur Docker ni sur un cluster. La conversion par Kompose n'a pas été lancée.
- Le prix des niveaux Standard et Premium d'AKS. La page de tarifs d'Azure n'affichait pas de montant lisible au moment de notre lecture.
- L'avenir de Docker Swarm. La documentation de Docker le présente comme intégré à Docker Engine, mais nous n'avons trouvé aucune feuille de route officielle.
- Metrics Server sur GKE et AKS. Nous n'avons trouvé aucune page officielle qui dise clairement s'il est installé d'office ; la documentation d'AKS dit seulement que les offres cloud « peuvent » le préinstaller (notre traduction).
- L'arrêt de l'API avec shareProcessNamespace. La documentation dit que le processus perd le PID 1 ; que Node.js s'arrête alors proprement sur SIGTERM est notre déduction, non testée. Tini dans l'image reste l'option la plus documentée.
---
Questions fréquentes
Kubernetes remplace-t-il Docker ?
Non, et c'est le cœur du débat Kubernetes vs Docker. Kubernetes a retiré dockershim dans la version 1.24 et ne prend plus en charge Docker Engine nativement sur ses nœuds, mais les images construites avec docker build fonctionnent avec tous les moteurs compatibles CRI, selon la FAQ officielle. Docker reste l'outil de construction et de développement local.
Docker Compose peut-il servir en production ?
Oui, sur une seule machine. La documentation de Docker cite les déploiements sur un seul hôte parmi les usages de Compose. Au-delà d'une machine, il faut Docker Swarm ou Kubernetes.
Comment passer de Docker Compose à Kubernetes ?
Kompose convertit un compose.yaml avec kompose convert -f compose.yaml. Relisez ensuite chaque fichier : les variables d'environnement y sont en clair, depends_on est ignoré et chaque volume devient une demande de stockage, selon le tableau de conversion.
Quelle est la version actuelle de Kubernetes ?
Au 1er novembre 2026, c'est la 1.37, sortie le 26 août 2026 et maintenue jusqu'au 28 octobre 2027, selon la page des versions. Les versions 1.36 et 1.35 sont aussi maintenues, et la 1.38 est prévue le 16 décembre 2026.
---
À propos de l'auteur
Abdelilah Ourti est ingénieur IA et architecte logiciel. Il conçoit et déploie des applications web et des systèmes d'IA, et publie sur NeurauTech des guides et des comparatifs destinés aux développeurs francophones. Ses autres articles sont réunis sur sa page auteur.
Méthode. Synthèse documentaire fondée exclusivement sur des sources publiques consultées le 1er novembre 2026 : documentation, blog et pages de versions de Kubernetes, documentation de Docker, de Kompose et de k3s, pages de publication sur GitHub, pages de tarifs d'AWS, de Google Cloud et d'Azure, enquête Stack Overflow 2026. Les fichiers YAML ont été validés syntaxiquement mais n'ont été exécutés ni sur Docker ni sur un cluster. Les citations issues de sources en anglais sont traduites par nos soins. Aucun lien n'est affilié et aucune relation commerciale ne lie l'auteur aux éditeurs mentionnés.
Une erreur ou un tarif périmé ? Écrivez à contact@neurautech.com : l'article sera corrigé et la date de mise à jour modifiée.
Dernière mise à jour : 1er novembre 2026.
Image d'en-tête : Tom Fisk (Pexels License). Graphiques : NeurauTech, d'après Stack Overflow et kubernetes.io.
【Vol.1】Python特化のOpenTelemetry+LGTMスタック最小構成
1. はじめに
OpenTelemetryの学習にあたり、公式デモを、ようやく動かせるようになったので、それを忘れないうちに、Python(Flask/Locust)特化のシンプルな最小構成環境を作ってみました。
手作業を排除し、EC2上にインフラ構築から自動監視までをTerraformで自動作成します。
:::note info
今回は 【Vol.1】環境構築編 として、まずは動かすことに特化しています。
自分自身の学習記録の備忘録としての意味合いもあります。
:::
!image.png
2. Grafana Labs公式「LGTMスタック」デモとは、
本環境は、Grafana Labs公式の OpenTelemetry Demo をベースにしています。公式デモは、モダンな可観測性(Observability)の標準である 「LGTMスタック」 を手軽に体験できる素晴らしい環境です。
📊 LGTMスタックの構成要素
| コンポーネント | 役割 |
| :--- | :--- |
| Loki | ログを集約・検索する軽量なログ管理システム |
| Grafana | 収集データを1つにまとめて可視化するダッシュボード |
| Tempo | リクエストの経路を追跡する分散トレーシング |
| Mimir / Prometheus | システム状態(CPUやリクエスト数など)を蓄積するメトリクス |
:::note indfo
「LGTMスタック」イメージのみ流用し、アプリ側を 「Python(Flask/Locust)の最小構成」 に絞り込み、ファイル構成、設定内容を理解しやすくしました。
:::
3. 今回のチャレンジと「あえてやらなかったこと」
今回の最大の目的は、 「必要な部分だけを切り出して、自分なりにカスタマイズしやすい環境を作る」 ことです。
具体的には、以下の要素に絞って手を動かし、理解を深めました。
- Grafanaでの可視化の仕組み
- ダッシュボードが「何を、どう表示しているか」の整理
- PromQLや関数を用いたグラフ描画の分析
- OpenTelemetry(メトリクス中心)の挙動確認
- アプリからデータが「どのように出力されるか」の観察
- 負荷(Locust)をかけたときのメトリクス変動の観測
:::note info
「あえてやらなかったこと」は、LGTMスタック各コンポーネント(Loki, Tempo, Mimir, OTel Collector)間の内部接続や詳細設定は公式デモの構成を流用しています。
:::
4. 技術スタック
本環境で使用している技術スタックは以下の通りです。
- インフラ(IaC):Terraform
- クラウド:AWS (VPC, Subnet, Security Group, EC2 [Amazon Linux 2023])
- コンテナ:Docker / Docker Compose
- アプリケーション:Python 3.11 / Flask (Webアプリ) / Locust (負荷テストツール)
- 可観測性(Observability)エコシステム:
- データ収集:OpenTelemetry
- 可視化:Grafana
- メトリクス蓄積:Prometheus
- ログ蓄積:Loki
- 分散トレーシング:Tempo
ディレクトリ構成
本環境の全体構成は以下の通りです。
このデータセットは、GitHubリポジトリ に登録しています。
├── ENV_1011/ # main.tfの構築・プロビジョニング時に使用される環境データ
│ ├── my-dashboards/ # Grafanaのダッシュボード設定を格納
│ │ └── grafana-dashboard-python-metrics.json
│ ├── python/ # OpenTelemetryによるメトリクス生成を組み込んだアプリ
│ │ ├── app.py # Flaskアプリケーション本体
│ │ └── locustfile.py # 負荷試験用のLocustスクリプト
│ ├── services/ # EC2上で常時稼働させるsystemd用の設定ファイル
│ │ ├── flask-app.service
│ │ └── locust-worker.service
│ ├── clear-defaults.yaml # 各種環境の初期設定をクリア・調整するための設定
│ ├── my-dashboard-config.yaml # Grafanaダッシュボードのプロバイダー設定
│ └── run-lgtm.sh # LGTMスタック(Grafana等)を起動・制御するスクリプト
└── devops_1011/ # 構築・自動化用コード
├── scripts/
│ └── setup.sh # お試し構築・初期セットアップ用スクリプト
└── terraform/ # IaC(Infrastructure as Code)関連コード
├── main.tf # AWS EC2インフラやネットワークの定義
└── output.tf # 構築後のパブリックIP等の出力定義
各ディレクトリ・ファイルの役割
>### インフラ自動化・構築 (./devops_1011/)
Terraformを用いて、AWSのEC2上にインフラ環境を自動構築します。
!image.png
>### アプリケーション & 自動監視環境 ( ./ENV_1011/ )
main.tf を実行し、EC2インスタンス起動後に、環境構築/python実行で必要なファイルを格納しています。
!image.png
5. システム構成
!image.png
今回はGrafana Labs公式デモ版のコンテナイメージ(grafana/otel-lgtm)をベースに使用しているため、OpenTelemetry Collector部や各バックエンド(Prometheus / Tempo / Loki / Pyroscope)の内部構成はそちらに依存しています。
このシステムに対して、自作した「Python Web App (Flask)」と「負荷生成器(Locust)」を接続し、EC2インスタンス(t3.small)内でsystemdとしてバックグラウンド実行させています。
インフラ構築や環境配備の自動化を図るために、Terraform でAWSリソースの自動生成を行い、EC2のUser DataをトリガーにGitHub・Docker Hubと連携してデプロイを自動化しています。
- GitHub:最新のソースコード(app.py / locustfile.py)を自動取得
- Docker Hub:可視化基盤のコンテナイメージ(grafana/otel-lgtm)をプル
🔄 各コンポーネントの役割
!image.png
6. メトリクス
ダッシュボードで可視化したメトリクスは以下の通りです。
Locustの負荷変動に応じて、これらの値も変動します。
!image.png
具体的なコードについては、末尾の [【付録:環境構築用コード】](#10付録環境構築用コード) にある「grafana-dashboard-python-metrics.json」を参照してください。
:::note info
今回の構成ではメトリクス収集に特化しており、LokiやTempoへのデータ送信に必要な機能実装は行わなかったので、 ログ、トレース の内容は見られません。
:::
7.デプロイ手順
インフラのプロビジョニングからアプリのデプロイ、監視の有効化まで、Terraformで自動化しています。
>### 1. 環境変数の設定
ローカル環境に自身のAWSの認証情報(アクセスキーなど)をセットします。
>### 2. Terraformの実行
terraform apply を実行します。AWS上にVPC、セキュリティグループ、EC2(Amazon Linux 2023)が自動生成されます。
※具体的なIaCコードは末尾の [【付録:環境構築用コード】](#10付録環境構築用コード) を参照。
>### 3. user_dataによる完全自動セットアップ
EC2の起動時、user_dataがバックグラウンドで以下の処理を全自動で実行します。
1. GitのSparse Checkout(部分チェックアウト) を使い、リポジトリから必要なディレクトリ(ENV_1011)のみダウンロード。
2. Docker / Docker Compose のインストールと起動。
3. 公式スクリプト(run-lgtm.sh)によるLGTMスタックコンテナ起動。
4. Python 3.11環境の構築と、必要ライブラリ(Flask, OpenTelemetry SDK, Locustなど)のインストール。
5. Pythonスクリプトの配置、およびsystemdへのサービス登録と自動起動。
>### 4. Grafanaへのアクセス
terraform apply 完了後、数分待つだけでセットアップが完了します。出力されたパブリックIPの3000番ポート(http://<EC2のパブリックIP>:3000)へアクセスすれば、すぐに完成したダッシュボードを確認できます。
<details>
<summary>詳細手順は、こちらを参照</summary>
>### URL指定(Grafana-GUI)
terraform apply 完了後、以下の様にパブリックIPが出力されます。
ec2_private_ip = "10.0.1.206"
ec2_public_ip = "3.112.205.128"
上記のケースでは、以下のURL に2~3分待ってアクセスします。
3.112.205.128
>### ダッシュボード選択
以下の様に、Grafana からダッシュボード選択を行います。
!image.png
>### メニュー選択
表示期間を「⏱ Last 5 minutes」に切り替えて、10分ほど待つと、以下のようなデータが見られるようになります。
!image.png
</details>
8. おわりに
今回の取り組みを通じ、OpenTelemetryの強力さとLGTMスタックによる監視基盤の構築方法を深く学ぶことができました。
手間の多い手動構築(SSH、クローン、権限変更、サービス登録など)を、Terraformの user_data と Gitの Sparse Checkout で自動化し、「コマンドで環境構築」する面白味を実感できました。
>### 周辺技術での学び
環境コード化にあたり、監視以外の実用的なスキルも習得できました。
- Locustによる動的負荷試験:リクエストを変動させ、意図的にメトリクスの波(トレンド)を作る手法
- Terraformの user_data:EC2起動時のプロビジョニングを完全自動化する実務的な手法
9. 今後の展望
今回は【Vol.1】として「最小構成で動かす」ことを最優先しました。このベース環境を活かし、以下の要素にも挑戦する予定です。(あくまでも予定です)
- 監視基盤の自作化:公式デモの流用を脱却し、各コンポーネントとCollector間の接続・設定をイチから自作する
- 自動制御の組み込み:各バックエンドのデータをシェルスクリプト等で定期取得し、異常を自動検知する仕組みの実装。
- 構成管理の高度化:user_data(シェルスクリプト)で行っているアプリ配置やサービス登録を、Ansibleによる管理へ移行する。
- オブザーバビリティの完全化:今回はメトリクスのみのだった構成にLoki(ログ)とTempo(トレース)の実装を本格追加し、「3つの柱」を可視化できる環境の構築。
10.付録:環境構築用コード
GitHubレポジトリ に登録しています
<details>
<summary>./terraform/main.tf</summary>
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "ap-northeast-1"
}
# 1. VPC(仮想ネットワーク)の作成
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = {
Name = "study-vpc"
}
}
# 2. パブリックサブネットの作成
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
map_public_ip_on_launch = true # 起動時にパブリックIPを自動割り当て
tags = {
Name = "study-public-subnet"
}
}
# 3. インターネットゲートウェイの作成(外の世界と繋ぐ窓口)
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "study-igw"
}
}
# 4. ルートテーブルの作成と紐付け(ネットへの道を登録)
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = {
Name = "study-public-rt"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
# =================================================================================
# 1. セキュリティグループの定義
# =================================================================================
resource "aws_security_group" "ec2" {
name = "study-ec2-sg"
description = "Security Group for OpenTelemetry and App Server"
vpc_id = aws_vpc.main.id # ※環境に合わせてVPC IDの変数名を調整してください
# -------------------------------------------------------------------------------
# 各種アプリケーション・可観測性ツール用のインバウンドルール(dynamicブロックで一括管理)
# -------------------------------------------------------------------------------
dynamic "ingress" {
for_each = {
22 = "SSH"
3000 = "Grafana UI"
16686 = "Jaeger UI"
9090 = "Prometheus UI"
3100 = "Loki"
8000 = "Python Web App"
9464 = "OpenTelemetry Prometheus Exporter"
5000 = "Python App (Flask)"
4317 = "OpenTelemetry (OTLP/gRPC)"
4318 = "OpenTelemetry (OTLP/HTTP)"
8080 = "OpenTelemetry Demo Web & Grafana"
}
content {
description = ingress.value
from_port = ingress.key
to_port = ingress.key
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
# -------------------------------------------------------------------------------
# 【超重要】EC2同士(VPC内部)の全通信を許可するルール
# -------------------------------------------------------------------------------
ingress {
description = "VPC Internal All Traffic"
from_port = 0
to_port = 0
protocol = "-1" # すべてのプロトコルを許可
cidr_blocks = ["10.0.0.0/16"] # VPC内部のIPアドレスからの通信をすべて許可
}
# -------------------------------------------------------------------------------
# インターネットへの外向き通信をすべて許可(重複を排除して1つにスッキリ整理)
# -------------------------------------------------------------------------------
egress {
description = "Allow all outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# =================================================================================
# 2. SSH接続用のキーペアを自動生成
# =================================================================================
resource "tls_private_key" "keygen" {
algorithm = "RSA"
rsa_bits = 4096
}
resource "aws_key_pair" "keypair" {
key_name = "study-key"
public_key = tls_private_key.keygen.public_key_openssh
}
# 生成された秘密鍵(Terra Termで使う.pemファイル)をローカルに保存
resource "local_file" "private_key" {
content = tls_private_key.keygen.private_key_pem
filename = "${path.module}/study-key.pem"
}
# =================================================================================
# 3. AWSから自動で最新のAmazon Linux 2023 AMI IDを取得
# =================================================================================
data "aws_ssm_parameter" "amzn_linux_2023" {
name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}
# =================================================================================
# 4. EC2インスタンスの作成(GitHubパブリック方式・完全自動化版)
# =================================================================================
resource "aws_instance" "app_server" {
ami = data.aws_ssm_parameter.amzn_linux_2023.value
instance_type = "t3.small"
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.ec2.id]
key_name = aws_key_pair.keypair.key_name
# ルートディスク容量を30GB(無料枠の最大値)に増やす設定
root_block_device {
volume_size = 30
volume_type = "gp3"
}
tags = {
Name = "study-ec2-1"
}
# ===============================================================================
# 【完全自動化】初期化シェルスクリプト(user_data)
# ===============================================================================
user_data = <<-EOF
#!/bin/bash
# エラーが発生したら即座にスクリプトを終了させる設定
set -euo pipefail
echo "=========================================================="
echo "=== 1. GitHubから特定のディレクトリ(ENV_1011)のみを取得 ==="
echo "=========================================================="
dnf install -y git
sleep 5
mkdir -p /opt/deploy_data
cd /opt/deploy_data
# Sparse Checkout(部分チェックアウト)の設定
git init
git remote add origin https://github.com/wata123-t/deploy_data
git config core.sparseCheckout true
echo "ENV_1011/" >> .git/info/sparse-checkout
# データの引っ張りと移動
git pull origin main
cd ENV_1011
echo "=========================================================="
echo "=== 2. Docker & Docker Compose のインストールと起動 ==="
echo "=========================================================="
dnf install -y docker
sleep 5
systemctl enable --now docker
# Docker Compose V2 のインストール
mkdir -p /usr/local/lib/docker/cli-plugins/
curl -SL https://github.com/docker/compose/releases/download/v2.29.2/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
echo "=========================================================="
echo "=== 3. Grafana Labs公式「LGTMスタック」デモの実行 ==="
echo "=========================================================="
# すでにGit側で権限を付与していますが、念のため再付与して実行
chmod +x ./run-lgtm.sh
./run-lgtm.sh
sleep 10
echo "=========================================================="
echo "=== 4. Python 3.11 & 開発ツールのインストール ==="
echo "=========================================================="
dnf install -y python3.11 python3.11-pip python3.11-devel gcc
sleep 10
echo "=========================================================="
echo "=== 5. 必要なPythonライブラリの一括インストール ==="
echo "=========================================================="
python3.11 -m pip install --upgrade pip
sleep 10
python3.11 -m pip install flask opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp opentelemetry-instrumentation-flask opentelemetry-instrumentation-system-metrics locust
sleep 10
echo "=========================================================="
echo "=== 6. アプリケーションフォルダの作成とファイルのコピー ==="
echo "=========================================================="
mkdir -p /opt/script/python/app
# GitHubから落としてきた相対パスからファイルをコピー
cp "./python/app.py" /opt/script/python/app/app.py
cp "./python/locustfile.py" /opt/script/python/locustfile.py
echo "=========================================================="
echo "=== 7. 各種サービス(systemd)ファイルの配置と起動 ==="
echo "=========================================================="
cp "./services/flask-app.service" /etc/systemd/system/flask-app.service
cp "./services/locust-worker.service" /etc/systemd/system/locust-worker.service
systemctl daemon-reload
systemctl enable --now flask-app locust-worker
echo "=========================================================="
echo "=== すべてのセットアップ処理が正常に完了しました! ==="
echo "=========================================================="
EOF
}
</details>
<details>
<summary>./terraform/output.tf</summary>
# =================================================================================
# 5. アウトプット(接続用IPアドレスの表示)
# =================================================================================
output "ec2_public_ip" {
value = aws_instance.app_server.public_ip
description = "起動したEC2のパブリックIPアドレスです"
}
output "ec2_private_ip" {
value = aws_instance.app_server.private_ip
description = "EC2のプライベートIPアドレスです"
}
</details>
<details>
<summary>./my-dashboards/grafana-dashboard-python-metrics.json
</summary>
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"target": {
"limit": 100,
"matchAny": false,
"tags": [],
"type": "dashboard"
},
"type": "dashboard"
}
]
},
"description": "Dashboard for **Python** metrics with OpenTelemetry instrumentation",
"editable": true,
"fiscalYearStartMonth": 0,
"gnetId": 18812,
"graphTooltip": 0,
"links": [
{
"asDropdown": false,
"icon": "info",
"includeVars": false,
"keepTime": false,
"tags": [],
"targetBlank": false,
"title": "Semantic Conventions: 1.20.0 - 1.22.0",
"tooltip": "multiple versions of the semantic conventions are supported using 'or' and regex queries",
"type": "link",
"url": "https://github.com/open-telemetry/semantic-conventions/blob/main/schemas/1.20.0"
}
],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"description": "HTTP server request rate",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 25,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 0
},
"id": 51,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "sum by (instance) (rate({__name__=~\"http_server_request_duration_seconds_count|http_server_request_duration_count|http_server_duration_seconds_count|http_server_duration_count|http_server_duration_milliseconds_count|http_server_requests_milliseconds_count|http_server_requests_count|http_server_requests_seconds_count\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))",
"instant": false,
"legendFormat": "__auto",
"range": true,
"refId": "A"
}
],
"title": "Rate",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"description": "HTTP server error ratio - ratio of requests that return 5xx",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 25,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 0
},
"id": 50,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "(sum(rate(http_server_duration_milliseconds_count{http_status_code=~\"5..\"}[$__rate_interval])) / sum(rate(http_server_duration_milliseconds_count[$__rate_interval]))*1) or on() vector(0)",
"instant": false,
"legendFormat": "__auto",
"range": true,
"refId": "A"
}
],
"title": "Error %",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"description": "95th percentile of HTTP server request duration in seconds",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 25,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 0
},
"id": 52,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "(histogram_quantile(0.95, sum by(le, instance) (rate({__name__=~\"http_server_request_duration_seconds_bucket|http_server_request_duration_bucket|http_server_duration_seconds_bucket|http_server_duration_bucket|http_server_requests_seconds_bucket\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))))\nor\n(histogram_quantile(0.95, sum by(le, instance) (rate({__name__=~\"http_server_duration_milliseconds_bucket|http_server_requests_milliseconds_bucket|http_server_requests_bucket\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))) / 1000)\n",
"instant": false,
"legendFormat": "{{instance}}",
"range": true,
"refId": "A"
}
],
"title": "Duration (95%)",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"description": "CPU utilization for the whole system",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 25,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 13,
"x": 0,
"y": 8
},
"id": 38,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "process_cpu_utilization_ratio",
"legendFormat": "{{instance}}",
"range": true,
"refId": "A"
}
],
"title": "CPU utilization",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"description": "Used heap memory / heap memory limit ",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 25,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 11,
"x": 13,
"y": 8
},
"id": 30,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "process_memory_usage_bytes",
"legendFormat": "{{instance}}",
"range": true,
"refId": "A"
}
],
"title": "Heap Memory utilization",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"description": "Percentage of time spend for garbage collection pauses",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "bar",
"fillOpacity": 25,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 13,
"x": 0,
"y": 15
},
"id": 46,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "sum by (instance) (rate(cpython_gc_collections_total[$__rate_interval]))*60",
"hide": false,
"legendFormat": "__auto",
"range": true,
"refId": "B"
}
],
"title": "Garbage Collection",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"description": "Number of currently executing (also called \"live\") threads",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 25,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 11,
"x": 13,
"y": 15
},
"id": 42,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "process_thread_count",
"legendFormat": "{{instance}}",
"range": true,
"refId": "A"
}
],
"title": "Threads",
"type": "timeseries"
}
],
"refresh": "",
"schemaVersion": 38,
"tags": ["Python"],
"templating": {
"list": [
{
"current": {
"selected": false,
"text": "Prometheus",
"value": "prometheus"
},
"description": "Choose a Prometheus data source",
"hide": 0,
"includeAll": false,
"label": "Data source",
"multi": false,
"name": "datasource",
"options": [],
"query": "prometheus",
"queryValue": "",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"type": "datasource"
},
{
"allValue": ".+",
"current": {
"selected": false,
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"definition": "label_values(http_server_request_duration_seconds_count, job)",
"hide": 0,
"includeAll": true,
"label": "Job",
"multi": true,
"name": "job",
"options": [],
"query": {
"query": "label_values(http_server_request_duration_seconds_count, job)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"tagValuesQuery": "",
"tagsQuery": "",
"type": "query",
"useTags": false
},
{
"allValue": ".+",
"current": {
"selected": false,
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"definition": "label_values(http_server_request_duration_seconds_count, instance)",
"description": "The instance of the application, e.g. pod1",
"hide": 0,
"includeAll": true,
"label": "Instance",
"multi": true,
"name": "instance",
"options": [],
"query": {
"query": "label_values(http_server_request_duration_seconds_count, instance)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"tagValuesQuery": "",
"tagsQuery": "",
"type": "query",
"useTags": false
}
]
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
},
"timezone": "",
"title": "python-Objability",
"uid": "python-dashboard-metrics",
"version": 1,
"weekStart": ""
}
</details>
1 like
Mentions
Kubernetes vs Docker : Compose, Swarm ou cluster, que choisir en 2026 ? L'essentiel en une minute Kubernetes vs Docker est une comparaison trompeuse : Docker construit des images et lance des conteneurs, Kubernetes décide sur quelles machines ils tournent, les remplace quand ils tombent et les met à jour par étapes. La vraie question est ailleurs : Docker Compose sur un serveur suffit-il, ou faut-il un orchestrateur comme Docker Swarm, k3s ou un cluster Kubernetes géré ? Cet article tranche avec un exemple concret : l'API de tâches de notre tutoriel Docker pour débutants, écrite une fois en compose.yaml et une fois en manifestes Kubernetes. Il s'appuie sur les documentations de Kubernetes, de Docker, de Kompose et de k3s, et sur les pages de tarifs d'AWS, de Google Cloud et d'Azure, consultées le 1er novembre 2026. Les fichiers YAML sont validés syntaxiquement, mais nous ne les avons pas exécutés sur un cluster. > Points clés > > - Docker et Kubernetes ne font pas le même travail. Depuis la version 1.24, Kubernetes ne prend plus en charge Docker Engine nativement comme moteur, mais les images construites avec docker build fonctionnent toujours. > - Au 1er novembre 2026, la version courante est Kubernetes 1.37, maintenue jusqu'au 28 octobre 2027. La 1.38 est prévue le 16 décembre 2026. > - Docker Compose reste l'outil officiel pour un seul serveur. Docker Swarm et Kubernetes servent à répartir les conteneurs sur plusieurs machines. > - Kompose 1.38.0 convertit un compose.yaml en manifestes, mais ignore depends_on et place les variables d'environnement en clair : le résultat est un point de départ. > - Un plan de contrôle géré coûte 0,10 $ par heure chez AWS, soit environ 73 $ par mois selon notre calcul. Google Cloud applique le même tarif, mais un crédit mensuel couvre un cluster zonal ou Autopilot par compte de facturation. Azure propose un niveau gratuit, sans SLA. --- Pourquoi « Kubernetes vs Docker » est une fausse opposition Les deux outils travaillent à des étages différents. Docker fabrique une image à partir d'un Dockerfile, la publie dans un registre et lance des conteneurs sur une machine. Kubernetes, selon sa documentation, est une plateforme qui gère des charges de travail conteneurisées sur un cluster : il redémarre les conteneurs défaillants, tue ceux qui ne répondent pas au test de santé et ne leur envoie pas de trafic tant qu'ils ne sont pas prêts (présentation de Kubernetes, consultée le 1er novembre 2026). La même page précise ce que Kubernetes ne fait pas : il ne compile pas votre code et ne construit pas vos images. Dans une chaîne réelle, Docker construit l'image, un registre la stocke et Kubernetes la déploie. C'est ce que fait notre tutoriel GitHub Actions, qui publie l'image de l'API sur ghcr.io à chaque modification. L'idée que « Kubernetes a abandonné Docker » vient d'un changement réel mais étroit. Pendant des années, Kubernetes s'est appuyé sur Docker Engine par l'intermédiaire d'un adaptateur intégré appelé dockershim. Ce composant a été retiré dans la version 1.24, car Docker Engine n'implémente pas l'interface CRI que Kubernetes utilise pour parler aux moteurs de conteneurs (FAQ sur le retrait de dockershim, blog de Kubernetes, 17 février 2022). La même FAQ répond aux deux inquiétudes habituelles : - Vos images. Celles produites par docker build fonctionnent avec toutes les implémentations de CRI, comme containerd ou CRI-O. - Votre poste. Si vous utilisez Docker pour développer et tester, rien ne change. Pour garder Docker Engine sur les nœuds d'un cluster, Mirantis et Docker se sont engagés à maintenir un adaptateur séparé, cri-dockerd, selon la FAQ et le dépôt de cri-dockerd. --- Compose, Swarm, k3s, Kubernetes géré : le tableau comparatif Le choix réel se fait entre quatre façons de faire tourner des conteneurs Docker en production. Le tableau résume ce que disent leurs documentations officielles, lues le 1er novembre 2026. | Critère | Docker Compose | Docker Swarm | k3s | Kubernetes géré (EKS, GKE, AKS) | |---|---|---|---|---| | Nombre de machines | Une seule | Plusieurs | Une ou plusieurs | Plusieurs | | Fichier de description | compose.yaml | compose.yaml via docker stack deploy | Manifestes Kubernetes | Manifestes Kubernetes | | Remplacement d'un conteneur tombé | Politique restart sur la machine | Oui, y compris sur un autre nœud | Oui, comme Kubernetes | Oui | | Mise à jour progressive et retour arrière | Non, recréation du service | Oui (update_config, rollback_config) | Oui, via le Deployment | Oui, via le Deployment | | Mise à l'échelle automatique | Non documentée | Non documentée dans les pages lues | Oui, Metrics Server installé d'office | Oui, Metrics Server à ajouter sur EKS, à vérifier ailleurs | | Plan de contrôle à gérer | Aucun | Nœuds managers Swarm | Un binaire, à mettre à jour vous-même | Fourni par le cloud, facturé ou non | | Versions au 1er novembre 2026 | Compose 5.6.1 (21 octobre) | Intégré à Docker Engine 29.9.2 | Canal stable 1.36.5+k3s1 | Selon le fournisseur | Sources : Compose en production, mode Swarm, spécification deploy de Compose, documentation de k3s, mise à l'échelle horizontale, versions relevées sur les pages de publication de Docker Compose, de Docker Engine et sur le canal de mise à jour de k3s. Quelques nuances que les tableaux concurrents oublient : - Compose est fait pour une machine. La documentation de Docker indique que Compose prend en charge la production sur un seul hôte, et que la façon la plus simple de déployer reste un seul serveur. Lancé sur un nœud Swarm, Compose prévient qu'il place tous les conteneurs sur la machine courante (déploiement d'une pile sur Swarm). - Swarm demande un registre. Le même tutoriel montre que docker stack deploy ignore la clé build : l'image doit être poussée dans un registre accessible à tous les nœuds. La page du mode Swarm conseille d'ailleurs Compose si vous ne comptez pas déployer avec Swarm. - k3s est un vrai Kubernetes. Sa documentation le décrit comme une distribution entièrement conforme, livrée en un binaire de moins de 100 Mo, avec containerd, CoreDNS, Traefik et un fournisseur de volumes locaux. Elle demande au minimum 2 cœurs et 2 Go de mémoire pour un serveur (prérequis de k3s). - Metrics Server n'est pas partout. Le HorizontalPodAutoscaler lit le processeur et la mémoire des Pods via l'API de métriques, fournie en général par le module Metrics Server, qui doit être lancé séparément (mise à l'échelle horizontale). k3s le déploie d'office parmi ses composants intégrés (composants de k3s). Amazon EKS ne le déploie pas par défaut (documentation d'EKS). La documentation d'AKS indique seulement que les offres cloud peuvent le préinstaller (HPA sur AKS). Vérifiez avec kubectl get deployment metrics-server -n kube-system. Côté usage, l'écart reste net. Dans l'enquête Stack Overflow 2026, 59,1 % des 10 889 répondants à la rubrique « Cloud development » ont travaillé avec Docker ou Docker Compose dans l'année, contre 26,0 % pour Kubernetes et 13,0 % pour Podman, soit 6 440, 2 836 et 1 415 répondants (Stack Overflow Developer Survey 2026, consulté le 1er novembre 2026). !Kubernetes vs Docker en barres horizontales : dans l'enquête Stack Overflow 2026, 59,1 % des répondants ont utilisé Docker ou Docker Compose, 26,0 % Kubernetes et 13,0 % Podman --- Kubernetes vs Docker Compose : quand passer le cap Docker Compose sur un serveur suffit tant qu'une panne de cette machine est acceptable et que la charge tient sur elle. Un orchestrateur devient utile dès que l'une de ces deux conditions tombe. Nous ne donnons pas de seuil en utilisateurs ou en requêtes par seconde : aucune des sources officielles consultées n'en fixe, et les chiffres qui circulent ne sont pas sourcés. Restez sur Compose si : - Une seule machine porte l'application. Une API, sa base et un Nginx reverse proxy devant tiennent dans un fichier, avec restart: unless-stopped pour relancer un conteneur arrêté. - Une courte coupure au déploiement est tolérable. docker compose up -d recrée le service : c'est simple, mais ce n'est pas une mise à jour progressive. - Personne n'est disponible pour exploiter un cluster. Compose se maintient avec les commandes Linux de base, Docker et un peu de supervision. Les signaux généraux en faveur d'un orchestrateur (plusieurs nœuds, mises à jour sans coupure, charge variable, équipes multiples) sont détaillés dans la section quand Kubernetes vaut le coup de notre guide pour débutants. Le choix se joue ensuite entre trois options : - Docker Swarm si vous voulez garder compose.yaml et la ligne de commande Docker. Swarm répartit les services sur plusieurs machines et sait revenir en arrière après une mise à jour, mais il exige un registre et nous n'avons trouvé aucune mise à l'échelle automatique dans sa documentation. - k3s si vous avez vos propres serveurs ou quelques machines virtuelles. Vous obtenez l'API Kubernetes complète, avec Traefik, des volumes locaux et Metrics Server déjà installés, mais le plan de contrôle et ses mises à jour sont à votre charge. - Un Kubernetes géré si la disponibilité prime et que le budget suit. Le fournisseur exploite le plan de contrôle. Par défaut, un Deployment garde au moins 75 % des Pods voulus disponibles et n'en crée pas plus de 125 % pendant une mise à jour (documentation des Deployments). Un piège revient souvent : Kubernetes ne rend pas une application capable de monter en charge. Notre API stocke ses données dans un fichier SQLite. Deux copies qui écrivent dans le même fichier posent problème, quel que soit l'orchestrateur : il faut d'abord passer à PostgreSQL, comme le recommande notre tutoriel pour créer une API REST avec Node.js. Verdict : dans le duel Kubernetes vs Docker Compose, le premier critère est le nombre de machines nécessaires ; le partage de l'infrastructure entre plusieurs équipes vient ensuite. Dans tous les cas, Docker reste l'outil pour construire les images et développer en local. --- La même API en compose.yaml puis en manifestes Kubernetes L'API de nos tutoriels écoute le port 3000, refuse de démarrer sans un JWT_SECRET d'au moins 32 octets, écrit sa base SQLite dans /app/data et répond {"statut":"ok"} sur /health. L'image est celle que publie le workflow GitHub Actions, ghcr.io/<compte>/api-taches. Le compose.yaml Ce fichier reprend les choix de nos guides Docker et Nginx : port publié sur l'adresse locale seulement, secret lu dans le .env, test de santé sur /health. services: api: build: . image: ghcr.io/votre-compte/api-taches:main ports: - "127.0.0.1:3000:3000" environment: JWT_SECRET: ${JWT_SECRET:?definir JWT_SECRET dans .env} DB_PATH: /app/data/data.db volumes: - api-data:/app/data init: true healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] interval: 30s timeout: 5s retries: 3 start_period: 10s restart: unless-stopped volumes: api-data: Vingt et une lignes, une commande (docker compose up -d --build), et aucun composant à installer en dehors de Docker. Les manifestes Kubernetes Côté Kubernetes, le secret vit dans un objet séparé. La documentation prévient que les Secrets sont stockés sans chiffrement par défaut dans etcd, la base du cluster, et que toute personne ayant accès à l'API peut les lire (documentation des Secrets). Générez d'abord une valeur de 32 octets, comme dans notre guide Nginx, puis créez le Secret en ligne de commande plutôt que de l'écrire dans un fichier versionné : export JWT_SECRET=$(docker run --rm node:24-alpine node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))") kubectl create secret generic api-taches --from-literal=JWT_SECRET="$JWT_SECRET" Le fichier équivalent, à ne jamais placer dans Git avec une vraie valeur. Le texte de remplacement fait moins de 32 octets : tant qu'il n'est pas changé, l'API refuse de démarrer, ce qui est voulu. apiVersion: v1 kind: Secret metadata: name: api-taches type: Opaque stringData: JWT_SECRET: "A-REMPLACER" Puis le fichier api-taches.yaml, avec le volume, le Deployment et le Service : apiVersion: v1 kind: PersistentVolumeClaim metadata: name: api-data spec: accessModes: - ReadWriteOnce resources: requests: storage: 1Gi --- apiVersion: apps/v1 kind: Deployment metadata: name: api-taches labels: app: api-taches spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app: api-taches template: metadata: labels: app: api-taches spec: shareProcessNamespace: true securityContext: fsGroup: 1000 fsGroupChangePolicy: OnRootMismatch containers: - name: api image: ghcr.io/votre-compte/api-taches:sha-1a2b3c4 ports: - containerPort: 3000 env: - name: JWT_SECRET valueFrom: secretKeyRef: name: api-taches key: JWT_SECRET - name: DB_PATH value: /app/data/data.db volumeMounts: - name: data mountPath: /app/data readinessProbe: httpGet: path: /health port: 3000 periodSeconds: 10 livenessProbe: httpGet: path: /health port: 3000 initialDelaySeconds: 10 periodSeconds: 30 volumes: - name: data persistentVolumeClaim: claimName: api-data --- apiVersion: v1 kind: Service metadata: name: api-taches spec: selector: app: api-taches ports: - port: 80 targetPort: 3000 Appliquez-le avec kubectl apply -f api-taches.yaml, puis suivez le déploiement avec kubectl rollout status deployment/api-taches. La taille de 1 Gi est un exemple : ajustez-la. Le tag sha-1a2b3c4 remplace le commit abrégé que produit notre workflow GitHub Actions : avec un tag autre que latest, Kubernetes règle par défaut imagePullPolicy sur IfNotPresent, et un nœud qui a déjà une image main en cache ne téléchargerait pas la nouvelle (documentation des images). Un tag par commit rend chaque déploiement explicite. Si le paquet GHCR est privé, le Pod aura besoin d'un imagePullSecrets. Pour les bases de ces objets, notre guide Kubernetes pour débutants les présente un par un. Ce qui change entre les deux Quatre choix de ces manifestes découlent de SQLite et de la documentation : - replicas: 1 et strategy: Recreate. Un volume en ReadWriteOnce ne peut être monté en écriture que par un seul nœud (volumes persistants). La stratégie Recreate arrête l'ancien Pod avant de lancer le nouveau : il y a donc une courte coupure, comme avec Compose. Pour une vraie mise à jour progressive, passez à PostgreSQL et à la stratégie par défaut. - fsGroup: 1000. L'image de notre guide Docker tourne sous l'utilisateur node, d'identifiant 1000. Pour les types de volumes qui le permettent, fsGroup attribue ce groupe aux fichiers du volume pour que SQLite puisse écrire ; fsGroupChangePolicy: OnRootMismatch évite de refaire ce changement à chaque démarrage si la racine du volume est déjà correcte (contexte de sécurité). - shareProcessNamespace: true, une alternative à init: true. Nous n'avons trouvé dans les pages consultées aucun champ de Pod qui lance un processus d'init comme le fait Compose ; avec ce réglage, les conteneurs d'un même Pod voient aussi les processus et les fichiers des autres, ce qui compte si vous ajoutez un second conteneur. Or Node.js n'est pas conçu pour tourner avec le PID 1 et ne répond alors pas à des signaux comme SIGINT (bonnes pratiques de l'image Node.js). Sans correctif, chaque arrêt du Pod attendrait la fin du délai de grâce, 30 secondes par défaut (cycle de vie des Pods). Avec un espace de processus partagé, le processus du conteneur n'a plus le PID 1 (partage de l'espace de processus). L'autre solution, que recommandent aussi les bonnes pratiques de l'image Node.js, consiste à ajouter Tini au Dockerfile : RUN apk add --no-cache tini et ENTRYPOINT ["/sbin/tini", "--"], selon le README de Tini. - Deux sondes au lieu d'un test de santé. La sonde de disponibilité (readiness) décide si le Pod reçoit du trafic, la sonde de vie (liveness) le redémarre s'il échoue. Une sonde de vie trop sévère peut provoquer des redémarrages en cascade sous forte charge, prévient la documentation des sondes. | Dans compose.yaml | Dans Kubernetes | Remarque | |---|---|---| | services.api | Deployment + Service | Le Service donne une adresse stable | | ports: 127.0.0.1:3000:3000 | Service ClusterIP, port 80 vers 3000 | Exposition publique : Ingress ou Gateway | | environment + .env | Secret + secretKeyRef | Secret non chiffré par défaut dans etcd | | volumes: api-data | PersistentVolumeClaim | Suppose une classe de stockage par défaut | | healthcheck | readinessProbe et livenessProbe | Deux rôles distincts | | restart: unless-stopped | restartPolicy: Always, seule valeur permise dans un Deployment | Le kubelet relance le conteneur ; le Deployment recrée le Pod, au besoin ailleurs | | build: . | Aucun équivalent | L'image doit venir d'un registre | Comptées de la même façon, sans les lignes vides, le fichier Compose fait 21 lignes et les manifestes Kubernetes 82 avec le Secret, soit plus du triple. Dans ce match Kubernetes vs Docker Compose, ce surcoût en YAML n'est rentable que si vous avez besoin de ce qu'il apporte. --- Migrer avec Kompose : ce qu'il convertit, ce qu'il laisse Kompose, un projet officiel du groupe Kubernetes, sert à passer d'un fichier Compose à des manifestes. Son README le dit lui-même : la transformation « peut ne pas être exacte », mais elle aide beaucoup pour un premier déploiement (notre traduction, dépôt de Kompose, consulté le 1er novembre 2026). La dernière version publiée est la 1.38.0, du 15 janvier 2026, selon la page des versions. Le README annonce un rythme de sortie toutes les trois semaines : aucune version n'est pourtant parue depuis plus de neuf mois. La commande de base est la suivante : kompose convert -f compose.yaml Le tableau de conversion de Kompose et son guide d'utilisation détaillent ce qui passe et ce qui ne passe pas : | Clé Compose | Ce que produit Kompose | |---|---| | image, ports | Deployment et Service | | environment | Variables en clair dans le Deployment | | volumes | PersistentVolumeClaim de 100 Mi par défaut, avec stratégie Recreate | | healthcheck | Sonde de vie (liveness) qui exécute la commande test | | depends_on | Non applicable, ignoré | | build | Construction seulement avec --build, envoi avec --push-image | | build: args | Pas encore implémenté | Trois limites comptent pour notre API. D'abord, le secret : environment devient une variable du Deployment, donc JWT_SECRET se retrouve en clair dans un fichier YAML. Remplacez-le par un Secret avant tout commit. Ensuite, l'ordre de démarrage : depends_on n'a pas d'équivalent, et chaque service doit savoir attendre ses dépendances. Enfin, les sondes : le healthcheck de Compose devient une sonde de vie qui lance la même commande, mais aucune sonde de disponibilité n'est créée sans étiquettes propres à Kompose. Ajoutées au service api, celles-ci produisent une sonde de disponibilité HTTP sur /health : labels: kompose.service.healthcheck.readiness.http_get_path: /health kompose.service.healthcheck.readiness.http_get_port: "3000" La taille par défaut de 100 Mi et le passage du healthcheck en sonde de vie se lisent dans le code source de la version 1.38.0, l'étiquette kompose.volume.size permettant de changer la taille. Verdict : utilisez Kompose pour obtenir un brouillon, puis relisez chaque fichier. Les points ci-dessus sont ceux que nos manifestes écrits à la main traitent différemment. --- Coûts et charge d'exploitation Le logiciel est gratuit, son exploitation ne l'est pas : il faut payer le plan de contrôle géré et suivre les versions. Le rythme des versions Le projet maintient les trois dernières versions mineures, soit 1.37, 1.36 et 1.35, avec environ un an de correctifs pour chacune, suivi de deux mois en mode maintenance (page des versions et politique des correctifs, consultées le 1er novembre 2026). La 1.37, nom de code Garhwal, est sortie le 26 août 2026 avec 67 améliorations (annonce de la 1.37). | Version | Sortie | Mode maintenance | Fin de vie | |---|---|---|---| | 1.37 | 26 août 2026 | 28 août 2027 | 28 octobre 2027 | | 1.36 | 22 avril 2026 | 28 avril 2027 | 28 juin 2027 | | 1.35 | 17 décembre 2025 | 28 décembre 2026 | 28 février 2027 | La 1.38 est prévue le 16 décembre 2026, selon le calendrier de la version 1.38. Un cluster installé aujourd'hui devra donc être mis à jour au moins une fois par an, en passant d'une version mineure à la suivante. Dernier correctif relevé : 1.37.2, publié le 28 octobre 2026 sur GitHub. !Diagramme de Gantt : versions de Kubernetes 1.35, 1.36 et 1.37 avec leurs périodes de correctifs et de maintenance jusqu'à leur fin de vie, et sortie de la 1.38 prévue le 16 décembre 2026 Le prix d'un plan de contrôle géré Les trois grands clouds facturent différemment le plan de contrôle, hors machines, stockage et réseau. Tarifs relevés le 1er novembre 2026 : | Offre | Plan de contrôle | Condition | |---|---|---| | Amazon EKS, support standard | 0,10 $ par cluster et par heure | 14 premiers mois d'une version dans EKS | | Amazon EKS, support étendu | 0,60 $ par cluster et par heure | 12 mois supplémentaires | | Google Kubernetes Engine | 0,10 $ par cluster et par heure | Crédit de 74,40 $ par mois et par compte de facturation | | Azure Kubernetes Service, niveau Free | Gratuit | Sans SLA, déconseillé au-delà de 10 nœuds | | Azure Kubernetes Service, niveau Standard | Prix non relevé, illisible sur la page | Avec SLA sur le serveur d'API | | Azure Kubernetes Service, niveau Premium | Prix non relevé, illisible sur la page | SLA et support à long terme | Sources : tarifs d'Amazon EKS, tarifs de GKE et tarifs d'AKS. Google précise que son crédit gratuit équivaut à un cluster zonal ou Autopilot par mois. > Notre calcul. 0,10 $ sur 730 heures donne environ 73 $ par mois pour le seul plan de contrôle chez AWS. Une version laissée en support étendu chez EKS passe à 0,60 $, soit environ 438 $ par mois. Ne pas mettre à jour son cluster a donc un prix direct. Ce surcoût place Kubernetes géré au-dessus des offres plus simples présentées dans notre guide de l'hébergement cloud pour développeurs. Pour comparer les trois fournisseurs au-delà de Kubernetes, notre duel AWS vs Azure vs Google Cloud détaille les autres services. --- Ce que nous n'avons pas pu vérifier Cet article est une synthèse documentaire. Les points suivants restent ouverts : - L'exécution des fichiers. Le compose.yaml et les manifestes ont été validés comme YAML, pas exécutés sur Docker ni sur un cluster. La conversion par Kompose n'a pas été lancée. - Le prix des niveaux Standard et Premium d'AKS. La page de tarifs d'Azure n'affichait pas de montant lisible au moment de notre lecture. - L'avenir de Docker Swarm. La documentation de Docker le présente comme intégré à Docker Engine, mais nous n'avons trouvé aucune feuille de route officielle. - Metrics Server sur GKE et AKS. Nous n'avons trouvé aucune page officielle qui dise clairement s'il est installé d'office ; la documentation d'AKS dit seulement que les offres cloud « peuvent » le préinstaller (notre traduction). - L'arrêt de l'API avec shareProcessNamespace. La documentation dit que le processus perd le PID 1 ; que Node.js s'arrête alors proprement sur SIGTERM est notre déduction, non testée. Tini dans l'image reste l'option la plus documentée. --- Questions fréquentes Kubernetes remplace-t-il Docker ? Non, et c'est le cœur du débat Kubernetes vs Docker. Kubernetes a retiré dockershim dans la version 1.24 et ne prend plus en charge Docker Engine nativement sur ses nœuds, mais les images construites avec docker build fonctionnent avec tous les moteurs compatibles CRI, selon la FAQ officielle. Docker reste l'outil de construction et de développement local. Docker Compose peut-il servir en production ? Oui, sur une seule machine. La documentation de Docker cite les déploiements sur un seul hôte parmi les usages de Compose. Au-delà d'une machine, il faut Docker Swarm ou Kubernetes. Comment passer de Docker Compose à Kubernetes ? Kompose convertit un compose.yaml avec kompose convert -f compose.yaml. Relisez ensuite chaque fichier : les variables d'environnement y sont en clair, depends_on est ignoré et chaque volume devient une demande de stockage, selon le tableau de conversion. Quelle est la version actuelle de Kubernetes ? Au 1er novembre 2026, c'est la 1.37, sortie le 26 août 2026 et maintenue jusqu'au 28 octobre 2027, selon la page des versions. Les versions 1.36 et 1.35 sont aussi maintenues, et la 1.38 est prévue le 16 décembre 2026. --- À propos de l'auteur Abdelilah Ourti est ingénieur IA et architecte logiciel. Il conçoit et déploie des applications web et des systèmes d'IA, et publie sur NeurauTech des guides et des comparatifs destinés aux développeurs francophones. Ses autres articles sont réunis sur sa page auteur. Méthode. Synthèse documentaire fondée exclusivement sur des sources publiques consultées le 1er novembre 2026 : documentation, blog et pages de versions de Kubernetes, documentation de Docker, de Kompose et de k3s, pages de publication sur GitHub, pages de tarifs d'AWS, de Google Cloud et d'Azure, enquête Stack Overflow 2026. Les fichiers YAML ont été validés syntaxiquement mais n'ont été exécutés ni sur Docker ni sur un cluster. Les citations issues de sources en anglais sont traduites par nos soins. Aucun lien n'est affilié et aucune relation commerciale ne lie l'auteur aux éditeurs mentionnés. Une erreur ou un tarif périmé ? Écrivez à contact@neurautech.com : l'article sera corrigé et la date de mise à jour modifiée. Dernière mise à jour : 1er novembre 2026. Image d'en-tête : Tom Fisk (Pexels License). Graphiques : NeurauTech, d'après Stack Overflow et kubernetes.io.
Hyper-V で Podman + docker-compose 環境を作る 1. 概要 自分用に、Hyper-V 上に Rocky Linux の VM を作る手順をまとめます。 この VM は、GitLab コンテナを動かす土台や、VSCode の DevContainer の基盤として使うことを想定しています。職場に近いコンテナ環境(Podman + docker-compose)を手元に用意しておくのが狙いです。 本記事では、仮想スイッチの作成から、VM の作成・OS インストール・NAT 設定・Podman / docker-compose の導入までを扱います(GitLab コンテナの構築自体は別記事に分けます)。 2. Hyper-V で VM を作る なお、自動化等が可能だと思うので、コマンドで実施していますが、GUI でも設定可能な内容です。 UEFI の設定や Hyper-V の有効化など、VM 作成の根本的な部分は省きますので、ご了承ください。 2.1. 構築の準備 職場の環境は、コンテナエンジンにPodmanを使用しており、コンテナの構成管理にdocker-composeを使用し、設定内容が見える(秘伝のタレにならない)ようになっています。それに近い環境の構築を目指したいと思います。 2.1.1. イメージの入手 手元にあるのが、Rocky Linux 10.1なのでこれを使用します。 dl.rockylinux.org 職場のディストリビューション(RHEL9系)とはやや異なりますが、大きな差分にはならないと考えています。 2.1.2. docker-compose の入手 これは手順の中で実施しますが、GitHub から取得する必要があります。 docs.docker.com この手順はLegacyとして扱われているので、あまり良い方法ではありませんが、職場の環境を再現するために実施します。 というより、そもそもRHEL標準リポジトリではDockerは配布されていない(Podmanに置換された)ので、標準リポジトリでdocker-composeを組み合わせるにはこの方法しかなさそう?(Docker 社の外部リポジトリから導入は可能だが Red Hat のサポート外) access.redhat.com ということで、以下を参考に standalone の docker-compose を Podman の Docker 互換ソケット(DOCKER_HOST)に向けて使うように設定します。 fedoramagazine.org 2.2. VM 構築手順 以降のコマンドは 「管理者として実行」した PowerShell で行います。 2.2.1. 仮想スイッチの作成 今回の検証では、外部からの接続は行わないので、内部仮想スイッチを作ります。 $SwitchName = "InternalSwitch" New-VMSwitch ` -Name $SwitchName ` -SwitchType Internal # 以下は出力結果 Name SwitchType NetAdapterInterfaceDescription ---- ---------- ------------------------------ InternalSwitch Internal 仮想スイッチのIPアドレスを192.168.100.1/24設定します。 New-NetIPAddress ` -InterfaceAlias "vEthernet ($SwitchName)" ` -IPAddress 192.168.100.1 ` -PrefixLength 24 # 以下は出力結果 IPAddress : 192.168.100.1 InterfaceIndex : 38 InterfaceAlias : vEthernet (InternalSwitch) AddressFamily : IPv4 Type : Unicast PrefixLength : 24 PrefixOrigin : Manual SuffixOrigin : Manual AddressState : Tentative ValidLifetime : Infinite ([TimeSpan]::MaxValue) PreferredLifetime : Infinite ([TimeSpan]::MaxValue) SkipAsSource : False PolicyStore : ActiveStore IPAddress : 192.168.100.1 InterfaceIndex : 38 InterfaceAlias : vEthernet (InternalSwitch) AddressFamily : IPv4 Type : Unicast PrefixLength : 24 PrefixOrigin : Manual SuffixOrigin : Manual AddressState : Invalid ValidLifetime : Infinite ([TimeSpan]::MaxValue) PreferredLifetime : Infinite ([TimeSpan]::MaxValue) SkipAsSource : False PolicyStore : PersistentStore 作成後、NIC の設定を確認してください。追加したネットワークアダプタ(仮想スイッチ)が表示されれば問題ありません。 ipconfig /all # 以下は出力結果 # 該当部分のみ掲載 イーサネット アダプター vEthernet (InternalSwitch): 接続固有の DNS サフィックス . . . . .: 説明. . . . . . . . . . . . . . . . .: Hyper-V Virtual Ethernet Adapter #4 物理アドレス. . . . . . . . . . . . .: 00-15-5D-20-86-11 DHCP 有効 . . . . . . . . . . . . . .: いいえ 自動構成有効. . . . . . . . . . . . .: はい リンクローカル IPv6 アドレス. . . . .: fe80::1390:7148:87d3:67fc%38(優先) IPv4 アドレス . . . . . . . . . . . .: 192.168.100.1(優先) サブネット マスク . . . . . . . . . .: 255.255.255.0 デフォルト ゲートウェイ . . . . . . .: DHCPv6 IAID . . . . . . . . . . . . .: 637539677 DHCPv6 クライアント DUID. . . . . . .: 00-01-01-00-30-E6-D2-3A-34-5A-60-66-C2-A9 NetBIOS over TCP/IP . . . . . . . . .: 有効 2.2.1.1. 外部スイッチの追加(任意) 今回の検証は内部スイッチ+NAT だけで完結しますが、「VM を物理 LAN に直結して、ホスト以外の端末からも VM に触りたい」場合は、内部スイッチに加えて外部スイッチも作れます(不要な方は読み飛ばしてください)。 外部スイッチは、VM を物理 NIC 経由で物理 LAN へ直結します(ホストと同一セグメント)。ここでは ホストと NIC を共有する方針 で作ります。こうすると、物理 NIC が 1 枚でもホスト側の通信(インターネット)が維持され、ホスト ↔ VM も外部スイッチ経由でそのまま通ります。 参考(仮想スイッチの作成):Create a virtual switch for Hyper-V virtual machines learn.microsoft.com まず、バインドする物理 NIC 名を確認します。 Get-NetAdapter 外部スイッチを作成します。-NetAdapterName を指定すると種別は自動的に「外部」になり、-AllowManagementOS を省略すると既定でホストと共有になります。<NIC名> は上で確認した物理 NIC(有線を推奨)に置き換えてください。 New-VMSwitch ` -Name "ExternalSwitch" ` -NetAdapterName "<NIC名>" > 外部スイッチの作成時は、その NIC のネットワーク接続が一時的に中断されます(作成後にホスト側 vEthernet (ExternalSwitch) が IP を取り直します)。 外部スイッチの使い方は、目的によって次の 2 通りに分かれます。なお、VM のアダプター操作(Add-VMNetworkAdapter / Connect-VMNetworkAdapter)は VM を作成した後([2.2.3. VM の作成](#223-vm-の作成) 以降)に実行してください($VmName も [2.2.2.1. VMの情報](#2221-vmの情報) で定義します)。 (a) 内部スイッチと両立させる(VM に NIC を 2 枚持たせる) 内部スイッチ側(192.168.100.10)はホスト ↔ VM の管理用として残し、外部スイッチ側でインターネット/LAN に出ます。VM 作成後、2 枚目のアダプターを追加します。 Add-VMNetworkAdapter ` -VMName $VmName ` -SwitchName "ExternalSwitch" VM 側では、外部側の NIC を DHCP(デフォルト経路を持つ側)、内部側の NIC を静的 IP(デフォルトゲートウェイなし)に設定します。 ※デフォルトルートを 2 本持たせると経路が二重化して不安定になります。デフォルトゲートウェイは外部側の 1 本だけにしてください。 (b) 外部スイッチだけにする(内部スイッチをやめる) VM の接続先を外部スイッチへ切り替えます。アダプター名は Get-VMNetworkAdapter -VMName $VmName で確認できます。 Connect-VMNetworkAdapter ` -VMName $VmName ` -Name "ネットワーク アダプター" ` -SwitchName "ExternalSwitch" VM 側の IP は物理 LAN に合わせて DHCP で取り直します(本手順の静的 IP 192.168.100.10 は内部スイッチ専用なので、そのままでは通りません)。 ※(a)(b) いずれも、インターネットは外部スイッチ直結で出られるので、[2.4. NAT 設定](#24-nat-設定) は不要です。 2.2.2. VM 設定用のシェル変数の準備 2.2.2.1. VMの情報 以降で使用するシェル変数を準備しておきます。 $VmName = "Rocky-Podman" $ISO = "C:\ISO\RockyLinux10.1.iso" # iso ファイルがあるパスに書き換えること $DefaultSaveFolder = (Get-VMHost).VirtualHardDiskPath $VhdFullPath = "$DefaultSaveFolder\$VmName.vhdx" 2.2.2.2. メモリ割り当て量 VMに割り当てるメモリサイズもシェル変数として準備しておきます(2MB の倍数にしないとエラーになります)。 なお、メモリのサイズについては、Windows 11 の最低要件である4GBを残すため、$MaxVmMemory は、$TotalHostMemory から4GB引いた値を割り当てています。 また、$MinVmMemoryを小さくしすぎると、メモリひっ迫時に VM が設定値通りの最小 RAM 付近まで搾られる可能性がありますので、最小とはいえ、なるべくシステムが落ちないような値を設定しましょう。 learn.microsoft.com $TotalHostMemory = (Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory $MaxVmMemory = [math]::Floor(($TotalHostMemory - 4GB) / 2MB) * 2MB $MinVmMemory = 4GB # 要件に応じて要調整 やや分かりにくいので、計算例を書いておきます(RAM: 32GB の場合)。 $MaxVmMemory: 32GB - 4GB = 28GB → ÷2MB = 14,336 個(今回は割り切れているので、Floor後も変化なし) Floor → 14,336 個 ×2MB = 28GB 2.2.2.3. CPU コア数 VM に割り当てる CPU のコア数も割り当てます。 最近の Intel CPU は P コア/E コア混在で、どのコアを使うか手動指定できないこともあるので(私が使用しているのは Ryzen 7 5700X ですが)、全論理コアを割り当て、使用率設定でリソースを共有することで、E コアだけで動く(遅くなる)のを避けられるようにしようと思います。 $TotalHostCores = (Get-ComputerInfo).CsNumberOfLogicalProcessors 2.2.2.4. 仮想ハードディスク 仮想ハードディスクのオブジェクトを生成します。なお、動的 VHDX にするので、サイズを大きめに取っても即座に占有はしません。 $VHD = New-VHD ` -Path $VhdFullPath ` -SizeBytes 128GB ` -Dynamic 2.2.3. VM の作成 これまでに準備したシェル変数をもとに、仮想マシンを作成します。 New-VM ` -Name $VmName ` -MemoryStartupBytes 4GB ` -Generation 2 ` -SwitchName $SwitchName ` -VHDPath $VHD.Path 2.2.4. CPU 使用率の割り当て 少数コアを 100% 占有させるのではなく、使用率(優先度) で共有します。最低 50%・最大 80% を割り当てます。 Set-VMProcessor ` -VMName $VmName ` -Count $TotalHostCores ` -Reserve 50 ` -Maximum 80 ※-Reserve/-Maximum は「常に占有」ではなく、スケジューリング(CPU を時間で分け合う仕組み)上の優先度の下限(必要時の最低保証)・上限を指します。 2.2.5. 動的メモリの設定 ホストと VM でメモリを効率的に融通するために、動的メモリを使用します。 Set-VMMemory ` -VMName $VmName ` -DynamicMemoryEnabled $true ` -MinimumBytes $MinVmMemory ` -MaximumBytes $MaxVmMemory ` -Buffer 30 ※-Buffer 30は、VM が必要とする量に対し 30%の空きを確保するよう動的に増やすこと。大きすぎるとホストのメモリが枯渇するので注意。 2.2.6. Secure Boot の有効化 OS イメージの正規性を検証でき、起動前の不正コード実行を防ぐことが可能です。今回の検証ではあまり意味はないおと思いますが、有効化しておきます。 Linux ディストリビューションでは MicrosoftUEFICertificateAuthority テンプレートの選択が必須です。間違うと起動しません。 Set-VMFirmware ` -VMName $VmName ` -EnableSecureBoot On ` -SecureBootTemplate MicrosoftUEFICertificateAuthority 2.2.7. チェックポイント種別の設定 現状では、ゲストエージェント(hyperv-daemons)を導入しないので、ここでは標準チェックポイントを使用します。 Set-VM ` -Name $VmName ` -CheckpointType Standard 2.2.8. ISO のマウントとブート順の設定 DVD-ISO をマウントします。 Add-VMDvdDrive ` -VMName $VmName ` -Path $ISO DVD ドライブを最優先で起動するよう、ブート順を設定します。 Set-VMFirmware ` -VMName $VmName ` -FirstBootDevice (Get-VMDvdDrive -VMName $VmName) 2.2.9. OS 起動前のエクスポート(バックアップ) 初回起動やOSのインストールに失敗した場合のため、起動前の状態(やり直しが効く状態)をエクスポートしておきます。 $ExportPath = "C:\EXAMPLE" # 任意のパスを指定してください Export-VM ` -Name $VmName ` -Path $ExportPath # ファイルが出力されたことを確認する explorer $ExportPath 2.3. OS インストール手順 以下のコマンドで VM を起動します。 vmconnect.exe localhost $VmName Start-VM -Name $VmName 2.3.1. インストーラーでの設定内容 インストーラーで次のような設定を行います。 | 設定項目 | 本手順の値 | 補足 | | ------------- | ------------------------------- | ---------------------- | | 言語 / キーボード | 日本語 or English(任意) | GUI は入らないので好みで可 | | 時刻と日付 | アジア/東京 | — | | ソフトウェアの選択 | 最小限のインストール(Minimal Install) | 軽量化のために GUI を入れない。アドオンも選択しない。 | | インストール先 | 対象ディスクを選択 → 完了(自動構成) | 単一ディスクを使用 | | KDUMP | 無効化(任意) | メモリ節約。チェックを外す | | ネットワークとホスト名 | [手順 2.3.2.](#232-ネットワークとホスト名) | 静的 IP | | root アカウント | [手順 2.3.3.](#233-root-アカウント) | root SSH は禁止 | | ユーザーの作成 | [手順 2.3.4.](#234-ユーザーの作成) | wheel(管理者)付き | 2.3.2. ネットワークとホスト名 まず、画面左下のホスト名に rocky-podman と入力して適用します。 その後、右上の Ethernet(eth0)トグルをオンにし、以降の手順でインタフェースの設定を行います。 1. 設定 → IPv4 Settings タブ: 2. Method:Manual 3. Add で以下を入力: - Address:192.168.100.10 - Netmask:255.255.255.0(または 24) - Gateway:192.168.100.1 ([2.2.1. 仮想スイッチの作成](#221-仮想スイッチの作成) で設定した IP アドレスを使用) - DNS サーバー:8.8.8.8(NAT 経由で到達できるもの) 4. Save → 左上の「完了」ボタンを押す 2.3.3. root アカウント 「root アカウントを有効化」を選び、強いパスワードを設定します(「良好」以上)。 この検証ではあまり関係ありませんが、「総当たり攻撃で root を直接狙われないようする」、ということを心がけようと思うので、root の SSH ログインは無効にします。 よって、「パスワードによる root SSH ログインを許可」のチェックは外しましょう。 2.3.4. ユーザーの作成 以下のように設定します。 1. フルネーム:人間が読むための名称。任意に入力する(ユーザー名と同じでも良い) 2. ユーザー名:ssh や su などで使用。英小文字・空白なし・英字始まりで、任意に入力する 3. 「ユーザーアカウントに管理者権限を付与する(wheel グループに加える)」にチェックを入れる 4. [2.2.3. root アカウント](#223-root-アカウント)と同様に、強いパスワードを設定する 5. 左上の「完了」ボタンを押す 2.3.5. インストール開始と再起動 設定の抜けがないか見直し、右下の「インストールの開始」を押下します。 なお、SMT(同時マルチスレッディング)に関する警告が出ても、本用途では、非本番・限定ネットワークなので無視します。 完了後、「システムの再起動」を押下してください。 また、ISO からの再起動ループを避けるため、再起動後に、以下のコマンドで ISO を外しておきます。 Set-VMDvdDrive -VMName $VmName -Path $null 2.3.6. 初回ログイン 次のような画面が表示されますので、ユーザー名とパスワードを入力し、ログインします。 rocky-podman login: # 設定したユーザー名を入力 Password: # 設定したパスワードを入力 動作確認として次のコマンドを入力します。 cat /etc/rocky-release すると、次の内容が出力されるはずです。 Rocky Linux release 10.1 (Red Quartz) 2.3.7. ホスト(Windows)からの SSH 疎通確認 最小構成でも OpenSSH サーバーは既定で起動し、firewalld も SSH を許可しているはずですが、念のため確認しておきます。 # SSH サーバーが稼働中か systemctl is-active sshd # active なら稼働中 # SSH サーバーが自動起動するか systemctl is-enabled sshd # enabled なら自動起動 # 22番ポートを待ち受けているか ss -tlnp | grep ':22' # 以下は出力結果 # :22 の LISTEN 行が出れば問題なし LISTEN 0 128 0.0.0.0:22 0.0.0.0:* ← IPv4・全インターフェースで :22 を待ち受けている LISTEN 0 128 [::]:22 [::]:* ← IPv6・全インターフェースで :22 を待ち受けている # firewalld が動作しているか sudo firewall-cmd --state # パスワードを尋ねられたら入力 # 稼働中の場合、running が出力される # ファイアウォールで許可(開放)されているサービスの一覧を確認する sudo firewall-cmd --list-services # 出力例:cockpit dhcpv6-client ssh # 上記の出力の場合、一番左に ssh があるため、SSHが使用可能である。 # 自分のIPが 192.168.100.10 になっているか ip -brief addr 上記で問題ないことを確認できたら、ssh を実行しましょう。 ssh <ユーザー名>@192.168.100.10 SSH 接続ができたら Hyper-V のコンソールを閉じます。 Stop-Process -Name vmconnect 2.4. NAT 設定 [2.2.1. 仮想スイッチの作成](#221-仮想スイッチの作成) で内部スイッチとホスト側 IP(192.168.100.1)は作成済みです。しかし、内部スイッチ単体では VM はインターネットに出られません(通信できるのは「ホスト ↔ VM」だけで、物理 NIC には直接つながらない)。 そこで、ホストが VM の通信を外へ中継する(送信元を書き換えて転送する=SNAT) NAT を作成します。 これで、以下の経路でインターネットにつながるようになります。 VM → ゲートウェイ(192.168.100.1=ホスト) → NAT → 物理NIC → インターネット 参考(NAT ネットワークの考え方):Set up a NAT network learn.microsoft.com 2.4.1. NAT の作成 以降のコマンドは 「管理者として実行」した PowerShell で行います。 内部ネットワークの範囲(192.168.100.0/24)に対する NAT を作成します。 New-NetNat ` -Name "HyperV-NAT" ` -InternalIPInterfaceAddressPrefix 192.168.100.0/24 作成できたか確認します。 Get-NetNat # 以下は出力結果 # Active が True、プレフィックスが 192.168.100.0/24 であれば OK Name : HyperV-NAT InternalIPInterfaceAddressPrefix : 192.168.100.0/24 Store : Local Active : True 2.4.2. 疎通確認 VM に戻り、「ホスト到達 → インターネット到達(名前解決)」の順に確認します。 まず、ゲートウェイ(ホスト)に届くか。これが通れば NAT の土台(内部スイッチ+ホスト IP)は正常です。 ping -c 3 192.168.100.1 # ホスト(ゲートウェイ)に届けば、内部スイッチ側は OK 次に、名前解決(DNS)ができるかを確認します。 ping -c 3 rockylinux.org # 応答があれば OK 2.5. 初期設定とPodman導入 2.5.1. システム更新 インターネットに接続できることを確認できたら、システムを更新します。 sudo dnf update -y 2.5.2. Hyper-V 連携デーモン この手順は任意です。 時刻同期・ホストからの正常シャットダウン・IP 情報の通知・(将来の)運用チェックポイントの有効化に効くので、Hyper-V 連携デーモン(hyperv-daemons)を導入します。 sudo dnf install -y hyperv-daemons デーモンの有効化および起動を行います。 sudo systemctl enable --now hypervkvpd hypervvssd hypervfcopyd [2.2.7. チェックポイント種別の設定](#227-チェックポイント種別の設定)では、標準チェックポイントを選択しましたが、運用チェックポイントに切り替えたい場合は、PowerShell で以下のコマンドを実行します。 Set-VM ` -Name $VmName ` -CheckpointType ProductionOnly ゲスト サービス インターフェイスも有効化します。 Get-VMIntegrationService -VMName $VmName # 出力例 VMName Name Enabled PrimaryStatusDescription SecondaryStatusDescription ------ ---- ------- ------------------------ -------------------------- Rocky-Podman ゲスト サービス インターフェイス False OK Rocky-Podman ハートビート True OK Rocky-Podman キー値ペア交換 True OK Rocky-Podman シャットダウン True OK Rocky-Podman 時刻の同期 True OK Rocky-Podman VSS True OK Enable-VMIntegrationService ` -VMName $VmName ` -Name "ゲスト サービス インターフェイス" 2.5.3. 基本ツールと Podman の導入 (VM に戻り、)次のパッケージを導入します。任意に追加しても良いです。 sudo dnf install -y podman git curl tar バージョンを確認します。 podman --version # 表示されれば OK 2.5.4. チェックポイント作成(Podman導入完了) ここまでの状態を保存したいので、念のため、チェックポイントを作成しておきます。PowerShell で以下のコマンドを実行してください。 Checkpoint-VM ` -Name $VmName ` -SnapshotName "Podman インストール完了" 2.5.5. Docker API 互換ソケットの作成 これは「Podman を Docker のふりをさせて、docker-compose から使えるようにする」ための手順です。 fedoramagazine.org ユーザーソケットを有効化します。 systemctl --user enable --now podman.socket # 出力例 Created symlink '/home/ユーザー名/.config/systemd/user/sockets.target.wants/podman.socket' → '/usr/lib/systemd/user/podman.socket'. ユーザーソケットは、docker-compose が命令を送る“受け口”(差込口)となります。 --user は「そのユーザー」専用の systemd で開くことを意味します。Podman 自体は一般ユーザー(=rootless)のまま動くので、特別な"rootless化"は不要です。ここで行っているのは、docker-compose がつなぐための「Docker 互換ソケット」を、そのユーザー権限で有効化することです。 ソケット作成後、DOCKER_HOST 変数を設定します。 echo 'export DOCKER_HOST=unix://${XDG_RUNTIME_DIR}/podman/podman.sock' >> ~/.bashrc source ~/.bashrc 実行後、状態を確認します。 systemctl --user status podman.socket # 出力例 # Active: active (listening) になっていれば OK podman.socket - Podman API Socket Loaded: loaded (/usr/lib/systemd/user/podman.socket; enabled; preset: disabled) Active: active (listening) since Sun 2026-10-25 19:40:33 JST; 7min ago rootless のユーザーサービスは、既定ではログアウトで止まるので、ログアウト後もコンテナが動くようにします。 loginctl enable-linger $USER 2.5.6. docker-compose の導入 事情は [2.1.2. docker-compose の入手](#212-docker-compose-の入手) にも書きましたが、Docker 公式の standalone インストール手順に従い、GitHub から docker-compose を入手します。 docs.docker.com 以下のコマンドを実行します。 # バージョン確認 COMPOSE_VER=$(curl -s https://api.github.com/repos/docker/compose/releases/latest | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') && echo $COMPOSE_VER # ダウンロード実行 sudo curl -L "https://github.com/docker/compose/releases/download/${COMPOSE_VER}/docker-compose-linux-x86_64" -o /usr/local/bin/docker-compose # 実行権限の付与 sudo chmod +x /usr/local/bin/docker-compose 動作確認としてバージョンを確認します。 docker-compose --version 先ほど設定したソケットを使って、コンテナエンジン(Podman)に問い合わせ可能かを確認します。 echo $DOCKER_HOST # unix:///run/user/1000/podman/podman.sock docker-compose ls # エラーにならず、空のプロジェクト一覧が出れば OK # NAME STATUS CONFIG FILES # 出力なし 2.5.7. チェックポイント作成(Docker Compose導入完了) ここまでの状態を保存したいので、念のため、チェックポイントを作成しておきます。PowerShell で以下のコマンドを実行してください。 Checkpoint-VM ` -Name $VmName ` -SnapshotName "Docker Compose インストール完了"
devcontainer のビルドが「--allow=fs.read」で止まるときの回避策(features を使っていなくても起きる) Docker Desktop を更新したあと、いつもどおり VS Code で WSL 環境の devcontainer を開いたら、ビルドがはじまった直後に失敗しました。Dockerfile も compose.yml も変えていません。ログを見ると、使っていないはずの features のファイル名が出ています。 additional privileges requested: pass "--allow=fs.read=/tmp/devcontainercli-<user>/container-features/0.89.0-1792734723773/Dockerfile-with-features" to grant requested privileges AI に聞くと「features を Dockerfile に移しましょう」と返ってきましたが、そもそも devcontainer.json に features は書いていません。調べると、Docker 側のセキュリティ修正と Dev Containers の作りがぶつかって起きていたようです。 この記事では、原因と回避策をまとめます。上記の additional privileges requested の行がログに出ていれば、同じ原因の可能性があります。 結論 ホストに環境変数を 1 つ設定し、devcontainer をリビルドします。 export BUILDX_BAKE_ENTITLEMENTS_FS=0 features を使っていなくても、devcontainer.json で Docker Compose を使い、サービスを Dockerfile からビルドしていれば起きます。 この環境変数は、作業ディレクトリの外のファイルを読み書きするときの確認を無効にします。Docker Compose の修正版が出たら削除してください。 検証環境 項目 内容 ホスト WSL の Ubuntu Docker Docker Desktop 4.96.3(Engine 29.9.2) Docker Buildx 0.38.1 Docker Compose 5.6.1 VS Code 1.126.0(Dev Containers CLI 0.469.0) devcontainer の構成 Docker Compose、build: で Dockerfile からビルド、features なし エラーの読み方 ログは長いですが、見るのは次の 3 か所だけです。 Start: Run: docker compose --project-name app -f /home/<user>/apps/app/.devcontainer/compose.yml -f /tmp/devcontainercli-<user>/docker-compose/docker-compose.devcontainer.build-1792734723775.yml build => [internal] load local bake definitions => => reading from stdin 679B additional privileges requested: pass "--allow=fs.read=/tmp/devcontainercli-<user>/container-features/0.89.0-1792734723773/Dockerfile-with-features" to grant requested privileges Dev Containers は、プロジェクトの compose.yml に加えて、/tmp に作った 2 つ目の Compose ファイルを -f で重ねて docker compose build を実行しています。 load local bake definitions は、docker compose build が中で Docker Buildx の bake を使ってビルドしていることを示しています。 bake が /tmp の Dockerfile-with-features を読む許可を求めて、止まっています。 この後に続く at Dp (...devContainersSpecCLI.js...) のようなスタックトレースは、Dev Containers の CLI が「docker compose build が失敗した」と報告しているだけです。原因は書かれていないので、読み飛ばしてかまいません。 AI の見立ては外れた 原因にたどり着く前に、AI の見立てが外れました。ログだけを見れば、もっともらしい見立てでした。 AI が挙げた原因は「features を使っているから」です。エラーに出ているファイル名が Dockerfile-with-features なので、そう読むのは自然です。ただ、devcontainer.json に features は書いていないので、この見立ては外れでした。 そこで、別の AI に、Dev Containers の CLI のソースを確かめてもらいました。features がないときに足される処理を読むと、入っていたのはイメージにラベルを付ける 1 行でした。何が足されているかは、次の節で説明します。 なぜ起きるか bake とは bake(docker buildx bake)は、Docker Buildx のビルド機能の 1 つです。ビルドの設定をコマンドの引数で並べる代わりに、ファイルに書いておき、複数のイメージをまとめて(並行して)ビルドできます(Docker のドキュメント「Bake」)。設定ファイルは HCL か JSON で書くのが基本で、Compose ファイルをそのまま読むこともできます(Building with Bake from a Compose file)。 target "app" { context = "." dockerfile = "Dockerfile" tags = ["myapp:latest"] } この記事で bake が関係するのは、docker compose build が中で bake を使っているからです。Compose は、BuildKit が有効で Buildx のプラグインが入っていれば、compose.yml のビルドの設定を bake 用の JSON に変換します。その JSON を docker buildx bake --file - --progress rawjson に標準入力で渡して、ビルドします(docker/compose の build_bake.go)。エラーのログにあった load local bake definitions と reading from stdin 679B は、この JSON を受け取った bake の出力です。 つまり、docker compose build を実行しているつもりでも、実際にビルドの可否を判断しているのは bake です。bake の確認に引っかかれば、Compose のビルドも止まります。Compose が渡している設定は、docker compose build --print で表示できます(docker compose build のリファレンス)。 Buildx が、作業ディレクトリの外のファイルを読むときに許可を求めるようになった bake には「エンタイトルメント」という仕組みがあります。ビルドが作業ディレクトリの外のファイルを読み書きするときは、--allow fs.read=<パス> で許可するか、確認のプロンプトで許可しないと進みません(docker buildx bake のリファレンスの --allow)。他人のリポジトリにある bake の定義を実行したときに、手元のファイルを勝手に読まれないようにするためのものです。 ところが、Buildx v0.21.0 から v0.37.1 までは、--progress=rawjson で実行するとこの確認が飛ばされていました。これは脆弱性として報告され(GHSA-gwr2-q96m-6682)、2026 年 9 月 30 日の Buildx v0.37.2 で修正されました。 Docker Compose は、bake を必ず --progress rawjson で呼び出します。今まで確認が飛ばされていたので問題になりませんでしたが、v0.37.2 以降は確認が効くようになりました。しかも Compose は、ビルドコンテキストには読み取りの許可を付けていましたが、Dockerfile には付けていませんでした(docker/compose#14285)。そのため、Dockerfile がビルドコンテキストの外にあると、Compose のビルドが止まるようになりました。 Docker Desktop を更新しただけで、設定を何も変えていないのに起きたのはこのためです。 Dev Containers は、features がなくても /tmp に Dockerfile を作る Dev Containers は、Compose のサービスを Dockerfile からビルドするとき、元の Dockerfile をそのまま使いません。元の Dockerfile の前後に数行を足したものを、/tmp/devcontainercli-<user>/container-features/<バージョン>-<数字>/Dockerfile-with-features に書き出します。そして 2 つ目の Compose ファイルで、dockerfile: をこのファイルに差し替えてビルドします(devcontainers/cli の dockerCompose.ts)。 features を使っていれば、足される数行に features のインストール処理が入ります。features を使っていなくても、次の処理は足されます(devcontainers/cli の containerFeatures.ts)。 FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage LABEL devcontainer.metadata="..." devcontainer.json の設定を、イメージのラベルとして焼き込んでいます。ファイル名に features と付いているのは、features 用の仕組みを使い回しているからです。features を使っているかどうかとは関係ありません。 まとめると、次の 2 つが重なって、ビルドが止まっています。 devcontainer を使わなくても再現できる 原因がこの 2 つの組み合わせなら、Dev Containers を使わなくても、Dockerfile をビルドコンテキストの外に置くだけで同じエラーになるはずです。docker/compose#14285 の再現手順で試しました。Compose の修正はまだリリースされていないので、今の Compose でも再現します。 mkdir -p /tmp/outside && echo 'FROM alpine' > /tmp/outside/Dockerfile mkdir -p /tmp/repro && cd /tmp/repro cat > compose.yaml <<'YAML' services: app: build: context: . dockerfile: /tmp/outside/Dockerfile YAML 環境変数なしでビルドすると、devcontainer のときと同じ行で止まります。 $ env -u BUILDX_BAKE_ENTITLEMENTS_FS docker compose build [+] Building 0.0s (1/1) FINISHED => [internal] load local bake definitions 0.0s => => reading from stdin 458B 0.0s additional privileges requested: pass "--allow=fs.read=../outside/Dockerfile" to grant requested privileges BUILDX_BAKE_ENTITLEMENTS_FS=0 を付けると通ります。 $ BUILDX_BAKE_ENTITLEMENTS_FS=0 docker compose build [+] Building 0.2s (7/7) FINISHED => [internal] load local bake definitions 0.0s => => reading from stdin 458B 0.0s => [internal] load build definition from Dockerfile 0.0s (中略) ✔ Image repro-app Built 試し終わったら rm -rf /tmp/outside /tmp/repro で削除してください。 回避策 エラーメッセージは --allow=fs.read=... を付けるよう求めていますが、docker compose build は Dev Containers が組み立てて実行するので、利用者が引数を足すことはできません。代わりに、bake のファイルシステムの確認を無効にする環境変数 BUILDX_BAKE_ENTITLEMENTS_FS を 0 にします。 この環境変数は、Dev Containers が docker compose を実行するプロセスに届いている必要があります。 BUILDX_BAKE_ENTITLEMENTS_FS とは BUILDX_BAKE_ENTITLEMENTS_FS は、bake のエンタイトルメントのうち、ファイルシステムに関するもの(fs・fs.read・fs.write)を確認するかどうかを切り替える Buildx の環境変数です。この環境変数を読むのは Buildx です。Compose は bake を子プロセスとして起動するので、Compose を実行するプロセスに設定しておけば Buildx まで届きます。 値ごとの動きは次のとおりです(buildx の bake/entitlements.go)。 値 動き 設定しない 確認する(既定) 0・false など ファイルシステムの確認をしない 1・true など 確認する no・off など、上記以外 許可が必要なビルドのときに failed to parse BUILDX_BAKE_ENTITLEMENTS_FS value のエラーで止まる 値は Go の strconv.ParseBool で読まれるので、使えるのは、確認しない 0・f・F・false・FALSE・False と、確認する 1・t・true などだけです。no や off は使えません。 この環境変数は、Docker のドキュメントのビルド用の環境変数の一覧には載っていません。Buildx のソースと、そこから出るメッセージ(To disable filesystem entitlements checks, you can set BUILDX_BAKE_ENTITLEMENTS_FS=0)に出てくるだけです。 シェルの設定ファイルに書く VS Code(Dev Containers)が docker compose を実行するマシン(WSL なら Ubuntu)の ~/.bashrc に追記します。 export BUILDX_BAKE_ENTITLEMENTS_FS=0 追記したら、コマンドパレットから「Dev Containers: Rebuild Container」を実行します。私の環境(WSL に接続した VS Code)では、VS Code を再起動しなくても、リビルドをもう一度実行するだけでエラーが消えました。 BUILDX_BAKE_ENTITLEMENTS_FS=0 にしたときの影響 無効になるのはファイルシステムの確認だけ 0 にして無効になるのは、bake を実行したディレクトリ(作業ディレクトリ)の外のファイルを読み書きするときの確認です。network.host や security.insecure など、ほかのエンタイトルメントの確認は残ります。 無効になる確認には、読み取り(fs.read)だけでなく書き込み(fs.write)も含まれます。 読み取り:ビルドコンテキスト、Dockerfile、secrets に file: で指定したファイル、SSH の鍵、ローカルのキャッシュなど 書き込み:ビルドの結果をローカルのディレクトリに書き出す出力先や、キャッシュの書き出し先など これらが作業ディレクトリの外を指していても、エラーにも確認のプロンプトにもならず、そのまま進みます。 有効範囲は、環境変数が届くすべてのビルド ~/.bashrc に書くと、そのシェルから起動したプロセスのビルドすべてに効きます。Dev Containers の /tmp の Dockerfile だけを許可するのではありません。ターミナルで実行する docker compose build や docker buildx bake も、同じように確認なしで進みます。 たとえば、他人のリポジトリの docker-bake.hcl にホームディレクトリの ~/.ssh をビルドコンテキストにする設定が書かれていたり、compose.yml に ~/.aws/credentials を secret として読む設定が書かれていたりしても、気付かないままビルドできてしまいます。Compose のビルドについては、Buildx v0.37.2 より前と同じように、ファイルシステムの確認が飛ばされる状態に戻ります。 環境変数をいつ削除するか 根本の修正は、Compose が Dockerfile のパスにも読み取りの許可を付けるようにする docker/compose#14325 です。2026 年 10 月 12 日にマージされました。ただ、執筆時点の最新版 v5.6.1(10 月 21 日)には入っていません。VS Code 側の microsoft/vscode-remote-release#11896 も、執筆時点では open のままです。修正を含む Compose がリリースされたら、環境変数を削除してもう一度「Rebuild Container」を実行し、エラーが出ないことを確かめてください。 まとめ エラーの原因は、Buildx v0.37.2 で bake のファイル読み取りの確認が効くようになったことと、Dev Containers が /tmp に Dockerfile を作ってビルドすることの組み合わせです。 features を使っていなくても、Compose で Dockerfile からビルドしていれば起きます。 回避策として BUILDX_BAKE_ENTITLEMENTS_FS=0 を設定し、devcontainer をリビルドします。 この 1 行は、環境変数が届くすべてのビルドで、作業ディレクトリの外のファイルを読み書きするときの確認を無効にします。Compose の修正版が出たら削除してください。 参考 GHSA-gwr2-q96m-6682: Bake filesystem entitlement consent is skipped under --progress=rawjson(docker/buildx) Buildx v0.37.2 のリリース(docker/buildx) docker/buildx の bake/entitlements.go Build fails with fs.read entitlement error when dockerfile is outside the build context(docker/compose#14285) fix(bake): grant permissions for configured build inputs(docker/compose#14325) Docker Compose v5.6.1 のリリース(docker/compose) docker/compose の pkg/compose/build_bake.go Buildx v0.37.2 breaks compose builds(microsoft/vscode-remote-release#11896) devcontainers/cli の src/spec-node/containerFeatures.ts devcontainers/cli の src/spec-node/dockerCompose.ts Bake(Docker) Building with Bake from a Compose file(Docker) docker buildx bake(Docker) docker compose build(Docker) Build variables(Docker) strconv.ParseBool(Go)
Monitoring a Flask App on EC2 with Grafana Alloy, Prometheus, and Loki --- title: "Monitoring a Flask App on EC2 with Grafana Alloy, Prometheus, and Loki" published: true tags: observability, grafana, prometheus, aws --- Monitoring a Flask App on EC2 with Grafana Alloy, Prometheus, and Loki *IT 3200 · Jayden Backus 1. Introduction In an earlier module I put a small Flask app in a Docker container and ran it on an AWS EC2 instance. It shows one page, and gunicorn serves it on port 3000. A public IP draws attention fast. Three minutes after I started the app on a new instance, my logs showed requests for /api/v1/version, a path my app has never had. Something was scanning for exposed services. Before this project I could not have seen that. I would have learned of an outage only when someone told me, and to read a log I had to SSH in. I wanted to know, without logging in, whether the app is up and fast, whether the server is healthy, and what happened when something failed. I also wanted the system to warn me. 2. Why I chose these tools I used four open-source tools: - Grafana Alloy collects metrics and logs and forwards them. - Prometheus stores metrics and checks alert rules. - Loki stores logs, indexing only their labels. - Grafana draws dashboards from both. Alloy was the key choice. Many tutorials run three collectors: node_exporter for host metrics, cAdvisor for container metrics, and Promtail for logs. Grafana's Promtail migration guide says Promtail reached end of life on March 2, 2026, and that new work happens in Alloy. Alloy also contains node_exporter and cAdvisor as built-in components, so one agent and one config file replace all three. On a 2 GB server, that matters. Prometheus and Loki fit together. Both label data the same way, so service="app" finds the app's metrics and its logs. A one-line library, prometheus-flask-exporter, makes Flask speak Prometheus's format. Loki's label-only index keeps it far lighter than Elasticsearch. Limitations. I host it all myself, on the same server as the app, so if the server dies, so does its monitoring. Alloy needs privileged access (see section 6). Alerts fire but don't page anyone yet; that needs Alertmanager. What I rejected. AWS CloudWatch needs no servers, but its container features target ECS and EKS rather than plain Docker, it bills per custom metric, and it ties me to AWS. The Elastic Stack searches logs well but would strain a 2 GB server. 3. Installation and configuration !Architecture diagram Alloy scrapes the app's /metrics every 15 seconds, reads host and container statistics, and tails every container's logs through the Docker socket. It pushes metrics to Prometheus and logs to Loki. Grafana queries both. 1. Launch EC2. I used Amazon Linux 2023 on a t3.small (2 GB; five containers won't fit in a t3.micro) with a 20 GiB disk. The security group allows SSH (22) and Grafana (3001) from my IP only, and the app (3000) from anywhere. Lesson learned: key pairs belong to one region. My old key didn't exist in mine, so SSH refused me. I imported my public key (ssh-keygen -y -f key.pem) under Key pairs → Import key pair and relaunched. 2. Install Docker. Amazon Linux's Docker package lacks the Compose and Buildx plugins, so I added them as Docker's Compose install guide describes: sudo dnf install -y docker git && sudo systemctl enable --now docker P=/usr/local/lib/docker/cli-plugins && sudo mkdir -p $P sudo curl -fsSL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o $P/docker-compose BX=$(curl -fsSI https://github.com/docker/buildx/releases/latest | grep -i '^location' | sed 's#.*/tag/##' | tr -d '\r') sudo curl -fsSL https://github.com/docker/buildx/releases/download/$BX/buildx-$BX.linux-amd64 -o $P/docker-buildx sudo chmod +x $P/* 3. Start the stack. git clone https://github.com/jaydenbackus/it-3200-m6.git && cd it-3200-m6 cp .env.example .env # then set GRAFANA_ADMIN_PASSWORD to a long random value chmod 600 .env sudo docker compose up -d --build Grafana loads its data sources and dashboard from files at startup, following its provisioning docs, so nothing needs clicking. The Alloy config reads like a pipeline: each component's output feeds the next. Here is the log path: loki.source.docker "containers" { host = "unix:///var/run/docker.sock" targets = discovery.docker.containers.targets relabel_rules = discovery.relabel.container_logs.rules forward_to = [loki.process.redact.receiver] } // Blank out anything that looks like a credential before Loki stores it loki.process "redact" { stage.replace { expression = "(?i)(?:password|passwd|secret|token|api[_-]?key)=(\\S+)" replace = "<redacted>" } forward_to = [loki.write.local.receiver] } 4. Implementation The app. I added prometheus-flask-exporter, turned on gunicorn's access log, and added a /health route. I group metrics by Flask endpoint rather than by URL, so scanners requesting random paths can't create endless new time series. Two demo routes, /simulate/error and /simulate/slow, let me trigger failures. They work only when ENABLE_SIMULATION=true and only from the server itself. What I collect: | Signal | Source | Stored in | Kept | |---|---|---|---| | Request rate, errors, latency | Flask /metrics | Prometheus | 15 days | | CPU, memory, disk | EC2 host, via Alloy | Prometheus | 15 days | | CPU and memory per container | Docker, via Alloy | Prometheus | 15 days | | App, access, and stack logs | Docker, via Alloy | Loki | 7 days | Dashboard. One Grafana dashboard shows app status, request rate, error ratio, latency percentiles, per-container and host resources, and live logs. Alerts. Prometheus checks six alerting rules: | Alert | Fires when | |---|---| | StudentAppDown | Alloy can't scrape the app for 1 minute | | StudentAppHighErrorRate | over 5% of requests fail for 2 minutes | | StudentAppHighLatency | p95 latency exceeds 1 second for 5 minutes | | HostHighCpu, HostLowMemory, HostDiskAlmostFull | the server runs short of CPU, memory, or disk | The waiting period keeps one slow request from raising an alarm. 5. Show it working I sent ten minutes of mixed traffic, about one failed and one slow request in every nine, then stopped the app for two and a half minutes. Prometheus's ALERTS series recorded what followed (UTC): | Time | Event | |---|---| | 04:43 | Traffic starts | | 04:46 | StudentAppHighErrorRate fires | | 04:49 | StudentAppHighLatency fires | | 04:53:15 | I stop the app | | 04:54:45 | StudentAppDown fires | | 04:55:46 | I restart the app | | 04:56:00 | StudentAppDown resolves | | 04:58 | Error and latency alerts resolve | At the peak, PromQL showed an 11% error ratio and a 1.5-second p95 latency, both well past their thresholds. Logs and metrics agreed: Loki held 251 lines for HTTP 500 responses, and Prometheus counted 248. One surprise: the error alert kept firing during the outage, though a stopped app returns no errors. The rule averages the last five minutes, which still held the earlier failures. The test also exposed a bug in my load generator. It quit when the app went down, because set -e treated one failed request as fatal. I removed it. 6. Security considerations - Exposure. Only the app is open to the internet; Grafana and SSH accept my IP alone. Prometheus, Loki, and Alloy listen on 127.0.0.1, and a port scan from outside found them closed. I reach them through an SSH tunnel. - Unauthenticated internals. Loki and Prometheus accept data without a password. That is safe only because nothing outside the server can reach them. - App endpoints. /metrics returns 403 to the public, and the demo routes return 404, so outsiders can neither read internal numbers nor trigger slow requests to tie up the app. - Grafana. A random admin password lives in .env, which Git ignores. Sign-up and anonymous access are off. Grafana runs over plain HTTP; a real deployment would add TLS. - Privileged collector. Alloy's cAdvisor component docs require privileged mode, the Docker socket, and host mounts, and control of the Docker socket means control of the host. I mount everything read-only, pin the image version, and keep Alloy's web page private. - Logs. Alloy blanks credential-like values before storage. Access logs hold client IPs, which count as personal data, so Loki deletes logs after seven days, configured per its retention docs. - Repository. The repository holds no keys, passwords, or personal IDs; .env supplies them on the server, and .gitignore excludes .env and *.pem. 7. Conclusion One docker compose up gave my app metrics, logs, a dashboard, and alerts that fired and cleared as designed. The biggest lesson was how much Alloy simplifies collection: one agent replaced three, which made the system easier to explain and to secure. Next I would send alerts to email, add HTTPS, and add tracing with OpenTelemetry. References The official documentation I used: - Grafana Alloy, Migrate from Promtail to Grafana Alloy: grafana.com - Grafana Alloy, prometheus.exporter.cadvisor: grafana.com - Grafana Loki, Log retention: grafana.com - Prometheus, Alerting rules: prometheus.io - Grafana, Provision Grafana: grafana.com - Docker, Install the Docker Compose plugin: docs.docker.com
Escape SaaS Fees with Local AI (Self-Host n8n, Dify, Langflow & Ollama) Self-host n8n, Dify, Langflow and Ollama on one $10–15/month VPS: an open-source alternative to per-seat SaaS fees for AI automation. This local AI deep dive explains Docker, RAG, AI agents with CrewAI, and running models with no API bill. Repositories and commands are below. Tell us in the comments which subscription you would cancel first. CHAPTERS 0:00 The barrier-to-entry myth 2:06 The SaaS trap: $30/seat adds up fast 4:19 Is self-hosting hard? Docker explained 6:14 Visual workflow builders: n8n, Langflow, Flowise 10:13 Dify & RAG (vector embeddings made simple) 13:30 Autonomous AI agents & feedback loops 17:15 Install Dify in 3 commands 20:00 Ollama: zero API cost 21:31 Recap & the future REPOSITORIES IN THIS VIDEO n8n: n8n-io/n8n Langflow: langflow-ai/langflow Flowise: FlowiseAI/Flowise Dify: langgenius/dify CrewAI: crewAIInc/crewAI Langfuse: langfuse/langfuse Ollama: ollama/ollama MCP servers: modelcontextprotocol/servers Docker Compose: docker/compose COMMANDS SHOWN git clone langgenius/dify cd dify/docker cp .env.example .env docker compose up -d WHAT THIS COVERS A ChatGPT Team seat, Zapier and a CRM can cost a solo founder hundreds of dollars (around ₹15,000) a month before a single paying client. We walk through the alternative: rent a bare-bones VPS and run open-source AI tools on it, including how to choose between Langflow and Dify, how supervisor and worker agents check each other, and why you should bind internal ports to 127.0.0.1. WHO THIS IS FOR Solo founders, small agencies and teams that handle sensitive data, such as law firms, accountants and clinics. You don't need deep Linux knowledge, but you will use a terminal and Docker. Stock footage: Pexels and Pixabay (free licences). Hosts are AI-generated presenters. If this helped, subscribe to Daily Useful Steps. Small steps. Everyday wins. #AIAutomation #LocalAI #SelfHosted
N8N: Fair-code workflow automation platform, Neovim, Supabase | GitHub Curated Picks | 2026-10-15 GitHub Curated Picks reviews 11 source-verified repositories selected for developer usefulness, not popularity position. Each pick is checked against its GitHub repository, README, release evidence, and recent commit; pick numbers are editorial order. The compact card keeps rank, signal, why it matters, and next check together while the ledger preserves direct URLs and release evidence. Treat the list as an educational review surface, not software endorsement, and verify license and maintenance before adoption. Chapters: 00:00 2026-10-15 | GitHub Curated Picks 00:05 Pick #1 N8N 00:33 Pick #2 Neovim 00:48 Pick #3 Supabase 01:03 Pick #4 Ruff 01:13 Pick #5 Nest 01:27 Pick #6 Compose 01:40 Pick #7 Transformers 01:56 Pick #8 Svelte 02:08 Pick #9 Paperless Ngx 02:23 Pick #10 Sqlfluff 02:37 Pick #11 Megatron LM Source ledger: github.com/trending api.github.com github.com/n8n-io/n8n/blob/master/README.md github.com/n8n-io/n8n/commit/50c4ced2241430e13fa7… github.com/n8n-io/n8n/releases/tag/stable api.github.com github.com/neovim/neovim/blob/master/README.md github.com/neovim/neovim/commit/f505b0636e0e0010f… api.github.com github.com/supabase/supabase/blob/master/README.md github.com/supabase/supabase/commit/4215b6ca32a3b… api.github.com github.com/astral-sh/ruff/blob/main/README.md github.com/astral-sh/ruff/commit/83a3afc3156d73ae… api.github.com github.com/nestjs/nest/blob/master/Readme.md github.com/nestjs/nest/commit/b461186f20c245d5238… api.github.com github.com/docker/compose/blob/main/README.md github.com/docker/compose/commit/89e818966a5c2110… api.github.com huggingface/transformers github.com/huggingface/transformers/commit/e24eb5… api.github.com github.com/sveltejs/svelte/blob/main/README.md github.com/sveltejs/svelte/commit/79e7c3bd346bec0… api.github.com paperless-ngx/paperless-ngx github.com/paperless-ngx/paperless-ngx/commit/14a… api.github.com sqlfluff/sqlfluff github.com/sqlfluff/sqlfluff/commit/8b68456e30eec… api.github.com NVIDIA/Megatron-LM github.com/NVIDIA/Megatron-LM/commit/9131593673d0… n8n-io/n8n neovim/neovim supabase/supabase astral-sh/ruff nestjs/nest docker/compose sveltejs/svelte Informational and educational purposes only. Not investment advice, product endorsement, or professional guidance.
RHELでDocker Engineを使わずにHarborを動かしてみた はじめに RHEL上にコンテナレジストリのHarborを構築したい。ただ、今回はDocker Engineではなく、Podmanを使いたい。 ということで、PodmanのDocker互換APIをDocker Composeから呼び出す構成で、Harborを動かしてみました。 HarborはDocker Composeによる構築を想定しているため、そのままでは少し引っかかるところがあります。実際、起動時にinvalid log driverというエラーが出ましたが、生成されたComposeのログ設定をsyslogからjournaldへ変更することで起動できました。 この記事では、ファイルの持ち込みから起動、OS再起動後の復帰、Trivyによる脆弱性スキャンまで、実際に確認した内容をまとめます。 まずは結果から 今回の環境では、次のところまで確認できました。 - Harbor 2.15.4を10コンテナすべてhealthyで起動できた - Docker Engineなしで、Podman 5.8.2 + Docker Compose v5.6.0を使用できた - OS再起動後にHarborが自動復帰し、journalのログも残っていた - 持ち込んだTrivy DBでイメージスキャンに成功した 一方で、通信を完全に遮断した状態でのスキャン試験や、長期運用・高負荷試験までは行っていません。あくまで今回の組み合わせでの構築・動作検証です。 :::note info Harbor公式のCompose構成はDocker Engineを前提にしています。この記事のPodman構成は、公式の標準手順ではなく実際に動かして確認した検証例として読んでください。 ::: 今回の構成 !Harbor構成図 役割をざっくり分けると、Docker Composeが起動構成を読み込み、実際のコンテナ操作はPodmanが担当します。 Docker Compose ↓ Docker互換API(/run/podman/podman.sock) Podman ↓ Harborの各コンテナ(Core / Registry / DB / Trivyなど) Harborの永続データは、OSディスクとは別に用意した30GBのXFSディスクを/dataにマウントして保存します。OS側にはRHEL、Podmanのイメージ、journalログなどが残る構成です。 検証環境 | 項目 | 今回の環境 | |---|---| | OS | RHEL 9.8 | | Kernel | 5.14.0-687.5.3.el9_8.x86_64 | | Podman / podman-docker | 5.8.2 | | Docker Compose | v5.6.0 | | Harbor | v2.15.4 | | Trivy | v0.75.0 | | ORAS | 1.3.4(Windows側) | | 仮想マシン | 6 vCPU / 約7.5GiBメモリ | | ディスク | OS 64GB / データ用30GB(XFS) | | SELinux | Enforcing | | ログ | journald(永続化あり) | RHELサーバーはインターネットに直接アクセスしない前提で作業しました。そのため、Harbor本体やTrivy DBはWindows側で取得してSCPで転送します。 1. 必要なファイルをWindowsで取得する まず、RHELへ持ち込むファイルをそろえます。 | ファイル | 用途 | |---|---| | harbor-offline-installer-v2.15.4.tgz | Harbor本体とコンテナイメージ | | docker-compose-linux-x86_64 | RHELで実行するDocker Compose | | db.tar.gz | Trivyの脆弱性DB | | javadb.tar.gz | TrivyのJava DB | 以下はWindows / PowerShellで実行します。 作業フォルダを作る $Work = Join-Path $env:TEMP 'harbor-offline' New-Item -ItemType Directory -Force $Work | Out-Null Set-Location $Work HarborとDocker Composeを取得する curl.exe -fL -o harbor-offline-installer-v2.15.4.tgz https://github.com/goharbor/harbor/releases/download/v2.15.4/harbor-offline-installer-v2.15.4.tgz curl.exe -fL -o docker-compose-linux-x86_64 https://github.com/docker/compose/releases/download/v5.6.0/docker-compose-linux-x86_64 今回はHarborのOffline Installerを使います。 ORASでTrivy DBを取得する Harborの脆弱性スキャンにはTrivyを使用します。通常、Trivyは脆弱性DBを取得・更新しますが、今回はDBも事前に持ち込むことにしました。 取得に使ったのは、OCI Artifactを扱えるORASです。 curl.exe -fL -o oras_1.3.4_windows_amd64.zip https://github.com/oras-project/oras/releases/download/v1.3.4/oras_1.3.4_windows_amd64.zip Expand-Archive oras_1.3.4_windows_amd64.zip -DestinationPath .\oras -Force .\oras\oras.exe version 今回の出力ではVersion: 1.3.4でした。 続けて、2種類のDBを取得します。 .\oras\oras.exe pull -o trivy-db ghcr.io/aquasecurity/trivy-db:2 .\oras\oras.exe pull -o trivy-java-db ghcr.io/aquasecurity/trivy-java-db:1 Copy-Item .\trivy-db\db.tar.gz . Copy-Item .\trivy-java-db\javadb.tar.gz . これで、最初の表にある4ファイルが作業フォルダにそろいます。 :::note info Vulnerability DBは脆弱性情報の照合に使うデータベースです。Java DBはJARなどのJava関連ファイルの特定に使います。今回の検証イメージはAlpineですが、Harbor側のTrivyをオフライン寄りの設定にするため、両方用意しました。 ::: 2. SCPでRHELへ転送する 転送先をRHEL側で作成します。以降、特に断りがなければRHEL側のコマンドはrootで実行しています。 mkdir -p /var/tmp/harbor-offline Windows / PowerShellから4ファイルを転送します。 scp harbor-offline-installer-v2.15.4.tgz docker-compose-linux-x86_64 db.tar.gz javadb.tar.gz "root@192.0.2.10:/var/tmp/harbor-offline/" 3. Harbor用のデータディスクを準備する HarborはイメージをPushするほどRegistryのデータが増えます。そこで今回は、OSディスクと分けて30GBの追加ディスクを/dataとして使うことにしました。 WWNで対象ディスクを特定する /dev/sdbのようなデバイス名は、環境によって変わる場合があります。誤ったディスクを操作しないように、今回はWWN(ディスクを識別するための値)を使いました。 [root@dev-1 ~]# lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINTS,WWN NAME SIZE TYPE FSTYPE MOUNTPOINTS WWN sda 64G disk 0x6002248049f82fbb0857ffedb5478441 ├─sda1 600M part vfat /boot/efi 0x6002248049f82fbb0857ffedb5478441 ├─sda2 1G part xfs /boot 0x6002248049f82fbb0857ffedb5478441 └─sda3 62.4G part LVM2_member ├─rhel_dev--1-root 56G lvm xfs / └─rhel_dev--1-swap 6.4G lvm swap [SWAP] sdb 30G disk 0x6002248037794f97f99303ef475df60f 今回の追加ディスクは30GBの/dev/sdbです。対応するWWNのシンボリックリンクも確認します。 [root@dev-1 ~]# ls -l /dev/disk/by-id/wwn-0x6002248037794f97f99303ef475df60f lrwxrwxrwx. 1 root root 9 Oct 16 18:03 /dev/disk/by-id/wwn-0x6002248037794f97f99303ef475df60f -> ../../sdb パーティション作成とXFSフォーマット DISK='/dev/disk/by-id/wwn-0x6002248037794f97f99303ef475df60f' PART="${DISK}-part1" parted "$DISK" --script mklabel gpt parted "$DISK" --script mkpart primary xfs 1MiB 100% partprobe "$DISK" udevadm settle mkfs.xfs -f -L harbor-data "$PART" mkdir -p /data partedでパーティションを作成し、mkfs.xfsでXFSファイルシステムにしています。partprobeとudevadm settleは、パーティション情報がOS側へ反映されるのを待つための処理です。 再起動後も/dataへマウントする 再起動しても同じ領域をマウントできるよう、UUIDを使って/etc/fstabに登録します。 UUID=$(blkid -s UUID -o value "$PART") cp -a /etc/fstab /etc/fstab.before-harbor printf 'UUID=%s /data xfs defaults 0 0\n' "$UUID" >> /etc/fstab systemctl daemon-reload mount /data マウントされたか確認します。 [root@dev-1 ~]# findmnt /data TARGET SOURCE FSTYPE OPTIONS /data /dev/sdb1 xfs rw,relatime,seclabel,attr2,inode64,logbufs=8,logbsize=32k,noquota [root@dev-1 ~]# df -h /data Filesystem Size Used Avail Use% Mounted on /dev/sdb1 30G 247M 30G 1% /data ここまでで、30GBのXFS領域を/dataに用意できました。 :::note info 今回は/etc/fstabにnofailを付けていません。さらに後述のharbor.serviceでも、/dataがマウントされていなければHarborを起動しないようにします。データディスクが見つからないまま、空の/dataへ書き込まれることを避けるためです。 ::: 4. PodmanとDocker Composeを準備する 今回の構成では、Composeファイルを読むのがDocker Compose、コンテナを実行するのがPodmanです。 RHELのDNFリポジトリからPodmanを導入します。 [root@dev-1 ~]# dnf install -y podman-5.8.2 podman-docker-5.8.2 (依存パッケージの表示は省略) Complete! [root@dev-1 ~]# podman --version podman version 5.8.2 Docker ComposeがPodmanを操作できるよう、Docker互換APIのソケットを有効にします。 [root@dev-1 ~]# systemctl enable --now podman.socket [root@dev-1 ~]# ls -l /var/run/docker.sock /run/podman/podman.sock srw-rw----. 1 root root 0 ... /run/podman/podman.sock lrwxrwxrwx. 1 root root 23 ... /var/run/docker.sock -> /run/podman/podman.sock 次に、Windowsから持ち込んだDocker Composeを配置します。 [root@dev-1 ~]# install -m 0755 /var/tmp/harbor-offline/docker-compose-linux-x86_64 /usr/local/bin/docker-compose [root@dev-1 ~]# docker-compose version Docker Compose version v5.6.0 これで、ComposeからPodmanを操作する準備が整いました。 5. Harborを展開・設定する Harbor本体とコンテナイメージを展開する tar -xzf /var/tmp/harbor-offline/harbor-offline-installer-v2.15.4.tgz -C /opt podman load -i /opt/harbor/harbor.v2.15.4.tar.gz 検証ではどちらも正常終了し、Harborのイメージをローカルに登録できました。 HTTPS用の自己署名証明書を作る HarborへのアクセスはHTTPSにします。今回は検証環境なので、自己署名証明書を使用しました。 IPアドレスで接続するため、証明書のSAN(Subject Alternative Name)にもIPアドレスを含めます。 HARBOR_IP=192.0.2.10 mkdir -p /data/cert openssl req -x509 -newkey rsa:3072 -sha256 -days 365 -nodes \ -subj "/CN=$HARBOR_IP" \ -addext "subjectAltName=IP:$HARBOR_IP" \ -keyout /data/cert/harbor.key \ -out /data/cert/harbor.crt chmod 600 /data/cert/harbor.key 作成したファイルは、証明書がharbor.crt、秘密鍵がharbor.keyです。-days 365で有効期間を365日に設定しています。 証明書の内容も確認しておきます。 openssl x509 -in /data/cert/harbor.crt -noout -subject -dates -ext subjectAltName Podmanに証明書を登録する 自己署名証明書は、そのままではPodmanから信頼されません。今回はレジストリごとの証明書ディレクトリへ配置しました。 mkdir -p "/etc/containers/certs.d/$HARBOR_IP" install -m 0644 /data/cert/harbor.crt "/etc/containers/certs.d/$HARBOR_IP/ca.crt" :::note warn この設定で証明書を信頼するのは、このRHEL上のPodmanです。別のPCからHarborへ接続する場合は、そのPCのブラウザやコンテナクライアント側でも証明書の信頼設定が必要になる場合があります。本番環境では、信頼されたCAが発行した証明書の利用を検討してください。 ::: firewalldでHTTPSを許可する RHEL側でTCP 443番ポートへの通信を許可します。 [root@dev-1 ~]# firewall-cmd --permanent --add-service=https success [root@dev-1 ~]# firewall-cmd --reload success harbor.ymlを設定する Harborのテンプレートをコピーし、設定ファイルを編集します。 cd /opt/harbor cp -a harbor.yml.tmpl harbor.yml vi harbor.yml 主な変更点は次のとおりです。 hostname: 192.0.2.10 https: port: 443 certificate: /data/cert/harbor.crt private_key: /data/cert/harbor.key harbor_admin_password: <Harbor管理者用のパスワード> database: password: <PostgreSQL用のパスワード> data_volume: /data trivy: skip_update: true skip_java_db_update: true offline_scan: true :::note info 上記は変更する項目だけの抜粋です。実際にはharbor.yml.tmplの他の項目も残してください。また、管理者用とPostgreSQL用には別々のパスワードを設定します。 ::: 今回はTrivy DBを手動で持ち込むので、skip_updateとskip_java_db_updateをtrueにしました。offline_scanは、スキャン中のJava関連の外部問い合わせを抑えるための設定です。 Trivy DBを配置する まず、Trivy Adapterの実行ユーザーを確認しました。 [root@dev-1 harbor]# podman run --rm --pull=never --entrypoint /usr/bin/id localhost/goharbor/trivy-adapter-photon:v2.15.4 uid=10000(scanner) gid=10000(scanner) groups=10000(scanner) UID/GIDがどちらも10000だったので、その値で所有者を設定します。 mkdir -p /data/trivy-adapter/trivy/db /data/trivy-adapter/trivy/java-db /data/trivy-adapter/reports tar -xzf /var/tmp/harbor-offline/db.tar.gz -C /data/trivy-adapter/trivy/db tar -xzf /var/tmp/harbor-offline/javadb.tar.gz -C /data/trivy-adapter/trivy/java-db chown -R 10000:10000 /data/trivy-adapter 展開後、trivy.db、trivy-java.dbと、それぞれのmetadata.jsonが配置されました。 prepareでComposeを生成する Harborのprepareは、harbor.ymlをもとに各サービスの設定とdocker-compose.ymlを生成します。 今回のPodman環境では、bind mount元のディレクトリを事前に作成してから実行しました。 cd /opt/harbor mkdir -p /opt/harbor/common/config ./prepare --with-trivy 出力には、次のファイルの生成が含まれていました。 Generated configuration file: /config/core/env Generated configuration file: /config/registry/config.yml Generated configuration file: /config/db/env Generated configuration file: /config/trivy-adapter/env Generated configuration file: /compose_location/docker-compose.yml 6. Composeのログ設定をsyslogからjournaldへ変更する Harborが生成したComposeには、次のようなログ設定があります。 logging: driver: "syslog" options: syslog-address: "tcp://localhost:1514" tag: "registry" これを変更せずPodmanのDocker互換APIで起動すると、invalid log driverで失敗しました。 Podmanが対応するログドライバと、HarborのComposeが指定するログドライバに違いがあるためです。そこで、今回はjournaldに変更します。 生成ファイルをバックアップして修正する cd /opt/harbor cp -a docker-compose.yml docker-compose.generated.yml grep -c 'driver: "syslog"' docker-compose.generated.yml # 9 sed -i 's/driver: "syslog"/driver: "journald"/g' docker-compose.yml sed -i '/^[[:space:]]*syslog-address:/d' docker-compose.yml grep -c 'driver: "journald"' docker-compose.yml # 9 syslog-addressも不要になるので削除します。tagはそのまま残しました。 修正後のイメージは次のとおりです。 logging: driver: "journald" options: tag: "registry" Composeとして読み込めるか確認します。 export DOCKER_HOST=unix:///run/podman/podman.sock docker-compose config -q config -qがエラーなく終了すれば、Composeファイルの構文チェックは通っています。 ログの保存先が変わることに注意 :::note warn ここで変更しているのは、単にエラーを回避するための文字列ではありません。コンテナの標準出力・標準エラーの保存経路も変わります。 Harbor標準のsyslog経由の収集ではなく、今回の構成ではホストのjournaldに記録されます。harbor-logコンテナが起動していても、標準構成と同じログ収集経路を使っているわけではありません。 ::: 実際、harbor-logには明示的なログドライバ設定がありませんでしたが、今回のPodman環境では既定のjournaldが使われました。 また、prepareを再実行するとComposeが再生成される点にも注意が必要です。Harborの設定変更やアップグレード後には、このjournaldへの修正を再適用して確認してください。 journalを永続化する せっかくログが出ても、再起動で消えてしまうと困ります。今回はjournalを永続保存するように設定しました。 mkdir -p /etc/systemd/journald.conf.d cat > /etc/systemd/journald.conf.d/60-harbor-persistent.conf <<'EOF' [Journal] Storage=persistent SystemMaxUse=512M EOF systemctl restart systemd-journald journalctl --flush Storage=persistentで再起動後もログを残し、SystemMaxUse=512Mで使用容量を制限します。 ここでの512MBはHarborだけでなく、ホストのjournal全体に対する上限です。今回は大量ログ発生時のローテーション試験までは行っていません。 7. Harborを起動してみる いよいよ起動です。今回は持ち込んだイメージだけを使うため、--pull neverを指定しました。 cd /opt/harbor export DOCKER_HOST=unix:///run/podman/podman.sock docker-compose up -d --pull never 起動直後、JobServiceがCoreの準備完了より先に接続しようとして、一時的に終了する場面がありました。 Failed on load rest config ... Get "http://core:8080/api/v2.0/internalconfig": connect: connection refused panic: failed to load configuration, error: failed to load rest config 少し焦ったログですが、今回はrestart: alwaysによる再起動で復旧しました。手動でComposeを修正したり、コンテナを再作成したりする必要はありませんでした。 全コンテナの状態を確認する [root@dev-1 ~]# podman inspect --format '{{.Name}} {{.State.Status}} {{.State.Health.Status}} log={{.HostConfig.LogConfig.Type}}' $(podman ps -aq) harbor-log running healthy log=journald harbor-portal running healthy log=journald registry running healthy log=journald redis running healthy log=journald registryctl running healthy log=journald harbor-db running healthy log=journald trivy-adapter running healthy log=journald harbor-core running healthy log=journald nginx running healthy log=journald harbor-jobservice running healthy log=journald 10コンテナすべてrunningかつhealthyになりました。 HarborのHealth APIも確認します。$HARBOR_IPが未設定の場合は、あらためてIPを設定してください。 curl --silent --show-error --fail --cacert /data/cert/harbor.crt "https://192.0.2.10/api/v2.0/health" レスポンスは次のとおりでした(整形しています)。 { "components": [ {"name": "core", "status": "healthy"}, {"name": "database", "status": "healthy"}, {"name": "jobservice", "status": "healthy"}, {"name": "portal", "status": "healthy"}, {"name": "redis", "status": "healthy"}, {"name": "registry", "status": "healthy"}, {"name": "registryctl", "status": "healthy"}, {"name": "trivy", "status": "healthy"} ], "status": "healthy" } コンテナの状態だけでなく、Harbor自身も正常と判断していることが確認できました。 journalctlでもログを確認する [root@dev-1 harbor]# journalctl -t core --no-pager -n 30 Oct 16 18:22:20 dev-1 core[17830]: 2026-10-16T09:22:20Z [INFO] [/core/main.go:279]: Version: v2.15.4, Git commit: d8841742 podman logsからも確認できます。 podman logs harbor-core journalctl CONTAINER_NAME=harbor-core --no-pager -n 30 今回の検証では、Coreで29件、Trivy Adapterで5件について、両方の方法で同じメッセージを取得できました。 8. systemdでOS起動時に自動復帰させる 手動で起動できても、サーバー再起動のたびに操作が必要では使いにくいですよね。 そこでHarbor全体をsystemdから起動するようにしました。ポイントは、Podmanソケットと/dataのマウントを前提条件にすることです。 harbor.serviceを作成する /etc/systemd/system/harbor.serviceに以下を保存します。 [Unit] Description=Harbor Container Registry (Docker Compose on Podman) Wants=network-online.target After=network-online.target podman.socket Requires=podman.socket RequiresMountsFor=/data [Service] Type=oneshot RemainAfterExit=yes WorkingDirectory=/opt/harbor Environment=DOCKER_HOST=unix:///run/podman/podman.sock ExecStartPre=/usr/bin/mountpoint -q /data ExecStart=/usr/local/bin/docker-compose up -d --pull never ExecStop=/usr/local/bin/docker-compose stop TimeoutStartSec=0 TimeoutStopSec=120 [Install] WantedBy=multi-user.target RequiresMountsFor=/dataで必要なマウントを要求し、さらにmountpoint -q /dataで実際にマウントされていることを確認しています。 systemctl daemon-reload systemctl enable --now harbor.service 実際にOSを再起動してみる systemctl reboot 再ログイン後、まずサービスを確認しました。 [root@dev-1 ~]# systemctl status harbor.service --no-pager ● harbor.service - Harbor Container Registry (Docker Compose on Podman) Loaded: loaded (/etc/systemd/system/harbor.service; enabled; preset: disabled) Active: active (exited) since Fri 2026-10-16 18:23:27 JST Process: 919 ExecStartPre=/usr/bin/mountpoint -q /data (code=exited, status=0/SUCCESS) Process: 932 ExecStart=/usr/local/bin/docker-compose up -d --pull never (code=exited, status=0/SUCCESS) active (exited)と表示されていますが、今回はType=oneshot + RemainAfterExit=yesなので想定どおりです。 その後、手動でdocker-compose upを実行することなく、10コンテナすべてとHealth APIが再びhealthyになったことを確認しました。 journalも再起動前のログが残っていました。 [root@dev-1 ~]# journalctl --list-boots IDX BOOT ID FIRST ENTRY LAST ENTRY -1 617ae65b8de8400189a9610560885380 Fri 2026-10-16 18:17:26 JST Fri 2026-10-16 18:23:14 JST 0 15981601a8384a6fac7280fdf6cb7c82 Fri 2026-10-16 18:23:18 JST Fri 2026-10-16 18:24:00 JST これで、Harborの自動復帰とログの永続化まで確認できました。 9. Trivyでイメージをスキャンする 最後に、手動で搬入したDBを使ってスキャンできるか試します。 オフライン向け設定が反映されているか確認 [root@dev-1 harbor]# grep -E 'SKIP_UPDATE|SKIP_JAVA_DB_UPDATE|OFFLINE_SCAN' /opt/harbor/common/config/trivy-adapter/env SCANNER_TRIVY_SKIP_UPDATE=True SCANNER_TRIVY_SKIP_JAVA_DB_UPDATE=True SCANNER_TRIVY_OFFLINE_SCAN=True 3つとも有効になっています。 検証用のAlpineイメージを用意する 今回はAlpine 3.18.0を使いました。実際の検証ではWindows側の補助スクリプトでOCIアーカイブを作成し、RHELへSCPで転送しています。 同じ形式のファイルを用意する方法として、インターネットに接続でき、Podmanを利用できる別マシンなら次の方法もあります(こちらは再現用の例で、今回実測したWindowsの補助スクリプトそのものではありません)。 podman pull --platform linux/amd64 docker.io/library/alpine:3.18.0 podman save --format oci-archive -o alpine-3.18.0-amd64.oci.tar docker.io/library/alpine:3.18.0 作成したalpine-3.18.0-amd64.oci.tarを、先ほどと同様にRHELの/var/tmp/harbor-offline/へ転送します。 RHEL側でロードします。 [root@dev-1 ~]# podman load -i /var/tmp/harbor-offline/alpine-3.18.0-amd64.oci.tar Loaded image: docker.io/library/alpine:3.18.0 :::note info ここでのイメージ取得・アーカイブ作成は再現用の一例です。元の検証で使用したWindowsの補助スクリプトの詳細は省略しています。アーカイブの作成方法によってはロード後のイメージ名を確認し、podman tagの参照元を調整してください。 ::: HarborへPushする まずHarborにverificationプロジェクトを作成します。今回の検証ではHarbor APIから作成しましたが、Web UIから同名のプロジェクトを用意しても構いません。 次は読者向けの簡略化した実行例です。実測時はパスワードを標準入力から渡し、専用の認証ファイルを指定して実施しました。 podman login -u admin 192.0.2.10 podman tag docker.io/library/alpine:3.18.0 "192.0.2.10/verification/alpine:3.18.0" podman push "$HARBOR_IP/verification/alpine:3.18.0" 実測ではLogin Succeeded!が表示され、Pushも終了コード0で成功しました。 スキャンを実行して結果を確認する 今回はHarbor APIからスキャンを要求しました。 POST /api/v2.0/projects/verification/repositories/alpine/artifacts/3.18.0/scan HTTP 202 GET /api/v2.0/projects/verification/repositories/alpine/artifacts/3.18.0?with_scan_overview=true HTTP 200 scan_status: Success scanner: Trivy v0.75.0 取得したレポートの集計結果は以下のとおりです。 | 深刻度 | 件数 | |---|---:| | Critical | 3 | | High | 6 | | Medium | 26 | | Low | 14 | | 合計 | 49 | 49件はパッケージごとの検出件数で、異なる脆弱性IDは22種類でした。 持ち込んだDBでスキャンが成功したことは確認できました。ただし、検出件数はDBの更新時点や対象イメージのdigestによって変わります。 10. ディスク使用量を確認する 最後に、OSディスクとHarborのデータ領域がどれくらい使われたか確認しました。 df -h / /data 実測時の使用量は次のとおりです(デバイス名ではなくマウント先で整理しています)。 | マウント先 | 容量 | 使用量 | 使用率 | |---|---:|---:|---:| | / | 56GB | 7.0GB | 13% | | /data | 30GB | 3.3GB | 11% | ディレクトリ別にも見てみました。 [root@dev-1 ~]# du -sh /data/database /data/registry /data/trivy-adapter /var/lib/containers/storage /var/log/journal 67M /data/database 3.4M /data/registry 3.0G /data/trivy-adapter 3.7G /var/lib/containers/storage 8.1M /var/log/journal 今回は小規模な検証用Registryなので、/data/registryは数MBです。一方、/dataの大部分はTrivy関連のデータが占めていました。 また、data_volume: /dataを設定しても、PodmanのイメージはOS側の/var/lib/containers/storageに保存されます。今回のjournaldログもOS側です。 つまり、Harborのデータディスクを分けても、OSディスクの容量監視は必要ということですね。 まとめ 今回は、RHEL 9.8上でDocker Engineを使わず、Podman + Docker Composeを使ってHarborを構築しました。 最初は「Docker ComposeをPodmanへ向ければ、そのまま動くのでは?」と思っていたのですが、やはり細かい差分はありますね。特にログの扱いは、起動させるだけでなく、あとから調査するときにも影響する部分だと感じました。 本番運用へ持っていくなら、少なくとも証明書の管理、Trivy DBの定期更新、ログの保全、prepare再実行時のCompose修正手順は別途整備したいところです。 同じようにRHEL + PodmanでHarborを動かしたい方の参考になれば幸いです。 参考 - Harbor公式ドキュメント - Harbor v2.15.4 リリース - Harborのシステム要件 - Harborの設定変更とprepare - Podman公式ドキュメント - Podman Docker互換API - Docker Compose - TrivyのAir-Gapped環境に関する説明 - ORAS
【Vol.1】Python特化のOpenTelemetry+LGTMスタック最小構成 1. はじめに OpenTelemetryの学習にあたり、公式デモを、ようやく動かせるようになったので、それを忘れないうちに、Python(Flask/Locust)特化のシンプルな最小構成環境を作ってみました。 手作業を排除し、EC2上にインフラ構築から自動監視までをTerraformで自動作成します。 :::note info 今回は 【Vol.1】環境構築編 として、まずは動かすことに特化しています。 自分自身の学習記録の備忘録としての意味合いもあります。 ::: !image.png 2. Grafana Labs公式「LGTMスタック」デモとは、 本環境は、Grafana Labs公式の OpenTelemetry Demo をベースにしています。公式デモは、モダンな可観測性(Observability)の標準である 「LGTMスタック」 を手軽に体験できる素晴らしい環境です。 📊 LGTMスタックの構成要素 | コンポーネント | 役割 | | :--- | :--- | | Loki | ログを集約・検索する軽量なログ管理システム | | Grafana | 収集データを1つにまとめて可視化するダッシュボード | | Tempo | リクエストの経路を追跡する分散トレーシング | | Mimir / Prometheus | システム状態(CPUやリクエスト数など)を蓄積するメトリクス | :::note indfo 「LGTMスタック」イメージのみ流用し、アプリ側を 「Python(Flask/Locust)の最小構成」 に絞り込み、ファイル構成、設定内容を理解しやすくしました。 ::: 3. 今回のチャレンジと「あえてやらなかったこと」 今回の最大の目的は、 「必要な部分だけを切り出して、自分なりにカスタマイズしやすい環境を作る」 ことです。 具体的には、以下の要素に絞って手を動かし、理解を深めました。 - Grafanaでの可視化の仕組み - ダッシュボードが「何を、どう表示しているか」の整理 - PromQLや関数を用いたグラフ描画の分析 - OpenTelemetry(メトリクス中心)の挙動確認 - アプリからデータが「どのように出力されるか」の観察 - 負荷(Locust)をかけたときのメトリクス変動の観測 :::note info 「あえてやらなかったこと」は、LGTMスタック各コンポーネント(Loki, Tempo, Mimir, OTel Collector)間の内部接続や詳細設定は公式デモの構成を流用しています。 ::: 4. 技術スタック 本環境で使用している技術スタックは以下の通りです。 - インフラ(IaC):Terraform - クラウド:AWS (VPC, Subnet, Security Group, EC2 [Amazon Linux 2023]) - コンテナ:Docker / Docker Compose - アプリケーション:Python 3.11 / Flask (Webアプリ) / Locust (負荷テストツール) - 可観測性(Observability)エコシステム: - データ収集:OpenTelemetry - 可視化:Grafana - メトリクス蓄積:Prometheus - ログ蓄積:Loki - 分散トレーシング:Tempo ディレクトリ構成 本環境の全体構成は以下の通りです。 このデータセットは、GitHubリポジトリ に登録しています。 ├── ENV_1011/ # main.tfの構築・プロビジョニング時に使用される環境データ │ ├── my-dashboards/ # Grafanaのダッシュボード設定を格納 │ │ └── grafana-dashboard-python-metrics.json │ ├── python/ # OpenTelemetryによるメトリクス生成を組み込んだアプリ │ │ ├── app.py # Flaskアプリケーション本体 │ │ └── locustfile.py # 負荷試験用のLocustスクリプト │ ├── services/ # EC2上で常時稼働させるsystemd用の設定ファイル │ │ ├── flask-app.service │ │ └── locust-worker.service │ ├── clear-defaults.yaml # 各種環境の初期設定をクリア・調整するための設定 │ ├── my-dashboard-config.yaml # Grafanaダッシュボードのプロバイダー設定 │ └── run-lgtm.sh # LGTMスタック(Grafana等)を起動・制御するスクリプト └── devops_1011/ # 構築・自動化用コード ├── scripts/ │ └── setup.sh # お試し構築・初期セットアップ用スクリプト └── terraform/ # IaC(Infrastructure as Code)関連コード ├── main.tf # AWS EC2インフラやネットワークの定義 └── output.tf # 構築後のパブリックIP等の出力定義 各ディレクトリ・ファイルの役割 >### インフラ自動化・構築 (./devops_1011/) Terraformを用いて、AWSのEC2上にインフラ環境を自動構築します。 !image.png >### アプリケーション & 自動監視環境 ( ./ENV_1011/ ) main.tf を実行し、EC2インスタンス起動後に、環境構築/python実行で必要なファイルを格納しています。 !image.png 5. システム構成 !image.png 今回はGrafana Labs公式デモ版のコンテナイメージ(grafana/otel-lgtm)をベースに使用しているため、OpenTelemetry Collector部や各バックエンド(Prometheus / Tempo / Loki / Pyroscope)の内部構成はそちらに依存しています。 このシステムに対して、自作した「Python Web App (Flask)」と「負荷生成器(Locust)」を接続し、EC2インスタンス(t3.small)内でsystemdとしてバックグラウンド実行させています。 インフラ構築や環境配備の自動化を図るために、Terraform でAWSリソースの自動生成を行い、EC2のUser DataをトリガーにGitHub・Docker Hubと連携してデプロイを自動化しています。 - GitHub:最新のソースコード(app.py / locustfile.py)を自動取得 - Docker Hub:可視化基盤のコンテナイメージ(grafana/otel-lgtm)をプル 🔄 各コンポーネントの役割 !image.png 6. メトリクス ダッシュボードで可視化したメトリクスは以下の通りです。 Locustの負荷変動に応じて、これらの値も変動します。 !image.png 具体的なコードについては、末尾の [【付録:環境構築用コード】](#10付録環境構築用コード) にある「grafana-dashboard-python-metrics.json」を参照してください。 :::note info 今回の構成ではメトリクス収集に特化しており、LokiやTempoへのデータ送信に必要な機能実装は行わなかったので、 ログ、トレース の内容は見られません。 ::: 7.デプロイ手順 インフラのプロビジョニングからアプリのデプロイ、監視の有効化まで、Terraformで自動化しています。 >### 1. 環境変数の設定 ローカル環境に自身のAWSの認証情報(アクセスキーなど)をセットします。 >### 2. Terraformの実行 terraform apply を実行します。AWS上にVPC、セキュリティグループ、EC2(Amazon Linux 2023)が自動生成されます。 ※具体的なIaCコードは末尾の [【付録:環境構築用コード】](#10付録環境構築用コード) を参照。 >### 3. user_dataによる完全自動セットアップ EC2の起動時、user_dataがバックグラウンドで以下の処理を全自動で実行します。 1. GitのSparse Checkout(部分チェックアウト) を使い、リポジトリから必要なディレクトリ(ENV_1011)のみダウンロード。 2. Docker / Docker Compose のインストールと起動。 3. 公式スクリプト(run-lgtm.sh)によるLGTMスタックコンテナ起動。 4. Python 3.11環境の構築と、必要ライブラリ(Flask, OpenTelemetry SDK, Locustなど)のインストール。 5. Pythonスクリプトの配置、およびsystemdへのサービス登録と自動起動。 >### 4. Grafanaへのアクセス terraform apply 完了後、数分待つだけでセットアップが完了します。出力されたパブリックIPの3000番ポート(http://<EC2のパブリックIP>:3000)へアクセスすれば、すぐに完成したダッシュボードを確認できます。 <details> <summary>詳細手順は、こちらを参照</summary> >### URL指定(Grafana-GUI) terraform apply 完了後、以下の様にパブリックIPが出力されます。 ec2_private_ip = "10.0.1.206" ec2_public_ip = "3.112.205.128" 上記のケースでは、以下のURL に2~3分待ってアクセスします。 3.112.205.128 >### ダッシュボード選択 以下の様に、Grafana からダッシュボード選択を行います。 !image.png >### メニュー選択 表示期間を「⏱ Last 5 minutes」に切り替えて、10分ほど待つと、以下のようなデータが見られるようになります。 !image.png </details> 8. おわりに 今回の取り組みを通じ、OpenTelemetryの強力さとLGTMスタックによる監視基盤の構築方法を深く学ぶことができました。 手間の多い手動構築(SSH、クローン、権限変更、サービス登録など)を、Terraformの user_data と Gitの Sparse Checkout で自動化し、「コマンドで環境構築」する面白味を実感できました。 >### 周辺技術での学び 環境コード化にあたり、監視以外の実用的なスキルも習得できました。 - Locustによる動的負荷試験:リクエストを変動させ、意図的にメトリクスの波(トレンド)を作る手法 - Terraformの user_data:EC2起動時のプロビジョニングを完全自動化する実務的な手法 9. 今後の展望 今回は【Vol.1】として「最小構成で動かす」ことを最優先しました。このベース環境を活かし、以下の要素にも挑戦する予定です。(あくまでも予定です) - 監視基盤の自作化:公式デモの流用を脱却し、各コンポーネントとCollector間の接続・設定をイチから自作する - 自動制御の組み込み:各バックエンドのデータをシェルスクリプト等で定期取得し、異常を自動検知する仕組みの実装。 - 構成管理の高度化:user_data(シェルスクリプト)で行っているアプリ配置やサービス登録を、Ansibleによる管理へ移行する。 - オブザーバビリティの完全化:今回はメトリクスのみのだった構成にLoki(ログ)とTempo(トレース)の実装を本格追加し、「3つの柱」を可視化できる環境の構築。 10.付録:環境構築用コード GitHubレポジトリ に登録しています <details> <summary>./terraform/main.tf</summary> terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } provider "aws" { region = "ap-northeast-1" } # 1. VPC(仮想ネットワーク)の作成 resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" enable_dns_hostnames = true tags = { Name = "study-vpc" } } # 2. パブリックサブネットの作成 resource "aws_subnet" "public" { vpc_id = aws_vpc.main.id cidr_block = "10.0.1.0/24" map_public_ip_on_launch = true # 起動時にパブリックIPを自動割り当て tags = { Name = "study-public-subnet" } } # 3. インターネットゲートウェイの作成(外の世界と繋ぐ窓口) resource "aws_internet_gateway" "igw" { vpc_id = aws_vpc.main.id tags = { Name = "study-igw" } } # 4. ルートテーブルの作成と紐付け(ネットへの道を登録) resource "aws_route_table" "public" { vpc_id = aws_vpc.main.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.igw.id } tags = { Name = "study-public-rt" } } resource "aws_route_table_association" "public" { subnet_id = aws_subnet.public.id route_table_id = aws_route_table.public.id } # ================================================================================= # 1. セキュリティグループの定義 # ================================================================================= resource "aws_security_group" "ec2" { name = "study-ec2-sg" description = "Security Group for OpenTelemetry and App Server" vpc_id = aws_vpc.main.id # ※環境に合わせてVPC IDの変数名を調整してください # ------------------------------------------------------------------------------- # 各種アプリケーション・可観測性ツール用のインバウンドルール(dynamicブロックで一括管理) # ------------------------------------------------------------------------------- dynamic "ingress" { for_each = { 22 = "SSH" 3000 = "Grafana UI" 16686 = "Jaeger UI" 9090 = "Prometheus UI" 3100 = "Loki" 8000 = "Python Web App" 9464 = "OpenTelemetry Prometheus Exporter" 5000 = "Python App (Flask)" 4317 = "OpenTelemetry (OTLP/gRPC)" 4318 = "OpenTelemetry (OTLP/HTTP)" 8080 = "OpenTelemetry Demo Web & Grafana" } content { description = ingress.value from_port = ingress.key to_port = ingress.key protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } } # ------------------------------------------------------------------------------- # 【超重要】EC2同士(VPC内部)の全通信を許可するルール # ------------------------------------------------------------------------------- ingress { description = "VPC Internal All Traffic" from_port = 0 to_port = 0 protocol = "-1" # すべてのプロトコルを許可 cidr_blocks = ["10.0.0.0/16"] # VPC内部のIPアドレスからの通信をすべて許可 } # ------------------------------------------------------------------------------- # インターネットへの外向き通信をすべて許可(重複を排除して1つにスッキリ整理) # ------------------------------------------------------------------------------- egress { description = "Allow all outbound traffic" from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } # ================================================================================= # 2. SSH接続用のキーペアを自動生成 # ================================================================================= resource "tls_private_key" "keygen" { algorithm = "RSA" rsa_bits = 4096 } resource "aws_key_pair" "keypair" { key_name = "study-key" public_key = tls_private_key.keygen.public_key_openssh } # 生成された秘密鍵(Terra Termで使う.pemファイル)をローカルに保存 resource "local_file" "private_key" { content = tls_private_key.keygen.private_key_pem filename = "${path.module}/study-key.pem" } # ================================================================================= # 3. AWSから自動で最新のAmazon Linux 2023 AMI IDを取得 # ================================================================================= data "aws_ssm_parameter" "amzn_linux_2023" { name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64" } # ================================================================================= # 4. EC2インスタンスの作成(GitHubパブリック方式・完全自動化版) # ================================================================================= resource "aws_instance" "app_server" { ami = data.aws_ssm_parameter.amzn_linux_2023.value instance_type = "t3.small" subnet_id = aws_subnet.public.id vpc_security_group_ids = [aws_security_group.ec2.id] key_name = aws_key_pair.keypair.key_name # ルートディスク容量を30GB(無料枠の最大値)に増やす設定 root_block_device { volume_size = 30 volume_type = "gp3" } tags = { Name = "study-ec2-1" } # =============================================================================== # 【完全自動化】初期化シェルスクリプト(user_data) # =============================================================================== user_data = <<-EOF #!/bin/bash # エラーが発生したら即座にスクリプトを終了させる設定 set -euo pipefail echo "==========================================================" echo "=== 1. GitHubから特定のディレクトリ(ENV_1011)のみを取得 ===" echo "==========================================================" dnf install -y git sleep 5 mkdir -p /opt/deploy_data cd /opt/deploy_data # Sparse Checkout(部分チェックアウト)の設定 git init git remote add origin https://github.com/wata123-t/deploy_data git config core.sparseCheckout true echo "ENV_1011/" >> .git/info/sparse-checkout # データの引っ張りと移動 git pull origin main cd ENV_1011 echo "==========================================================" echo "=== 2. Docker & Docker Compose のインストールと起動 ===" echo "==========================================================" dnf install -y docker sleep 5 systemctl enable --now docker # Docker Compose V2 のインストール mkdir -p /usr/local/lib/docker/cli-plugins/ curl -SL https://github.com/docker/compose/releases/download/v2.29.2/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose chmod +x /usr/local/lib/docker/cli-plugins/docker-compose echo "==========================================================" echo "=== 3. Grafana Labs公式「LGTMスタック」デモの実行 ===" echo "==========================================================" # すでにGit側で権限を付与していますが、念のため再付与して実行 chmod +x ./run-lgtm.sh ./run-lgtm.sh sleep 10 echo "==========================================================" echo "=== 4. Python 3.11 & 開発ツールのインストール ===" echo "==========================================================" dnf install -y python3.11 python3.11-pip python3.11-devel gcc sleep 10 echo "==========================================================" echo "=== 5. 必要なPythonライブラリの一括インストール ===" echo "==========================================================" python3.11 -m pip install --upgrade pip sleep 10 python3.11 -m pip install flask opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp opentelemetry-instrumentation-flask opentelemetry-instrumentation-system-metrics locust sleep 10 echo "==========================================================" echo "=== 6. アプリケーションフォルダの作成とファイルのコピー ===" echo "==========================================================" mkdir -p /opt/script/python/app # GitHubから落としてきた相対パスからファイルをコピー cp "./python/app.py" /opt/script/python/app/app.py cp "./python/locustfile.py" /opt/script/python/locustfile.py echo "==========================================================" echo "=== 7. 各種サービス(systemd)ファイルの配置と起動 ===" echo "==========================================================" cp "./services/flask-app.service" /etc/systemd/system/flask-app.service cp "./services/locust-worker.service" /etc/systemd/system/locust-worker.service systemctl daemon-reload systemctl enable --now flask-app locust-worker echo "==========================================================" echo "=== すべてのセットアップ処理が正常に完了しました! ===" echo "==========================================================" EOF } </details> <details> <summary>./terraform/output.tf</summary> # ================================================================================= # 5. アウトプット(接続用IPアドレスの表示) # ================================================================================= output "ec2_public_ip" { value = aws_instance.app_server.public_ip description = "起動したEC2のパブリックIPアドレスです" } output "ec2_private_ip" { value = aws_instance.app_server.private_ip description = "EC2のプライベートIPアドレスです" } </details> <details> <summary>./my-dashboards/grafana-dashboard-python-metrics.json </summary> { "annotations": { "list": [ { "builtIn": 1, "datasource": { "type": "datasource", "uid": "grafana" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "target": { "limit": 100, "matchAny": false, "tags": [], "type": "dashboard" }, "type": "dashboard" } ] }, "description": "Dashboard for **Python** metrics with OpenTelemetry instrumentation", "editable": true, "fiscalYearStartMonth": 0, "gnetId": 18812, "graphTooltip": 0, "links": [ { "asDropdown": false, "icon": "info", "includeVars": false, "keepTime": false, "tags": [], "targetBlank": false, "title": "Semantic Conventions: 1.20.0 - 1.22.0", "tooltip": "multiple versions of the semantic conventions are supported using 'or' and regex queries", "type": "link", "url": "https://github.com/open-telemetry/semantic-conventions/blob/main/schemas/1.20.0" } ], "liveNow": false, "panels": [ { "datasource": { "type": "prometheus", "uid": "${datasource}" }, "description": "HTTP server request rate", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 25, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] }, "unit": "reqps" }, "overrides": [] }, "gridPos": { "h": 8, "w": 8, "x": 0, "y": 0 }, "id": 51, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, "targets": [ { "datasource": { "type": "prometheus", "uid": "${datasource}" }, "editorMode": "code", "expr": "sum by (instance) (rate({__name__=~\"http_server_request_duration_seconds_count|http_server_request_duration_count|http_server_duration_seconds_count|http_server_duration_count|http_server_duration_milliseconds_count|http_server_requests_milliseconds_count|http_server_requests_count|http_server_requests_seconds_count\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", "instant": false, "legendFormat": "__auto", "range": true, "refId": "A" } ], "title": "Rate", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "${datasource}" }, "description": "HTTP server error ratio - ratio of requests that return 5xx", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 25, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] }, "unit": "percentunit" }, "overrides": [] }, "gridPos": { "h": 8, "w": 8, "x": 8, "y": 0 }, "id": 50, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, "targets": [ { "datasource": { "type": "prometheus", "uid": "${datasource}" }, "editorMode": "code", "expr": "(sum(rate(http_server_duration_milliseconds_count{http_status_code=~\"5..\"}[$__rate_interval])) / sum(rate(http_server_duration_milliseconds_count[$__rate_interval]))*1) or on() vector(0)", "instant": false, "legendFormat": "__auto", "range": true, "refId": "A" } ], "title": "Error %", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "${datasource}" }, "description": "95th percentile of HTTP server request duration in seconds", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 25, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] }, "unit": "s" }, "overrides": [] }, "gridPos": { "h": 8, "w": 8, "x": 16, "y": 0 }, "id": 52, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, "targets": [ { "datasource": { "type": "prometheus", "uid": "${datasource}" }, "editorMode": "code", "expr": "(histogram_quantile(0.95, sum by(le, instance) (rate({__name__=~\"http_server_request_duration_seconds_bucket|http_server_request_duration_bucket|http_server_duration_seconds_bucket|http_server_duration_bucket|http_server_requests_seconds_bucket\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))))\nor\n(histogram_quantile(0.95, sum by(le, instance) (rate({__name__=~\"http_server_duration_milliseconds_bucket|http_server_requests_milliseconds_bucket|http_server_requests_bucket\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))) / 1000)\n", "instant": false, "legendFormat": "{{instance}}", "range": true, "refId": "A" } ], "title": "Duration (95%)", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "${datasource}" }, "description": "CPU utilization for the whole system", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 25, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] }, "unit": "percentunit" }, "overrides": [] }, "gridPos": { "h": 7, "w": 13, "x": 0, "y": 8 }, "id": 38, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } }, "targets": [ { "datasource": { "type": "prometheus", "uid": "${datasource}" }, "editorMode": "code", "expr": "process_cpu_utilization_ratio", "legendFormat": "{{instance}}", "range": true, "refId": "A" } ], "title": "CPU utilization", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "${datasource}" }, "description": "Used heap memory / heap memory limit ", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 25, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] }, "unit": "bytes" }, "overrides": [] }, "gridPos": { "h": 7, "w": 11, "x": 13, "y": 8 }, "id": 30, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "none" } }, "targets": [ { "datasource": { "type": "prometheus", "uid": "${datasource}" }, "editorMode": "code", "expr": "process_memory_usage_bytes", "legendFormat": "{{instance}}", "range": true, "refId": "A" } ], "title": "Heap Memory utilization", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "${datasource}" }, "description": "Percentage of time spend for garbage collection pauses", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "bar", "fillOpacity": 25, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null } ] }, "unit": "short" }, "overrides": [] }, "gridPos": { "h": 7, "w": 13, "x": 0, "y": 15 }, "id": 46, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, "targets": [ { "datasource": { "type": "prometheus", "uid": "${datasource}" }, "editorMode": "code", "expr": "sum by (instance) (rate(cpython_gc_collections_total[$__rate_interval]))*60", "hide": false, "legendFormat": "__auto", "range": true, "refId": "B" } ], "title": "Garbage Collection", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "${datasource}" }, "description": "Number of currently executing (also called \"live\") threads", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 25, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] }, "unit": "none" }, "overrides": [] }, "gridPos": { "h": 7, "w": 11, "x": 13, "y": 15 }, "id": 42, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, "targets": [ { "datasource": { "type": "prometheus", "uid": "${datasource}" }, "editorMode": "code", "expr": "process_thread_count", "legendFormat": "{{instance}}", "range": true, "refId": "A" } ], "title": "Threads", "type": "timeseries" } ], "refresh": "", "schemaVersion": 38, "tags": ["Python"], "templating": { "list": [ { "current": { "selected": false, "text": "Prometheus", "value": "prometheus" }, "description": "Choose a Prometheus data source", "hide": 0, "includeAll": false, "label": "Data source", "multi": false, "name": "datasource", "options": [], "query": "prometheus", "queryValue": "", "refresh": 1, "regex": "", "skipUrlSync": false, "type": "datasource" }, { "allValue": ".+", "current": { "selected": false, "text": "All", "value": "$__all" }, "datasource": { "type": "prometheus", "uid": "${datasource}" }, "definition": "label_values(http_server_request_duration_seconds_count, job)", "hide": 0, "includeAll": true, "label": "Job", "multi": true, "name": "job", "options": [], "query": { "query": "label_values(http_server_request_duration_seconds_count, job)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 2, "regex": "", "skipUrlSync": false, "sort": 1, "tagValuesQuery": "", "tagsQuery": "", "type": "query", "useTags": false }, { "allValue": ".+", "current": { "selected": false, "text": "All", "value": "$__all" }, "datasource": { "type": "prometheus", "uid": "${datasource}" }, "definition": "label_values(http_server_request_duration_seconds_count, instance)", "description": "The instance of the application, e.g. pod1", "hide": 0, "includeAll": true, "label": "Instance", "multi": true, "name": "instance", "options": [], "query": { "query": "label_values(http_server_request_duration_seconds_count, instance)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 2, "regex": "", "skipUrlSync": false, "sort": 1, "tagValuesQuery": "", "tagsQuery": "", "type": "query", "useTags": false } ] }, "time": { "from": "now-1h", "to": "now" }, "timepicker": { "refresh_intervals": [ "5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d" ], "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] }, "timezone": "", "title": "python-Objability", "uid": "python-dashboard-metrics", "version": 1, "weekStart": "" } </details>
なるほど、シンドイ compose#14285
The "DB_PASSWORD" variable is not set. Defaulting to a blank string: fixing unset ${VAR} in Docker Compose WARN[0000] The "DB_PASSWORD" variable is not set. Defaulting to a blank string. Compose prints that, then starts the stack anyway. The database exits, or the app connects with an empty password, and you only find the yellow line by scrolling back up. The frustrating part: DB_PASSWORD is right there in app.env, which the service loads with env_file:. Two stages, two sets of files Interpolation happens on your machine when Compose reads the YAML. ${VAR} and $VAR are filled from your shell, --env-file, or the project .env next to the compose file. Nothing else. If a variable is missing and has no default, it becomes "" with a warning. Container environment comes later. env_file: and environment: decide what the process inside the container sees. app.env is read here, so it can never fill a ${...} in the compose file. Two more traps from the same root cause: - environment: [DEBUG] with nothing set on the host is silently dropped, no warning. - command: sh -c 'echo $CACHE_DIR' is interpolated from your laptop while the file loads. Quotes are just YAML text. Write $$CACHE_DIR if the container should expand it. The request to make this fail by default is years old: docker/compose#5505. The fix # require it: Compose stops with an error instead of a blank POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} Put interpolation variables in the project .env, escape container-side ones as $$, and never commit ${DB_PASSWORD:-hunter2}. docker compose config shows the result, but only when you run it, and it prints secrets in clear text. Catch it in the editor I built ComposeVars, a free VS Code extension that reads your compose files and the env files Compose would read (.env, include: env files, and --env-file / COMPOSE_ENV_FILES found in package.json, Makefile or justfile). Six rules, CV001 to CV006: unset variables, .env keys nothing uses, pass-through env that is not set, missing env_file paths, host-side $VAR in commands, and committed secret defaults. Quick Fixes add :?, :-, $$ or an .env.example entry, and hovering a variable shows where its value comes from. Secret-looking values are always masked, files outside the workspace are never read, and there is no network or telemetry. It cannot see your shell, so a variable you always export needs to go in composeVars.ignoreVariables. It gets its own activity bar icon with a badge, and a Findings panel listing each problem by variable name, with inline Quick Fixes. code --install-extension jaytankdev.composevars Source (MIT): jay-tank/composevars Full write-up (every rule, the interpolation syntax, limitations, FAQ): The "DB_PASSWORD" variable is not set. Defaulting to a blank string