## Le plafond, mesuré — la section du README qui disait « à vérifier » ne le dit plus
`minio/README.md` portait : « ce plafond n'a PAS été mesuré ici, il doit l'être
avec un vrai téléversement avant d'annoncer une limite aux utilisateurs ».
Sonde par PUT NON SIGNÉ vers le bucket : rien ne s'écrit, et les deux réponses se
distinguent proprement — un 403 vient de MinIO (le corps a donc traversé le
tunnel), un 413 vient du tunnel.
100 Mio → 403 corps passé
101 Mio → 413 refusé par le tunnel
⚠ Le plafond est EXACTEMENT 100 Mio, alors que kadans-api annonçait 200 Mio.
⚠ Et la parade retenue n'est PAS celle que le README recommandait. Ramener le
gabarit sous le plafond aurait aussi fermé les cours longs (~29 min au palier
« travail »). C'est le téléversement en PARTS qui a été livré (kadans-api #188).
## Ce que ça crée comme déchet, et à qui il appartient
Un téléversement en parts jamais refermé laisse ses parts dans le bucket :
`mc ls` n'en dit rien, la console non plus, aucun objet ne les montre. Une fuite
qui ne se voit qu'à la facture — ou à la saturation d'un volume de 50 Gi.
L'app abandonne ce qu'elle ouvre quand elle échoue en route. Elle ne peut PAS
rattraper le navigateur qui ferme l'onglet.
⚠ LE README DU MODULE DISAIT « il ne pose ni quota, ni règle de cycle de vie » —
et cette règle-ci ne le contredit pas, elle en précise la frontière. La question
qui tranche est « à QUOI cette connaissance appartient-elle ? » :
- une EXPIRATION DE CONTENU (« ces vidéos se purgent à 90 jours ») demande de
connaître le produit. Elle est chez l'app ;
- un téléversement incomplet n'est le contenu de PERSONNE. Aucune app ne veut
le garder, aucune ne peut le voir depuis son code. C'est un déchet de
PROTOCOLE, produit par le mécanisme même du bucket : il est chez celui qui
crée les buckets.
Test pratique écrit au README : si répondre à « combien de temps ? » exige de
connaître le produit, c'est chez l'app. Ici la réponse n'exige que de connaître
S3 — passé l'expiration des URL signées (2 h côté kadans-api), un téléversement
ne peut plus RIEN recevoir. D'où 1 jour, douze fois la marge, et pas 7.
⚠ Non paramétrable (YAGNI) : un seul cas. Le déclencheur pour en faire une
variable est écrit — une app qui signerait des parts au-delà de 24 h.
## ⚠ Ce réglage-ci fonctionne, contrairement au CORS par bucket
MinIO communautaire stubbe `PutBucketCors` en 501 (`cmd/dummy-handlers.go`), ce
qui avait déjà coûté une tentative d'IaC — c'est écrit dans le `iac/main.tf` du
dépôt front. Les handlers de CYCLE DE VIE, eux, n'y figurent PAS : vérifié à la
source avant d'écrire une ligne, pour ne pas répéter exactement cette erreur.
## Le plancher de version, et pourquoi il est là
`abort_incomplete_multipart_upload` est apparu en **3.10.0** — mesuré en
interrogeant le schéma du provider version par version (3.9.0 ne l'a pas,
3.10.0 l'a). Le module déclare donc `>= 3.10.0` LUI-MÊME.
⚠ Sans ce plancher, un appelant resté sur 3.3.0 échouerait au plan sur un
« unsupported block type » qui ne dit pas qu'il faut monter de version. Avec, il
lit dès `tofu init` : « no available releases match the given constraints 3.3.0,
>= 3.10.0 ».
## ⚠ ORDRE DE FUSION
Cette PR fait passer tout appelant du module sous le plancher 3.10.0. Le dépôt
`kadans` épingle encore 3.3.0 : **son bump doit atterrir AVANT celle-ci**, sinon
son apply casse entre les deux fusions.
## Preuve
`tofu validate` contre le schéma RÉEL du provider 3.10.0, module instancié depuis
un bac à sable (aucun backend, aucun appel à MinIO ni Vault) : « Success! The
configuration is valid. »
Et le plancher a été éprouvé plutôt que relu : épinglé à 3.3.0, `tofu init` rend
bien le refus cité ci-dessus.
Co-Authored-By: Claude Opus 5 <[email protected]>
169 lines
8.4 KiB
Markdown
169 lines
8.4 KiB
Markdown
# MinIO — stockage objet S3 du homelab
|
||
|
||
Brique **partagée** du namespace `tools`, au même titre que pgbouncer ou
|
||
clickhouse. Le serveur vit ici ; les buckets, quotas et identifiants d'une
|
||
application vivent avec cette application.
|
||
|
||
## Premier consommateur : Kadans
|
||
|
||
- [ADR-012](https://gitea.arcodange.lab/arcodange/kadans/src/branch/main/docs/adr/012-video-storage-minio-first.md)
|
||
« MinIO local d'abord » — bascule vers Cloudflare R2 prévue aux seuils :
|
||
100+ utilisateurs actifs, > 10 To/mois, ou dispersion géographique.
|
||
- [ADR-013](https://gitea.arcodange.lab/arcodange/kadans/src/branch/main/docs/adr/013-video-storage-opfs-local-first.md)
|
||
le gratuit est **local-first** (la vidéo ne quitte pas l'appareil) ; MinIO sert
|
||
les **paliers payants**.
|
||
- [ADR-018](https://gitea.arcodange.lab/arcodange/kadans/src/branch/main/docs/adr/018-qualite-video-au-transfert.md)
|
||
ce qui transite est **dérivé** (aperçu 240p ~50 Ko, travail 360p ~3,4 Mo/min) —
|
||
le master reste chez l'utilisateur. D'où le dimensionnement ci-dessous.
|
||
|
||
## Ce que ce chart pose
|
||
|
||
| | |
|
||
|---|---|
|
||
| Mode | **standalone** (1 réplique) — la donnée est dérivée et Longhorn réplique déjà le volume ; l'erasure coding distribué coûterait de la RAM que des Pi 5 n'ont pas à dépenser pour ça |
|
||
| Volume | **50 Gi** sur `longhorn` ≈ **250 h de cours** au palier « travail ». ⚠ Longhorn réplique : compter **×3** sur la capacité du cluster avant d'augmenter |
|
||
| Ressources | requests 512 Mi / 100 m · limit 2 Gi — la limite protège les voisins de `tools`, pas MinIO |
|
||
| API S3 | `s3.arcodange.lab` (interne) **et `s3.arcodange.fr`** (public, tunnel Cloudflare → entrypoint `web` + crowdsec) — voir « Pourquoi une exposition publique » |
|
||
| Console | `minio.arcodange.lab` (Traefik) |
|
||
| Buckets | **aucun ici** — chaque app déclare les siens depuis son dépôt (module `minio_app`). Tous privés : l'accès passe par des URL signées (ADR-0002 du dossier produit) |
|
||
| Identifiants | **jamais dans le dépôt** : `iac/` les génère dans Vault (`kvv2/minio/config`), le Vault Secrets Operator les matérialise en secret `minio-config`, le chart les lit via `existingSecret` |
|
||
|
||
Le ServiceAccount du pod est nommé `minio` (et non le `minio-sa` par défaut du
|
||
chart amont) parce que le module Vault `app_roles` borne l'authentification au
|
||
SA portant le nom de l'app : un seul SA, rien à réconcilier.
|
||
|
||
## Première mise en service
|
||
|
||
L'ordre compte, et il compte **deux fois** :
|
||
|
||
1. **Workflow `Hashicorp Vault`** — MinIO doit d'abord figurer dans
|
||
`hashicorp-vault/iac/terraform.tfvars` (c'est fait) : c'est **là** que naît
|
||
le rôle CI `gitea_cicd_minio`, et non dans `minio/iac`. Sans cette étape,
|
||
le workflow MinIO échoue sur
|
||
`role "gitea_cicd_minio" could not be found` — il essaie de s'authentifier
|
||
avec un rôle que personne n'a encore créé.
|
||
2. **Workflow `MinIO`** — applique `minio/iac` : rôle Kubernetes pour le Vault
|
||
Secrets Operator, et **génération** du mot de passe root dans
|
||
`kvv2/minio/config`.
|
||
3. **ArgoCD** synchronise l'application (déclarée dans `chart/values.yaml`).
|
||
4. Vérifier : `kubectl -n tools get vaultstaticsecret minio` (secret
|
||
matérialisé) puis `kubectl -n tools get pods -l app=minio`.
|
||
|
||
> [!NOTE]
|
||
> Sans le secret `minio-config`, le pod ne démarre pas. C'est voulu — mieux
|
||
> vaut un pod en attente qu'un MinIO ouvert avec des identifiants par défaut.
|
||
|
||
|
||
## Pourquoi une exposition publique (`s3.arcodange.fr`)
|
||
|
||
La PWA Kadans est servie en `https://kadans.arcodange.fr` et téléverse ses vidéos
|
||
**directement** vers MinIO, avec des URL présignées émises par kadans-api
|
||
(kadans-api#23) : les octets ne passent jamais par l'API.
|
||
|
||
Deux raisons rendent le `.lab` inutilisable pour ça, et ce sont des faits du
|
||
navigateur, pas des préférences :
|
||
|
||
1. **Contenu mixte** — une page servie en `https` ne peut pas émettre une requête
|
||
vers `http://`. L'ingress `.lab` est en entrypoint `web` sans TLS.
|
||
2. **`.lab` n'est pas résolvable hors du LAN** — la synchronisation ne marcherait
|
||
qu'à la maison, ce qui vide de son sens « retrouver mes vidéos sur mon autre
|
||
appareil ».
|
||
|
||
**Pas de basic-auth** sur cet ingress, contrairement à `kadans-public` : une
|
||
requête S3 porte sa propre signature (SigV4). Un défi HTTP Basic casserait le PUT
|
||
présigné, auquel le navigateur ne peut pas répondre. L'autorisation vient de
|
||
l'URL signée et de sa durée de vie courte (15 min pour déposer, 1 h pour lire).
|
||
|
||
**CORS** (`MINIO_API_CORS_ALLOW_ORIGIN`) liste les origines EXACTES de la PWA —
|
||
jamais `*` : une URL présignée qui fuiterait serait sinon rejouable depuis
|
||
n'importe quel site.
|
||
|
||
### La taille maximale d'une requête — **mesurée le 2026-08-20**
|
||
|
||
Le trafic public passe par un **tunnel Cloudflare**, qui plafonne la taille du
|
||
corps d'une requête proxifiée. Ce plafond était marqué ici « à vérifier avant de
|
||
s'y fier ». Il l'est.
|
||
|
||
Sonde : un PUT **non signé** vers le bucket. Rien ne s'écrit, et les deux
|
||
réponses se distinguent proprement — un 403 vient de MinIO (donc le corps a
|
||
traversé le tunnel), un 413 vient du tunnel lui-même.
|
||
|
||
| Corps | Réponse | Lecture |
|
||
|---:|---|---|
|
||
| 50 Mio | `403` | corps passé, refus de signature |
|
||
| **100 Mio** | `403` | **corps passé** |
|
||
| **101 Mio** | `413` | **refusé par le tunnel** |
|
||
| 200 Mio | `413` | refusé par le tunnel |
|
||
|
||
⚠ **Le plafond est exactement 100 Mio.** Or `kadans-api` annonçait un gabarit de
|
||
200 Mio — le **double**. Une vidéo entre les deux était acceptée, signée,
|
||
téléversée pendant ~100 Mo… puis coupée.
|
||
|
||
⚠ **La parade retenue n'est PAS celle que cette section recommandait.** Ramener
|
||
le gabarit sous le plafond du tunnel aurait aussi fermé les cours longs (au
|
||
palier « travail » de l'ADR-018, 360p ≈ 3,4 Mo/min, 100 Mio ≈ 29 min). C'est le
|
||
**téléversement en plusieurs parts** qui a été livré (kadans-api PR #188) : des
|
||
parts de 8 Mio passent chacune très en dessous du plafond, sans rétrécir la
|
||
promesse.
|
||
|
||
Le corpus, lui, reste largement sous la limite : sur les 707 vidéos du fondateur
|
||
(~2 ans), **la durée moyenne est de 53 secondes** et **deux seulement dépassent
|
||
5 minutes**. C'est ce qui explique que le défaut n'ait jamais été rencontré — et
|
||
pourquoi il attendait le premier cours entier.
|
||
|
||
⚠ **Conséquence pour le bucket, et elle est ici :** un téléversement en parts
|
||
jamais refermé laisse des parts que *rien ne montre*. Le module `minio_app` pose
|
||
donc sur chaque bucket une règle de cycle de vie
|
||
`AbortIncompleteMultipartUpload` à **1 jour** — voir son `main.tf`.
|
||
|
||
|
||
## Donner à une app l'accès au stockage
|
||
|
||
**Rien à faire ici.** Chaque application déclare **ses** buckets **depuis son
|
||
propre dépôt**, avec le module que ce dépôt-ci fournit :
|
||
|
||
```hcl
|
||
# iac/main.tf de l'application
|
||
data "vault_kv_secret_v2" "minio_provisioner" {
|
||
mount = "kvv2"
|
||
name = "minio/provisioner"
|
||
}
|
||
|
||
provider "minio" {
|
||
minio_server = "s3.arcodange.fr"
|
||
minio_user = data.vault_kv_secret_v2.minio_provisioner.data["MINIO_ACCESS_KEY"]
|
||
minio_password = data.vault_kv_secret_v2.minio_provisioner.data["MINIO_SECRET_KEY"]
|
||
minio_ssl = true
|
||
}
|
||
|
||
module "stockage" {
|
||
source = "git::…/tools.git//minio/iac/modules/minio_app?depth=1&ref=main"
|
||
app = "mon-app"
|
||
buckets = ["mon-app-fichiers"]
|
||
providers = { minio = minio }
|
||
}
|
||
```
|
||
|
||
Voir `iac/modules/minio_app/README.md`. **Chacun son périmètre** : `tools`
|
||
fournit le serveur, le provisionneur et le module — pas la liste des buckets.
|
||
Sans ça, chaque bucket de chaque app deviendrait une PR sur l'infra partagée.
|
||
|
||
### Ce que `tools` fournit, et pourquoi
|
||
|
||
| Pièce | Rôle |
|
||
|---|---|
|
||
| Le serveur | le chart, son volume, ses ingress (interne + public) |
|
||
| Le **root** | généré ici, écrit dans `kvv2/minio/config`, **ne sort jamais** de ce pipeline |
|
||
| Le **provisionneur** | un compte aux droits d'administration MINIMAUX (créer bucket, politique, compte de service) et **aucun droit sur les objets** — lisible par le rôle CI de chaque app |
|
||
| Le **module** | `minio_app` : standardise la déclaration, sans la détenir |
|
||
|
||
Donner le root aux apps aurait été absurde : il lit et écrit **tous** les objets
|
||
de **toutes** les apps. Le provisionneur, lui, peut créer des buckets — une
|
||
nuisance si une app est compromise — mais **pas lire les vidéos d'une autre**.
|
||
|
||
### Rotation
|
||
|
||
Depuis l'`iac/` de l'app : détruire `module.stockage.random_password.app` et
|
||
relancer son plan. La clé change, `force_destroy = false` garde le compte, et
|
||
les objets déjà déposés conservent leur propriétaire.
|