Tous les articles
Mar. 4 août 2026·7 min de lecture

☸️ Kubernetes chez Scaleway : Kapsule, HAProxy en ingress et le piège du CPU steal

Kubernetes Kapsule chez Scaleway

📚 Introduction

Kubernetes managé chez Scaleway, c’est Kapsule : Scaleway opère le control plane (API server, etcd, scheduler, controller manager), tu opères les nœuds. Le prix d’entrée est bas, la console est agréable, et le provider Terraform couvre tout le produit. Sur le papier, c’est trente lignes de HCL et un cluster tourne.

Le problème n’est jamais là. Il est dans deux décisions qu’on prend en cinq secondes au moment du scaffolding et qu’on regrette six mois plus tard : le type d’Instance des nœuds et l’ingress controller. La première coûte de la latence inexpliquée, la seconde des rechargements de config au pire moment. On monte donc l’ensemble en Terraform, on pose HAProxy en ingress derrière un Load Balancer Scaleway, et on regarde pourquoi la gamme qui s’appelle « PRO2 » n’est pas celle que tu crois. Si Kubernetes lui-même est encore flou, commence par comprendre l’orchestration de conteneurs ou par un k3s en local.

🧱 Le scaffolding Terraform

Le provider officiel est scaleway/scaleway (2.80.0 au moment où j’écris). On commence par le socle réseau : un VPC, un Private Network — il est obligatoire sur Kapsule, private_network_id est un champ requis de la ressource cluster.

terraform {
  required_version = ">= 1.9"
 
  required_providers {
    scaleway = {
      source  = "scaleway/scaleway"
      version = "~> 2.80"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "~> 3.2"
    }
  }
}
 
provider "scaleway" {
  region = "fr-par"
  zone   = "fr-par-1"
}
 
locals {
  pn_subnet = "172.16.32.0/22"
}
 
resource "scaleway_vpc" "main" {
  name = "prod"
}
 
resource "scaleway_vpc_private_network" "main" {
  name   = "prod-k8s"
  vpc_id = scaleway_vpc.main.id
 
  ipv4_subnet {
    subnet = local.pn_subnet
  }
}

Vient ensuite le cluster, avec deux arbitrages structurants. Le type : kapsule désigne le control plane mutualisé (pas de SLA, 150 nœuds max, etcd plafonné à 55 Mo), tandis que kapsule-dedicated-4, -8 et -16 donnent de la RAM dédiée, deux réplicas d’API server en HA, un SLA de 99,5 % et les audit logs. Et delete_additional_resources : à true, détruire le cluster emporte volumes Block, Load Balancers et Private Network. En production, false.

resource "scaleway_k8s_cluster" "main" {
  name                        = "prod"
  type                        = "kapsule-dedicated-4"
  version                     = "1.35"
  cni                         = "cilium"
  private_network_id          = scaleway_vpc_private_network.main.id
  delete_additional_resources = false
 
  auto_upgrade {
    enable                        = true
    maintenance_window_start_hour = 3
    maintenance_window_day        = "tuesday"
  }
 
  autoscaler_config {
    balance_similar_node_groups   = true
    ignore_daemonsets_utilization = true
    scale_down_delay_after_add    = "10m"
  }
}

Note le version = "1.35" sans patch : c’est ce qu’exige auto_upgrade, qui attend une version mineure x.y. La liste des versions réellement disponibles se sort avec scw k8s version list.

Puis les pools. Je sépare toujours un pool « system » (ingress, monitoring, cert-manager) d’un pool « apps », pour pouvoir leur donner des types d’Instance différents — c’est exactement le sujet de la section suivante.

resource "scaleway_k8s_pool" "system" {
  cluster_id  = scaleway_k8s_cluster.main.id
  name        = "system"
  node_type   = "POP2-4C-16G"
  size        = 2
  min_size    = 2
  max_size    = 3
  autoscaling = true
  autohealing = true
 
  labels = {
    "arkoder.dev/pool" = "system"
  }
 
  lifecycle {
    create_before_destroy = true
  }
}
 
resource "scaleway_k8s_pool" "apps" {
  cluster_id  = scaleway_k8s_cluster.main.id
  name        = "apps"
  node_type   = "PRO2-S"
  size        = 3
  min_size    = 2
  max_size    = 8
  autoscaling = true
  autohealing = true
}

🔎 Pourquoi PRO2 n’est-il pas un type à vCPU dédié ?

