<?xml version='1.0' encoding='UTF-8'?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="fr" xml:base="https://vincent.bernat.ch">
  <style xmlns="http://www.w3.org/1999/xhtml">
    feed &gt; * { display: none }
    feed &gt; div { display: block }
  </style>
  <div xmlns="http://www.w3.org/1999/xhtml">
    <p>
      Ce flux RSS est aussi disponible dans <a href="/fr/blog/atom">votre navigateur</a> !

    </p>
  </div>
  <meta xmlns="http://www.w3.org/1999/xhtml" http-equiv="refresh" content="0; url=/fr/blog/atom"/>
  <title>Vincent Bernat</title>
  <link href="https://vincent.bernat.ch/fr/blog/atom.xml" rel="self"/>
  <link href="https://vincent.bernat.ch/fr" rel="alternate"/>
  <id>http://www.luffy.cx/fr/blog/atom.xml/</id>
  <updated>2026-09-27T11:23:24Z</updated>

  <entry>
    <title type="html">Des statistiques sans robots avec GoatCounter auto-hébergé sur NixOS</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/fr/blog/2026-goatcounter" rel="alternate"/>
    <link href="https://vincent.bernat.ch/fr/blog/2026-goatcounter#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-09-20T19:44:50Z</updated>
    <id>http://www.luffy.cx/fr/blog/2026-goatcounter.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml"><p>En 2016, j’ai <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/4c3be65404031bf6638c1ea3d891a3ea58e0a0fc" title="layout: remove Google Analytics">retiré Google Analytics</a> de ce blog pour ne pas me