Réponse courte : parce que le nom commercial ne dit rien du mode d’allocation CPU. La documentation Scaleway est explicite sur ce point — dans la gamme General Purpose, PLAY2 et PRO2 sont en « Shared vCPUs », alors que POP2 et STANDARD3-X sont en « Dedicated vCPUs ».

GammeAllocation CPU
PLAY2, PRO2, BASIC2-A, BASIC3-XvCPU partagés
POP2, STANDARD3-XvCPU dédiés

Ce que « partagé » veut dire concrètement : l’hyperviseur ordonnance tes vCPU sur des cœurs physiques que se partagent plusieurs Instances. Scaleway le formule sans détour dans sa doc : « during peak demand from other Instances on the same host, your workloads might temporarily slow down due to CPU contention (also known as “CPU steal”) ». La RAM, elle, reste toujours dédiée.

Le steal est vicieux pour une raison précise : il n’apparaît nulle part dans tes métriques Kubernetes. Le temps volé par l’hyperviseur n’est imputé à aucun conteneur. Ton pod semble consommer 300 mCPU sur les 500 demandés, le HPA ne bronche pas, les requests/limits sont respectées — et pourtant le p99 double. Vu depuis le cluster, tout va bien. Vu depuis l’utilisateur, non.

D’où le pool séparé plus haut : sur system, qui héberge l’ingress et donc chaque requête entrante, je paie du POP2 ; sur apps, du PRO2 partagé suffit tant que les workloads tolèrent de la variabilité. Scaleway liste d’ailleurs « worker nodes in container orchestration clusters such as Kubernetes » parmi les cas d’usage légitimes du vCPU partagé — c’est un arbitrage assumé, pas une erreur.

🛠️ HAProxy en ingress controller

Kapsule ne pré-installe pas d’ingress controller. Je prends HAProxy pour une raison technique concrète : le controller pré-alloue des emplacements de serveurs dans ses backends (scale-server-slots, 42 par défaut) et les remplit dynamiquement via la Runtime API. Résultat, un scale-up de pods ne provoque pas de rechargement de configuration. Sur un cluster qui scale toute la journée, ça se voit.

Le Load Balancer est fourni par le cloud controller manager Scaleway dès qu’un Service est de type LoadBalancer. On réserve l’IP en Terraform pour qu’elle survive à une recréation du service :

resource "scaleway_lb_ip" "ingress" {
  zone = "fr-par-1"
}
 
resource "helm_release" "haproxy" {
  name             = "haproxy"
  namespace        = "ingress"
  create_namespace = true
 
  repository = "https://haproxytech.github.io/helm-charts"
  chart      = "kubernetes-ingress"
  version    = "1.52.1"
 
  values = [yamlencode({
    controller = {
      kind         = "DaemonSet"
      ingressClass = "haproxy"
 
      ingressClassResource = {
        name    = "haproxy"
        default = true
      }
 
      config = {
        "proxy-protocol"     = local.pn_subnet
        "scale-server-slots" = "64"
        "timeout-client"     = "30s"
      }
 
      nodeSelector = {
        "arkoder.dev/pool" = "system"
      }
 
      service = {
        type                  = "LoadBalancer"
        externalTrafficPolicy = "Local"
 
        annotations = {
          "service.beta.kubernetes.io/scw-loadbalancer-ip-ids"            = split("/", scaleway_lb_ip.ingress.id)[1]
          "service.beta.kubernetes.io/scw-loadbalancer-zone"              = scaleway_lb_ip.ingress.zone
          "service.beta.kubernetes.io/scw-loadbalancer-proxy-protocol-v2" = "true"
        }
      }
    }
  })]
}

Trois points méritent qu’on s’y arrête. scw-loadbalancer-proxy-protocol-v2 active le PROXY protocol côté Load Balancer, sans quoi tes logs applicatifs voient l’IP du LB au lieu de celle du client ; côté HAProxy, la clé de configmap proxy-protocol déclare quelles sources ont le droit de l’envoyer — d’où le CIDR du Private Network plutôt qu’un 0.0.0.0/0 paresseux. externalTrafficPolicy: Local évite le saut réseau supplémentaire entre nœuds et préserve l’IP source. Enfin nodeSelector épingle l’ingress sur le pool à vCPU dédiés : c’est là que la section précédente devient une décision d’architecture. Au-dessus, Helm et Argo CD prennent le relais — Terraform s’arrête à l’infrastructure.

🔍 Détecter le steal avant qu’il ne te détecte

Sur un nœud, vmstat 1 suffit : la colonne st donne le pourcentage de temps CPU volé. Au-delà de quelques pourcents soutenus, tu paies pour du CPU que tu n’as pas.

Côté cluster, node-exporter expose tout ce qu’il faut :

# proportion de temps CPU volée par l'hyperviseur, par nœud
sum by (instance) (rate(node_cpu_seconds_total{mode="steal"}[5m]))
  / sum by (instance) (rate(node_cpu_seconds_total[5m]))

Et surtout, ne confonds pas les deux causes de ralentissement. Le steal vient de l’extérieur (l’hyperviseur), le throttling CFS vient de tes propres limites :

# throttling CFS : ce sont tes limits, pas ton voisin
rate(container_cpu_cfs_throttled_seconds_total[5m])

La première se corrige en changeant de gamme d’Instance, la seconde en changeant tes limits. Traiter l’une pour l’autre est le meilleur moyen de perdre une après-midi. Une stack comme SigNoz ou le Cockpit intégré de Scaleway te donne les deux séries côte à côte.

⚠️ Quelques précautions

  • L’engagement des control planes dédiés est de 30 jours calendaires. Passer à un tier supérieur remet le compteur à zéro, et un downgrade est interdit pendant l’engagement. terraform apply ne t’en préviendra pas.
  • etcd est plafonné : 55 Mo en mutualisé, 200 Mo en dédié. Et on ne peut pas redescendre du dédié vers le mutualisé si le quota dépasse déjà l’allocation mutualisée.
  • Le kubeconfig atterrit dans le state. L’attribut kubeconfig du cluster contient le token d’accès : backend distant chiffré obligatoire, comme pour n’importe quel état Terraform ou OpenTofu sérieux.
  • Changer node_type recrée le pool. D’où le lifecycle { create_before_destroy = true }, qui impose au passage de laisser Scaleway générer le nom du pool — l’API refuse deux pools homonymes.
  • public_ip_disabled exige une Public Gateway attachée au Private Network. C’est la bonne pratique, mais ça ajoute une ressource et un coût qu’il vaut mieux prévoir dès le scaffolding.

🎉 Conclusion

Kapsule fait très correctement son travail : control plane managé, provider Terraform sans zone d’ombre, Load Balancers pilotés proprement par annotations. Ce qui demande de l’attention, ce n’est pas l’outillage, c’est le choix de gamme. Retiens ceci : PLAY2 et PRO2 partagent leurs vCPU, POP2 et STANDARD3-X non. Sépare tes pools, mets l’ingress et tout ce qui est sur le chemin critique de la requête sur du dédié, garde le partagé pour ce qui tolère la variabilité, et instrumente mode="steal" dès le premier jour. Le reste, c’est du Terraform ordinaire.

🔗 Liens utiles

/faq

Questions fréquentes

Les Instances PRO2 de Scaleway ont-elles des vCPU dédiés ?

+

Non. La documentation Scaleway classe PLAY2 et PRO2 dans les « Shared vCPUs » : leurs vCPU sont ordonnancés sur des cœurs physiques partagés entre plusieurs Instances. Pour des vCPU dédiés dans la gamme General Purpose, il faut passer sur POP2 ou STANDARD3-X.

Comment mesurer le CPU steal sur un nœud Kubernetes ?

+

Le steal se lit dans la colonne « st » de vmstat ou top sur le nœud, et se collecte via node-exporter avec la métrique node_cpu_seconds_total filtrée sur mode=steal. Il ne faut pas le confondre avec le throttling CFS, qui se mesure lui avec container_cpu_cfs_throttled_seconds_total.

Pourquoi choisir HAProxy plutôt que NGINX comme ingress controller ?

+

L'ingress controller HAProxy pré-alloue des emplacements de serveurs dans ses backends (clé scale-server-slots, 42 par défaut) et les remplit via la Runtime API. Le scaling des pods ne déclenche donc pas de rechargement de la configuration, ce qui évite les à-coups de latence sur un cluster qui scale souvent.

Un Private Network est-il obligatoire sur un cluster Kapsule ?

+

Oui. Le champ private_network_id de la ressource scaleway_k8s_cluster est requis, et le provider précise qu'un cluster historique sans Private Network peut être migré sans être recréé.