rendre complice de la plus grande machine à aspirer les données personnelles. À
la place, j’analysais les journaux de mes serveurs<sup id="fnref:scrub"><a class="footnote-ref" href="#fn:scrub">1</a></sup> avec <a href="https://goaccess.io/" title="GoAccess: Visual Web Log Analyzer">GoAccess</a>.
Depuis quelques années, les statistiques ne veulent plus rien dire, malgré mes
tentatives pour filtrer les robots : <a href="https://herman.bearblog.dev/the-great-scrape/" title="The Great Scrape">les robots pilleurs des IA</a>
gonflent le nombre de visiteurs à environ 2 000 par jour. J’ai fini par adopter
<a href="https://www.goatcounter.com/" title="GoatCounter: open source web analytics">GoatCounter</a>, une plateforme libre de mesure d’audience, respectueuse de la
vie privée. J’ai remplacé le client JavaScript pour filtrer les robots de façon
plus agressive et ajouté une solution de repli en CSS. Pour gagner en fiabilité,
j’ai écrit un proxy local, installé sur chacun des cinq serveurs web qui
hébergent ce blog. La suite de cet article détaille comment ces éléments
s’articulent et comment je déploie l’ensemble sur NixOS. ❄️</p>
<div class="toc">
<ul>
<li><a href="#pourquoi-goatcounter">Pourquoi GoatCounter ?</a></li>
<li><a href="#client-javascript-sur-mesure">Client JavaScript sur mesure</a></li>
<li><a href="#proxy-local">Proxy local</a></li>
<li><a href="#deploiement-sur-nixos">Déploiement sur NixOS</a><ul>
<li><a href="#deployer-des-applications-dans-des-conteneurs">Déployer des applications dans des conteneurs</a></li>
<li><a href="#serveur-goatcounter">Serveur GoatCounter</a></li>
<li><a href="#proxy-goatcounter">Proxy GoatCounter</a></li>
<li><a href="#sauvegarde-de-la-base-de-donnees-sqlite-avec-litestream">Sauvegarde de la base de données SQLite avec Litestream</a></li>
</ul>
</li>
</ul>
</div>
<h1 id="pourquoi-goatcounter">Pourquoi GoatCounter ?</h1>
<p>GoatCounter ne <a href="https://www.goatcounter.com/help/sessions#technical-details-11181" title="GoatCounter documentation: sessions">collecte aucune donnée personnelle</a> : au
lieu de stocker l’adresse IP du lecteur ou de s’appuyer sur des cookies, il crée
un identifiant de session valide 8 heures à partir de l’agent utilisateur et de
l’adresse IP. Ses fonctionnalités sont modestes, mais suffisantes pour un blog.
Pour un aperçu de l’interface, l’auteur de GoatCounter propose une <a href="https://stats.arp242.net" title="Analytics for www.arp242.net">instance
publique</a> pour <a href="https://www.arp242.net" title="Personal website of Martin Tournoij">son site</a>. Une version en ligne permet de
l’essayer avant de l’héberger soi-même. Avec un seul binaire et une base de
données SQLite, GoatCounter est l’une des solutions auto-hébergées les plus
légères. Parmi les alternatives respectueuses de la vie privée figurent, par
ordre croissant de complexité, <a href="https://umami.is/" title="Umami: Privacy-First Analytics Platform">Umami</a>, <a href="https://plausible.io/" title="Plausible Analytics: Simple, privacy-friendly Google Analytics alternative">Plausible</a> et <a href="https://rybbit.com/" title="Rybbit: Cookieless Google Analytics Replacement">Rybbit</a>.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 889px"><span class="lf-media-inner" style="padding-bottom: 96.850%"><img alt="Tableau de bord de GoatCounter montrant les statistiques de mon blog sur la&#10;semaine passée, dont l'article sur le protocole Spanning Tree avec 1 224 vues,&#10;ainsi que les statistiques sur les navigateurs (52 % pour Chrome, 32 % pour&#10;Firefox)" src="https://d2pzklc15kok91.cloudfront.net/images/goatcounter@1x.66acd322278cc7.png" srcset="https://d2pzklc15kok91.cloudfront.net/images/goatcounter@1x.66acd322278cc7.png 1117w,https://d2pzklc15kok91.cloudfront.net/images/goatcounter@2x.2a83a0a4ba35db.png 1778w" sizes="auto, (max-width: 889px) 100vw, 889px" width="889" height="861" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAH8AAAB7AQMAAABn4s1GAAAABlBMVEX5+PcAAAD0D8ScAAAAGElEQVR42mNgGAWjYBSMglEwCkbBKMAJAAgrAAG9UB6fAAAAAElFTkSuQmCC)"/></span></div></figure>
<h1 id="client-javascript-sur-mesure">Client JavaScript sur mesure</h1>
<p>GoatCounter fournit un <a href="https://github.com/arp242/goatcounter/blob/main/public/count.js" title="GoatCounter's JavaScript client">petit client JavaScript</a> :
2 189 octets une fois minifié et compressé avec gzip. Il embarque des
fonctionnalités dont je ne me sers pas : un compteur de visites, le suivi des
clics, divers réglages, etc. Je le remplace par cette fonction pour enregistrer
une visite :</p>
<div class="language-javascript codehilite"><pre><span/><code><span class="kd">const</span><span class="w"> </span><span class="nx">count</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">({</span><span class="w"> </span><span class="nx">event</span><span class="p">,</span><span class="w"> </span><span class="nx">title</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{})</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kd">const</span><span class="w"> </span><span class="nx">params</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="ow">new</span><span class="w"> </span><span class="nx">URLSearchParams</span><span class="p">({</span>
<span class="w">    </span><span class="nx">p</span><span class="o">:</span><span class="w"> </span><span class="nx">event</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="nx">location</span><span class="p">.</span><span class="nx">pathname</span><span class="p">,</span>
<span class="w">    </span><span class="nx">t</span><span class="o">:</span><span class="w"> </span><span class="nx">title</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">title</span><span class="p">,</span>
<span class="w">    </span><span class="nx">r</span><span class="o">:</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">referrer</span><span class="p">,</span>
<span class="w">    </span><span class="nx">q</span><span class="o">:</span><span class="w"> </span><span class="nx">location</span><span class="p">.</span><span class="nx">search</span><span class="p">,</span>
<span class="w">    </span><span class="nx">s</span><span class="o">:</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">clientWidth</span><span class="p">,</span>
<span class="w">    </span><span class="nx">e</span><span class="o">:</span><span class="w"> </span><span class="o">!!</span><span class="nx">event</span><span class="p">,</span>
<span class="w">    </span><span class="nx">rnd</span><span class="o">:</span><span class="w"> </span><span class="nb">Math</span><span class="p">.</span><span class="nx">random</span><span class="p">().</span><span class="nx">toString</span><span class="p">(</span><span class="mf">36</span><span class="p">).</span><span class="nx">slice</span><span class="p">(</span><span class="mf">2</span><span class="p">,</span><span class="w"> </span><span class="mf">7</span><span class="p">),</span>
<span class="w">  </span><span class="p">});</span>
<span class="w">  </span><span class="nx">fetch</span><span class="p">(</span><span class="sb">`/count?</span><span class="si">${</span><span class="nx">params</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">keepalive</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">}).</span><span class="k">catch</span><span class="p">(()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{});</span>
<span class="p">};</span>
</code></pre></div>

<p>Pour filtrer les robots<sup id="fnref:bot-filter"><a class="footnote-ref" href="#fn:bot-filter">2</a></sup>, je vais plus loin en exigeant une
interaction de l’utilisateur, une idée reprise de <a href="https://bearblog.dev/" title="Bear Blog">Bear Blog</a>.</p>
<div class="language-javascript codehilite"><pre><span/><code><span class="kd">let</span><span class="w"> </span><span class="nx">sendHit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">(</span><span class="nx">sendHit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{},</span><span class="w"> </span><span class="nx">count</span><span class="p">());</span>
<span class="p">[</span><span class="s2">"touchmove"</span><span class="p">,</span><span class="w"> </span><span class="s2">"mousemove"</span><span class="p">,</span><span class="w"> </span><span class="s2">"keydown"</span><span class="p">,</span><span class="w"> </span><span class="s2">"pointerdown"</span><span class="p">].</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">eventName</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span>
<span class="w">  </span><span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="nx">eventName</span><span class="p">,</span><span class="w"> </span><span class="nx">sendHit</span><span class="p">,</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nx">once</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w">    </span><span class="nx">passive</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w">  </span><span class="p">}),</span>
<span class="p">);</span>
</code></pre></div>

<p>Si un lecteur a désactivé JavaScript dans son navigateur, j’enregistre la visite
à l’aide d’une image CSS. La pseudo-classe <code>:hover</code> ne la charge qu’après une
interaction, une autre astuce <a href="https://herman.bearblog.dev/how-bear-does-analytics-with-css/" title="How Bear does analytics with CSS">piquée à Bear Blog</a>. Environ 2 % de mes
visiteurs entrent dans cette catégorie<sup id="fnref:referrer"><a class="footnote-ref" href="#fn:referrer">3</a></sup>.</p>
<div class="language-html codehilite"><pre><span/><code><span class="cp">&lt;!DOCTYPE html&gt;</span>
<span class="p">&lt;</span><span class="nt">html</span> <span class="na">lang</span><span class="o">=</span><span class="s">"en"</span> <span class="na">class</span><span class="o">=</span><span class="s">"nojs"</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">head</span><span class="p">&gt;</span>
    <span class="p">&lt;</span><span class="nt">script</span><span class="p">&gt;</span>
<span class="w">      </span><span class="c1">// Le code JavaScript de ce blog requiert ES6</span>
<span class="w">      </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="s2">"noModule"</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="nx">HTMLScriptElement</span><span class="p">.</span><span class="nx">prototype</span><span class="p">)</span>
<span class="w">        </span><span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">remove</span><span class="p">(</span><span class="s2">"nojs"</span><span class="p">);</span>
<span class="w">    </span><span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
  <span class="p">&lt;/</span><span class="nt">head</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">body</span><span class="p">&gt;</span>
  <span class="cm">&lt;!-- ... --&gt;</span>
    <span class="p">&lt;</span><span class="nt">style</span><span class="p">&gt;</span>
<span class="w">      </span><span class="p">.</span><span class="nc">nojs</span><span class="w"> </span><span class="nt">body</span><span class="p">:</span><span class="nd">hover</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">border-width</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span>
<span class="w">        </span><span class="k">border-image</span><span class="p">:</span><span class="w"> </span><span class="nb">url</span><span class="p">(</span><span class="s1">'/count?p=/en/blog/2026-kpi-goodhart&amp;t=Building...&amp;r=NoJS&amp;e=false'</span><span class="p">);</span>
<span class="w">      </span><span class="p">}</span>
<span class="w">    </span><span class="p">&lt;/</span><span class="nt">style</span><span class="p">&gt;</span>
  <span class="p">&lt;/</span><span class="nt">body</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">html</span><span class="p">&gt;</span>
</code></pre></div>

<p>Là où GoAccess annonçait environ 2 000 visiteurs par jour, GoatCounter compte
moins de 200 humains<sup id="fnref:rss"><a class="footnote-ref" href="#fn:rss">4</a></sup>. Je suppose que ces pilleurs privilégient le moindre
effort : si le contenu est accessible sans obstacle, comme sur ce blog, ils ne
vont pas lancer une usine à gaz qui déclencherait une page vue. Même les robots
qui exécutent JavaScript, comme <a href="https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics" title="Google Search Central: Understand the JavaScript SEO basics">Googlebot avec son Chromium sans
interface</a>, <a href="https://developers.google.com/search/docs/crawling-indexing/javascript/lazy-loading#:~:text=Google%20Search%20does%20not%20interact%20with%20your%20page" title="Google Search Central: Fix lazy-loaded content">n’interagissent pas avec la page</a>
et ne déclenchent jamais les événements que j’écoute. La « preuve d’humanité »
par l’interaction que j’utilise me semble pouvoir durer<sup id="fnref:css-test"><a class="footnote-ref" href="#fn:css-test">5</a></sup>.</p>
<h1 id="proxy-local">Proxy local</h1>
<p>Cinq serveurs <del>à travers le monde</del> <ins>en Europe et en Amérique du
Nord</ins> servent le contenu de ce site, mais GoatCounter ne tourne que sur
l’un d’eux. Pour ne pas perdre la trace des visiteurs quand GoatCounter est
indisponible, j’exécute un proxy local qui écoute sur le même point de
terminaison <code>/count</code>. Sur chaque serveur, il conserve les visites en mémoire,
avec un tampon assez grand pour tenir plusieurs jours en cas d’indisponibilité.
Il les envoie par lots au serveur amont via le <a href="https://www.goatcounter.com/help/api" title="GoatCounter documentation: API">point de terminaison authentifié
<code>/api/v0/count</code></a>.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 836px"><span class="lf-media-inner" style="padding-bottom: 58.852%"><object width="836" height="492" type="image/svg+xml" data="https://d2pzklc15kok91.cloudfront.net/images/luffy-web-servers.0b64480815ecb8.svg" class="lf-media">&amp;#128444; Serveurs sur une carte. web02 est à Paris, web03 à Helsinki, web04 à
Nuremberg, web05 à Ashburn, web06 à
Chicago.</object></span></div></figure>
<p>J’ai proposé le code du proxy dans la <a href="https://github.com/arp242/goatcounter/pull/909" title="PR #909: add a proxy to forward page counts"><em>pull request</em> #909</a>. Le
mainteneur de GoatCounter a refusé de maintenir cet amas de code pour un cas
d’usage aussi niche. Développant moi aussi des logiciels libres, j’adopte
souvent la même position pour mes propres projets : l’effort ponctuel d’un
contributeur peut se transformer en engagement sur le long terme pour le
mainteneur.</p>
<p>J’expose le point de terminaison du proxy sur le domaine de ce site pour
échapper aux bloqueurs de publicité. Cela donne l’impression que je ne respecte
pas le choix du lecteur, mais comme GoatCounter respecte la vie privée, je
trouve cela acceptable.</p>
<div class="language-nginx-configuration-file codehilite"><pre><span/><code><span class="k">location</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">/count</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kn">access_log</span><span class="w"> </span><span class="no">off</span><span class="p">;</span>
<span class="w">  </span><span class="kn">proxy_pass</span><span class="w"> </span><span class="s">http://127.0.0.3:8087/count</span><span class="p">;</span>
<span class="w">  </span><span class="kn">proxy_pass_request_headers</span><span class="w"> </span><span class="no">off</span><span class="p">;</span>
<span class="w">  </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">Accept-Language</span><span class="w"> </span><span class="nv">$http_accept_language</span><span class="p">;</span>
<span class="w">  </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">User-Agent</span><span class="w"> </span><span class="nv">$http_user_agent</span><span class="p">;</span>
<span class="w">  </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">X-Real-Ip</span><span class="w"> </span><span class="nv">$remote_addr</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<h1 id="deploiement-sur-nixos">Déploiement sur NixOS</h1>
<p>Mes serveurs web tournent sous <a href="https://nixos.org/" title="Nix &amp; NixOS: Declarative builds and deployments">NixOS</a>, une distribution Linux déclarative
avec une gestion de configuration intégrée. Je gère ce petit troupeau avec
<a href="https://colmena.cli.rs/" title="Colmena documentation">Colmena</a>, un outil de déploiement sans état pour NixOS. Ma configuration est
disponible sur <a href="https://github.com/vincentbernat/nixops-take1/" title="vincentbernat/nixops-take1: Playground for NixOps">GitHub</a>.</p>
<h2 id="deployer-des-applications-dans-des-conteneurs">Déployer des applications dans des conteneurs</h2>
<p>Pour une meilleure isolation, chaque application tourne dans un conteneur léger
et éphémère, propulsé par <a href="https://manpages.debian.org/systemd-nspawn.1.html" title="Page de manuel pour systemd-nspawn(1)">systemd-nspawn</a>. Chaque conteneur exécute une
instance allégée de NixOS. Un <a href="https://github.com/vincentbernat/nixops-take1/blob/master/modules/container.nix" title="nixops-take1/modules/container.nix">module encapsule les options <code>containers</code> de
NixOS</a> pour éviter de répéter les mêmes options pour chaque
application<sup id="fnref:module"><a class="footnote-ref" href="#fn:module">6</a></sup>. Les conteneurs partagent la pile réseau de l’hôte :
l’isolation supplémentaire ne vaut pas la complexité accrue. Pour les alléger,
je désactive aussi quelques services non essentiels.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  <span class="c1"># Réglages configurables pour notre module</span>
  options<span class="o">.</span>luffy<span class="o">.</span><span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
    <span class="ss">default</span> <span class="o">=</span> <span class="p">{</span> <span class="p">};</span>
    <span class="ss">description</span> <span class="o">=</span> <span class="s2">"Ephemeral containers sharing the host network."</span><span class="p">;</span>
    <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>attrsOf <span class="p">(</span>lib<span class="o">.</span>types<span class="o">.</span>submodule <span class="p">{</span>
      <span class="ss">options</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">config</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
          <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>deferredModule<span class="p">;</span>
          <span class="ss">default</span> <span class="o">=</span> <span class="p">{</span> <span class="p">};</span>
          <span class="ss">description</span> <span class="o">=</span> <span class="s2">"NixOS configuration of the container."</span><span class="p">;</span>
        <span class="p">};</span>
      <span class="p">};</span>
    <span class="p">});</span>
  <span class="p">};</span>

  <span class="c1"># Traduit nos options en conteneurs NixOS</span>
  <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrs
      <span class="p">(</span>name<span class="p">:</span> container<span class="p">:</span> <span class="p">{</span>
<span class="hll">        <span class="ss">ephemeral</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
</span>        <span class="ss">autoStart</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
<span class="hll">        <span class="ss">privateNetwork</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
</span>        <span class="ss">extraFlags</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"--resolv-conf=replace-host"</span> <span class="p">];</span>
        <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
<span class="hll">          <span class="ss">imports</span> <span class="o">=</span> <span class="p">[</span> container<span class="o">.</span>config <span class="p">];</span>
</span>          networking<span class="o">.</span>firewall<span class="o">.</span><span class="ss">enable</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
          system<span class="o">.</span><span class="ss">stateVersion</span> <span class="o">=</span> config<span class="o">.</span>system<span class="o">.</span>stateVersion<span class="p">;</span>
          systemd<span class="o">.</span><span class="ss">services</span> <span class="o">=</span> <span class="p">{</span>
<span class="hll">            console-getty<span class="o">.</span><span class="ss">enable</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
</span><span class="hll">            systemd-logind<span class="o">.</span><span class="ss">enable</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
</span><span class="hll">            systemd-oomd<span class="o">.</span><span class="ss">enable</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
</span>          <span class="p">};</span>
        <span class="p">};</span>
      <span class="p">})</span>
      cfg<span class="p">;</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Pour configurer une instance GoatCounter tournant dans un conteneur et écoutant
sur <code>127.0.0.4:8088</code>, nous importons le module<sup id="fnref:import"><a class="footnote-ref" href="#fn:import">7</a></sup> et déclarons le
conteneur dans l’ensemble d’attributs <code>config.luffy.containers</code> :</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> pkgs<span class="p">,</span> config<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span> <span class="p">{</span>
  <span class="ss">imports</span> <span class="o">=</span> <span class="p">[</span> <span class="l">./modules/container.nix</span> <span class="p">];</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
      services<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">enable</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
        <span class="ss">address</span> <span class="o">=</span> <span class="s2">"127.0.0.4"</span><span class="p">;</span>
        <span class="ss">port</span> <span class="o">=</span> <span class="mi">8088</span><span class="p">;</span>
        <span class="ss">proxy</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Comme les conteneurs sont éphémères, il faut conserver les données persistantes
dans des répertoires de l’hôte. Nous ajoutons une option <code>mounts</code> et demandons
aux conteneurs de NixOS d’exposer les répertoires ainsi configurés via l’option
<code>bindMounts</code>.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  options<span class="o">.</span>luffy<span class="o">.</span><span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
    <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>attrsOf <span class="p">(</span>lib<span class="o">.</span>types<span class="o">.</span>submodule <span class="p">{</span>
      <span class="ss">options</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">mounts</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
          <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>listOf lib<span class="o">.</span>types<span class="o">.</span>str<span class="p">;</span>
          <span class="ss">default</span> <span class="o">=</span> <span class="p">[</span> <span class="p">];</span>
          <span class="ss">description</span> <span class="o">=</span> <span class="s2">"Host directories mounted read-write at the same place."</span><span class="p">;</span>
        <span class="p">};</span>
      <span class="p">};</span>
    <span class="p">});</span>
  <span class="p">};</span>

  <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrs
      <span class="p">(</span>name<span class="p">:</span> container<span class="p">:</span> <span class="p">{</span>
        <span class="ss">bindMounts</span> <span class="o">=</span>
          lib<span class="o">.</span>genAttrs container<span class="o">.</span>mounts <span class="p">(</span>path<span class="p">:</span> <span class="p">{</span> <span class="ss">hostPath</span> <span class="o">=</span> path<span class="p">;</span> <span class="ss">isReadOnly</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span> <span class="p">});</span>
      <span class="p">})</span>
      cfg<span class="p">;</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Par exemple, pour conserver la base de données de GoatCounter dans le répertoire
<code>/var/db/goatcounter</code> de l’hôte, nous ajoutons ce répertoire à l’option <code>mounts</code>
et modifions la définition du service pour indiquer à GoatCounter où trouver la
base de données.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">databaseDirectory</span> <span class="o">=</span> <span class="s2">"/var/db/goatcounter"</span><span class="p">;</span>
<span class="k">in</span> <span class="p">{</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">mounts</span> <span class="o">=</span> <span class="p">[</span> databaseDirectory <span class="p">];</span>
    <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
      services<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">extraArgs</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"-db=sqlite+</span><span class="si">${</span>databaseDirectory<span class="si">}</span><span class="s2">/db.sqlite"</span> <span class="p">];</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Un conteneur peut aussi avoir besoin de secrets. Colmena peut <a href="https://colmena.cli.rs/unstable/features/keys.html" title="Colmena documentation: Secrets">envoyer des
secrets</a> sans les stocker dans <code>/nix</code>. Nous ajoutons une option
<code>keys</code> à nos conteneurs. Elle prend un ensemble d’attributs associant le nom de
chaque secret à la commande qui le fournit. Ensuite, le module déclare les
secrets requis à Colmena dans l’option <code>deployment.keys</code>, fait dépendre le
conteneur de la présence des secrets, puis les attache au conteneur.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  options<span class="o">.</span>luffy<span class="o">.</span><span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
    <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>attrsOf <span class="p">(</span>lib<span class="o">.</span>types<span class="o">.</span>submodule <span class="p">{</span>
      <span class="ss">options</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">keys</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
          <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>attrsOf <span class="p">(</span>lib<span class="o">.</span>types<span class="o">.</span>listOf lib<span class="o">.</span>types<span class="o">.</span>str<span class="p">);</span>
          <span class="ss">default</span> <span class="o">=</span> <span class="p">{</span> <span class="p">};</span>
          <span class="ss">description</span> <span class="o">=</span> <span class="s2">"Secrets, as a command to run locally. They are mounted in /etc."</span><span class="p">;</span>
        <span class="p">};</span>
      <span class="p">};</span>
    <span class="p">});</span>
  <span class="p">};</span>

  <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
    <span class="c1"># Colmena place chaque secret dans `/var/keys` et le rend accessible</span>
    <span class="c1"># au groupe "keys".</span>
    deployment<span class="o">.</span><span class="ss">keys</span> <span class="o">=</span> lib<span class="o">.</span>concatMapAttrs
      <span class="p">(</span>_<span class="p">:</span> container<span class="p">:</span> lib<span class="o">.</span>mapAttrs
        <span class="p">(</span>_<span class="p">:</span> keyCommand<span class="p">:</span> <span class="p">{</span>
          <span class="k">inherit</span> keyCommand<span class="p">;</span>
          <span class="ss">group</span> <span class="o">=</span> <span class="s2">"keys"</span><span class="p">;</span>
          <span class="ss">permissions</span> <span class="o">=</span> <span class="s2">"0640"</span><span class="p">;</span>
          <span class="ss">destDir</span> <span class="o">=</span> <span class="s2">"/var/keys"</span><span class="p">;</span>
        <span class="p">})</span>
        container<span class="o">.</span>keys<span class="p">)</span>
      cfg<span class="p">;</span>

    <span class="c1"># Le conteneur ne peut démarrer que si les secrets requis sont disponibles.</span>
    systemd<span class="o">.</span><span class="ss">services</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrs'
      <span class="p">(</span>name<span class="p">:</span> container<span class="p">:</span>
        <span class="k">let</span>
          <span class="ss">units</span> <span class="o">=</span> <span class="nb">map</span> <span class="p">(</span>key<span class="p">:</span> <span class="s2">"</span><span class="si">${</span>key<span class="si">}</span><span class="s2">-key.service"</span><span class="p">)</span> <span class="p">(</span>lib<span class="o">.</span>attrNames container<span class="o">.</span>keys<span class="p">);</span>
        <span class="k">in</span>
        lib<span class="o">.</span>nameValuePair <span class="s2">"container@</span><span class="si">${</span>name<span class="si">}</span><span class="s2">"</span> <span class="p">{</span>
          <span class="ss">requires</span> <span class="o">=</span> units<span class="p">;</span>
          <span class="ss">after</span> <span class="o">=</span> units<span class="p">;</span>
        <span class="p">})</span>
      cfg<span class="p">;</span>

    <span class="c1"># Attache chaque secret dans le conteneur.</span>
    <span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrs
      <span class="p">(</span>name<span class="p">:</span> container<span class="p">:</span> <span class="p">{</span>
        <span class="ss">bindMounts</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrs'
          <span class="p">(</span>key<span class="p">:</span> _<span class="p">:</span> lib<span class="o">.</span>nameValuePair <span class="s2">"/etc/</span><span class="si">${</span>key<span class="si">}</span><span class="s2">"</span> <span class="p">{</span>
            <span class="ss">hostPath</span> <span class="o">=</span> <span class="s2">"/var/keys/</span><span class="si">${</span>key<span class="si">}</span><span class="s2">"</span><span class="p">;</span>
            <span class="ss">isReadOnly</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
          <span class="p">})</span>
          container<span class="o">.</span>keys<span class="p">;</span>
      <span class="p">})</span>
      cfg<span class="p">;</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Par exemple, GoatCounter a besoin d’identifiants pour télécharger la base de
données GeoIP. Je fournis une commande locale pour récupérer le secret depuis
mon gestionnaire de mots de passe et je l’expose dans le conteneur via le
fichier d’environnement <code>/etc/goatcounter.env</code>.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> pkgs<span class="p">,</span> config<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span> 
<span class="k">let</span>
  <span class="ss">keyCommand</span> <span class="o">=</span> variable<span class="p">:</span> <span class="p">[</span>
    <span class="s2">"</span><span class="si">${</span>pkgs<span class="o">.</span>runtimeShell<span class="si">}</span><span class="s2">"</span>
    <span class="s2">"-c"</span>
    <span class="s2">"pass show personal/nixops/secrets | grep '^</span><span class="si">${</span>variable<span class="si">}</span><span class="s2">='"</span>
  <span class="p">];</span>
<span class="k">in</span> <span class="p">{</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
<span class="hll">    keys<span class="o">.</span><span class="s2">"goatcounter.env"</span> <span class="o">=</span> keyCommand <span class="s2">"GOATCOUNTER_GEODB"</span><span class="p">;</span>
</span>    <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
      systemd<span class="o">.</span>services<span class="o">.</span>goatcounter<span class="o">.</span><span class="ss">serviceConfig</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">EnvironmentFile</span> <span class="o">=</span> <span class="s2">"/etc/goatcounter.env"</span><span class="p">;</span>
        <span class="ss">SupplementaryGroups</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"keys"</span> <span class="p">];</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<h2 id="serveur-goatcounter">Serveur GoatCounter</h2>
<p>Nixpkgs fournit déjà un paquet pour GoatCounter. En surchargeant les attributs
<code>src</code> et <code>vendorHash</code>, je réutilise sa définition pour ma version personnalisée
avec le proxy :</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> goatcounter<span class="p">,</span> fetchFromGitHub <span class="p">}:</span>
goatcounter<span class="o">.</span>overrideAttrs <span class="p">(</span>_<span class="p">:</span> <span class="p">{</span>
  <span class="ss">src</span> <span class="o">=</span> fetchFromGitHub <span class="p">{</span>
    <span class="ss">owner</span> <span class="o">=</span> <span class="s2">"vincentbernat"</span><span class="p">;</span>
    <span class="ss">repo</span> <span class="o">=</span> <span class="s2">"goatcounter"</span><span class="p">;</span>
    <span class="ss">rev</span> <span class="o">=</span> <span class="s2">"feature/proxy"</span><span class="p">;</span>
    <span class="ss">hash</span> <span class="o">=</span> <span class="s2">"sha256-dJRlQlFu3tjcEgabT1LEbyFrasJlhmYu4L/T7EkoNcY="</span><span class="p">;</span>
  <span class="p">};</span>
  <span class="ss">vendorHash</span> <span class="o">=</span> <span class="s2">"sha256-c9Q5OrbZR+q6pD3SgPPWe8JUzcZco1AVUKGaV61k5DE="</span><span class="p">;</span>
<span class="p">})</span>
</code></pre></div>

<p>J’ai écrit un <a href="https://github.com/vincentbernat/nixops-take1/blob/master/modules/goatcounter.nix" title="nixops-take1/modules/goatcounter.nix">module NixOS</a> pour encapsuler GoatCounter : la
définition du conteneur, celle du service et les secrets. Le module accepte les
options suivantes : <code>package</code>, <code>serve.enable</code>, <code>serve.listenAddress</code>,
<code>serve.port</code> et <code>serve.databaseFile</code>. J’ai déjà détaillé la configuration du
conteneur dans la section précédente. Finalement, j’ai choisi de ne pas
réutiliser le module GoatCounter de NixOS : il est plutôt simple et le réécrire
permet de me protéger de changements futurs inattendus.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> pkgs<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>goatcounter<span class="p">;</span>
  <span class="ss">databaseDirectory</span> <span class="o">=</span> <span class="nb">builtins</span><span class="o">.</span><span class="nb">dirOf</span> cfg<span class="o">.</span>serve<span class="o">.</span>databaseFile<span class="p">;</span>
  <span class="ss">chown</span> <span class="o">=</span> <span class="s2">"</span><span class="si">${</span>pkgs<span class="o">.</span>coreutils<span class="si">}</span><span class="s2">/bin/chown -R"</span><span class="p">;</span>
<span class="k">in</span> <span class="p">{</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
    config<span class="o">.</span>systemd<span class="o">.</span>services<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">description</span> <span class="o">=</span> <span class="s2">"GoatCounter Web Analytics"</span><span class="p">;</span>
      <span class="ss">wantedBy</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"multi-user.target"</span> <span class="p">];</span>
      <span class="ss">serviceConfig</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">EnvironmentFile</span> <span class="o">=</span> <span class="s2">"/etc/goatcounter.env"</span><span class="p">;</span>
        <span class="ss">SupplementaryGroups</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"keys"</span> <span class="p">];</span>
        <span class="ss">DynamicUser</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
        <span class="ss">Restart</span> <span class="o">=</span> <span class="s2">"always"</span><span class="p">;</span>
        <span class="ss">ExecStart</span> <span class="o">=</span> lib<span class="o">.</span>escapeShellArgs <span class="p">[</span>
          <span class="p">(</span>lib<span class="o">.</span>getExe cfg<span class="o">.</span>package<span class="p">)</span>
          <span class="s2">"serve"</span>
          <span class="s2">"-listen=</span><span class="si">${</span>cfg<span class="o">.</span>serve<span class="o">.</span>listenAddress<span class="si">}</span><span class="s2">:</span><span class="si">${</span><span class="nb">toString</span> cfg<span class="o">.</span>serve<span class="o">.</span>port<span class="si">}</span><span class="s2">"</span>
          <span class="s2">"-tls=none"</span>
          <span class="s2">"-db=sqlite+</span><span class="si">${</span>cfg<span class="o">.</span>serve<span class="o">.</span>databaseFile<span class="si">}</span><span class="s2">"</span>
          <span class="s2">"-automigrate"</span>
        <span class="p">];</span>
        <span class="c1"># Donne la base de données à l'utilisateur dynamique "goatcounter".</span>
        <span class="ss">ExecStartPre</span> <span class="o">=</span> <span class="s2">"+</span><span class="si">${</span>chown<span class="si">}</span><span class="s2"> goatcounter:goatcounter </span><span class="si">${</span>databaseDirectory<span class="si">}</span><span class="s2">"</span><span class="p">;</span>
        <span class="ss">ReadWritePaths</span> <span class="o">=</span> databaseDirectory<span class="p">;</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Le bout de code suivant configure GoatCounter pour écouter sur
<code>127.0.0.4:8088</code> :</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span>
  luffy<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">serve</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">enable</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
      <span class="ss">listenAddress</span> <span class="o">=</span> <span class="s2">"127.0.0.4"</span><span class="p">;</span>
      <span class="ss">port</span> <span class="o">=</span> <span class="mi">8088</span><span class="p">;</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>La dernière étape consiste à configurer nginx pour exposer GoatCounter sur
Internet. Je désactive le point de terminaison <code>/count</code>, car sa fonction est
assurée par le proxy local.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>goatcounter<span class="o">.</span>serve<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  services<span class="o">.</span>nginx<span class="o">.</span>virtualHosts<span class="o">.</span><span class="s2">"goatcounter.luffy.cx"</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">forceSSL</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
    <span class="ss">locations</span> <span class="o">=</span> <span class="p">{</span>
      <span class="s2">"/"</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">proxyPass</span> <span class="o">=</span> <span class="s2">"http://</span><span class="si">${</span>cfg<span class="o">.</span>listenAddress<span class="si">}</span><span class="s2">:</span><span class="si">${</span><span class="nb">toString</span> cfg<span class="o">.</span>port<span class="si">}</span><span class="s2">"</span><span class="p">;</span>
      <span class="p">};</span>
      <span class="s2">"= /count"</span><span class="o">.</span><span class="ss">extraConfig</span> <span class="o">=</span> <span class="s s-Multiline">''</span>
<span class="s s-Multiline">        return 404;</span>
<span class="s s-Multiline">      ''</span><span class="p">;</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<h2 id="proxy-goatcounter">Proxy GoatCounter</h2>
<p>Le même <a href="https://github.com/vincentbernat/nixops-take1/blob/master/modules/goatcounter.nix" title="nixops-take1/modules/goatcounter.nix">module NixOS</a> configure le proxy local, avec les
options suivantes : <code>proxy.enable</code>, <code>proxy.listenAddress</code>, <code>proxy.port</code> et
<code>proxy.site</code> (le site qui reçoit les lots de pages vues). Le proxy local n’a
aucune donnée persistante, mais il a besoin de la clé d’API pour s’authentifier
auprès de l’instance principale de GoatCounter : son conteneur utilise l’option
<code>keys</code>, mais pas l’option <code>mounts</code>.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> pkgs<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>goatcounter<span class="p">;</span>
  <span class="ss">keyCommand</span> <span class="o">=</span> _<span class="p">:</span> <span class="p">[</span> <span class="s2">"…"</span> <span class="p">];</span>
<span class="k">in</span>
<span class="p">{</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">goatcounter-proxy</span> <span class="o">=</span> <span class="p">{</span>
<span class="hll">    keys<span class="o">.</span><span class="s2">"goatcounter-proxy.env"</span> <span class="o">=</span> keyCommand <span class="s2">"GOATCOUNTER_API_KEY"</span><span class="p">;</span>
</span>    config<span class="o">.</span>systemd<span class="o">.</span>services<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">description</span> <span class="o">=</span> <span class="s2">"GoatCounter Proxy."</span><span class="p">;</span>
      <span class="ss">wantedBy</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"multi-user.target"</span> <span class="p">];</span>
      <span class="ss">serviceConfig</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">EnvironmentFile</span> <span class="o">=</span> <span class="s2">"/etc/goatcounter-proxy.env"</span><span class="p">;</span>
        <span class="ss">SupplementaryGroups</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"keys"</span> <span class="p">];</span>
        <span class="ss">DynamicUser</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
        <span class="ss">Restart</span> <span class="o">=</span> <span class="s2">"always"</span><span class="p">;</span>
        <span class="ss">ExecStart</span> <span class="o">=</span> lib<span class="o">.</span>escapeShellArgs <span class="p">[</span>
          <span class="p">(</span>lib<span class="o">.</span>getExe cfg<span class="o">.</span>package<span class="p">)</span>
          <span class="s2">"proxy"</span>
          <span class="s2">"-site=</span><span class="si">${</span>cfg<span class="o">.</span>proxy<span class="o">.</span>site<span class="si">}</span><span class="s2">"</span>
          <span class="s2">"-listen=</span><span class="si">${</span>cfg<span class="o">.</span>proxy<span class="o">.</span>listenAddress<span class="si">}</span><span class="s2">:</span><span class="si">${</span><span class="nb">toString</span> cfg<span class="o">.</span>proxy<span class="o">.</span>port<span class="si">}</span><span class="s2">"</span>
          <span class="s2">"-ratelimit=10/1"</span>  <span class="c1"># 10 requêtes par seconde par IP</span>
        <span class="p">];</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Sur chaque serveur, j’active le proxy local avec le bout de code suivant. La
configuration nginx présentée plus haut expose le point de terminaison <code>/count</code>
sous le même domaine que mon blog.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span>
  luffy<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">proxy</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">enable</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
      <span class="ss">site</span> <span class="o">=</span> <span class="s2">"goatcounter.luffy.cx"</span><span class="p">;</span>
      <span class="ss">listenAddress</span> <span class="o">=</span> <span class="s2">"127.0.0.3"</span><span class="p">;</span>
      <span class="ss">port</span> <span class="o">=</span> <span class="mi">8087</span><span class="p">;</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<h2 id="sauvegarde-de-la-base-de-donnees-sqlite-avec-litestream">Sauvegarde de la base de données SQLite avec Litestream</h2>
<p><a href="https://litestream.io/" title="Litestream: Streaming SQLite Replication">Litestream</a> est un outil de réplication en continu pour les bases de données
SQLite. Il compresse les changements placés dans le journal d’écriture
anticipée (<abbr title="Write-ahead log">WAL</abbr>) situé à côté de la base de données et les envoie vers une
destination distante. J’encapsule sa configuration dans un <a href="https://github.com/vincentbernat/nixops-take1/blob/master/modules/litestream.nix" title="nixops-take1/modules/litestream.nix">module
NixOS</a>, qui prend un ensemble d’attributs <code>databases</code> associant
un nom au chemin de la base de données à sauvegarder.</p>
<p>Litestream tourne lui aussi dans un conteneur. Je monte les bases de données à
répliquer, ainsi que les secrets pour pousser les sauvegardes vers une <a href="https://www.hetzner.com/storage/storage-box/" title="Hetzner Storage Box"><em>storage
box</em> de Hetzner</a> via <abbr title="Secure File Transfer Protocol">SFTP</abbr> :</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> pkgs<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>litestream<span class="p">;</span>
  <span class="ss">databaseDirs</span> <span class="o">=</span> lib<span class="o">.</span>unique <span class="p">(</span><span class="nb">map</span> <span class="nb">builtins</span><span class="o">.</span><span class="nb">dirOf</span> <span class="p">(</span><span class="nb">builtins</span><span class="o">.</span>attrValues cfg<span class="o">.</span>databases<span class="p">));</span>
<span class="k">in</span>
<span class="p">{</span>
  <span class="ss">config</span> <span class="o">=</span> lib<span class="o">.</span>mkIf <span class="p">(</span>cfg<span class="o">.</span>databases <span class="o">!=</span> <span class="p">{</span> <span class="p">})</span> <span class="p">{</span>
    luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">litestream</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">mounts</span> <span class="o">=</span> databaseDirs<span class="p">;</span>
      keys<span class="o">.</span><span class="s2">"litestream.env"</span> <span class="o">=</span> <span class="p">[</span>
        <span class="s2">"</span><span class="si">${</span>pkgs<span class="o">.</span>runtimeShell<span class="si">}</span><span class="s2">"</span>
        <span class="s2">"-c"</span>
        <span class="s2">"pass show personal/nixops/secrets | grep '^SQLITE_BACKUP_'"</span>
      <span class="p">];</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Dans le conteneur, je configure Litestream à travers les options
<code>services.litestream</code> de NixOS :</p>
<ul>
<li>des instantanés complets chaque jour, conservés 15 jours ;</li>
<li>trois <a href="https://fly.io/blog/litestream-v050-is-here/" title="Litestream v0.5.0 is Here">niveaux de compactage</a> pour les transactions :
  5 minutes, 30 minutes et 3 heures ;</li>
<li>la reprise automatique<sup id="fnref:auto-recover"><a class="footnote-ref" href="#fn:auto-recover">8</a></sup> ;</li>
<li>un réplica stocké dans un répertoire portant le nom de l’hôte ;</li>
<li>des identifiants lus depuis <code>/etc/litestream.env</code> et exposés par expansion de variables.</li>
</ul>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> pkgs<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>litestream<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">litestream</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
      <span class="c1"># Les bases de données appartiennent à des utilisateurs dynamiques, dont</span>
      <span class="c1"># l'UID n'est pas connu ici : Litestream tourne donc en root.</span>
      systemd<span class="o">.</span>services<span class="o">.</span>litestream<span class="o">.</span><span class="ss">serviceConfig</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">User</span> <span class="o">=</span> lib<span class="o">.</span>mkForce <span class="s2">"root"</span><span class="p">;</span>
        <span class="ss">Group</span> <span class="o">=</span> lib<span class="o">.</span>mkForce <span class="s2">"root"</span><span class="p">;</span>
      <span class="p">};</span>
      <span class="c1"># Utilise le service NixOS.</span>
      services<span class="o">.</span><span class="ss">litestream</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">enable</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
        <span class="ss">environmentFile</span> <span class="o">=</span> <span class="s2">"/etc/litestream.env"</span><span class="p">;</span>
        <span class="ss">settings</span> <span class="o">=</span> <span class="p">{</span>
          <span class="ss">auto-recover</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
          <span class="ss">snapshot</span> <span class="o">=</span> <span class="p">{</span>
            <span class="ss">interval</span> <span class="o">=</span> <span class="s2">"24h"</span><span class="p">;</span>
            <span class="ss">retention</span> <span class="o">=</span> <span class="s2">"360h"</span><span class="p">;</span>
          <span class="p">};</span>
          <span class="ss">levels</span> <span class="o">=</span> <span class="p">[</span>
            <span class="p">{</span> <span class="ss">interval</span> <span class="o">=</span> <span class="s2">"5m"</span><span class="p">;</span> <span class="p">}</span>
            <span class="p">{</span> <span class="ss">interval</span> <span class="o">=</span> <span class="s2">"30m"</span><span class="p">;</span> <span class="p">}</span>
            <span class="p">{</span> <span class="ss">interval</span> <span class="o">=</span> <span class="s2">"3h"</span><span class="p">;</span> <span class="p">}</span>
          <span class="p">];</span>
          <span class="ss">dbs</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrsToList
            <span class="p">(</span>name<span class="p">:</span> path<span class="p">:</span> <span class="p">{</span>
              <span class="k">inherit</span> path<span class="p">;</span>
              <span class="ss">replica</span> <span class="o">=</span> <span class="p">{</span>
                <span class="ss">type</span> <span class="o">=</span> <span class="s2">"sftp"</span><span class="p">;</span>
                <span class="ss">host</span> <span class="o">=</span> <span class="s2">"</span><span class="se">\$</span><span class="s2">{SQLITE_BACKUP_HOST}"</span><span class="p">;</span>
                <span class="ss">user</span> <span class="o">=</span> <span class="s2">"</span><span class="se">\$</span><span class="s2">{SQLITE_BACKUP_USER}"</span><span class="p">;</span>
                <span class="ss">password</span> <span class="o">=</span> <span class="s2">"</span><span class="se">\$</span><span class="s2">{SQLITE_BACKUP_PASSWORD}"</span><span class="p">;</span>
                <span class="ss">host-key</span> <span class="o">=</span> <span class="s2">"</span><span class="se">\$</span><span class="s2">{SQLITE_BACKUP_HOSTKEY}"</span><span class="p">;</span>
                <span class="ss">path</span> <span class="o">=</span> <span class="s2">"</span><span class="si">${</span>config<span class="o">.</span>networking<span class="o">.</span>hostName<span class="si">}</span><span class="s2">/</span><span class="si">${</span>name<span class="si">}</span><span class="s2">"</span><span class="p">;</span>
              <span class="p">};</span>
            <span class="p">})</span>
            cfg<span class="o">.</span>databases<span class="p">;</span>
        <span class="p">};</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Pour sauvegarder la base de données de GoatCounter, je déclare un attribut
<code>goatcounter</code> dans <code>luffy.litestream.databases</code> avec pour valeur le chemin de la
base de données :</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>goatcounter<span class="o">.</span>serve<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  luffy<span class="o">.</span>litestream<span class="o">.</span>databases<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> cfg<span class="o">.</span>databaseFile<span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<p>Sur le serveur <abbr title="Secure File Transfer Protocol">SFTP</abbr>, nous pouvons inspecter le travail de Litestream, avec les
transactions compactées et les instantanés complets :</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">❯ </span>ls<span class="w"> </span>web02/goatcounter/ltx
<span class="go">web02/goatcounter/ltx/0</span>
<span class="go">web02/goatcounter/ltx/1</span>
<span class="go">web02/goatcounter/ltx/2</span>
<span class="go">web02/goatcounter/ltx/3</span>
<span class="go">web02/goatcounter/ltx/9</span>
<span class="gp">❯ </span>ls<span class="w"> </span>-lh<span class="w"> </span>web02/goatcounter/ltx/1
<span class="go">29.1K Sep  5 01:25 0000000000003f2a-0000000000003f2b.ltx</span>
<span class="go">72.4K Sep  5 02:03 0000000000003f2c-0000000000003f2d.ltx</span>
<span class="go">63.3K Sep  5 02:24 0000000000003f2e-0000000000003f2f.ltx</span>
<span class="go">[…]</span>
<span class="gp">❯ </span>ls<span class="w"> </span>-lh<span class="w"> </span>web02/goatcounter/ltx/9
<span class="go"> 8.5M Sep  5 02:00 0000000000000001-0000000000003f2b.ltx</span>
<span class="go"> 8.5M Sep  6 02:03 0000000000000001-0000000000004008.ltx</span>
<span class="go"> 8.6M Sep  7 02:03 0000000000000001-00000000000043a8.ltx</span>
<span class="go">[…]</span>
</code></pre></div>

<p>Nous pouvons restaurer la base de données depuis la sauvegarde avec quelques
commandes shell. D’abord, nous arrêtons les conteneurs. Ensuite, nous mettons de
côté la base de données endommagée, invoquons <code>litestream restore</code> depuis le bon
environnement et redémarrons les conteneurs<sup id="fnref:slow"><a class="footnote-ref" href="#fn:slow">9</a></sup>.</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp"># </span>systemctl<span class="w"> </span>stop<span class="w"> </span>container@goatcounter<span class="w"> </span>container@litestream
<span class="gp"># </span>mv<span class="w"> </span>/var/db/goatcounter/db.sqlite<span class="o">{</span>,.old<span class="o">}</span>
<span class="gp"># </span><span class="o">(</span><span class="w"> </span>.<span class="w"> </span>/etc/nixos-containers/litestream.conf<span class="w"> </span><span class="p">;</span><span class="w"> </span>
<span class="gp">&gt; </span><span class="w">  </span><span class="nb">set</span><span class="w"> </span>-a<span class="w"> </span><span class="p">;</span><span class="w"> </span>.<span class="w"> </span>/var/keys/litestream.env<span class="w"> </span><span class="p">;</span><span class="w"> </span><span class="nb">set</span><span class="w"> </span>+a<span class="w"> </span><span class="p">;</span>
<span class="gp">&gt; </span><span class="w">  </span><span class="nv">$SYSTEM_PATH</span>/sw/bin/litestream<span class="w"> </span><span class="se">\</span>
<span class="gp">&gt; </span><span class="w">    </span>restore<span class="w"> </span>-config<span class="w"> </span><span class="nv">$SYSTEM_PATH</span>/etc/litestream.yml<span class="w"> </span>/var/db/goatcounter/db.sqlite<span class="o">)</span>
<span class="gp"># </span>ls<span class="w"> </span>-lh<span class="w"> </span>/var/db/goatcounter/db.sqlite
<span class="go">-rw-r--r-- 1 root root 20M Sep 20 07:33 /var/db/goatcounter/db.sqlite</span>
<span class="gp"># </span>systemctl<span class="w"> </span>start<span class="w"> </span>container@goatcounter<span class="w"> </span>container@litestream
</code></pre></div>

<hr/>
<p>Dix ans après <a href="/fr/blog/2018-blog-respect-vie-privee" title="Un blog plus respectueux de la vie privée">avoir retiré Google Analytics</a>, les
statistiques en JavaScript sont de retour sur ce blog, mais sans stocker de
cookies ni d’adresses IP, et sans se reposer sur un tiers. J’écris d’abord pour
moi, notamment parce que cela me permet de creuser un sujet et de m’y référer
des années plus tard. Cependant, en savoir un peu plus sur mes lecteurs humains
est un bonus appréciable, y compris sur ceux qui n’activent pas JavaScript. 🐐</p>
<div class="footnote">
<hr/>
<ol>
<li id="fn:scrub">
<p>Nginx brouille les adresses IP avant de les enregistrer, grâce au <a href="https://github.com/masonicboom/ipscrub" title="ipscrub: IP address anonymizer module for nginx">module
<code>ipscrub</code></a>. <a class="footnote-backref" href="#fnref:scrub" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:bot-filter">
<p>GoatCounter filtre déjà certains robots grâce à leur agent utilisateur ou à
leur adresse IP. Mais les robots pilleurs mentent sur leur agent utilisateur
et se cachent derrière des <a href="https://jacob.gold/posts/residential-proxies-are-a-national-security-threat/" title="Residential Proxies Are a National Security Threat">proxys résidentiels</a>. <a class="footnote-backref" href="#fnref:bot-filter" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:referrer">
<p>Sans JavaScript, impossible d’envoyer la véritable origine. J’indique
« NoJS » à la place. <a class="footnote-backref" href="#fnref:referrer" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
<li id="fn:rss">
<p>Il me manque les humains lisant le flux RSS. Je ne suis pas à l’aise avec
l’idée d’ajouter un <a href="https://www.goatcounter.com/help/pixel" title="GoatCounter documentation: tracking pixel without JavaScript">pixel transparent</a>, et les robots le
chargeraient sans doute, ce qui fausserait les statistiques. Je me passe de
compter ces lecteurs. <a class="footnote-backref" href="#fnref:rss" title="Jump back to footnote 4 in the text">↩</a></p>
</li>
<li id="fn:css-test">
<p>En février 2025, j’ai testé une autre approche reposant uniquement sur CSS
sans détecter d’interaction : chaque page chargeait une image vide comme
arrière-plan. Sur un mois, elle a enregistré jusqu’à dix fois moins de pages
vues que les journaux de nginx, même après avoir retiré les robots qui
s’identifiaient comme tels. Par rapport à cette expérience, 18 mois plus
tard, GoatCounter compte environ trois fois moins de vues. Comme les
périodes diffèrent, la comparaison n’est pas rigoureuse, mais elle donne une
idée de l’efficacité de cette preuve d’humanité. <a class="footnote-backref" href="#fnref:css-test" title="Jump back to footnote 5 in the text">↩</a></p>
</li>
<li id="fn:module">
<p>Un <a href="https://nixos.org/manual/nixos/stable/#sec-writing-modules" title="NixOS Manual: Writing NixOS Modules">module NixOS</a> est une fonction qui reçoit la configuration
de l’ensemble du système dans <code>config</code> et renvoie trois attributs :</p>
<ul>
<li><code>imports</code> liste d’autres modules à importer ;</li>
<li><code>options</code> déclare les réglages configurables par l’utilisateur ;</li>
<li><code>config</code> attribue des valeurs aux options déclarées par n’importe quel module, comme <code>containers</code> de NixOS.</li>
</ul>
<p>Si un module ne déclare aucune option, il est possible de renvoyer directement les attributs
contenus dans <code>config</code>. Si un module n’a besoin d’aucun argument, vous pouvez le
définir comme un simple ensemble d’attributs. <a class="footnote-backref" href="#fnref:module" title="Jump back to footnote 6 in the text">↩</a></p>
</li>
<li id="fn:import">
<p>La plupart du temps, un import explicite est inutile. NixOS <a href="https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/module-list.nix" title="nixpkgs/nixos/modules/module-list.nix">importe
automatiquement</a> les modules livrés avec Nixpkgs. Pour mes
propres modules, <a href="https://github.com/vincentbernat/nixops-take1/blob/master/modules/default.nix" title="nixops-take1/modules/default.nix">un peu de code</a> les importe aussi
automatiquement. <a class="footnote-backref" href="#fnref:import" title="Jump back to footnote 7 in the text">↩</a></p>
</li>
<li id="fn:auto-recover">
<p>Litestream <a href="https://litestream.io/reference/config/#auto-recover" title="Litestream: auto-recover option">déconseille l’option <code>auto-recover</code></a>, car elle
peut entraîner une perte de données. Mais je ne surveille pas assez
attentivement mes serveurs et je préfère des sauvegardes toujours
fonctionnelles à un faible risque de perdre les derniers enregistrements. <a class="footnote-backref" href="#fnref:auto-recover" title="Jump back to footnote 8 in the text">↩</a></p>
</li>
<li id="fn:slow">
<p>Chez moi, le processus est lent : environ 20 minutes pour une base de
données de 20 Mio. Vous pouvez tester en restaurant vers une copie avec
l’option <code>-o</code>, mais il faut tout de même arrêter le conteneur Litestream. <a class="footnote-backref" href="#fnref:slow" title="Jump back to footnote 9 in the text">↩</a></p>
</li>
</ol>
</div>
      </div></content>
  </entry>
  <entry>
    <title type="html">Notes de marge avec le positionnement par ancre CSS</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/fr/blog/2026-css-notes-de-marge" rel="alternate"/>
    <link href="https://vincent.bernat.ch/fr/blog/2026-css-notes-de-marge#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-09-07T06:49:58Z</updated>
    <id>http://www.luffy.cx/fr/blog/2026-css-notes-de-marge.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml"><p>Je fais un usage intensif des notes de marge<sup id="fnref:phd"><a class="footnote-ref" href="#fn:phd">1</a></sup> : elles gardent le contenu
optionnel à côté du texte au lieu de renvoyer le lecteur en bas de page. <a href="https://edwardtufte.github.io/tufte-css/" title="Tufte CSS">Tufte
CSS</a> les affiche sans JavaScript, mais n’accepte qu’un seul paragraphe. Le
<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Anchor_positioning" title="CSS anchor positioning">positionnement par ancre CSS</a>, désormais pris en charge
par les navigateurs récents<sup id="fnref:anchor-support"><a class="footnote-ref" href="#fn:anchor-support">2</a></sup>, est une alternative élégante. Les
notes peuvent contenir plusieurs blocs, toujours sans JavaScript, et se
positionnent sous le paragraphe qui les appelle sur les fenêtres étroites et les
navigateurs plus anciens.</p>
<p>En 2023, Eric Meyer a présenté cette technique dans « <a href="https://meyerweb.com/eric/thoughts/2023/09/12/nuclear-anchored-sidenotes/" title="Nuclear Anchored Sidenotes">Nuclear Anchored
Sidenotes</a> ». Le principal avantage par rapport aux autres solutions est que
les notes peuvent se trouver n’importe où dans le document HTML. Il est possible
de les placer après le paragraphe qui les appelle, comme des éléments de bloc
ordinaires, afin que les navigateurs en mode texte, les lecteurs d’écran, les
agrégateurs de flux et le mode lecture les affichent correctement :</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 465px"><span class="lf-media-inner" style="padding-bottom: 129.032%"><img alt="Les notes de marge affichées dans Lynx apparaissent après le paragraphe qui&#10;les appelle." src="https://d2pzklc15kok91.cloudfront.net/images/css-sidenotes/lynx@1x.62cb4bfb14577a.png" srcset="https://d2pzklc15kok91.cloudfront.net/images/css-sidenotes/lynx@1x.62cb4bfb14577a.png 585w,https://d2pzklc15kok91.cloudfront.net/images/css-sidenotes/lynx@2x.02093c2d3ab35b.png 930w" sizes="auto, (max-width: 465px) 100vw, 465px" width="465" height="600" class="lf-media"/></span></div><figcaption>Rendu dans Lynx, un navigateur en mode texte</figcaption></figure>
<p>Lorsque la fenêtre est trop étroite ou que le navigateur ne prend pas en charge
le positionnement par ancre, il est possible de leur appliquer un style qui
permet au lecteur de les sauter ou d’y jeter un œil sans perdre sa place dans le
texte :</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 375px"><span class="lf-media-inner" style="padding-bottom: 177.867%"><img alt="Les notes de marge affichées sur une fenêtre étroite apparaissent avec une&#10;typographie distincte après le paragraphe qui les&#10;appelle." src="https://d2pzklc15kok91.cloudfront.net/images/css-sidenotes/narrow@1x.9afbc67e0bd4e9.png" srcset="https://d2pzklc15kok91.cloudfront.net/images/css-sidenotes/narrow@1x.9afbc67e0bd4e9.png 471w,https://d2pzklc15kok91.cloudfront.net/images/css-sidenotes/narrow@2x.acc4c3f30fdda4.png 750w" sizes="auto, (max-width: 375px) 100vw, 375px" width="375" height="667" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAXcAAAKbAQMAAAA0YFjZAAAABlBMVEX28egAAAAAM1PxAAAANklEQVR42u3BgQAAAADDoPlTX+AIVQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8A30QAAGQR2y6AAAAAElFTkSuQmCC)"/></span></div><figcaption>Rendu sous le paragraphe sur une fenêtre étroite</figcaption></figure>
<p>Dès que la fenêtre est assez large, elles apparaissent dans la marge, à la même
hauteur que l’appel de note correspondant, sauf si elles entrent en collision
avec une note précédente, comme dans l’exemple ci-dessous<sup id="fnref:runt"><a class="footnote-ref" href="#fn:runt">3</a></sup> :</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 1427px"><span class="lf-media-inner" style="padding-bottom: 39.944%"><img alt="Les notes de marge affichées sur une fenêtre large apparaissent dans la marge.&#10;Il y en a deux. La première est alignée verticalement avec l'appel de note&#10;correspondant, tandis que la seconde est affichée plus bas, car elle entrerait&#10;sinon en collision avec la première." src="https://d2pzklc15kok91.cloudfront.net/images/css-sidenotes/large@1x.b9822706e29afa.png" srcset="https://d2pzklc15kok91.cloudfront.net/images/css-sidenotes/large@1x.b9822706e29afa.png 1794w,https://d2pzklc15kok91.cloudfront.net/images/css-sidenotes/large@2x.257f9656b6563e.png 2854w" sizes="auto, (max-width: 972px) 100vw, 900px" width="1427" height="570" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABZMAAAI6AQMAAABo8EQbAAAABlBMVEX++O8AAAB8p4tUAAAAeklEQVR42u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgykNcAAfcP/P8AAAAASUVORK5CYII=)"/></span></div><figcaption>Rendu dans la marge sur une fenêtre large</figcaption></figure>
<p>Le principe de l’ancrage CSS est de positionner un élément par rapport à un
autre élément, l’ancre. Pour les notes de marge, l’ancre est l’appel de note.
J’utilise le balisage suivant, avec un attribut de données pour indiquer le nom
de l’ancre :</p>
<div class="language-html codehilite"><pre><span/><code><span class="p">&lt;</span><span class="nt">sup</span> <span class="na">id</span><span class="o">=</span><span class="s">"fnref:YYY"</span> <span class="na">data-anchor</span><span class="o">=</span><span class="s">"--lf-sn-YYY"</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">"#sidenote-YYY"</span><span class="p">&gt;</span>1<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">sup</span><span class="p">&gt;</span>
</code></pre></div>

<p>La note correspondante est un élément <code>&lt;aside&gt;</code> portant le même attribut de
données pour le nom de l’ancre. Elle est placée après le paragraphe contenant
l’appel de note :</p>
<div class="language-html codehilite"><pre><span/><code><span class="p">&lt;</span><span class="nt">aside</span> <span class="na">role</span><span class="o">=</span><span class="s">"note"</span> <span class="na">id</span><span class="o">=</span><span class="s">"sidenote-YYY"</span> <span class="na">data-anchor</span><span class="o">=</span><span class="s">"--lf-sn-YYY"</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">sup</span><span class="p">&gt;</span>1<span class="p">&lt;/</span><span class="nt">sup</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>Un premier paragraphe.<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>Un second paragraphe.<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">aside</span><span class="p">&gt;</span>
</code></pre></div>

<p>Sur une fenêtre étroite ou lorsque le navigateur est trop ancien pour l’ancrage
CSS, la note reçoit une couleur atténuée et reste sous son paragraphe :</p>
<div class="language-css codehilite"><pre><span/><code><span class="nt">aside</span><span class="o">[</span><span class="nt">role</span><span class="o">=</span><span class="s2">"note"</span><span class="o">]</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">margin-block</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">;</span>
<span class="w">  </span><span class="k">color</span><span class="p">:</span><span class="w"> </span><span class="mh">#444</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<p>Sur une fenêtre plus large et lorsque le navigateur est assez récent, la note
est déplacée dans la marge de droite :</p>
<div class="language-css codehilite"><pre><span/><code><span class="p">@</span><span class="k">supports</span><span class="w"> </span><span class="o">(</span><span class="nt">anchor-name</span><span class="o">:</span><span class="w"> </span><span class="nt">attr</span><span class="o">(</span><span class="nt">data-anchor</span><span class="w"> </span><span class="nt">type</span><span class="o">(&lt;</span><span class="nt">custom-ident</span><span class="o">&gt;)))</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="p">@</span><span class="k">media</span><span class="w"> </span><span class="o">(</span><span class="nt">min-width</span><span class="o">:</span><span class="w"> </span><span class="nt">72rem</span><span class="o">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nt">main</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="k">position</span><span class="p">:</span><span class="w"> </span><span class="kc">relative</span><span class="p">;</span>
<span class="w">      </span><span class="nt">sup</span><span class="o">[</span><span class="nt">data-anchor</span><span class="o">]</span><span class="w"> </span><span class="p">{</span>
<span class="hll"><span class="w">        </span><span class="k">anchor-name</span><span class="p">:</span><span class="w"> </span><span class="nb">attr</span><span class="p">(</span><span class="n">data-anchor</span><span class="w"> </span><span class="nf">type</span><span class="p">(</span><span class="o">&lt;</span><span class="n">custom-ident</span><span class="o">&gt;</span><span class="p">));</span>
</span><span class="w">        </span><span class="c">/* → anchor-name: --lf-sn-YYY */</span>
<span class="w">      </span><span class="p">}</span>
<span class="w">      </span><span class="nt">aside</span><span class="o">[</span><span class="nt">role</span><span class="o">=</span><span class="s2">"note"</span><span class="o">][</span><span class="nt">data-anchor</span><span class="o">]</span><span class="w"> </span><span class="p">{</span>
<span class="hll"><span class="w">        </span><span class="k">anchor-name</span><span class="p">:</span><span class="w"> </span><span class="nv">--lf-sidenote</span><span class="p">;</span>
</span><span class="w">        </span><span class="k">position</span><span class="p">:</span><span class="w"> </span><span class="kc">absolute</span><span class="p">;</span>
<span class="hll"><span class="w">        </span><span class="k">position-anchor</span><span class="p">:</span><span class="w"> </span><span class="nb">attr</span><span class="p">(</span><span class="n">data-anchor</span><span class="w"> </span><span class="nf">type</span><span class="p">(</span><span class="o">&lt;</span><span class="n">custom-ident</span><span class="o">&gt;</span><span class="p">));</span>
</span><span class="w">        </span><span class="c">/* → position-anchor: --lf-sn-YYY */</span>
<span class="hll"><span class="w">        </span><span class="k">top</span><span class="p">:</span><span class="w"> </span><span class="nf">max</span><span class="p">(</span><span class="nf">anchor</span><span class="p">(</span><span class="kc">top</span><span class="p">),</span><span class="w"> </span><span class="nf">anchor</span><span class="p">(</span><span class="nv">--lf-sidenote</span><span class="w"> </span><span class="kc">bottom</span><span class="p">,</span><span class="w"> </span><span class="mi">-1</span><span class="kt">rlh</span><span class="p">)</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">);</span>
</span><span class="w">        </span><span class="k">left</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="kt">%</span><span class="p">;</span>
<span class="w">        </span><span class="k">margin</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="mi">2</span><span class="kt">rem</span><span class="p">;</span>
<span class="w">        </span><span class="k">width</span><span class="p">:</span><span class="w"> </span><span class="mi">18</span><span class="kt">rem</span><span class="p">;</span>
<span class="w">        </span><span class="k">color</span><span class="p">:</span><span class="w"> </span><span class="kc">inherit</span><span class="p">;</span>
<span class="w">      </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p><code>attr()</code> extrait le nom de l’ancre de l’appel de note depuis l’attribut
<code>data-anchor</code>. Elle renvoie une chaîne de caractères, sauf si une unité CSS ou
un type est précisé, comme ici : le navigateur analyse l’attribut de données
comme un <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/custom-ident" title="&lt;custom-ident&gt; on MDN">identifiant personnalisé</a>, que <code>anchor-name</code> valide
comme un <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/dashed-ident" title="&lt;dashed-ident&gt; on MDN">identifiant à tirets</a>, c’est-à-dire un identifiant
personnalisé commençant par deux tirets<sup id="fnref:attr-support"><a class="footnote-ref" href="#fn:attr-support">4</a></sup>.</p>
<p>La note elle-même est positionnée de manière absolue au-delà du bord droit du
bloc principal. Elle choisit l’appel de note correspondant comme ancre grâce à
<code>position-anchor</code>, dont la valeur provient de l’attribut <code>data-anchor</code>. Chaque
note est elle-même une ancre nommée <code>--lf-sidenote</code>. Elle sert à empêcher la
note suivante d’entrer en collision avec celle-ci.</p>
<p>La <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/anchor" title="anchor() CSS function on MDN">fonction CSS <code>anchor()</code></a> permet de positionner le bord
supérieur de la note par rapport à son ancre : <code>anchor(top)</code> aligne le bord
supérieur de la note avec le bord supérieur de l’appel de note. Elle accepte
aussi une autre ancre en paramètre : <code>anchor(--lf-sidenote bottom)</code> alignerait
le bord supérieur de la note avec le bord inférieur de l’ancre précédente la
plus proche nommée <code>--lf-sidenote</code>, c’est-à-dire la note
précédente<sup id="fnref:reference"><a class="footnote-ref" href="#fn:reference">5</a></sup>. Comme <code>attr()</code>, <code>anchor()</code> accepte une valeur de repli
en second paramètre et l’utilise lorsque l’ancre nommée n’existe pas.</p>
<p>La propriété <code>top</code> gère trois cas, illustrés dans le schéma suivant :</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 913px"><span class="lf-media-inner" style="padding-bottom: 72.289%"><img alt="Schéma de trois notes de marge ancrées à leurs appels de note. La première est&#10;alignée avec le haut de son propre appel de note, car aucune note ne la précède.&#10;La deuxième chevaucherait la première : elle prend donc le bas de la première&#10;note comme ancre et se place une ligne en dessous. La troisième arrive assez bas&#10;dans la page pour s'aligner de nouveau avec son propre appel de&#10;note." src="https://d2pzklc15kok91.cloudfront.net/images/css-sidenotes/anchors.7ed3294df30cae.svg" width="913" height="660" class="lf-media"/></span></div><figcaption>Les trois cas pour la position verticale d'une note</figcaption></figure>
<ol>
<li>Le bord supérieur de la première note s’aligne avec le bord supérieur de son
   appel de note : en l’absence de note précédente, <code>anchor(--lf-sidenote
   bottom, -1rlh) + 1rlh</code> vaut 0 et <code>max()</code> renvoie <code>anchor(top)</code>.</li>
<li>Lorsque l’appel de note d’une note ultérieure se trouve au-dessus du bas de
   la note précédente, augmenté d’un espace vertical, la note passe sous
   la précédente pour éviter une collision. <code>max()</code> renvoie <code>anchor(--lf-sidenote
   bottom) + 1rlh</code>.</li>
<li>Sinon, <code>max()</code> renvoie <code>anchor(top)</code> et le bord supérieur de la note s’aligne
   avec le bord supérieur de l’appel de note.</li>
</ol>
<hr/>
<p>Jetez un œil à la <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/latest/content/media/css/luffy1.sidenotes.css" title="Stylesheet for the sidenotes of this blog">feuille de style complète</a>, qui adapte aussi
l’appel de note à l’emplacement de la note : une flèche « ↓ » lorsque la note se
trouve sous le paragraphe, une flèche « → » lorsqu’elle part dans la marge.
« <a href="https://gwern.net/sidenote" title="Sidenotes In Web Design">Sidenotes In Web Design</a> », de Gwern, recense d’autres implémentations
et leurs compromis.</p>
<p><a href="https://danluu.com/bug-blind/" title="Bug blindness">Certains blogueurs</a> se fixent pour objectif d’écrire un billet en 30
minutes. Je comptais publier trois articles liés au web ce week-end. À la place,
j’ai passé un temps fou sur n’importe quoi : une quinzaine de changements sur le
système de construction du site, une contribution pour <a href="https://github.com/pygments/pygments/pull/3294" title="CSS: support nested rule">mettre à jour la
coloration CSS des sélecteurs imbriqués dans Pygments</a> et une
petite correction de l’article de MDN sur la <a href="https://github.com/mdn/content/pull/45532" title="Fix wording for return value of anchor() function">fonction CSS <code>anchor()</code></a>.
L’illustration SVG a demandé un peu moins d’une heure et l’article lui-même
quelques heures. La fonction <code>attr()</code> est arrivée après que je me suis demandé :
« n’y-a-t-il pas plus élégant que de placer le nom de l’ancre dans un style en
ligne ? » Mais bon, je trouve que ça en vaut malgré tout la peine ! 🎨</p>
<div class="footnote">
<hr/>
<ol>
<li id="fn:phd">
<p>Mon directeur de thèse m’a toujours dit que c’était une mauvaise pratique. <a class="footnote-backref" href="#fnref:phd" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:anchor-support">
<p>Les premières briques du positionnement par ancre sont <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/anchor-name#browser_compatibility" title="Browser compatibility for anchor-name on MDN">prises en
charge</a> à partir de Chrome 125 (mai 2024), Firefox 147 (janvier
2026) et Safari 26 (septembre 2025).</p>
<p>Avant Safari 26.5, les notes peuvent se chevaucher à cause d’un <a href="https://bugs.webkit.org/show_bug.cgi?id=315704" title="[anchor-position-1] anchor referencing an anchor of the same name does not resolve to the previous one.">bug</a> dans
la gestion des chaînes de dépendances. Il est possible de <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/1e6299da5c95dfce6549dfb20da2cea7c9e58664/content/media/js/luffy1.sidenotes.js">détecter cette
situation avec un peu de JavaScript</a>. Ce n’est toutefois pas nécessaire pour la solution décrite ici,
car elle repose sur une fonctionnalité plus récente. <a class="footnote-backref" href="#fnref:anchor-support" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:runt">
<p>Si vous avez remarqué la ligne creuse à la fin de la première note, je
partage votre peine et regrette amèrement que Firefox n’implémente pas
<code>text-wrap: pretty</code>. <a class="footnote-backref" href="#fnref:runt" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
<li id="fn:attr-support">
<p>La fonction <code>attr()</code> typée est <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/attr#browser_compatibility" title="Browser compatibility for attr() on MDN">prise en charge</a> à partir de
Chrome 133 (février 2025), Firefox 155 (septembre 2026) et Safari 27
(septembre 2026). Consultez <a href="https://una.im/advanced-attr" title="New capabilities for attr()">l’article d’Una Kravets</a> pour plus de détails. Pour prendre en charge davantage de navigateurs,
il est possible de placer le nom de l’ancre et l’ancre de positionnement
directement dans le HTML :</p>
<div class="language-html codehilite"><pre><span/><code><span class="p">&lt;</span><span class="nt">sup</span> <span class="na">id</span><span class="o">=</span><span class="s">"…"</span> <span class="na">style</span><span class="o">=</span><span class="s">"anchor-name: --lf-sn-…"</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">"#sidenote-…"</span><span class="p">&gt;</span>1<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">sup</span><span class="p">&gt;</span>
</code></pre></div>

<p>« <a href="https://blog.master.dev/managing-anchor-associations-with-data-attributes-and-advanced-attr/" title="Managing Anchor Associations With Data Attributes and Advanced attr()">Managing Anchor Associations With Data Attributes and Advanced
<code>attr()</code></a> », de Daniel Schwarz, explore plus
en détail les ancres CSS et la fonction <code>attr()</code> typée. <a class="footnote-backref" href="#fnref:attr-support" title="Jump back to footnote 4 in the text">↩</a></p>
</li>
<li id="fn:reference">
<p>La règle exacte pour l’<a href="https://drafts.csswg.org/css-anchor-position-1/#target-anchor-element" title="CSS Anchor Positioning: target anchor element">élément d’ancrage cible</a> est
plus complexe : « si un ancêtre de la note satisfait les conditions
suivantes, renvoyer celui qui est le plus proche de la note. Sinon, renvoyer
le dernier élément dans l’ordre de l’arbre qui satisfait ces conditions ».
L’une de ces conditions est que « le candidat est un <a href="https://drafts.csswg.org/css-anchor-position-1/#acceptable-anchor-element" title="CSS Anchor Positioning: acceptable anchor element">élément d’ancrage
acceptable</a> pour la note », ce qui exige que « le
candidat soit disposé strictement avant la note », la clause pertinente
étant que « le candidat n’est pas positionné de manière absolue ou apparaît
plus tôt que la note dans l’ordre de l’arbre à plat ». <a class="footnote-backref" href="#fnref:reference" title="Jump back to footnote 5 in the text">↩</a></p>
</li>
</ol>
</div>
      </div></content>
  </entry>
  <entry>
    <title type="html">Un guide non interactif du Spanning Tree Protocol</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/fr/blog/2026-spanning-tree-video" rel="alternate"/>
    <link href="https://vincent.bernat.ch/fr/blog/2026-spanning-tree-video#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-08-30T20:54:32Z</updated>
    <id>http://www.luffy.cx/fr/blog/2026-spanning-tree-video.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml"><p>Imaginez que vous louiez des bureaux pour un événement de trois jours. Vous
installez à la hâte quelques commutateurs Ethernet et scotchez des câbles au sol
pour connecter tout le monde. Malheureusement, Gaston, votre collègue le plus
maladroit, trébuche sur un câble à chaque fois qu’il se lève pour aller chercher
un café. Des câbles supplémentaires régleraient le problème, mais une boucle se
transforme en tempête de diffusion : les paquets Ethernet se multiplient jusqu’à
saturation. C’est là qu’intervient le <em>spanning tree protocol</em> : il bloque juste
assez de câbles pour ne laisser qu’un arbre sans boucle, et le reconstruit en
une seconde à chaque nouvelle bévue de Gaston<sup id="fnref:credits"><a class="footnote-ref" href="#fn:credits">1</a></sup>.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 1920px"><span class="lf-media-inner" style="padding-bottom: 56.250%"><video width="1920" height="1080" controls="" preload="none" crossorigin="anonymous" poster="https://d2pzklc15kok91.cloudfront.net/images/posters/2026-spanning-tree.f67ef4b82d3073.jpg" class="lf-media lf-opaque"><source src="https://media.bernat.ch/videos/2026-spanning-tree.m3u8" type="application/vnd.apple.mpegurl"/><source src="https://media.bernat.ch/videos/2026-spanning-tree/progressive.mp4" type="video/mp4; codecs=&quot;mp4a.40.2,avc1.4d401f&quot;"/><track src="https://media.bernat.ch/videos/2026-spanning-tree.chapters.vtt" kind="chapters" label="Chapitres"/><track src="https://media.bernat.ch/videos/2026-spanning-tree.en.vtt" kind="subtitles" srclang="en" label="English"/><track src="https://media.bernat.ch/videos/2026-spanning-tree.fr.vtt" kind="subtitles" srclang="fr" default="" label="français"/></video></span></div></figure>
<p>Ce contenu est aussi disponible en <a href="/fr/blog/2026-spanning-tree" title="Un guide interactif du Spanning Tree Protocol">version texte</a>, avec des
démonstrations interactives qui font tourner une véritable implémentation
directement dans votre navigateur !</p>
<hr/>
<p>Cette vidéo est une expérimentation<sup id="fnref:time"><a class="footnote-ref" href="#fn:time">2</a></sup>. Honnêtement, hormis pour Radia
Perlman <a href="#video:seek-130">lisant son poème</a><sup id="fnref:radia"><a class="footnote-ref" href="#fn:radia">3</a></sup>, vous devriez plutôt lire
l’<a href="/fr/blog/2026-spanning-tree" title="Un guide interactif du Spanning Tree Protocol">article original</a>. Il présente le même contenu, mais vous
pouvez jouer avec les exemples interactifs, qui en sont la principale
contribution. En revanche, si la vidéo vous plaît, dites-le moi en commentaire !</p>
<div class="footnote">
<hr/>
<ol>
<li id="fn:credits">
<p>Les sprites de Gaston et Nono proviennent de <a href="https://craftpix.net/" title="Craftpix: 2D game assets">Craftpix</a>. La
musique est « <a href="https://www.chosic.com/download-audio/25044/" title="Sonatina No. 2 In G Major – III. Allegro by Aaron Dunn">Sonatina No. 2 in G Major – III. Allegro</a> », d’Aaron
Dunn. <a class="footnote-backref" href="#fnref:credits" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:time">
<p>Je pensais pouvoir produire cette vidéo en deux coups de cuiller à pot
avec quelques outils. Au final, c’était encore un puits sans fond et il m’a
fallu plus de 12 heures. <a class="footnote-backref" href="#fnref:time" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:radia">
<p>Sa voix provient d’une <a href="https://www.youtube.com/watch?v=jOqzLcM2Hbo">vidéo YouTube</a> et a été
nettoyée. <a class="footnote-backref" href="#fnref:radia" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
</ol>
</div>
      </div></content>
  </entry>
  <entry>
    <title type="html">Un guide interactif du Spanning Tree Protocol</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/fr/blog/2026-spanning-tree" rel="alternate"/>
    <link href="https://vincent.bernat.ch/fr/blog/2026-spanning-tree#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-08-24T15:00:00Z</updated>
    <id>http://www.luffy.cx/fr/blog/2026-spanning-tree.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml"><div class="admonition when-rss">
<p class="admonition-title">Avertissement</p>
<p>Cet article contient des exemples interactifs. Pour les
visualiser et interagir avec eux, vous devez <a href="/fr/blog/2026-spanning-tree" title="Un guide interactif du Spanning Tree Protocol">quitter votre lecteur de
flux</a>.</p>
</div>

<p>Imaginez que vous louiez des bureaux pour un événement de trois jours. Vous
installez à la hâte quelques commutateurs Ethernet et scotchez des câbles au sol
pour connecter tout le monde. Malheureusement, Gaston, votre collègue le plus
maladroit, trébuche sur un câble à chaque fois qu’il se lève pour aller chercher
un café. Vous pourriez ajouter des câbles supplémentaires, mais vous
provoqueriez alors une tempête de diffusion : des paquets Ethernet qui tournent
en boucle et se multiplient jusqu’à saturation.</p>
<p>C’est là qu’intervient le <em>spanning tree protocol</em> (<abbr title="Spanning Tree Protocol">STP</abbr>). <abbr title="Spanning Tree Protocol">STP</abbr> bloque le trafic
sur un sous-ensemble des câbles pour ne laisser qu’un arbre sans boucle. Quand
Gaston récidive, <abbr title="Spanning Tree Protocol">STP</abbr> reconstruit l’arbre en une seconde, ce qui laisse à Nono,
votre unique renfort technique, le temps de rebrancher le câble<sup id="fnref:sprites"><a class="footnote-ref" href="#fn:sprites">1</a></sup>. Jugez
par vous-même : l’illustration ci-dessous fait tourner une véritable
implémentation de <abbr title="Spanning Tree Protocol">STP</abbr> dans votre navigateur !</p>
<div class="language-text-only mstp-topology codehilite"><pre><span/><code>:demo

A1 @0,0 prio=4096
A2 @0,1
A3 @0,2
A4 @0,3

B1 @1,0 prio=8192
B2 @1,1
B3 @1,2
B4 @1,3

C1 @2,0 prio=8192
C2 @2,1
C3 @2,2
C4 @2,3

A1 -- A2 hazard=0
A2 -- A3 hazard=0
A3 -- A4 hazard=0
B1 -- B2
B2 -- B3
B3 -- B4
C1 -- C2 hazard=0
C2 -- C3 hazard=0
C3 -- C4 hazard=0

A1 -- B1 cost=10
B1 -- C1 cost=10
A4 -- B4 cost=20
B4 -- C4 cost=20

Léo @-0.3,0.7 proto=none icon=👦🏻
Zoé @-0.3,1.3 proto=none icon=👧🏽
Eva @0.3,0.7  proto=none icon=👱🏻‍♀️
Luc @0.3,1.3  proto=none icon=👨🏾
A2 -- Léo hazard=0 A2:edge
A2 -- Zoé hazard=0 A2:edge
A2 -- Eva hazard=0 A2:edge
A2 -- Luc hazard=0 A2:edge

Max @-0.3,1.7 proto=none icon=👨🏽
Ana @-0.3,2.3 proto=none icon=👩🏾
Ida @0.3,1.7  proto=none icon=👵🏾
Léa @0.3,2.3  proto=none icon=👩🏼
A3 -- Max hazard=0 A3:edge
A3 -- Ana hazard=0 A3:edge
A3 -- Ida hazard=0 A3:edge
A3 -- Léa hazard=0 A3:edge

Tom @0.7,0.7 proto=none icon=👦🏼
Zac @0.7,1.3 proto=none icon=👨🏻
Sam @1.3,0.7  proto=none icon=🧑🏽
Noa @1.3,1.3  proto=none icon=👱🏼
B2 -- Tom hazard=0.2 B2:edge
B2 -- Zac hazard=0.2 B2:edge
B2 -- Sam hazard=0.2 B2:edge
B2 -- Noa hazard=0.2 B2:edge

Isa @0.7,1.7 proto=none icon=👩🏻
Cam @0.7,2.3 proto=none icon=🧑🏾‍🦱
Aya @1.3,1.7  proto=none icon=🧕🏽
Guy @1.3,2.3  proto=none icon=👴🏿
B3 -- Isa hazard=0.2 B3:edge
B3 -- Cam hazard=0.2 B3:edge
B3 -- Aya hazard=0.2 B3:edge
B3 -- Guy hazard=0.2 B3:edge

Awa @1.7,0.7 proto=none icon=👩🏿
Ève @1.7,1.3 proto=none icon=👧🏼
Aël @2.3,0.7  proto=none icon=🧓🏿
Ali @2.3,1.3  proto=none icon=🧔🏾
C2 -- Awa hazard=0 C2:edge
C2 -- Ève hazard=0 C2:edge
C2 -- Aël hazard=0 C2:edge
C2 -- Ali hazard=0 C2:edge

Gil @1.7,1.7 proto=none icon=👨🏼‍🦳
Lou @1.7,2.3 proto=none icon=🧑🏿
Lia @2.3,1.7  proto=none icon=👧🏻
Mia @2.3,2.3  proto=none icon=👩🏽‍🦰
C3 -- Gil hazard=0 C3:edge
C3 -- Lou hazard=0 C3:edge
C3 -- Lia hazard=0 C3:edge
C3 -- Mia hazard=0 C3:edge
</code></pre></div>

<div class="admonition">
<p class="admonition-title">Note</p>
<p>Cet article est aussi disponible en <a href="/fr/blog/2026-spanning-tree-video" title="Un guide non interactif du Spanning Tree Protocol">vidéo</a>, mais je vous
conseille de continuer ici afin d’explorer les démonstrations interactives.</p>
</div>
<div class="toc">
<ul>
<li><a href="#les-bases">Les bases</a><ul>
<li><a href="#interlude-historique">Interlude historique</a></li>
<li><a href="#election-de-la-racine">Élection de la racine</a></li>
<li><a href="#attribution-des-roles-aux-ports">Attribution des rôles aux ports</a></li>
<li><a href="#transition-detat-des-ports">Transition d’état des ports</a></li>
<li><a href="#notification-de-changement-de-topologie">Notification de changement de topologie</a></li>
<li><a href="#securite">Sécurité</a></li>
</ul>
</li>
<li><a href="#pourquoi-rstp-aujourdhui">Pourquoi RSTP aujourd’hui ?</a><ul>
<li><a href="#quelle-taille-pour-un-reseau">Quelle taille pour un réseau ?</a></li>
<li><a href="#rstp-est-il-rapide">RSTP est-il rapide ?</a></li>
</ul>
</li>
<li><a href="#a-propos-de-mstp">À propos de MSTP</a></li>
<li><a href="#a-propos-des-exemples-interactifs">À propos des exemples interactifs</a></li>
</ul>
</div>
<h1 id="les-bases">Les bases</h1>
<p>Conçu dans les années 80, le <em>spanning tree protocol</em> a donné naissance à une
déclinaison « rapide » (<abbr title="Rapid Spanning Tree Protocol">RSTP</abbr>) et à une variante « compatible VLAN »
(<abbr title="Multiple Spanning Tree Protocol">MSTP</abbr>)<sup id="fnref:history"><a class="footnote-ref" href="#fn:history">2</a></sup>. Tout ingénieur réseau sensé sait qu’il existe de meilleures
solutions, comme <a href="/fr/blog/2017-vxlan-bgp-evpn" title="VXLAN: BGP EVPN avec FRR">BGP EVPN VXLAN</a>. Pourtant, puisque n’importe quel
commutateur le parle, le vénérable <em>spanning tree protocol</em> n’a pas dit son
dernier mot.</p>
<p>Nous nous concentrons sur <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> : il a remplacé le protocole d’origine en 2004.
Pour éliminer les boucles réseau, <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> met en œuvre une machine à états
complexe. Les temporisateurs, les changements d’état des liens et les trames de
contrôle qu’un pont reçoit de ses voisins directs pilotent ses transitions. Ces
trames Ethernet sont les <em>Bridge Protocol Data Units</em> (<abbr title="Bridge Protocol Data Unit">BPDU</abbr>). Vous pouvez les
voir à l’œuvre ci-dessous : appuyez sur le bouton « <em>Start</em> ».</p>
<div class="language-text-only mstp-topology codehilite"><pre><span/><code>:protocol rstp
:tx-hold 10

A1 @0,1
C11 @1,0 prio=4096 icon=🌳
C12 @1,2 prio=4096 icon=🌳
C21 @2,0 prio=4096 icon=🌳
C22 @2,2 prio=4096 icon=🌳
A2 @3,1

H1 @0,0.2 proto=none icon=💻
H2 @0,1.8 proto=none icon=🖨️
H3 @3,0.2 proto=none icon=📠
H4 @3,1.8 proto=none icon=📺

A1 -- C11
A1 -- C12
A2 -- C21
A2 -- C22
C11 -- C12
C11 -- C21
C11 -- C21
C11 -- C22
C12 -- C21
C12 -- C22
C21 -- C22
A1 -- H1 A1:edge
A1 -- H2 A1:edge
A2 -- H3 A2:edge
A2 -- H4 A2:edge
</code></pre></div>

<p>Au bout de <a href="#mstp:5,...">quelques instants</a>, la topologie converge vers un
arbre : depuis la racine C11, il existe un chemin vers chaque pont<sup id="fnref:bridge"><a class="footnote-ref" href="#fn:bridge">3</a></sup> et
aucune boucle. En haut à droite, l’interface affiche une icône d’arbre 🌳 suivie
du temps qu’il a fallu pour atteindre cet état. <a href="#mstp:9,C11--C12,...">Coupez un
lien</a> et observez comment le protocole trouve en moins
d’une seconde un autre chemin pour joindre C12. Vous pouvez arrêter la
simulation, l’avancer pas à pas, la réinitialiser ou la ralentir avec le mode « escargot » 🐌. Ne vous inquiétez pas de toutes les informations affichées : je
les explique plus loin.</p>
<div class="admonition">
<p class="admonition-title">Note</p>
<p>Pendant que vous faites défiler la page, la simulation en cours reste
visible pour que vous puissiez la garder en vue au fil de votre lecture.
Décochez cette case pour désactiver ce comportement : <input type="checkbox" id="lf-mstp-sticky" checked="checked"/>.</p>
</div>
<p>Tous les exemples s’exécutent dans votre navigateur grâce à <a href="https://github.com/mstpd/mstpd" title="Multiple Spanning Tree Protocol Daemon">MSTPD</a>, une
implémentation libre de <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr><sup id="fnref:incomplete"><a class="footnote-ref" href="#fn:incomplete">4</a></sup> fonctionnant en espace
utilisateur<sup id="fnref:kernel"><a class="footnote-ref" href="#fn:kernel">5</a></sup>.</p>
<h2 id="interlude-historique">Interlude historique</h2>
<p><a href="https://hiddenheroes.netguru.com/radia-perlman" title="The Magic Box and the Spanning Tree: How Radia Perlman made the Internet work">Radia Perlman</a>, intronisée à l’<a href="https://www.internethalloffame.org/inductee/radia-perlman/" title="Radia Perlman profile on the Internet Hall of Fame">Internet Hall of Fame</a> en 2014, a résumé
dans ce poème l’ancêtre de <abbr title="Spanning Tree Protocol">STP</abbr> qu’elle a inventé chez <abbr title="Digital Equipment Corporation">DEC</abbr>. Il a été repris plus
tard dans un <a href="https://patents.google.com/patent/US7339900B2/en" title="Method and apparatus for preventing spanning tree loops during traffic overload conditions">brevet américain</a> :</p>
<blockquote>
<p>I think that I shall never see<br/>
A graph more lovely than a tree.<br/>
A tree whose crucial property<br/>
Is loop-free connectivity.<br/>
A tree which must be sure to span<br/>
So packets can reach every LAN.<br/>
First, the root must be selected.<br/>
By ID, it is elected.<br/>
Least cost paths from root are traced.<br/>
In the tree, these paths are placed.<br/>
A mesh is made by folks like me,<br/>
Then bridges find a spanning tree.</p>
<p>― <em>Radia Perlman</em>, <a href="https://hiddenheroes.netguru.com/radia-perlman" title="The Magic Box and the Spanning Tree: How Radia Perlman made the Internet work">Algorhyme</a>.</p>
</blockquote>
<h2 id="election-de-la-racine">Élection de la racine</h2>
<p>Pour construire un arbre, <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> commence par élire comme <strong>pont racine</strong> celui
qui a l’<strong>identifiant de pont le plus faible</strong>. Cet identifiant combine la
priorité et l’adresse MAC : <code>8192.6e:2b:10:a0:5f:29</code>.</p>
<p>Dans l’exemple ci-dessous, S1 et S2 ont des priorités de 4 096 et 8 192 : S1
devient racine. S4 a une priorité de 12 288, tandis que S3 conserve la priorité
par défaut de 32 768<sup id="fnref:priority"><a class="footnote-ref" href="#fn:priority">6</a></sup> : S4 devient racine. S5 et S6 n’ont pas de
priorité particulière : l’adresse MAC la plus faible l’emporte et S5 devient
racine.</p>
<div class="language-text-only mstp-topology codehilite"><pre><span/><code>:protocol rstp

S1 @0,0 prio=4096
S2 @0,1 prio=8192
S1 -- S2

S3 @1,0
S4 @1,1 prio=12288
S3 -- S4

S5 @2,0
S6 @2,1
S5 -- S6
</code></pre></div>

<p><a href="#mstp:2,S2-&gt;S1,@">Au départ</a>, chaque pont s’annonce comme racine<sup id="fnref:wireshark"><a class="footnote-ref" href="#fn:wireshark">7</a></sup> :</p>
<div class="language-wireshark codehilite"><pre><span/><code><span class="nc">Spanning Tree Protocol</span>
<span class="ss">    Protocol Identifier</span>: Spanning Tree Protocol <span class="p">(</span><span class="m">0x0000</span><span class="p">)</span>
<span class="ss">    Protocol Version Identifier</span>: Rapid Spanning Tree <span class="p">(</span><span class="m">2</span><span class="p">)</span>
<span class="ss">    BPDU Type</span>: Rapid/Multiple Spanning Tree <span class="p">(</span><span class="m">0x02</span><span class="p">)</span>
<span class="hll"><span class="ss">    Root Identifier</span>: 8192.02:00:00:01:00:01
</span><span class="hll"><span class="ss">    Bridge Identifier</span>: 8192.02:00:00:01:00:01
</span></code></pre></div>

<p>Dès qu’un pont reçoit une <abbr title="Bridge Protocol Data Unit">BPDU</abbr> annonçant une meilleure racine, il
<a href="#mstp:3,S2-&gt;S1,@">propage</a> cette nouvelle information à ses voisins.</p>
<div class="language-wireshark codehilite"><pre><span/><code><span class="nc">Spanning Tree Protocol</span>
<span class="ss">    Protocol Identifier</span>: Spanning Tree Protocol <span class="p">(</span><span class="m">0x0000</span><span class="p">)</span>
<span class="ss">    Protocol Version Identifier</span>: Rapid Spanning Tree <span class="p">(</span><span class="m">2</span><span class="p">)</span>
<span class="ss">    BPDU Type</span>: Rapid/Multiple Spanning Tree <span class="p">(</span><span class="m">0x02</span><span class="p">)</span>
<span class="hll"><span class="ss">    Root Identifier</span>: 4096.02:00:00:00:00:00
</span><span class="hll"><span class="ss">    Bridge Identifier</span>: 8192.02:00:00:00:00:01
</span></code></pre></div>

<h2 id="attribution-des-roles-aux-ports">Attribution des rôles aux ports</h2>
<p>La deuxième étape consiste à attribuer un rôle à chaque port. <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> définit cinq
rôles, chacun représenté par une lettre :</p>
<ul>
<li>racine (R, <em>root</em>),</li>
<li>désigné (D, <em>designated</em>),</li>
<li>alternatif (A, <em>alternate</em>),</li>
<li>désactivé (X, <em>disabled</em>),</li>
<li>de secours (B, <em>backup</em>)<sup id="fnref:backup"><a class="footnote-ref" href="#fn:backup">8</a></sup>.</li>
</ul>
<p>Chaque pont qui n’est pas racine choisit comme <strong>port racine</strong> celui dont le
chemin vers la racine a le coût le plus faible. Sauf configuration explicite,
chaque pont déduit le coût d’un lien à partir de son débit : 20 000 pour
1 Gbit/s. En cas d’égalité, l’identifiant de port le plus faible l’emporte.</p>
<p>Chacun des ports restants devient un <strong>port désigné</strong> si la <abbr title="Bridge Protocol Data Unit">BPDU</abbr> qu’il émet est
« meilleure » que celle qu’il reçoit. Sinon, il devient un <strong>port alternatif</strong>.
Plus tard, si le port racine tombe, le « meilleur » port alternatif devient le
port racine. Les critères pour choisir la meilleure <abbr title="Bridge Protocol Data Unit">BPDU</abbr> sont :</p>
<ol>
<li>l’identifiant de pont racine le plus faible,</li>
<li>le coût cumulé jusqu’à la racine le plus faible,</li>
<li>l’identifiant de pont le plus faible,</li>
<li>l’identifiant de port le plus faible.</li>
</ol>
<div class="language-text-only mstp-topology codehilite"><pre><span/><code>:protocol rstp

S1 @1,0  prio=4096 icon=🌳
S2 @0,1
S3 @2,1

S1 -- S2
S1 -- S3
S1 -- S3
S2 -- S3
</code></pre></div>

<p>Dans l’exemple ci-dessus, <a href="#mstp:13">après convergence</a>, S1 est la racine car sa
priorité est de 4 096, alors que les autres ponts ont une priorité de 32 768.
Tous ses ports sont des ports désignés puisque le coût cumulé jusqu’à la racine
est nul.</p>
<p>Le port de S2 face à S1 devient un port racine car il présente le coût cumulé le
plus faible vers la racine : 20 000 contre 40 000. S3 possède deux ports face à
S1 et celui dont l’identifiant de port est le plus faible devient le port
racine : <code>0x8000</code> contre <code>0x8001</code>. L’autre candidat est un port alternatif car
le port distant sur ce lien émet une meilleure <abbr title="Bridge Protocol Data Unit">BPDU</abbr>, avec un coût cumulé nul.
Sur le segment entre S2 et S3, c’est le port de S2 qui l’emporte : les deux
ponts ont le même coût cumulé jusqu’à la racine (20 000), mais l’identifiant de
pont de S2 est plus petit : <code>32768.02:00:00:00:00:01</code> contre
<code>32768.02:00:00:00:00:02</code>.</p>
<div class="language-wireshark codehilite"><pre><span/><code><span class="nc">Spanning Tree Protocol</span>
<span class="ss">    Protocol Identifier</span>: Spanning Tree Protocol <span class="p">(</span><span class="m">0x0000</span><span class="p">)</span>
<span class="ss">    Protocol Version Identifier</span>: Rapid Spanning Tree <span class="p">(</span><span class="m">2</span><span class="p">)</span>
<span class="ss">    BPDU Type</span>: Rapid/Multiple Spanning Tree <span class="p">(</span><span class="m">0x02</span><span class="p">)</span>
<span class="hll"><span class="ss">    Root Identifier</span>: 4096.02:00:00:00:00:00
</span><span class="hll"><span class="ss">    Root Path Cost</span>: 20000
</span><span class="hll"><span class="ss">    Bridge Identifier</span>: 32768.02:00:00:00:00:01
</span><span class="ss">    Port identifier</span>: 0x8002
</code></pre></div>

<p>Si vous <a href="#mstp:13,S1--S3:1">coupez le lien actif entre S1 et S3</a>, S3 promeut le
« meilleur » port alternatif en port racine. Si vous <a href="#mstp:13,S1--S3:1,S1--S3:2">désactivez aussi le second
lien</a>, S3 retient le port alternatif restant comme
port racine. En revanche, si vous <a href="#mstp:13,S1--S2,@,...">désactivez le lien entre S1 et
S2</a>, S2 doit travailler un peu plus pour élire un nouveau
port racine, car il ne dispose d’aucun port alternatif.</p>
<p>Sauf événement particulier, les ports désignés émettent une <abbr title="Bridge Protocol Data Unit">BPDU</abbr> <a href="#mstp:13,S1-&gt;S3:2,S2-&gt;S3,S1-&gt;S2,@">toutes les 2
secondes</a><sup id="fnref:hello"><a class="footnote-ref" href="#fn:hello">9</a></sup>. Si un pont ne reçoit plus de
<abbr title="Bridge Protocol Data Unit">BPDU</abbr> de son voisin pendant 3 périodes « <em>hello</em> » consécutives, il le considère
comme mort et efface les informations associées au port.</p>
<h2 id="transition-detat-des-ports">Transition d’état des ports</h2>
<p>Chaque port se trouve dans l’un des trois états suivants. Le schéma représente
chaque état par une couleur de fond :</p>
<ul>
<li>rejet (<em>discarding</em>, rouge),</li>
<li>apprentissage (<em>learning</em>, jaune),</li>
<li>transmission (<em>forwarding</em>, vert).</li>
</ul>
<p>Un <em>port racine</em> passe automatiquement à l’état de transmission. Un <em>port
alternatif</em> reste à l’état de rejet. Un <em>port désigné</em> dispose de deux moyens
pour passer de l’état de rejet à l’état de transmission :</p>
<ul>
<li>Si le port est un <strong>port d’extrémité</strong> (<em>edge port</em>), soit par configuration,
  soit parce que l’équipement distant ne parle aucune variante de <abbr title="Spanning Tree Protocol">STP</abbr>, le pont
  suppose que cet équipement ne participe pas au protocole et ne peut donc pas
  créer de boucle. Dans ce cas, le port désigné passe immédiatement à l’état de
  transmission.</li>
<li>Sinon, il envoie une <strong>proposition</strong> à son voisin en aval. Si le pont distant
  estime que la <abbr title="Bridge Protocol Data Unit">BPDU</abbr> reçue est « meilleure » que toutes celles mémorisées pour
  ses autres ports, il élit le port de réception comme port racine et démarre le
  processus de <strong>synchronisation</strong> : pour éviter une boucle, il fait passer à
  l’état de rejet tous les ports désignés qui ne sont ni des ports d’extrémité
  ni déjà synchronisés. Il renvoie ensuite un <strong>accord</strong>. À la réception de cet
  accord, le port désigné du pair passe à l’état de transmission<sup id="fnref:learning"><a class="footnote-ref" href="#fn:learning">10</a></sup>.</li>
</ul>
<div class="language-text-only mstp-topology codehilite"><pre><span/><code>:protocol rstp

S1 @1,0 prio=4096 icon=🌳
S2 @1,1
S3 @0,2
S4 @2,2
S5 @0,3 prio=8192 icon=🪾
S6 @2,3
H1 @0,1.2   proto=none icon=🖨️
H2 @2,1.2   proto=none icon=📠
H3 @2.5,1.3 proto=none icon=📺
H4 @2.5,2.3 proto=none icon=💻

S1 -- S2
S2 -- S3
S2 -- S4
S3 -- S5
S4 -- S6
S4 -- S3
S5 -- S6

S3 -- H1 S3:edge
S4 -- H2 S4:edge
S4 -- H3 S4:edge
S6 -- H4 S6:edge
</code></pre></div>

<p>Dans la topologie ci-dessus, H1, H2, H3 et H4 sont des équipements terminaux qui
ne participent pas au protocole. Nous configurons les ports auxquels ils sont
raccordés comme des ports d’extrémité : ces ports passent donc immédiatement à
l’état de transmission.</p>
<p>Utilisez le bouton « <em>step</em> » pour faire avancer la simulation. L’horloge passe
à 1 seconde. <a href="#mstp:2,S1-&gt;S2,S2-&gt;S1,@">Avancez encore d’un cran</a> : S1 et S2
s’envoient mutuellement une proposition. Voici celle de S2 :</p>
<div class="language-wireshark codehilite"><pre><span/><code><span class="nc">Spanning Tree Protocol</span>
<span class="ss">    Protocol Identifier</span>: Spanning Tree Protocol <span class="p">(</span><span class="m">0x0000</span><span class="p">)</span>
<span class="ss">    Protocol Version Identifier</span>: Rapid Spanning Tree <span class="p">(</span><span class="m">2</span><span class="p">)</span>
<span class="ss">    BPDU Type</span>: Rapid/Multiple Spanning Tree <span class="p">(</span><span class="m">0x02</span><span class="p">)</span>
<span class="ss">    BPDU flags</span>: 0x4e, Agreement, Port Role: Designated, Proposal
        <span class="no">0... .... </span><span class="o">= </span><span class="ss">Topology Change Acknowledgment</span>: No
        <span class="no">.1.. .... </span><span class="o">= </span><span class="ss">Agreement</span>: Yes
        <span class="no">..0. .... </span><span class="o">= </span><span class="ss">Forwarding</span>: No
        <span class="no">...0 .... </span><span class="o">= </span><span class="ss">Learning</span>: No
<span class="hll">        <span class="no">.... 11.. </span><span class="o">= </span><span class="ss">Port Role</span>: Designated <span class="p">(</span><span class="m">3</span><span class="p">)</span>
</span><span class="hll">        <span class="no">.... ..1. </span><span class="o">= </span><span class="ss">Proposal</span>: Yes
</span>        <span class="no">.... ...0 </span><span class="o">= </span><span class="ss">Topology Change</span>: No
<span class="hll"><span class="ss">    Root Identifier</span>: 32768.02:00:00:00:00:01
</span><span class="ss">    Root Path Cost</span>: 0
<span class="ss">    Bridge Identifier</span>: 32768.02:00:00:00:00:01
<span class="ss">    Port identifier</span>: 0x8001
</code></pre></div>

<p>S1 ignore cette proposition : son propre identifiant de racine est plus faible.
Quand S2 reçoit une proposition similaire de S1, il accepte S1 comme racine. Il
élit également le port vers S1 comme port racine et démarre le processus de
synchronisation. Ses deux ports désignés sont déjà à l’état de rejet : rien ne
change de ce côté. <a href="#mstp:3,S2-&gt;S1#2,@">Avancez encore d’un cran</a> : S2 envoie
deux <abbr title="Bridge Protocol Data Unit">BPDU</abbr> à S1. Dans l’une d’elles, le bit d’accord vaut 1 et le bit de
proposition vaut 0. Elle montre aussi que S2 a accepté S1 comme racine et que
son port racine est désormais à l’état de transmission. À la réception de cette
<abbr title="Bridge Protocol Data Unit">BPDU</abbr>, S1 fait passer son propre port désigné à l’état de transmission. À partir
de cet instant, le lien entre S1 et S2 achemine le trafic utilisateur.</p>
<div class="language-wireshark codehilite"><pre><span/><code><span class="nc">Spanning Tree Protocol</span>
<span class="ss">    Protocol Identifier</span>: Spanning Tree Protocol <span class="p">(</span><span class="m">0x0000</span><span class="p">)</span>
<span class="ss">    Protocol Version Identifier</span>: Rapid Spanning Tree <span class="p">(</span><span class="m">2</span><span class="p">)</span>
<span class="ss">    BPDU Type</span>: Rapid/Multiple Spanning Tree <span class="p">(</span><span class="m">0x02</span><span class="p">)</span>
<span class="ss">    BPDU flags</span>: 0x79, Agreement, Forwarding, Learning, Port Role: Root, Topology Change
        <span class="no">0... .... </span><span class="o">= </span><span class="ss">Topology Change Acknowledgment</span>: No
<span class="hll">        <span class="no">.1.. .... </span><span class="o">= </span><span class="ss">Agreement</span>: Yes
</span><span class="hll">        <span class="no">..1. .... </span><span class="o">= </span><span class="ss">Forwarding</span>: Yes
</span>        <span class="no">...1 .... </span><span class="o">= </span><span class="ss">Learning</span>: Yes
<span class="hll">        <span class="no">.... 10.. </span><span class="o">= </span><span class="ss">Port Role</span>: Root <span class="p">(</span><span class="m">2</span><span class="p">)</span>
</span><span class="hll">        <span class="no">.... ..0. </span><span class="o">= </span><span class="ss">Proposal</span>: No
</span>        <span class="no">.... ...1 </span><span class="o">= </span><span class="ss">Topology Change</span>: Yes
<span class="hll"><span class="ss">    Root Identifier</span>: 4096.02:00:00:00:00:00
</span><span class="ss">    Root Path Cost</span>: 20000
<span class="ss">    Bridge Identifier</span>: 32768.02:00:00:00:00:01
<span class="ss">    Port identifier</span>: 0x8001
</code></pre></div>

<p>Voyons maintenant ce qui est arrivé à S5. <a href="#mstp:2,S5-&gt;S3,S3-&gt;S5,S5-&gt;S6,S6-&gt;S5,@">Réinitialisez la simulation et
avancez de deux pas</a>. S5 échange des <abbr title="Bridge Protocol Data Unit">BPDU</abbr>
avec S3 et S6. Comme S5 possède un identifiant de racine plus faible que S3 et
S6, il reste la racine, tandis que S3 et S6 acceptent la proposition et élisent
leurs ports racines. S3 et S6 démarrent le processus de synchronisation. Le port
de S6 vers H4 reste actif car il s’agit d’un port d’extrémité. <a href="#mstp:3,S3-&gt;S5#1,S6-&gt;S5#1,@">Avancez d’un
cran</a> : S3 et S6 renvoient tous deux un accord à
S5, qui fait passer ses deux ports désignés à l’état de transmission. Pourtant,
le lien entre S5 et S3 continue de rejeter le trafic utilisateur ! Si vous
regardez attentivement, le port de S3 vers S5 est maintenant un port désigné, et
non un port racine. Lors de la <a href="#mstp:3,S2-&gt;S3,@">même étape</a>, S3 reçoit aussi
une meilleure <abbr title="Bridge Protocol Data Unit">BPDU</abbr> de S2, avec S1 comme racine. Il élit son port vers S2 comme
port racine et rétrograde le port vers S5 en port désigné, qui reste à l’état de
rejet.</p>
<p>À l’<a href="#mstp:4,S3-&gt;S5,@">étape suivante</a>, les choses se corsent un peu. S3 envoie
une proposition à S5<sup id="fnref:agreement"><a class="footnote-ref" href="#fn:agreement">11</a></sup> :</p>
<div class="language-wireshark codehilite"><pre><span/><code><span class="nc">Spanning Tree Protocol</span>
<span class="ss">    Protocol Identifier</span>: Spanning Tree Protocol <span class="p">(</span><span class="m">0x0000</span><span class="p">)</span>
<span class="ss">    Protocol Version Identifier</span>: Rapid Spanning Tree <span class="p">(</span><span class="m">2</span><span class="p">)</span>
<span class="ss">    BPDU Type</span>: Rapid/Multiple Spanning Tree <span class="p">(</span><span class="m">0x02</span><span class="p">)</span>
<span class="ss">    BPDU flags</span>: 0x4f, Agreement, Port Role: Designated, Proposal, Topology Change
        <span class="no">0... .... </span><span class="o">= </span><span class="ss">Topology Change Acknowledgment</span>: No
        <span class="no">.1.. .... </span><span class="o">= </span><span class="ss">Agreement</span>: Yes
        <span class="no">..0. .... </span><span class="o">= </span><span class="ss">Forwarding</span>: No
        <span class="no">...0 .... </span><span class="o">= </span><span class="ss">Learning</span>: No
        <span class="no">.... 11.. </span><span class="o">= </span><span class="ss">Port Role</span>: Designated <span class="p">(</span><span class="m">3</span><span class="p">)</span>
<span class="hll">        <span class="no">.... ..1. </span><span class="o">= </span><span class="ss">Proposal</span>: Yes
</span>        <span class="no">.... ...1 </span><span class="o">= </span><span class="ss">Topology Change</span>: Yes
<span class="hll"><span class="ss">    Root Identifier</span>: 4096.02:00:00:00:00:00
</span><span class="ss">    Root Path Cost</span>: 40000
<span class="ss">    Bridge Identifier</span>: 32768.02:00:00:00:00:02
<span class="ss">    Port identifier</span>: 0x8002
</code></pre></div>

<p>S5 élit S1 comme racine et le port vers S3 comme port racine. Il démarre son
processus de synchronisation, mais le port désigné vers S6 ne passe <em>pas</em> à
l’état de rejet. Pourquoi ? Ce port reste un port désigné et son voisin S6 a
déjà envoyé un accord sur ce lien : il conserve donc son statut de port
synchronisé.</p>
<p>Revenons maintenant <a href="#mstp:3,@">un pas en arrière</a> pour observer ce qui arrive à
S6. À cet instant, S6 croit que S5 est la racine. <a href="#mstp:4,S4-&gt;S6#1,@">Avancez d’un
cran</a> : S4 envoie une nouvelle proposition à S6. S6
l’accepte, élit S1 comme racine et le port vers S4 comme port racine. Le rôle du
port face à S5 change : de port racine, il devient port désigné. Comme son pair
continue d’annoncer une <abbr title="Bridge Protocol Data Unit">BPDU</abbr> inférieure sur le lien, ce port devient contesté
(<em>disputed</em>) et passe à l’état de rejet. Le port racine passe à l’état de
transmission et le lien devient immédiatement opérationnel, car le port désigné
de S4 est déjà à l’état de transmission. Si nous <a href="#mstp:5,S5-&gt;S6,S6-&gt;S5,@">avançons d’un
cran</a>, S5 et S6 échangent deux <abbr title="Bridge Protocol Data Unit">BPDU</abbr>. Celle de S5 est
meilleure grâce à son identifiant de pont plus faible. Le port de S5 reste un
port désigné, tandis que S6 rétrograde le sien en port alternatif.</p>
<p>Reprenons une dernière fois depuis le début : coupez le lien entre S1 et S2,
<a href="#mstp:S1--S2,9">laissez tourner la simulation jusqu’à ce que la topologie soit
stable</a>, arrêtez-la, puis rétablissez le lien entre S1 et S2.
Lors du <a href="#mstp:S1--S2,9,S1--S2,1,S1-&gt;S2,S2-&gt;S1,@">premier pas</a>, S1 et S2
échangent des propositions. S2 élit S1 comme racine à la place de S5, et le port
vers S1 comme port racine. Il rétrograde son ancien port racine en port désigné
et le place à l’état de rejet. L’autre port désigné reste synchronisé et
conserve son état de transmission. À l’<a href="#mstp:S1--S2,9,S1--S2,2,S2-&gt;S1,@">étape
suivante</a>, S2 envoie un accord à S1 et le lien
entre eux commence à acheminer le trafic utilisateur. S2 <a href="#mstp:S1--S2,9,S1--S2,2,S2-&gt;S3,@">envoie également une
proposition à S3</a>, mais pas à S4 : <a href="#mstp:S1--S2,9,S1--S2,2,S2-&gt;S4,@">il lui
envoie une <abbr title="Bridge Protocol Data Unit">BPDU</abbr> ordinaire</a>. S4 élit malgré
tout S1 comme racine et le port vers S2 comme port racine. Il rétrograde son
ancien port racine, celui vers S3, en port désigné, qui passe à l’état de rejet
à cause du changement de port racine. L’autre port alternatif, celui vers S6,
devient lui aussi un port désigné et reste à l’état de rejet. Le nouveau port
racine passe à l’état de transmission. À l’<a href="#mstp:S1--S2,9,S1--S2,3,S3-&gt;S4#1,@">étape
suivante</a>, le port de S4 vers S3 se
stabilise comme port alternatif après avoir reçu une « meilleure » <abbr title="Bridge Protocol Data Unit">BPDU</abbr> de S3.</p>
<p><abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> est une gigantesque machine à états découpée en machines plus petites :
<em>Bridge Detection</em>, <em>Port Information</em>, <em>Port Protocol Migration</em>, <em>Port Role
Selection</em>, <em>Port Role Transitions</em>, <em>Port Receive</em>, <em>Port State Transitions</em>,
<em>Port Timers</em>, <em>Port Transmit</em> et <em>Topology Change</em>. Certaines s’appliquent à
l’ensemble du pont, d’autres à chaque port. Chaque pont en exécute une instance.
Le temps, les changements d’état opérationnel des ports et les <abbr title="Bridge Protocol Data Unit">BPDU</abbr> reçues des
autres instances en pilotent les transitions. Ce fonctionnement par événements
rend <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> plus efficace, mais aussi plus difficile à appréhender.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 900px"><span class="lf-media-inner" style="padding-bottom: 43.333%"><img alt="Locomotive à vapeur articulée Garratt de classe Msa des Western Australian&#10;Government Railways : élévation et plan" src="https://d2pzklc15kok91.cloudfront.net/images/msa-garratt@1x.77dc2f6581ab04.jpg" srcset="https://d2pzklc15kok91.cloudfront.net/images/msa-garratt@1x.77dc2f6581ab04.jpg 1131w,https://d2pzklc15kok91.cloudfront.net/images/msa-garratt@2x.c5396e20b4e553.jpg 1800w" sizes="auto, (max-width: 900px) 100vw, 900px" width="900" height="390" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAANAQMAAACEvEzUAAAABlBMVEX///8AAABVwtN+AAAADElEQVR42mNgoBAAAABBAAEodTPOAAAAAElFTkSuQmCC)"/></span></div><figcaption>Image provisoire de la machine à états <em>Port Information</em> extraite d'IEEE 802.1Q-2005, page 182. Dans l'attente de l'autorisation de reproduction de l'IEEE, voici le plan de la locomotive à vapeur articulée Garratt de classe Msa des Western Australian Government Railways.</figcaption></figure>
<h2 id="notification-de-changement-de-topologie">Notification de changement de topologie</h2>
<p>Un pont maintient automatiquement une table d’adresses MAC : il associe chaque
adresse MAC source au port qui l’a reçue en dernier. Pour commuter une trame
Ethernet, il consulte cette table afin de choisir le bon port<sup id="fnref:bum"><a class="footnote-ref" href="#fn:bum">12</a></sup>. Quand un
lien tombe, un frigo connecté joignable par un port peut le devenir par un
autre. Les ponts concernés doivent alors purger les adresses MAC apprises :
elles ne sont peut-être plus valables.</p>
<p>Pour cela, <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> met en œuvre des <em>notifications de changement de topologie</em> à
l’aide d’un mécanisme d’inondation. Lorsqu’un port qui n’est pas un port
d’extrémité passe à l’état de transmission, un pont génère des <abbr title="Bridge Protocol Data Unit">BPDU</abbr> dont le bit
<em>topology change</em> (<abbr title="Topology Change">TC</abbr>) est activé. Il les envoie à tous ses ports désignés, à
l’exception des ports d’extrémité, ainsi qu’à son port racine. Il purge
également la table d’adresses MAC sur ces ports. Quand un pont reçoit une telle
<abbr title="Bridge Protocol Data Unit">BPDU</abbr>, il propage la notification sur ses ports désignés hors ports d’extrémité
et sur son port racine, sauf celui par lequel elle est arrivée. Il purge lui
aussi la table d’adresses MAC sur ces ports. Dans les exemples, les <abbr title="Bridge Protocol Data Unit">BPDU</abbr> dont le
bit <abbr title="Topology Change">TC</abbr> vaut 1 sont entourées d’un cercle rouge.</p>
<div class="language-text-only mstp-topology codehilite"><pre><span/><code>:protocol rstp

S1 @1,0 prio=4096 icon=🌳
S2 @0,1
S3 @1,1
S4 @2,1
S5 @1,2
LPT @0.1,2 proto=none icon=🖨️

S1 -- S2
S1 -- S3
S1 -- S4
S2 -- S3
S2 -- S5
S4 -- S5
S5 -- LPT S5:edge
</code></pre></div>

<p>Lancez la simulation et attendez <a href="#mstp:10">quelques secondes</a> que la topologie
se stabilise. Arrêtez la simulation et <a href="#mstp:10,S2--S5">désactivez le lien entre S2 et
S5</a>. S5 élit le port face à S4 comme port racine, lequel passe
immédiatement à l’état de transmission. <a href="#mstp:10,S2--S5,1,S5-&gt;S4,@">Avancez d’un
cran</a> : S5 émet une <abbr title="Bridge Protocol Data Unit">BPDU</abbr> avec le bit <abbr title="Topology Change">TC</abbr> à 1 :</p>
<div class="language-wireshark codehilite"><pre><span/><code><span class="nc">Spanning Tree Protocol</span>
<span class="ss">    Protocol Identifier</span>: Spanning Tree Protocol <span class="p">(</span><span class="m">0x0000</span><span class="p">)</span>
<span class="ss">    Protocol Version Identifier</span>: Rapid Spanning Tree <span class="p">(</span><span class="m">2</span><span class="p">)</span>
<span class="ss">    BPDU Type</span>: Rapid/Multiple Spanning Tree <span class="p">(</span><span class="m">0x02</span><span class="p">)</span>
<span class="ss">    BPDU flags</span>: 0x79, Agreement, Forwarding, Learning, Port Role: Root, Topology Change
        <span class="no">0... .... </span><span class="o">= </span><span class="ss">Topology Change Acknowledgment</span>: No
        <span class="no">.1.. .... </span><span class="o">= </span><span class="ss">Agreement</span>: Yes
        <span class="no">..1. .... </span><span class="o">= </span><span class="ss">Forwarding</span>: Yes
        <span class="no">...1 .... </span><span class="o">= </span><span class="ss">Learning</span>: Yes
        <span class="no">.... 10.. </span><span class="o">= </span><span class="ss">Port Role</span>: Root <span class="p">(</span><span class="m">2</span><span class="p">)</span>
        <span class="no">.... ..0. </span><span class="o">= </span><span class="ss">Proposal</span>: No
<span class="hll">        <span class="no">.... ...1 </span><span class="o">= </span><span class="ss">Topology Change</span>: Yes
</span><span class="ss">    Root Identifier</span>: 4096.02:00:00:00:00:00
<span class="ss">    Root Path Cost</span>: 40000
<span class="ss">    Bridge Identifier</span>: 32768.02:00:00:00:00:04
<span class="ss">    Port identifier</span>: 0x8002
</code></pre></div>

<p>S4 reçoit cette <abbr title="Bridge Protocol Data Unit">BPDU</abbr>. Il purge la table d’adresses MAC sur le port face à S1 :
LPT était auparavant joignable par ce port, mais il l’est désormais par S5.
<a href="#mstp:10,S2--S5,2,S4-&gt;S1,@">Avancez d’un cran</a> : S4 envoie à S1
une <abbr title="Bridge Protocol Data Unit">BPDU</abbr> avec le bit <abbr title="Topology Change">TC</abbr> à 1. À la réception de cette <abbr title="Bridge Protocol Data Unit">BPDU</abbr>, S1 purge la table
d’adresses MAC sur les ports face à S2 et S3. <a href="#mstp:10,S2--S5,3,S1-&gt;S2,S1-&gt;S3,@">Avancez d’un
cran</a> : S1 envoie une notification à S2 et à
S3. <a href="#mstp:10,S2--S5,4,S2-&gt;S3,@">Avancez encore d’un cran</a> : S2 envoie une
notification à S3, tandis que S3 ne fait rien car son port vers S2 est un <em>port
alternatif</em>. S3 ne purge aucune table d’adresses MAC : LPT reste joignable par
son port vers S1.</p>
<p>Si vous <a href="#mstp:10,S2--S5,6,@">avancez encore un peu</a>, vous verrez que certaines
<abbr title="Bridge Protocol Data Unit">BPDU</abbr> périodiques conservent le bit <abbr title="Topology Change">TC</abbr> à 1. Chaque port dispose d’un
temporisateur égal au temporisateur « <em>hello</em> » plus une seconde<sup id="fnref:timer"><a class="footnote-ref" href="#fn:timer">13</a></sup>. Ce
temporisateur démarre quand le port émet une notification. Jusqu’à son
expiration, le port positionne le bit <abbr title="Topology Change">TC</abbr> à 1 dans toutes les <abbr title="Bridge Protocol Data Unit">BPDU</abbr> qu’il envoie.
Vous pouvez aussi voir <a href="#mstp:10,S2--S5,7,S1-&gt;S4,S4-&gt;S5,@">certaines <abbr title="Bridge Protocol Data Unit">BPDU</abbr>
périodiques</a> sans le bit <abbr title="Topology Change">TC</abbr> : elles
proviennent d’un port qui n’a fait que recevoir une notification et qui n’a donc
pas armé son temporisateur.</p>
<h2 id="securite">Sécurité</h2>
<p><abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> est sensible aux erreurs de configuration et peu résistant face aux acteurs
malveillants. Un pont qui ne parle pas <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> peut créer une boucle. Une personne
malveillante peut s’insérer dans la topologie pour perturber le service,
espionner le trafic ou le modifier.</p>
<p>Pour limiter ces problèmes, vous devez identifier les ports d’extrémité. Un port
d’extrémité est raccordé à un équipement tel qu’un PC ou une imprimante. Ces
équipements ne génèrent pas de <abbr title="Bridge Protocol Data Unit">BPDU</abbr> et ne peuvent pas créer de boucle. <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr>
propose deux options liées :</p>
<ul>
<li>Quand elle est vraie, <strong>AdminEdge</strong> initialise un port comme port d’extrémité.</li>
<li>Quand elle est vraie, <strong>AutoEdge</strong> permet à un port de devenir un port
  d’extrémité s’il ne reçoit aucune <abbr title="Bridge Protocol Data Unit">BPDU</abbr> pendant 3 secondes. Cette option est
  activée par défaut.</li>
</ul>
<p>Si un port d’extrémité reçoit une <abbr title="Bridge Protocol Data Unit">BPDU</abbr>, quelles que soient les valeurs de ces
deux options, il redevient un port ordinaire.</p>
<div class="language-text-only mstp-topology codehilite"><pre><span/><code>R0 @1.5,1.5 prio=8192

# AutoEdge=true, AdminEdge=false, bridge
S1 @3,1.58
R0 -- S1

# AutoEdge=true, AdminEdge=false, end device
H1 @2.84,2.18 icon=🖨️ proto=none
R0 -- H1

# AutoEdge=true, AdminEdge=true, bridge
S2 @2.18,2.84
R0 -- S2 R0:edge

# AutoEdge=true, AdminEdge=true, end device
H2 @1.58,3 icon=💻 proto=none
R0 -- H2 R0:edge

# AutoEdge=false, AdminEdge=true, bridge
S3 @0.68,2.76
R0 -- S3 R0:edge R0:no-auto-edge

# AutoEdge=false, AdminEdge=true, end device
H3 @0.24,2.32 icon=📠 proto=none
R0 -- H3 R0:edge R0:no-auto-edge

# AutoEdge=false, AdminEdge=false, bridge
S4 @0,1.42
R0 -- S4 R0:no-auto-edge

# AutoEdge=false, AdminEdge=false, end device
H4 @0.16,0.82 icon=📺 proto=none
R0 -- H4 R0:no-auto-edge

# Network port, bridge
S5 @0.82,0.16
R0 -- S5 R0:network S5:network

# Network port, end device
H5 @1.42,0 icon=☕ proto=none
R0 -- H5 R0:network

# AdminEdge=true, bpdu-guard=true, bridge
S6 @2.32,0.24
R0 -- S6 R0:bpdu-guard R0:edge

# AdminEdge=true, bpdu-guard=true, end device
H6 @2.76,0.68 icon=💡 proto=none
R0 -- H6 R0:bpdu-guard R0:edge
</code></pre></div>

<p>Dans la topologie ci-dessus, S1, S2, S3, S4, S5 et S6 se comportent comme des
ponts, tandis que H1, H2, H3, H4, H5 et H6 se comportent comme des équipements
terminaux :</p>
<ul>
<li>S1 et H1 sont sur un port sans configuration : <em>AutoEdge</em> est vraie,
  <em>AdminEdge</em> est fausse,</li>
<li>S2 et H2 sont sur un port où <em>AdminEdge</em> est vraie,</li>
<li>S3 et H3 sont sur un port où <em>AutoEdge</em> est fausse et <em>AdminEdge</em> est vraie,</li>
<li>S4 et H4 sont sur un port où <em>AutoEdge</em> est fausse.</li>
</ul>
<p>Si vous lancez la simulation et <a href="#mstp:24">attendez une vingtaine de secondes</a>,
les liens vers S1, S2, S3, S4, H1, H2, H3 et H4 finissent par acheminer le
trafic utilisateur : aucune de ces options n’a d’importance.</p>
<p>Mais qu’en est-il des deux dernières paires ? S5 et H5 sont raccordés à un port
de type <em>network</em>. Un tel port active une fonctionnalité propriétaire : le
<strong>bridge assurance</strong>. Le port émet des <abbr title="Bridge Protocol Data Unit">BPDU</abbr> quel que soit son rôle. S’il n’en
reçoit aucune pendant 3 périodes « <em>hello</em> » consécutives, il passe à l’état de
rejet. Sur le lien entre R0 et S5, vous pouvez voir des <a href="#mstp:24,R0-&gt;S5,S5-&gt;R0,@"><abbr title="Bridge Protocol Data Unit">BPDU</abbr> circuler dans les
deux sens</a>, contrairement aux autres liens, où seuls
les ports désignés en émettent.</p>
<p>S6 et H6 sont raccordés à un port où <em>AdminEdge</em> est vraie et où le <strong><abbr title="Bridge Protocol Data Unit">BPDU</abbr>
guard</strong> est activé. Il s’agit d’une autre fonctionnalité propriétaire, qui
désactive un port <a href="#mstp:2,S6-&gt;R0,@">s’il reçoit une <abbr title="Bridge Protocol Data Unit">BPDU</abbr></a>.</p>
<p>En résumé, si vous attendez d’un port qu’il soit un port d’extrémité,
positionnez <em>AdminEdge</em> à vrai et activez le <em><abbr title="Bridge Protocol Data Unit">BPDU</abbr> guard</em>. Sinon, déclarez-le
comme port <em>network</em>.</p>
<h1 id="pourquoi-rstp-aujourdhui">Pourquoi <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> aujourd’hui ?</h1>
<p>Un cas d’usage solide pour <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> aujourd’hui est le réseau d’administration hors
bande (<abbr title="Out-of-band">OOB</abbr>) d’un centre de données, où quelques secondes d’indisponibilité sont
tolérables. La configuration est minimale et vous pouvez utiliser des
commutateurs bon marché, comme un Cisco 2960X<sup id="fnref:price"><a class="footnote-ref" href="#fn:price">14</a></sup>. Deux commutateurs jouent
le rôle de ponts racines et plusieurs boucles raccordent les commutateurs
présents dans chaque baie. Cette conception simple survit à une panne sur chaque
boucle<sup id="fnref:erps"><a class="footnote-ref" href="#fn:erps">15</a></sup>.</p>
<div class="language-text-only mstp-topology codehilite"><pre><span/><code>:protocol rstp
:tx-hold 10

# Root bridges
R1 @0,1 prio=0
R2 @0,2 prio=4096
R1 -- R2 cost=200 R1:network R2:network
R1 -- R2 cost=200 R1:network R2:network

# First loop
C1  @1,0 icon=🗄️
C4  @2,0 icon=🗄️
C7  @3,0 icon=🗄️
C10 @4,0 icon=🗄️
C12 @5,0 icon=🗄️
C13 @5,3 icon=🗄️
C15 @4,3 icon=🗄️
C18 @3,3 icon=🗄️
C21 @2,3 icon=🗄️
C24 @1,3 icon=🗄️
R1  -- C1  R1:network C1:network
C1  -- C4  C1:network C4:network
C4  -- C7  C4:network C7:network
C7  -- C10 C7:network C10:network
C10 -- C12 C10:network C12:network
C12 -- C13 C12:network C13:network
C13 -- C15 C13:network C15:network
C15 -- C18 C15:network C18:network
C18 -- C21 C18:network C21:network
C21 -- C24 C21:network C24:network
C24 -- R2  C24:network R2:network

# Second loop
C2  @1,0.5 icon=🗄️
C5  @2,0.5 icon=🗄️
C8  @3,0.5 icon=🗄️
C11 @4,0.5 icon=🗄️
C14 @4,2.5 icon=🗄️
C17 @3,2.5 icon=🗄️
C20 @2,2.5 icon=🗄️
C23 @1,2.5 icon=🗄️
R1  -- C2  R1:network C2:network
C2  -- C5  C2:network C5:network
C5  -- C8  C5:network C8:network
C8  -- C11 C8:network C11:network
C11 -- C14 C11:network C14:network
C14 -- C17 C14:network C17:network
C17 -- C20 C17:network C20:network
C20 -- C23 C20:network C23:network
C23 -- R2  C23:network R2:network

# Third loop
C3  @1,1 icon=🗄️
C6  @2,1 icon=🗄️
C9  @3,1 icon=🗄️
C16 @3,2 icon=🗄️
C19 @2,2 icon=🗄️
C22 @1,2 icon=🗄️
R1  -- C3  R1:network C3:network
C3  -- C6  C3:network C6:network
C6  -- C9  C6:network C9:network
C9  -- C16 C9:network C16:network
C16 -- C19 C16:network C19:network
C19 -- C22 C19:network C22:network
C22 -- R2  C22:network R2:network
</code></pre></div>

<p>Cette topologie converge en environ <a href="#mstp:6">6 secondes</a>. Chaque boucle doit
rester petite (environ 16 ponts) pour réduire la probabilité d’une double panne
et éviter de partager trop de bande passante. Cette conception peut évoluer un
peu sans devenir trop complexe : un VLAN par boucle ou un domaine de pont par
boucle.</p>
<h2 id="quelle-taille-pour-un-reseau">Quelle taille pour un réseau ?</h2>
<p>L’âge maximal, dont la valeur par défaut est 20, détermine la distance maximale
entre un pont et la racine. La topologie ci-dessous est trop grande : les <abbr title="Bridge Protocol Data Unit">BPDU</abbr>
issues de R1 ne parviennent pas au-delà de S20<sup id="fnref:root-mac"><a class="footnote-ref" href="#fn:root-mac">16</a></sup>.</p>
<div class="language-text-only mstp-topology codehilite"><pre><span/><code>:protocol rstp
:tx-hold 10
:max-age 20

R1 @0,0 prio=4096 icon=🌳
R2 @0,5 prio=4096 icon=🪾

S1  @1,0
S2  @2,0
S3  @3,0
S4  @4,0
S5  @5,0
S6  @6,0

S7  @6,1
S8  @5,1
S9  @4,1
S10 @3,1
S11 @2,1
S12 @1,1

S13 @1,2
S14 @2,2
S15 @3,2
S16 @4,2
S17 @5,2
S18 @6,2

S19 @6,3
S20 @5,3
S21 @4,3
S22 @3,3
S23 @2,3
S24 @1,3

S25 @1,4
S26 @2,4
S27 @3,4
S28 @4,4
S29 @5,4
S30 @6,4

S31 @6,5
S32 @5,5
S33 @4,5
S34 @3,5
S35 @2,5
S36 @1,5

R1  -- S1
S1  -- S2
S2  -- S3
S3  -- S4
S4  -- S5
S5  -- S6
S6  -- S7
S7  -- S8
S8  -- S9
S9  -- S10
S10 -- S11
S11 -- S12
S12 -- S13
S13 -- S14
S14 -- S15
S15 -- S16
S16 -- S17
S17 -- S18
S18 -- S19
S19 -- S20
S20 -- S21
S21 -- S22
S22 -- S23
S23 -- S24
S24 -- S25
S25 -- S26
S26 -- S27
S27 -- S28
S28 -- S29
S29 -- S30
S30 -- S31
S31 -- S32
S32 -- S33
S33 -- S34
S34 -- S35
S35 -- S36
S36 -- R2
R1  -- R2 cost=200 down
</code></pre></div>

<p>Une fois la <a href="#mstp:40">topologie stabilisée</a>, une partie du réseau considère R1
comme racine et l’autre partie vote pour R2. À la frontière, S20 tente de
démarrer une synchronisation avec S21 pour faire passer son <em>port désigné</em> à
l’état de transmission. Sa <abbr title="Bridge Protocol Data Unit">BPDU</abbr> ressemble à ceci :</p>
<div class="language-wireshark codehilite"><pre><span/><code><span class="nc">Spanning Tree Protocol</span>
<span class="ss">    Protocol Identifier</span>: Spanning Tree Protocol <span class="p">(</span><span class="m">0x0000</span><span class="p">)</span>
<span class="ss">    Protocol Version Identifier</span>: Rapid Spanning Tree <span class="p">(</span><span class="m">2</span><span class="p">)</span>
<span class="ss">    BPDU Type</span>: Rapid/Multiple Spanning Tree <span class="p">(</span><span class="m">0x02</span><span class="p">)</span>
<span class="ss">    BPDU flags</span>: 0x4e, Agreement, Port Role: Designated, Proposal
<span class="ss">    Root Identifier</span>: 4096.02:00:00:00:00:00
<span class="ss">    Root Path Cost</span>: 400000
<span class="ss">    Bridge Identifier</span>: 32768.02:00:00:00:00:15
<span class="ss">    Port identifier</span>: 0x8002
<span class="hll"><span class="ss">    Message Age</span>: 20
</span><span class="hll"><span class="ss">    Max Age</span>: 20
</span></code></pre></div>

<p>S21 la rejette car l’âge du message est égal à l’âge maximal. De son côté, la
<abbr title="Bridge Protocol Data Unit">BPDU</abbr> que S21 envoie à S20 ressemble à ceci :</p>
<div class="language-wireshark codehilite"><pre><span/><code><span class="nc">Spanning Tree Protocol</span>
<span class="ss">    Protocol Identifier</span>: Spanning Tree Protocol <span class="p">(</span><span class="m">0x0000</span><span class="p">)</span>
<span class="ss">    Protocol Version Identifier</span>: Rapid Spanning Tree <span class="p">(</span><span class="m">2</span><span class="p">)</span>
<span class="ss">    BPDU Type</span>: Rapid/Multiple Spanning Tree <span class="p">(</span><span class="m">0x02</span><span class="p">)</span>
<span class="ss">    BPDU flags</span>: 0x7c, Agreement, Forwarding, Learning, Port Role: Designated
<span class="hll"><span class="ss">    Root Identifier</span>: 4096.02:00:00:00:00:01
</span><span class="ss">    Root Path Cost</span>: 320000
<span class="ss">    Bridge Identifier</span>: 32768.02:00:00:00:00:16
<span class="ss">    Port identifier</span>: 0x8001
<span class="hll"><span class="ss">    Message Age</span>: 16
</span><span class="hll"><span class="ss">    Max Age</span>: 20
</span></code></pre></div>

<p>Cela ne suffit pas à changer le <em>port racine</em> de S20, car S20 dispose d’un
<em>identifiant de racine</em> plus faible : <code>4096.02:00:00:00:00:00</code> contre
<code>4096.02:00:00:00:00:01</code>.</p>
<p><a href="#mstp:40,R1--R2,...">Réparer le lien entre R1 et R2</a> résout le problème. L’âge
de message maximal transporté par un paquet est désormais de 18, en dessous de
l’âge maximal configuré. Mais cela ne fonctionne que jusqu’à la rupture d’un
autre lien. Une correction possible consiste à porter l’âge maximal à
40<sup id="fnref:forward-delay"><a class="footnote-ref" href="#fn:forward-delay">17</a></sup>.</p>
<h2 id="rstp-est-il-rapide"><abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> est-il rapide ?</h2>
<p><abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> converge en général en quelques secondes au démarrage. Il répare souvent un
arbre en moins d’une seconde. Même la <a href="/fr/blog/2026-spanning-tree#quelle-taille-pour-un-reseau">topologie à 38 ponts</a>
converge en moins de 10 secondes<sup id="fnref:time"><a class="footnote-ref" href="#fn:time">18</a></sup>. Certaines topologies mettent un peu
plus de temps à se rétablir quand la racine devient indisponible<sup id="fnref:slow"><a class="footnote-ref" href="#fn:slow">19</a></sup>.</p>
<div class="language-text-only mstp-topology codehilite"><pre><span/><code>:protocol rstp

R0 @1,0 prio=0
S1 @1,1 prio=4096
S2 @0,2 prio=8192
S3 @2,2

R0 -- S1
S1 -- S2
S2 -- S3
S3 -- S1
</code></pre></div>

<p>Dans la topologie ci-dessus, lancez la simulation, <a href="#mstp:10">attendez la
convergence</a>, arrêtez-la, puis <a href="#mstp:10,R0--S1">coupez le lien entre R0 et
S1</a>. La topologie est déjà optimale, mais <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> peine à
converger de nouveau.</p>
<p>D’abord, S1 perd son port racine. Il n’a plus aucune information sur R0 et se
proclame racine. Il conserve ses ports vers S2 et S3 comme ports désignés à
l’état de transmission. <a href="#mstp:10,R0--S1,1,S1-&gt;S2,S1-&gt;S3,@">Avancez d’un cran</a> :
il envoie une <abbr title="Bridge Protocol Data Unit">BPDU</abbr> à S2 et à S3 pour les informer du changement de racine. À sa
réception, S2 accepte S1 comme racine, car il ne connaît pas de meilleure racine
sur un autre port. Il élit le port vers S1 comme port racine. L’autre port reste
un port désigné. Aucun des deux ports ne change d’état.</p>
<p>À la réception de la <abbr title="Bridge Protocol Data Unit">BPDU</abbr> de S1, S3 se comporte différemment : il connaît R0
comme une meilleure racine que S1 grâce à son port alternatif vers S2. Il
promeut ce port en port racine et rétrograde le port vers S1 en port désigné, ce
qui nécessite un nouvel accord. <a href="#mstp:10,R0--S1,2,S3-&gt;S1#1,@">Avancez d’un
cran</a> : S3 envoie une proposition à S1 avec R0
comme racine. S1 élit R0 comme racine et promeut son port vers S3 en port
racine.</p>
<p>Lors de la <a href="#mstp:10,R0--S1,2,S2-&gt;S3,@">même séquence</a>, S3 reçoit aussi une <abbr title="Bridge Protocol Data Unit">BPDU</abbr>
de S2 affirmant que S1 est la racine. S3 n’a donc plus aucun port annonçant R0
comme racine : il élit S1 comme racine et son port vers S2 comme port racine.
<a href="#mstp:10,R0--S1,3,S3-&gt;S1,@">Avancez d’un cran</a> : sa <abbr title="Bridge Protocol Data Unit">BPDU</abbr> suivante vers S1
contient cette information et S1 s’élit de nouveau racine. Mais lors de la <a href="#mstp:10,R0--S1,3,S1-&gt;S2#1,@">même
vague</a>, S1 envoie une proposition à S2 avec R0
comme racine. Alors que S1 et S3 s’accordent sur le fait que S1 est la racine,
S2 croit désormais que c’est R0 ! <a href="#mstp:10,R0--S1,4,@,...">À son tour</a>, S2
convainc de nouveau S3 que R0 est la racine, S3 convainc S1, S1 convainc S2 et
S2 convainc S3.</p>
<p>Cela pourrait durer indéfiniment, mais ce n’est pas le cas. Les <abbr title="Bridge Protocol Data Unit">BPDU</abbr> affirmant « R0 est la racine » finissent par se périmer lorsque l’âge du message dépasse
l’âge maximal. Dans l’exemple ci-dessus, à la <a href="#mstp:10,R0--S1,24,S2-&gt;S3,@">onzième
seconde</a>, S2 envoie une <abbr title="Bridge Protocol Data Unit">BPDU</abbr> à S3 avec R0 comme
racine, mais S3 la jette car elle a atteint l’âge maximal. Avec un peu de
chance, la convergence peut aussi être plus rapide si un port cesse de
transmettre des <abbr title="Bridge Protocol Data Unit">BPDU</abbr> après avoir atteint le nombre maximal autorisé par
seconde : il s’agit du <em>transmit hold count</em>, dont la valeur par défaut est 6.</p>
<h1 id="a-propos-de-mstp">À propos de <abbr title="Multiple Spanning Tree Protocol">MSTP</abbr></h1>
<p><abbr title="Multiple Spanning Tree Protocol">MSTP</abbr> est la version « compatible VLAN » de <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> : il exécute plusieurs instances
de <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> et permet d’associer chaque VLAN à une instance donnée. Par exemple,
vous pouvez rattacher les VLAN 100 à 200 à une première instance et les VLAN 300
à 400 à une seconde. Les VLAN restants sont rattachés à une instance spéciale
appelée <em>Internal Spanning Tree</em> (<abbr title="Internal Spanning Tree">IST</abbr>). <abbr title="Multiple Spanning Tree Protocol">MSTP</abbr> apporte sa propre complexité, mais
l’idée est de disposer de plusieurs topologies logiques indépendantes. Pour
creuser le sujet, jetez un œil à « <a href="https://ine.com/blog/2008-07-27-mstp-tutorial-part-i-inside-a-region" title="MSTP Tutorial Part I: Inside a Region"><abbr title="Multiple Spanning Tree Protocol">MSTP</abbr> Tutorial Part I: Inside a Region</a> ».</p>
<h1 id="a-propos-des-exemples-interactifs">À propos des exemples interactifs</h1>
<p>Les exemples interactifs exécutent <a href="https://github.com/mstpd/mstpd" title="Multiple Spanning Tree Protocol Daemon">MSTPD</a> directement dans votre navigateur,
compilé en <a href="https://developer.mozilla.org/en-US/docs/WebAssembly" title="WebAssembly on MDN">WebAssembly</a> avec <a href="https://emscripten.org/" title="Emscripten documentation">emscripten</a>. Une API C remplace le code qui
dialogue avec le noyau Linux : elle gère les ponts et les ports, exporte l’état
en JSON et fait avancer le temps de manière déterministe. Une surcouche
JavaScript la rend plus agréable à utiliser :</p>
<div class="language-javascript codehilite"><pre><span/><code><span class="k">import</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">loadMSTPD</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">from</span><span class="w"> </span><span class="s2">"./dist/mstpd.mjs"</span><span class="p">;</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">mstp</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="nx">loadMSTPD</span><span class="p">();</span>

<span class="c1">// Crée 3 ponts</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">a</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">mstp</span><span class="p">.</span><span class="nx">createBridge</span><span class="p">(</span><span class="s2">"A"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">priority</span><span class="o">:</span><span class="w"> </span><span class="mf">4096</span><span class="w"> </span><span class="p">});</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">b</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">mstp</span><span class="p">.</span><span class="nx">createBridge</span><span class="p">(</span><span class="s2">"B"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">priority</span><span class="o">:</span><span class="w"> </span><span class="mf">8192</span><span class="w"> </span><span class="p">});</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">c</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">mstp</span><span class="p">.</span><span class="nx">createBridge</span><span class="p">(</span><span class="s2">"C"</span><span class="p">);</span>

<span class="c1">// Chaque pont a deux ports</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">a1</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">a</span><span class="p">.</span><span class="nx">addPort</span><span class="p">(</span><span class="s2">"a-b"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">portno</span><span class="o">:</span><span class="w"> </span><span class="mf">1</span><span class="w"> </span><span class="p">});</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">a2</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">a</span><span class="p">.</span><span class="nx">addPort</span><span class="p">(</span><span class="s2">"a-c"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">portno</span><span class="o">:</span><span class="w"> </span><span class="mf">2</span><span class="w"> </span><span class="p">});</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">b1</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">b</span><span class="p">.</span><span class="nx">addPort</span><span class="p">(</span><span class="s2">"b-a"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">portno</span><span class="o">:</span><span class="w"> </span><span class="mf">1</span><span class="w"> </span><span class="p">});</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">b2</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">b</span><span class="p">.</span><span class="nx">addPort</span><span class="p">(</span><span class="s2">"b-c"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">portno</span><span class="o">:</span><span class="w"> </span><span class="mf">2</span><span class="w"> </span><span class="p">});</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">c1</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">c</span><span class="p">.</span><span class="nx">addPort</span><span class="p">(</span><span class="s2">"c-a"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">portno</span><span class="o">:</span><span class="w"> </span><span class="mf">1</span><span class="w"> </span><span class="p">});</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">c2</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">c</span><span class="p">.</span><span class="nx">addPort</span><span class="p">(</span><span class="s2">"c-b"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">portno</span><span class="o">:</span><span class="w"> </span><span class="mf">2</span><span class="w"> </span><span class="p">});</span>

<span class="c1">// Construit une topologie en triangle</span>
<span class="nx">mstp</span><span class="p">.</span><span class="nx">link</span><span class="p">(</span><span class="nx">a1</span><span class="p">,</span><span class="w"> </span><span class="nx">b1</span><span class="p">);</span>
<span class="nx">mstp</span><span class="p">.</span><span class="nx">link</span><span class="p">(</span><span class="nx">a2</span><span class="p">,</span><span class="w"> </span><span class="nx">c1</span><span class="p">);</span>
<span class="nx">mstp</span><span class="p">.</span><span class="nx">link</span><span class="p">(</span><span class="nx">b2</span><span class="p">,</span><span class="w"> </span><span class="nx">c2</span><span class="p">);</span>

<span class="c1">// Active tous les ponts et tous les ports</span>
<span class="k">for</span><span class="w"> </span><span class="p">(</span><span class="kd">const</span><span class="w"> </span><span class="nx">br</span><span class="w"> </span><span class="k">of</span><span class="w"> </span><span class="p">[</span><span class="nx">a</span><span class="p">,</span><span class="w"> </span><span class="nx">b</span><span class="p">,</span><span class="w"> </span><span class="nx">c</span><span class="p">])</span><span class="w"> </span><span class="nx">br</span><span class="p">.</span><span class="nx">enable</span><span class="p">();</span>
<span class="k">for</span><span class="w"> </span><span class="p">(</span><span class="kd">const</span><span class="w"> </span><span class="nx">p</span><span class="w"> </span><span class="k">of</span><span class="w"> </span><span class="p">[</span><span class="nx">a1</span><span class="p">,</span><span class="w"> </span><span class="nx">a2</span><span class="p">,</span><span class="w"> </span><span class="nx">b1</span><span class="p">,</span><span class="w"> </span><span class="nx">b2</span><span class="p">,</span><span class="w"> </span><span class="nx">c1</span><span class="p">,</span><span class="w"> </span><span class="nx">c2</span><span class="p">])</span><span class="w"> </span><span class="nx">p</span><span class="p">.</span><span class="nx">enable</span><span class="p">();</span>

<span class="c1">// Exécute 40 secondes de temps réel et affiche la topologie</span>
<span class="nx">mstp</span><span class="p">.</span><span class="nx">step</span><span class="p">(</span><span class="mf">40</span><span class="p">);</span>
<span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">"Topology:"</span><span class="p">,</span><span class="w"> </span><span class="nx">mstp</span><span class="p">.</span><span class="nx">topology</span><span class="p">());</span>
</code></pre></div>

<p>Plusieurs dizaines de tests unitaires explorent les fonctionnalités de MSTPD et
vérifient qu’elles se comportent correctement dans cet environnement :</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span>node<span class="w"> </span>--test<span class="w"> </span>*.test.mjs
<span class="go">✔ two bridges: lower priority becomes root (41.657342ms)</span>
<span class="go">✔ triangle loop: exactly one port blocks and all agree on the root (5.832ms)</span>
<span class="go">✔ breaking the active link reconverges and restoring recovers (18.730753ms)</span>
<span class="go">[…]</span>
<span class="go">ℹ tests 40</span>
<span class="go">ℹ pass 40</span>
<span class="go">ℹ fail 0</span>
<span class="go">[…]</span>
<span class="go">ℹ duration_ms 396.190897</span>
</code></pre></div>

<p>Du code JavaScript supplémentaire recherche les blocs <code>&lt;pre&gt;</code> contenant une
définition de topologie et les transforme en composants interactifs. Vous pouvez
inspecter et modifier la définition en cliquant sur le bouton « <em>edit</em> ».</p>
<p>Il y a aussi une astuce pour déterminer si la topologie a convergé. Après chaque
pas, nous enregistrons un instantané de la mémoire de la simulation, jouons 50
secondes en accéléré pour vérifier que la topologie est stable, puis remontons
le temps en restaurant cet instantané. 🕰️</p>
<p>Le code complet se trouve sur <a href="https://github.com/vincentbernat/mstpd/tree/feature/wasm/wasm">GitHub</a>. Je suis très satisfait du résultat. Il
peut être difficile de suivre tout ce qui se passe à chaque étape, mais la
possibilité d’avancer et reculer aide beaucoup. Je compte réutiliser cette
approche dans de prochains articles.</p>
<div class="admonition">
<p class="admonition-title">Note</p>
<p><a href="https://mtlynch.io/">Michael Lynch</a> a relu une première version de la version anglaise
de cet article. Il est l’auteur de « <a href="https://refactoringenglish.com/" title="Refactoring English: Effective Writing for Software Developers">Refactoring English</a> », un livre pour
améliorer votre écriture en anglais : articles de blog, documentation, messages
de commit et tutoriels. Les erreurs restantes sont les miennes !</p>
</div>
<div class="footnote">
<hr/>
<ol>
<li id="fn:sprites">
<p>Les sprites de Gaston et Nono proviennent de <a href="https://craftpix.net/" title="Craftpix: 2D game assets">Craftpix</a>. La
tasse de café est dessinée par <a href="https://yaninyunus.itch.io/16x16-specialtycoffee" title="Yanin: 16x16 specialty coffee assets">Yanin</a>. <a class="footnote-backref" href="#fnref:sprites" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:history">
<p><abbr title="Spanning Tree Protocol">STP</abbr> a été introduit dans <a href="https://sci-hub.fr/10.1109/IEEESTD.1991.101050" title="IEEE Standards for Local and Metropolitan Area Networks: Media Access Control (MAC) Bridges">IEEE 802.1D-1990</a>. Il est encore présent
dans <a href="https://sci-hub.fr/10.1109/IEEESTD.1998.95619" title="IEEE Standard for Local and Metropolitan Area Networks: Media Access Control (MAC) Bridges">IEEE 802.1D-1998</a>, mais il a été retiré d’<a href="https://sci-hub.fr/10.1109/IEEESTD.2004.94569" title="IEEE Standard for Local and Metropolitan Area Networks: Media Access Control (MAC) Bridges">IEEE 802.1D-2004</a> au
profit de <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr>, introduit dans <a href="https://sci-hub.fr/10.1109/IEEESTD.2001.93287" title="IEEE Standard for Local and Metropolitan Area Networks: Rapid Reconfiguration of Spanning Tree">IEEE 802.1w-2001</a>. <abbr title="Multiple Spanning Tree Protocol">MSTP</abbr> est apparu dans
<a href="https://sci-hub.fr/10.1109/IEEESTD.2002.94223" title="IEEE Standards for Local and Metropolitan Area Networks: Virtual Bridged Local Area Networks — Amendment: Multiple Spanning Trees">IEEE 802.1s-2002</a> avant d’être intégré à <a href="https://sci-hub.fr/10.1109/IEEESTD.2003.94280" title="IEEE Standards for Local and Metropolitan Area Networks: Virtual Bridged Local Area Networks">IEEE 802.1Q-2003</a>. Tous deux
font partie d’<a href="ahttps://oadoi.org/10.1109/IEEESTD.2022.10004498" title="IEEE Standard for Local and Metropolitan Area Networks: Bridges and Bridged Networks">IEEE 802.1Q-2022</a>, aux côtés de <a href="https://sci-hub.fr/10.1109/IEEESTD.2012.6231597" title="IEEE Standard for Local and Metropolitan Area Networks: Shortest Path Bridging"><abbr title="Shortest Path Bridging">SPB</abbr></a>,
<del>un protocole dont je n’avais jamais entendu parler avant d’écrire cet
article</del>. <a class="footnote-backref" href="#fnref:history" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:bridge">
<p>À partir d’ici, j’emploie le terme « pont » plutôt que le mot plus
courant « commutateur ». <a class="footnote-backref" href="#fnref:bridge" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
<li id="fn:incomplete">
<p>MSTPD implémente la machine à états d’<a href="https://sci-hub.fr/10.1109/IEEESTD.2006.216285" title="IEEE Standard for Local and Metropolitan Area Networks: Virtual Bridged Local Area Networks">IEEE 802.1Q-2005</a>, mais
sous Linux, il ne fait tourner que <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr>. Linux 5.18 a ajouté la <a href="https://git.kernel.org/pub/scm/linux/kernel/git/netdev/net-next.git/commit/?id=ec7328b59176227216c461601c6bd0e922232a9b" title="net: bridge: mst: Multiple Spanning Tree (MST) mode">prise en
charge de la commutation pour plusieurs arbres recouvrants</a>, mais MSTPD ne l’utilise pas encore.
Consultez la <a href="https://github.com/mstpd/mstpd/pull/150" title="[RFC/RFT] use kernel MST support if available">PR #150</a> pour suivre les avancées sur ce point. <a class="footnote-backref" href="#fnref:incomplete" title="Jump back to footnote 4 in the text">↩</a></p>
</li>
<li id="fn:kernel">
<p>Le noyau Linux ne gère que <abbr title="Spanning Tree Protocol">STP</abbr>. Il délègue les autres protocoles à
l’espace utilisateur. <a class="footnote-backref" href="#fnref:kernel" title="Jump back to footnote 5 in the text">↩</a></p>
</li>
<li id="fn:priority">
<p>La priorité est un multiple de 4 096 : avec <abbr title="Multiple Spanning Tree Protocol">MSTP</abbr>, les 12 bits de
poids faible de la priorité du pont encodent l’identifiant de l’instance
<abbr title="Multiple Spanning Tree">MST</abbr>, ne laissant que les 4 bits de poids fort pour la priorité configurée. <a class="footnote-backref" href="#fnref:priority" title="Jump back to footnote 6 in the text">↩</a></p>
</li>
<li id="fn:wireshark">
<p>Pour inspecter les <abbr title="Bridge Protocol Data Unit">BPDU</abbr> qui circulent sur un lien, sélectionnez-le,
cliquez sur le bouton « <em>Download packets</em> », puis ouvrez le fichier avec
<a href="https://www.wireshark.org/" title="Wireshark: network protocol analyzer">Wireshark</a>. <a class="footnote-backref" href="#fnref:wireshark" title="Jump back to footnote 7 in the text">↩</a></p>
</li>
<li id="fn:backup">
<p>Un port de secours n’existe que si le pont possède plusieurs ports
sur le même domaine de collision. Cela ne devrait pas se produire dans un
réseau commuté. <a class="footnote-backref" href="#fnref:backup" title="Jump back to footnote 8 in the text">↩</a></p>
</li>
<li id="fn:hello">
<p>C’est la valeur du temporisateur « <em>hello</em> ». Elle était autrefois
configurable, mais <a href="https://sci-hub.fr/10.1109/IEEESTD.2006.216285" title="IEEE Standard for Local and Metropolitan Area Networks: Virtual Bridged Local Area Networks">IEEE 802.1Q-2005</a> la fixe à 2. MSTPD n’autorise pas
d’autre valeur. <a class="footnote-backref" href="#fnref:hello" title="Jump back to footnote 9 in the text">↩</a></p>
</li>
<li id="fn:learning">
<p>Si un port ne reçoit pas d’accord à l’expiration du temporisateur
« <em>hello</em> » (ou de l’âge maximal si le port vient tout juste d’être activé),
il se rabat sur la méthode à base de temporisateurs, par compatibilité avec
<abbr title="Spanning Tree Protocol">STP</abbr> : il passe à l’état d’apprentissage, attend de nouveau l’expiration du
temporisateur « <em>hello</em> », puis passe à l’état de transmission. <a class="footnote-backref" href="#fnref:learning" title="Jump back to footnote 10 in the text">↩</a></p>
</li>
<li id="fn:agreement">
<p>Comme dans beaucoup de propositions, S3 positionne aussi le bit
d’accord à 1. Le bit de proposition signifie « je suis le port désigné sur
ce lien et je veux passer à l’état de transmission ». Le bit d’accord
signifie « je suis déjà synchronisé avec le reste de mon pont sur cette
information de racine ». Les deux peuvent être vrais en même temps. <a class="footnote-backref" href="#fnref:agreement" title="Jump back to footnote 11 in the text">↩</a></p>
</li>
<li id="fn:bum">
<p>S’il ne trouve aucune entrée, le pont duplique la trame Ethernet sur
tous les ports, sauf celui d’entrée. Il en va de même si l’adresse MAC de
destination est l’adresse de diffusion (<code>ff:ff:ff:ff:ff:ff</code>). Ce
comportement amorce le processus d’apprentissage. <a class="footnote-backref" href="#fnref:bum" title="Jump back to footnote 12 in the text">↩</a></p>
</li>
<li id="fn:timer">
<p>Ce temporisateur rend <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> résistant à la perte de paquets. <a class="footnote-backref" href="#fnref:timer" title="Jump back to footnote 13 in the text">↩</a></p>
</li>
<li id="fn:price">
<p>Vous pouvez en trouver d’occasion pour moins de 100 €. Tous les ports
utilisent <abbr title="Per-VLAN Spanning Tree">PVST</abbr>+ par défaut et basculent automatiquement vers <abbr title="Rapid Spanning Tree Protocol">RSTP</abbr> classique. <a class="footnote-backref" href="#fnref:price" title="Jump back to footnote 14 in the text">↩</a></p>
</li>
<li id="fn:erps">
<p>Une solution de rechange serait l’<a href="https://www.itu.int/rec/T-REC-G.8032" title="ITU-T G.8032">Ethernet Ring Protection
Switching</a> (<abbr title="Ethernet Ring Protection Switching">ERPS</abbr>), un autre protocole dont je n’avais jamais
entendu parler avant de me documenter pour cet article. <a class="footnote-backref" href="#fnref:erps" title="Jump back to footnote 15 in the text">↩</a></p>
</li>
<li id="fn:root-mac">
<p>Si vous observez attentivement ce qui se passe à t=2s, vous verrez
que R2 gagne en popularité comme racine : S17 à S36 croient que R2 est la
racine. S16 ne suit pas car nous atteignons l’âge maximal. Plus tard, S17 à
S20 changent d’avis. Je vous laisse explorer l’état des différents ponts
pour en comprendre la cause. <a class="footnote-backref" href="#fnref:root-mac" title="Jump back to footnote 16 in the text">↩</a></p>
</li>
<li id="fn:forward-delay">
<p>En portant l’âge maximal à 40, vous devez aussi augmenter le
délai de transmission à 21 (<code>:forward-delay 21</code>), car le standard impose la
condition suivante : 2 × (<em>Forward Delay</em> − 1) ≥ <em>Max Age</em>. Pour cette
topologie précise, vous pourriez aussi porter l’âge maximal à 37 et le délai
de transmission à 20. <a class="footnote-backref" href="#fnref:forward-delay" title="Jump back to footnote 17 in the text">↩</a></p>
</li>
<li id="fn:time">
<p>La simulation peut sembler lente, mais elle ne tourne pas en temps
réel. Regardez l’horodatage dans le coin supérieur droit pour connaître le
temps écoulé, par exemple « t=8s ». Une fois la topologie stabilisée, ce
même coin affiche le temps de convergence, par exemple « 🌳 2s ». <a class="footnote-backref" href="#fnref:time" title="Jump back to footnote 18 in the text">↩</a></p>
</li>
<li id="fn:slow">
<p>Khaled Elmeleegy, Alan Cox et Eugene Ng ont formalisé ce phénomène dans
« <a href="https://sci-hub.fr/10.1109/INFOCOM.2006.229" title="On Count-to-Infinity Induced Forwarding Loops in Ethernet Networks">On Count-to-Infinity Induced Forwarding Loops in Ethernet Networks</a> »,
puis dans « <a href="https://sci-hub.fr/10.1109/TNET.2008.920874" title="Understanding and Mitigating the Effects of Count to Infinity in Ethernet Networks">Understanding and Mitigating the Effects of Count to Infinity
in Ethernet Networks</a> ». Ils proposent une correction qui n’a jamais
trouvé sa place dans un standard. <a class="footnote-backref" href="#fnref:slow" title="Jump back to footnote 19 in the text">↩</a></p>
</li>
</ol>
</div>
      </div></content>
  </entry>
  <entry>
    <title type="html">L'usine à clous soviétique ou l'échec des KPI</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/fr/blog/2026-kpi-goodhart" rel="alternate"/>
    <link href="https://vincent.bernat.ch/fr/blog/2026-kpi-goodhart#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-06-16T06:26:27Z</updated>
    <id>http://www.luffy.cx/fr/blog/2026-kpi-goodhart.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml"><p>En 2008, j’ai décroché mon second emploi, dans l’équipe réseau d’<em>Orange
Portails</em><sup id="fnref:hebex"><a class="footnote-ref" href="#fn:hebex">1</a></sup>, la division derrière les sites web et le moteur de recherche de
l’opérateur télécom français Orange. Tout y fonctionnait comme sur des
roulettes : un environnement technique complet, une équipe dédiée pour chaque
pan de l’activité et la liberté de me concentrer sur ce que je faisais de mieux.
Quelques années plus tard, plus rien n’allait : minés par une obsession du
chiffre, nous n’arrivions plus à livrer les nouveaux services dans les temps.</p>
<div class="admonition">
<p class="admonition-title">Avertissement</p>
<p>C’est une histoire que j’aime raconter pour mettre en garde
contre la <a href="https://fr.wikipedia.org/wiki/Loi_de_Goodhart" title="La loi de Goodhart sur Wikipédia">loi de Goodhart</a><sup id="fnref:campbell"><a class="footnote-ref" href="#fn:campbell">2</a></sup>. Comme ces événements
remontent à près de 15 ans, mes souvenirs sont un peu flous. Je suis parti en
2012.</p>
</div>
<h1 id="les-premieres-annees">Les premières années</h1>
<p>À mes débuts, le département fonctionnait comme une startup. Son origine était
l’entreprise française Echo. Elle avait bâti un moteur de recherche. France
Télécom l’a racheté et rebaptisé <a href="https://fr.wikipedia.org/wiki/Voila">Voila</a>. C’était le moteur de recherche le
plus consulté en France au début des années 2000. France Télécom a regroupé les
activités de portail au sein de la division <em>Wanadoo Portails</em>, renommée plus
tard <em>Orange Portails</em>.</p>
<p>L’environnement technique était excellent. Nous disposions de nombreux outils
internes<sup id="fnref:nocloud"><a class="footnote-ref" href="#fn:nocloud">3</a></sup> : un système de tickets, un outil de graphes RRD, un IPAM, un
outil de reporting et un outil d’alerte basé sur SNMP<sup id="fnref:snalert"><a class="footnote-ref" href="#fn:snalert">4</a></sup>. Nous déployions
nos serveurs Linux avec <a href="https://cfengine.com/">CFEngine</a>. Nous installions les systèmes et les
applications depuis des dépôts Debian internes. Nous documentions tout dans une
instance <a href="https://www.mediawiki.org/wiki/MediaWiki" title="MediaWiki est une plateforme de collaboration et de documentation">MediaWiki</a> privée. La supervision était basée sur un ancêtre de
<a href="https://www.xymon.com/servers/servers.html" title="The Xymon Monitor">XYmon</a>. L’architecture réseau était propre et évolutive, avec peu de dette
technique. Un nouvel arrivant était opérationnel en une journée.</p>
<p>C’était un environnement épanouissant pour moi. J’ai développé plusieurs
outils : <a href="https://lldpd.github.io/" title="lldpd : implémentation de IEEE 802.1AB">lldpd</a>, une implémentation de 802.1AB, <a href="/fr/blog/2013-snimpy" title="Snimpy : SNMP &amp; Python">Snimpy</a>, une interface
pythonique pour Net-SNMP, <a href="https://github.com/vincentbernat/wiremaps">Wiremaps</a>, un outil de découverte de niveau 2 doté
d’une machine à remonter le temps pour savoir quel équipement est connecté où,
<a href="https://github.com/vincentbernat/Kitero">Kitérő</a>, un outil pour simuler des conditions réseau, <a href="https://github.com/vincentbernat/QCss-3/">QCSS-3</a>, un
contrôleur pour répartiteurs de charge, et <a href="https://github.com/vincentbernat/ipoo">ipoo</a>, un service accessible via
un bot Jabber et un script Greasemonkey pour exposer des informations liées aux
adresses IP. J’ai ajouté le <a href="/fr/blog/2011-keepalived-snmp-ipv6" title="Support SNMP pour Keepalived">support SNMP pour Keepalived</a> et <a href="https://github.com/search?q=repo%3AFRRouting%2Ffrr+author%3Avincentbernat+snmp&amp;type=commits" title="Commits liés à SNMP pour Quagga/FRR">Quagga</a>. J’ai aussi lancé ce blog, avec des articles comme
« <a href="/fr/blog/2011-dns-anycast" title="DNS anycast">DNS anycast</a> », des articles sur TLS comme « <a href="/fr/blog/2011-dos-ssl-solutions" title="Déni de service TLS : quelles solutions ?">Déni de service
TLS : quelles solutions ?</a> », des articles sur
SNMP comme « <a href="/fr/blog/2012-snmp-boucle-evenements" title="Intégration de Net-⁠SNMP dans une boucle d'évènements">Intégration de Net-SNMP dans une boucle d’évènements</a> », des articles sur Linux comme « <a href="/fr/blog/2011-ipv4-route-cache-linux" title="Comprendre la mise en cache des routes IPv4 sous Linux">Comprendre la
mise en cache des routes IPv4 sous Linux</a> », et
un <a href="/fr/blog/2012-multicast-vxlan" title="Réseaux virtuels avec VXLAN">article sur VXLAN</a> bien avant que ce soit à la mode.</p>
<h1 id="la-chute">La chute</h1>
<p>Quand nous avions besoin de nouveaux serveurs, l’équipe de proximité en prenait
un lot dans l’inventaire, y installait notre distribution Linux, les déplaçait dans le
datacenter et les câblait au réseau. Nous ouvrions un ticket décrivant les
serveurs dont nous avions besoin et, une semaine plus tard, nos serveurs étaient
disponibles. 💫</p>
<p>Orange voulait savoir si l’équipe de proximité était performante : elle a donc
réclamé des <abbr title="Indicateur clé de performance">KPI</abbr>. Le choix s’est porté sur le nombre de tickets traités dans
l’année. Orange a demandé à doubler ce nombre. Au lieu d’un seul ticket pour un
nouveau service, nous en ouvrions six, un par serveur. À la fin de l’année, les
<abbr title="Indicateur clé de performance">KPI</abbr> avaient plus que doublé.</p>
<p>Tout le monde y a vu une réussite du pilotage de la performance. Il a donc été
demandé de récidiver l’année suivante. Cette fois, nous devions ouvrir un ticket
par serveur et par étape. De nouveau, les <abbr title="Indicateur clé de performance">KPI</abbr> ont doublé. En coulisses, les
tickets partaient à différentes personnes et n’étaient plus traités dans
l’ordre. Aussi, pour l’année suivante, il a été décidé de créer des méta-tickets
et de tenir des réunions pour suivre l’avancement de ces tickets. Bien sûr,
toutes ces étapes supplémentaires faisaient encore grimper le <abbr title="Indicateur clé de performance">KPI</abbr>.</p>
<p>Cette méthode de pilotage de la performance s’est étendue aux autres
équipes<sup id="fnref:firewall"><a class="footnote-ref" href="#fn:firewall">5</a></sup>. Tout est devenu plus lent. Au lieu de quelques semaines, il
nous fallait six mois pour déployer un nouveau service. Nous avions bâti une
<a href="https://fronterabrands.com/goodharts-law/" title="Goodhart’s Law: Soviet Nail Factories &amp; The Power of Incentives">usine à clous soviétique</a>. Mais les <abbr title="Indicateur clé de performance">KPI</abbr> étaient bons et
nous avons lâché l’affaire.</p>
<p>Prenons un autre exemple. Nous devions estimer l’impact de chaque opération de
nuit. Nous n’étions pas mauvais : l’essentiel des opérations étaient déclarées
« sans impact ». La plupart du temps, c’était le cas. Parfois, il y avait un
impact de 5 secondes. On nous a demandé de faire plus d’efforts pour tenir
l’impact annoncé. Qu’avons-nous fait ? Nous avons commencé à déclarer un impact
attendu de 5 secondes. Un jour, nous avons eu un impact de 30 secondes et il
nous a été reproché de ne pas avoir respecté l’impact annoncé. Au final, la
plupart des opérations étaient déclarées avec un impact attendu de 10 minutes,
et nous avons cessé de nous en préoccuper : au lieu de basculer le trafic
avec soin, nous nous autorisions un impact de 5 minutes. Et nos <abbr title="Indicateur clé de performance">KPI</abbr> n’avaient
jamais été aussi bons.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 630px"><span class="lf-media-inner" style="padding-bottom: 38.889%"><img alt="Graphique montrant l'impact des opérations de nuit. Année après année, la&#10;tolérance de l'impact est augmentée. La dernière année, l'impact attendu est de&#10;10 minutes et toutes les opérations sont en dessous de ce seuil. Toutefois, les&#10;impacts sont beaucoup plus importants que la première&#10;année." src="https://d2pzklc15kok91.cloudfront.net/images/orange-impacts.03b883aacf07da.svg" width="630" height="245" class="lf-media"/></span></div><figcaption>Vue d'artiste de l'évolution des impacts au fil des années.</figcaption></figure>
<hr/>
<p>Les <abbr title="Indicateur clé de performance">KPI</abbr> ne sont pas inutiles, mais ils peuvent avoir des conséquences négatives.
Utilisez-les avec précaution : laissez les personnes opérationnelles participer
au choix des indicateurs et reliez ces indicateurs à la qualité du service
rendu, par exemple avec des <a href="https://sre.google/sre-book/service-level-objectives/" title="Google SRE book: Service Level Objectives">objectifs de niveau de service</a>. Sinon, même les
plus consciencieux finissent par lâcher prise et détourner le système avant de
partir<sup id="fnref:google"><a class="footnote-ref" href="#fn:google">6</a></sup>. 📊</p>
<div class="footnote">
<hr/>
<ol>
<li id="fn:hebex">
<p>En interne, cette entité s’appelait « Hebex » (hébergement et
exploitation) et était située sur les sites de Bagnolet et de
Sophia-Antipolis. <a class="footnote-backref" href="#fnref:hebex" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:campbell">
<p>La loi de Goodhart est souvent citée, mais la <a href="https://fr.wikipedia.org/wiki/Loi_de_Campbell" title="La loi de Campbell sur Wikipédia">loi de
Campbell</a> décrit encore mieux mon expérience : plus on
s’appuie sur un chiffre pour prendre des décisions, plus vite les gens le
corrompent. <a class="footnote-backref" href="#fnref:campbell" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:nocloud">
<p>À l’époque, le <abbr title="Software as a Service">SaaS</abbr> n’était pas vraiment répandu. Je me souviens
avoir envisagé, avec deux collègues, de vendre <a href="https://github.com/vincentbernat/wiremaps">Wiremaps</a> en <abbr title="Software as a Service">SaaS</abbr>, avec un
chiffrement homomorphe pour la base de données. Mais qui aurait accepté
d’externaliser sa pile d’observabilité ? <a class="footnote-backref" href="#fnref:nocloud" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
<li id="fn:snalert">
<p><em>Snalert</em> était un outil d’alerte métacirculaire écrit en Perl. Il
pouvait interroger un très grand nombre de cibles SNMP en peu de temps.
Toute notre supervision reposait sur SNMP, y compris la supervision système. <a class="footnote-backref" href="#fnref:snalert" title="Jump back to footnote 4 in the text">↩</a></p>
</li>
<li id="fn:firewall">
<p>Mon équipe gérait aussi les règles de nombreux pare-feux sous Linux.
Pour gonfler nos <abbr title="Indicateur clé de performance">KPI</abbr>, nous appliquions la même méthode : plutôt que d’accepter
un seul ticket avec une matrice de flux, nous exigions un ticket par flux. <a class="footnote-backref" href="#fnref:firewall" title="Jump back to footnote 5 in the text">↩</a></p>
</li>
<li id="fn:google">
<p>Orange n’est pas un cas isolé. Le processus de promotion de Google
est un autre exemple bien connu de <abbr title="Indicateur clé de performance">KPI</abbr> défaillant. Michael Lynch le raconte
dans « <a href="https://mtlynch.io/why-i-quit-google/" title="Why I Quit Google to Work for Myself">Why I Quit Google to Work for Myself</a> ». <a class="footnote-backref" href="#fnref:google" title="Jump back to footnote 6 in the text">↩</a></p>
</li>
</ol>
</div>
      </div></content>
  </entry>
  <entry>
    <title type="html">Tenir un blog bilingue avec l'aide d'une IA</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/fr/blog/2026-blog-ia" rel="alternate"/>
    <link href="https://vincent.bernat.ch/fr/blog/2026-blog-ia#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-06-09T20:15:13Z</updated>
    <id>http://www.luffy.cx/fr/blog/2026-blog-ia.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml"><p>L’<abbr title="Intelligence artificielle">IA</abbr> inonde le web de contenus insipides. Les articles sur <a href="https://www.linkedin.com/">LinkedIn</a> avec une
image générée par <abbr title="Intelligence artificielle">IA</abbr>, remplis d’émojis, publiés par des soi-disant experts sur
un sujet auquel ils ne portent pas assez d’intérêt pour écrire eux-mêmes, sont
les pires spécimens. Bien que je sois très contrarié de cette situation, je
m’appuie sur des <abbr title="Large Language Models">LLMs</abbr> pour la <strong>grammaire</strong>, les <strong>révisions linguistiques</strong> et
la <strong>traduction</strong>. Je ne trouve pas cela incohérent.</p>
<p>Ma langue maternelle est le français mais j’écris la plupart des articles en
anglais et en français. Quand j’ai commencé à écrire ce blog en 2011, je
rédigeais d’abord en <a href="/fr/blog/2011-migration-vers-github" title="Migration de Trac vers GitHub">français</a> avant de traduire en <a href="/en/blog/2011-migrating-to-github" title="Migrating from Trac to GitHub">anglais</a>.
J’ai fini par comprendre qu’il était <a href="https://sci-hub.fr/10.1016/j.jslw.2009.06.003" title="L1 use during L2 writing: An empirical study of a complex phenomenon">préférable de le faire dans l’autre
sens</a> afin de rendre le texte plus naturel
et idiomatique. J’essaie d’écrire un anglais « correct » mais cela n’a jamais
été mon point fort<sup id="fnref:book"><a class="footnote-ref" href="#fn:book">1</a></sup>. Par exemple, j’ai toujours eu des difficultés sur
l’accord des temps. J’apprends les règles et je les oublie immédiatement. Je ne
me vois pas non plus <a href="https://mtlynch.io/editor/" title="How I Hired a Freelance Editor for My Blog">embaucher un éditeur</a> pour quelque chose
que je considère comme un loisir.</p>
<p>À titre d’exemple, j’ai gardé l’historique des modifications successives lors de
l’écriture de « <em><a href="/en/blog/2026-akvorado-rib-sharding" title="Scaling Akvorado BMP RIB with sharding">Scaling Akvorado BMP RIB with sharding</a></em> » :</p>
<ol>
<li>la <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/aad263c20c7b021b1069952d1487374ff559d3b3" title="article: Akvorado BMP RIB with sharding">première ébauche</a> rédigée à l’aide d’un dictionnaire de synonymes<sup id="fnref:kagi"><a class="footnote-ref" href="#fn:kagi">2</a></sup> ;</li>
<li>la <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/11231af4be15160c1dd48e45c9b4dc7c042cf191" title="content: copyediting of Akvorado BMP RIB article">version modifiée</a> par la compétence « <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/latest/.claude/skills/edit/SKILL.md">révision linguistique</a> » ;</li>
<li>la <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/0435ae3a8450132abb89507e856a012831457e49" title="article: Akvorado BMP RIB in French">traduction en français</a> générée par la compétence
   « <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/latest/.claude/skills/translate/SKILL.md">traduction</a> » ;</li>
<li>la <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/c0629b1d9fe450335fcd30c071fb44301615dd15" title="content: human proofread of the French translation">version révisée par un humain</a> avec quelques modifications
   sur la version anglaise.</li>
</ol>
<p>Je sais que les <abbr title="Intelligence artificielle">IA</abbr> peuvent <a href="https://sites.google.com/view/llmwritingdistortion/home" title="How LLMs Distort Our Written Language">dénaturer la voix de l’auteur pendant
l’édition</a>, mais les corrections
apportées lors de la seconde étape sont mineures. Les instructions sont « d’appliquer de légères modifications de style » tout en demandant d’éviter les
formes passives, les phrases trop longues, les verbes peu expressifs et les mots
inutiles. Elles définissent également l’audience cible : technique avec un
niveau d’anglais B2.</p>
<p>Dans l’extrait suivant, j’ai utilisé « <em>long time</em> » au lieu de « <em>long-standing</em> ». Outre le trait d’union manquant, le premier s’applique à des
personnes (<em>a <a href="https://dictionary.cambridge.org/us/dictionary/english/long-time" title="Definition of long-time in the Cambridge Dictionary">long-time</a> friend</em>), tandis que le second concerne des
situations (<em>a <a href="https://dictionary.cambridge.org/us/dictionary/english/long-standing" title="Definition of long-standing in the Cambridge Dictionary">long-standing</a> agreement</em>). J’ai mis pas mal de temps à
comprendre la seconde modification : l’<abbr title="Intelligence artificielle">IA</abbr> préfère <a href="https://dictionary.cambridge.org/us/grammar/british-grammar/relative-clauses-defining-and-non-defining" title="Relative clauses: defining and non-defining">cette forme
grammaticale</a> pour introduire la définition de « <em>RIB
sharding</em> ».</p>
<blockquote>
<p>As the Internet routing table contains more than 1 million routes, Akvorado
needs to scale to tens of millions of routes. This has been a <del>long
time</del> <ins>long-standing</ins> challenge, but I expect this issue is now
fixed by using RIB sharding, a method <del>to split</del> <ins>that
splits</ins> the routing database into several parts to enable concurrent
updates.</p>
</blockquote>
<p>Dans la modification ci-dessous, l’<abbr title="Intelligence artificielle">IA</abbr> utilise le mot « <em>device</em> » plutôt que le
mot « <em>équipement</em> » car ce dernier est indénombrable. Je le sais mais je tombe
régulièrement dans ce piège.</p>
<blockquote>
<p>When Akvorado does not find a route from a specific device, it falls back to a
route sent by another <del>equipment</del> <ins>device</ins>.</p>
</blockquote>
<p>J’encourage l’<abbr title="Intelligence artificielle">IA</abbr> à utiliser des verbes descriptifs et elle s’exécute en
remplaçant une périphrase par un verbe précis :</p>
<blockquote>
<p>The benchmarks demonstrate it <del>has better performance than</del>
<ins>outperforms</ins> other <del>packages, both</del> <ins>packages</ins> for
lookups, insertions, and memory usage.</p>
</blockquote>
<p>Elle corrige aussi les erreurs de grammaire. Dans le passage suivant, « <em>list of
routes</em> » est au singulier. De plus, « <em>stored</em> » représente un état et il ne
faut pas le faire suivre de « <em>into</em> » qui indique un changement.</p>
<blockquote>
<p>The list of routes for each prefix <del>are</del> <ins>is</ins> not stored
directly <del>into</del> <ins>in</ins> the prefix tree.</p>
</blockquote>
<p>Voici un dernier exemple : le verbe « <em><a href="https://dictionary.cambridge.org/dictionary/english/require" title="Definition of require in the Cambridge Dictionary">require</a></em> » doit être suivi d’un nom
ou d’un objet suivi d’un infinitif. Il n’est pas possible de le faire suivre
directement par un infinitif.</p>
<blockquote>
<p>An alternative would be to have one prefix tree for each peer but it would
require <del>to configure</del> <ins>configuring</ins> all routers to export
their routes.</p>
</blockquote>
<p>Même si je lis beaucoup d’anglais<sup id="fnref:monkey"><a class="footnote-ref" href="#fn:monkey">3</a></sup>, je bute toujours sur ces règles de
grammaire. Le français est une langue relativement simple une fois les bases
acquises alors que l’anglais est rempli d’irrégularités.</p>
<hr/>
<p>J’indique dans chaque pied de page si une <abbr title="Intelligence artificielle">IA</abbr> a été utilisée pour modifier le
contenu. Il y a 3 niveaux :</p>
<ul>
<li>🧠: pas d’<abbr title="Intelligence artificielle">IA</abbr> ou pratiquement pas (par exemple, des corrections grammaticales)</li>
<li>✨: amélioré par l’<abbr title="Intelligence artificielle">IA</abbr> (par exemple, des révisions linguistiques)</li>
<li>🤖: généré par <abbr title="Intelligence artificielle">IA</abbr> (par exemple, traduit depuis une autre langue)</li>
</ul>
<p>Survolez avec la souris ou tapotez sur l’icône pour révéler le nom de l’<abbr title="Intelligence artificielle">IA</abbr> et
son rôle dans la modification du document.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 264px"><span class="lf-media-inner" style="padding-bottom: 45.833%"><img alt="Capture d'écran montrant le pied de page avec l'émoji&#10;« étincelles »" src="https://d2pzklc15kok91.cloudfront.net/images/ai-usage@1x.03aee3edade4f4.jpg" srcset="https://d2pzklc15kok91.cloudfront.net/images/ai-usage@1x.03aee3edade4f4.jpg 332w,https://d2pzklc15kok91.cloudfront.net/images/ai-usage@2x.84457fd4153330.jpg 528w" sizes="auto, (max-width: 264px) 100vw, 264px" width="264" height="121" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAhEAAADyAQMAAADJHwVUAAAABlBMVEUiIiIAAABeOQYjAAAAJklEQVR42u3BAQ0AAADCoPdPbQ8HFAAAAAAAAAAAAAAAAAAAAPBiQEgAAWwH/fsAAAAASUVORK5CYII=)"/></span></div><figcaption>Exemple d'utilisation de transparence sur l'usage de l'IA : Claude Sonnet 4.5 a édité cet article.</figcaption></figure>
<p>Le graphique ci-dessous représente par année les outils utilisés pour modifier
chaque article. J’ai récemment demandé à une <abbr title="Intelligence artificielle">IA</abbr> des <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/latest/.claude/skills/grammar/SKILL.md">corrections
grammaticales</a> sur les anciens articles, aussi bien en
<a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/7baf2c8f18b57e351cc25e7e62f4aa6611362c8c" title="content: fix English grammar">anglais</a> qu’en <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/0713a8800321da710c81ed10a731da5da34e686d" title="content: fix French grammar">français</a>. Depuis 2018, les
articles sont traduits en français à l’aide de <a href="https://www.deepl.com">DeepL</a> puis ensuite d’une <abbr title="Intelligence artificielle">IA</abbr>.
Depuis 2024, je m’appuie sur une <abbr title="Intelligence artificielle">IA</abbr> pour les révisions linguistiques des
articles en anglais.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 733px"><span class="lf-media-inner" style="padding-bottom: 68.349%"><object width="733" height="501" type="image/svg+xml" data="https://d2pzklc15kok91.cloudfront.net/images/ai-usage.53c0a437736854.svg" class="lf-media">&amp;#128444; Graphique montrant l'utilisation de l'IA année par année. Chaque niveau a sa
propre teinte.</object></span></div><figcaption>Utilisation de l'IA année par année. Passez la souris sur une bande pour plus de détails.</figcaption></figure>
<hr/>
<p>Si vous êtes fermement opposé à l’utilisation des <abbr title="Large Language Model">LLM</abbr> dans l’écriture, j’espère
que vous acceptez mon point de vue plus nuancé comme une contrepartie pour des
articles en français, l’<abbr title="Intelligence artificielle">IA</abbr> mâchant énormément le travail de traduction. En ce
qui concerne l’anglais, la littérature sur le sujet s’accorde sur l’importance
de choisir le bon mot pour capturer l’attention du lecteur jusqu’à la fin.</p>
<blockquote>
<p>[…] Bien écrire consiste à maîtriser un certain nombre de choses
fondamentales, à savoir le vocabulaire, la grammaire et le style, et à remplir
le troisième compartiment de sa boîte à outils avec les bons instruments.</p>
<p>― <em>Stephen King</em>, On Writing</p>
</blockquote>
<div class="admonition">
<p class="admonition-title">Note</p>
<p>Contrairement à d’autres articles récents, je n’ai pas utilisé d’<abbr title="Intelligence artificielle">IA</abbr>,
ni pour traduire en français, ni pour relire la <a href="/en/blog/2026-blogging-llm" title="Blogging with LLMs as a non-native speaker">version anglaise</a>. Pour cette
dernière, une personne anonyme a accepté de corriger ma copie.</p>
</div>
<div class="admonition">
<p class="admonition-title">Mise à jour (06.2026)</p>
<p>Voir la <a href="https://lobste.rs/s/tdvu7a/blogging_with_llm_assistant">discussion associée à cet article sur
Lobsters</a>, ainsi que l’article « <a href="https://writethatblog.substack.com/p/dev-reaction-to-ai-blog-posts" title="Report: How developers react to AI-scented blog posts">How developers react to AI-scented blog
posts</a> » de Cynthia Dunlop, l’une des coautrices de « <a href="https://www.manning.com/books/writing-for-developers" title="“Writing for Developers” by Piotr Sarna and Cynthia Dunlop">Writing for
Developers</a> ».</p>
</div>
<div class="footnote">
<hr/>
<ol>
<li id="fn:book">
<p>J’ai récemment lu « <a href="https://www.manning.com/books/writing-for-developers" title="“Writing for Developers” by Piotr Sarna and Cynthia Dunlop">Writing for Developers</a> » du début à la fin et
je l’ai trouvé motivant. <a href="https://mtlynch.io/about/">Michael Lynch</a> est en train d’écrire
« <a href="https://refactoringenglish.com/" title="“Refactoring English: Effective Writing for Software Developers” by Michael Lynch">Refactoring English</a> » sur le même sujet et je me suis inscrit à
l’accès anticipé. <a class="footnote-backref" href="#fnref:book" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:kagi">
<p>Je suis très satisfait des outils d’aide à l’écriture proposés par
<a href="https://www.kagi.com">Kagi</a>. L’<a href="https://translate.kagi.com">outil de traduction</a> et le
<a href="https://translate.kagi.com/dictionary">dictionnaire</a> me sont très utiles pour trouver le bon mot. Je
recours aussi à l’<a href="https://help.kagi.com/kagi/ai/kagi-research.html" title="Kagi Research Assistants">assistant de recherche de Kagi</a> lors de la phase de documentation. <a class="footnote-backref" href="#fnref:kagi" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:monkey">
<p>Dès le CM2, je jouais à <em>Monkey Island 2</em> en anglais sans l’avoir
appris à l’école. J’utilisais un dictionnaire et je restais perplexe à la
vue des verbes irréguliers. <a class="footnote-backref" href="#fnref:monkey" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
</ol>
</div>
      </div></content>
  </entry>
</feed>