Aller au contenu
Penser en flux : lignes, champs et petits outils

Penser en flux : lignes, champs et petits outils

100 Apprenti ⏱ 1 h 30 bashlinuxdebianubuntu

À la fin, vous saurez

  • Décrire un fichier texte en termes d'octets, de lignes et de champs, et repérer une dernière ligne sans saut de ligne, des fins de ligne CRLF ou un séparateur ambigu
  • Prévoir l'effet de la locale (C, C.UTF-8, fr_FR.UTF-8) sur sort, comm, join, wc et tr, et la fixer dans un script
  • Répondre à une question sur un journal en composant wc, head, tail, cut, tr, sort, uniq et tee dans un tube, en justifiant chaque étape
  • Trier sur une clé précise avec sort -k, -t, -n, -h, -V et -s, vérifier un tri avec -c et fusionner des fichiers déjà triés avec -m
  • Comparer et rapprocher deux listes avec comm et join, après les avoir triées dans la même locale
  • Expliquer pourquoi un tube peut retenir sa sortie, se terminer par le code 141 sous pipefail ou remplir /tmp, et corriger le script en conséquence

Prérequis

Testé avec bash 5.2.21 (Ubuntu 24.04), 5.2.37 (Debian 13) coreutils 9.4 (Ubuntu 24.04), 9.7 (Debian 13) grep 3.11 (Ubuntu 24.04 et Debian 13) python 3.12 (Ubuntu 24.04), 3.13 (Debian 13) , vérifié le 9 octobre 2026

Pourquoi

Le jeudi 8 octobre 2026, à 9 h, le service des usagers de la mairie d'Exempleville transfère trois messages reçus la veille : « l'application ne charge pas », « erreur en envoyant ma photo », « ça tourne puis plus rien ». Tous datés du mercredi 7 octobre, en début d'après-midi. Personne dans l'équipe n'a reçu d'alerte, la supervision est restée verte, et ce matin tout fonctionne.

Les questions viennent tout de suite : y a-t-il vraiment eu des erreurs ? Combien, à quelle heure, sur quel serveur, pour quelles requêtes ? Le problème est-il terminé ? Les réponses sont dans les fichiers que sig-outils reçoit chaque nuit et chaque minute des deux serveurs de l'API : deux journaux de plus d'un mégaoctet chacun, des journaux JSON, des exports CSV. Seize mille lignes, rien qu'en texte.

On peut ouvrir ces fichiers dans less et faire défiler. On trouvera peut-être quelque chose, au bout d'une heure, sans savoir si l'on a tout vu. On peut aussi écrire un script Python, ce qui demande un quart d'heure avant la première réponse, pour une question qui changera dès qu'on aura cette réponse. Ou bien on peut poser la question au fichier, en une ligne, en quelques secondes, puis la reformuler, l'affiner, la croiser avec une autre. C'est ce que permettent les outils de traitement de texte d'Unix, à condition de comprendre leur logique commune : des filtres qui lisent des lignes et en écrivent d'autres, que l'on enchaîne par des tubes.

Cette leçon pose cette logique. Elle ne parle encore ni de sed, ni d'awk, ni de jq, mais des pièces que toutes les autres leçons supposent : ce qu'est une ligne et un champ pour un programme, pourquoi la langue de votre système change l'ordre d'un tri et le résultat d'une comparaison, comment sort, uniq, cut, comm et join se combinent, et ce qui se passe réellement quand on écrit a | b | c. Les leçons Lire et éditer des fichiers et Redirections et tubes de Premiers pas ont montré cut | sort | uniq -c sur un journal de huit lignes ; ici, les fichiers sont réels, les pièges aussi.

Les concepts

Le filtre, unité de base

Un filtre est un programme qui lit des données sur son entrée standard (ou dans les fichiers qu'on lui donne), les transforme, et écrit le résultat sur sa sortie standard, en réservant la sortie d'erreur aux messages destinés à un humain. Il ne pose aucune question, n'attend aucune touche, et signale sa réussite ou son échec par son code de sortie. Les flux standard et le tube font le reste : a | b branche la sortie de a sur l'entrée de b.

Cette manière de construire des outils a été formulée dès 1978 par Doug McIlroy, dans l'avant-propos du numéro du Bell System Technical Journal consacré à Unix. Deux de ses maximes résument la leçon : « faire en sorte que chaque programme fasse une seule chose, et la fasse bien », et « s'attendre à ce que la sortie de chaque programme devienne l'entrée d'un autre, encore inconnu ». La seconde a une conséquence pratique : un bon filtre écrit des données sans décoration (pas de titre, pas de couleur, pas de phrase autour du résultat), pour que le suivant puisse les lire.

Un filtre a trois propriétés qu'il faut garder en tête :

  • Il lit en flux. La plupart des filtres (grep, cut, tr, head) traitent une ligne, l'écrivent, et passent à la suivante, sans garder le fichier en mémoire. Ils traitent un fichier de 50 Go avec quelques kilooctets de mémoire. Quelques-uns, par nature, doivent tout lire avant d'écrire quoi que ce soit : sort (la dernière ligne lue peut être la première à écrire), tac (qui inverse l'ordre), wc (qui ne connaît le total qu'à la fin).
  • Il ne modifie pas ses fichiers. sort fichier écrit le résultat trié sur la sortie standard ; fichier reste intact. Modifier un fichier « sur place » est une opération à part, dangereuse, à laquelle la leçon 4 est consacrée.
  • Il se compose. Chaque filtre ne sait faire qu'une chose ; c'est la chaîne qui répond à la question. L'art consiste à découper la question en étapes, et à vérifier chaque étape avant d'ajouter la suivante.

Ce qu'est une ligne

Pour un programme, un fichier n'est qu'une suite d'octets. La norme POSIX définit une ligne comme une suite de zéro ou plusieurs caractères suivie d'un saut de ligne (\n, l'octet 10), et un fichier texte comme un fichier fait de lignes, donc qui se termine par un saut de ligne. Trois conséquences, que vous croiserez toutes :

  • La dernière ligne sans saut de ligne n'est pas, au sens strict, une ligne. wc -l compte les sauts de ligne, pas les lignes : printf 'a\nb' | wc -l répond 1. Les outils GNU traitent généralement ce morceau final comme une ligne (le manuel de sort précise qu'il ajoute silencieusement le saut de ligne manquant), mais pas tous, et pas toujours de la même façon ; la leçon 5 du cours Bash a montré que read le perd.
  • Les fins de ligne Windows (CRLF, \r\n) laissent un \r à la fin de chaque ligne, qui fait partie du dernier champ. cut -d, -f2 sur un fichier CRLF renvoie graffiti\r, que sort -u considère comme différent de graffiti.
  • L'octet nul (\0) n'apparaît jamais dans un texte. Sa présence fait basculer grep en mode binaire, et il sert de séparateur sûr entre noms de fichiers ; la leçon 2 y revient.

Ce qu'est un champ

Un champ est un morceau de ligne délimité par un séparateur. Toute la difficulté est dans le mot « séparateur », car il en existe deux conceptions incompatibles :

ConceptionExemple de donnéesDeux séparateurs consécutifsOutils
Caractère unique, strictCSV, /etc/passwd, TSVdélimitent un champ videcut -d, sort -t, join -t, paste -d, awk -F,
Suite de blancssortie de ps, ls -l, uniq -c, journaux alignéscomptent pour un seul séparateur, et les blancs de début de ligne sont ignoréssort sans -t, join sans -t, awk par défaut, read

cut ne connaît que la première conception : cut -d' ' sur une sortie alignée par des espaces voit une multitude de champs vides. C'est pourquoi l'on rencontre si souvent tr -s ' ' avant cut, et c'est l'une des raisons d'être d'awk (leçon 5).

Et un séparateur peut apparaître à l'intérieur d'un champ. Le format CSV, tel que le décrit la RFC 4180, autorise un champ entre guillemets à contenir des virgules : "Saint-Exemple, centre" est un champ pour un lecteur CSV, et deux pour cut -d,. Aucun des outils de cette leçon ne comprend les guillemets ; vous verrez concrètement ce que cela produit sur les exports de Signalements.

Enfin, une ligne de journal n'est pas toujours régulière. Une requête de l'API et un message de systemd n'ont pas le même nombre de champs ; découper « le douzième champ » n'a de sens que pour les lignes qui ont la bonne forme. D'où la règle : filtrer d'abord les lignes, découper ensuite les champs.

Le flux comme méthode

Les questions de l'enquête ont presque toutes la même forme : « combien de X, par Y, triés par Z ». Elles se traduisent presque toutes par la même chaîne :

sélectionner les lignes  →  extraire le champ  →  normaliser  →  trier  →  compter  →  trier par nombre  →  garder le début
       grep                     cut                 tr, cut        sort     uniq -c      sort -rn            head

On la construit de gauche à droite, en regardant le résultat à chaque étape : d'abord grep ... | head, pour vérifier que les lignes retenues sont les bonnes ; puis on ajoute cut, on regarde ; puis le tri et le comptage. Une chaîne écrite d'un coup et lancée à l'aveugle donne un nombre, mais on ne sait pas de quoi.

La locale

La locale est l'ensemble des réglages de langue et de région d'un processus, choisis par les variables LANG, LC_* et LC_ALL (cette dernière les remplace toutes). Pour le traitement de texte, trois catégories comptent :

  • LC_CTYPE décide de l'encodage : en C, un caractère est un octet ; en C.UTF-8 ou fr_FR.UTF-8, é est un caractère de deux octets. Elle décide aussi de ce que sont une lettre, un chiffre, une majuscule ([[:alpha:]], [[:upper:]]).
  • LC_COLLATE décide de l'ordre de collation, c'est-à-dire de l'ordre dans lequel sort range les chaînes. En C et C.UTF-8, on compare les octets (ou les points de code) un à un : toutes les majuscules avant toutes les minuscules, les lettres accentuées après z. En fr_FR.UTF-8, on suit les règles de tri du français : la casse et les accents ne comptent qu'en cas d'égalité, la ponctuation est d'abord ignorée.
  • LC_NUMERIC décide du séparateur décimal : le point en C, la virgule en fr_FR.UTF-8. sort -n le respecte.

La règle qui en découle, et que tout le cours applique : un traitement qui produit ou compare des données fixe sa locale, presque toujours à LC_ALL=C.UTF-8, qui compare par point de code (donc de façon prévisible et rapide) tout en acceptant l'UTF-8. On garde fr_FR.UTF-8 pour ce qui est affiché à un humain et doit suivre l'ordre du dictionnaire, comme une liste de communes dans un rapport. Et l'on veille à ce que deux outils qui coopèrent (sort puis comm, sort puis join) utilisent la même locale.

C.UTF-8 est fournie par la bibliothèque C (glibc) depuis la version 2.35 et toujours présente sur Ubuntu 24.04 et Debian 13, contrairement à fr_FR.UTF-8 qu'il faut avoir générée (locale -a liste celles qui sont installées).

Les petits outils

Voici la boîte à outils de cette leçon, avec ce que chacun lit et ce qu'il garde en mémoire :

OutilRôleLit tout avant d'écrire ?Exige une entrée triée ?
wccompter lignes (-l), mots (-w), octets (-c), caractères (-m)ouinon
head, tailgarder le début, la fin, ou tout sauf le début (tail -n +2)tail sur un tube, ouinon
cutextraire des champs (-d, -f) ou des positions (-c, -b)nonnon
trremplacer, supprimer (-d), compresser (-s) des octetsnonnon
sorttrier, dédoublonner (-u), vérifier (-c), fusionner (-m)oui (sauf -c, -m)non (sauf -m)
uniqfusionner les lignes identiques consécutives, compter (-c)nonoui
commcomparer deux listes : lignes propres à l'une, à l'autre, communesnonoui
joinrapprocher deux fichiers sur un champ communnonoui
pastecoller des fichiers côte à côte, ou les lignes d'un fichier en une seule (-s)nonnon
columnaligner des colonnes pour l'affichage (-t)ouinon
tacinverser l'ordre des lignesouinon
teecopier le flux dans un fichier tout en le transmettantnonnon

Les quatre outils marqués « exige une entrée triée » sont la première source d'erreurs silencieuses : ils ne trient pas eux-mêmes, et uniq ne se plaint même pas quand l'entrée ne l'est pas.

column mérite une remarque : sur Debian 13, il vient du paquet bsdextrautils, de priorité « optional », qui n'est pas forcément installé sur une machine minimale comme sig-outils. Dans un script, ne comptez pas dessus ; en interactif, c'est un confort.

En pratique

Les sorties de cette partie ont été produites avec les outils d'Ubuntu 24.04 (coreutils 9.4, GNU grep 3.11, Bash 5.2.21) et LC_ALL=C.UTF-8, sur le bac à sable décrit ci-dessous. On ne travaille jamais sur sig-outils pour apprendre : on y fera tourner les commandes une fois qu'on les saura justes.

Préparer le bac à sable

Le script suivant fabrique, en local, une copie réduite de ce que contient /srv/donnees sur sig-outils pour la semaine du 5 au 8 octobre 2026 : les journaux reçus des deux serveurs de l'API (texte et JSON), les exports CSV quotidiens, le référentiel des communes, la configuration de l'API et deux réponses JSON. Toutes les leçons du cours l'utilisent. Il est écrit en Python 3 (présent sur Ubuntu 24.04 comme sur Debian 13), sans aucune dépendance, parce qu'il faut générer des données réalistes, ce qui n'est pas le métier des filtres. Vous n'avez pas besoin de le comprendre pour suivre le cours ; lisez-le quand même avant de l'exécuter, comme tout script trouvé sur une page web.

Enregistrez-le sous le nom preparer-donnees, hors du répertoire qu'il va créer :

#!/usr/bin/env python3
"""preparer-donnees : fabrique les données d'essai du cours « Traiter du texte ».

Usage : preparer-donnees RÉPERTOIRE [--volume N]

Crée, sous RÉPERTOIRE, une copie réduite de ce que l'on trouve sur sig-outils
du lundi 5 au jeudi 8 octobre 2026 : journaux reçus des deux serveurs de l'API
(texte et JSON), exports CSV quotidiens, référentiel des communes, configuration
et réponses JSON de l'API et de la CLI scw. Le générateur est déterministe : avec
la même version de Python 3 et le même --volume, il produit toujours les mêmes
octets. --volume multiplie le trafic (1 par défaut, 10 pour mesurer des durées).
"""

import json
import pathlib
import random
import sys
from datetime import datetime, timedelta, timezone

DEBUT = datetime(2026, 10, 5, tzinfo=timezone.utc)
JOURS = 4
DOMAINE = "pn-signalements.internal"
REPARTITEUR = "172.16.8.20"
HOTES = {"sig-app-1": 812, "sig-app-2": 790}
INCIDENT = (datetime(2026, 10, 7, 14, 2, 10, tzinfo=timezone.utc),
            datetime(2026, 10, 7, 14, 19, 40, tzinfo=timezone.utc))
COMMUNES = [  # nom, code fictif, population, poids dans le trafic
    ("Exempleville", "99101", 48210, 50),
    ("Saint-Exemple, centre", "99102", 12034, 15),
    ("Val-d'Essai", "99103", 8730, 12),
    ("Bourg-Témoin", "99104", 3105, 8),
    ("Les Essarts-du-Test", "99105", 1520, 5),
]
TYPES = ["nid-de-poule", "lampadaire", "graffiti", "depot-sauvage", "signalisation"]
AGENTS = ["Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0",
          "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15",
          "Signalements-Android/3.2.1", "Mairie-Exempleville-Export/1.0"]
SONDES = ["/wp-login.php", "/.env", "/.git/config", "/admin/config.php", "/phpmyadmin/"]
MOIS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
        "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]


def horodatage(t):
    """Format RFC 3339 à la microseconde, celui de rsyslog sur sig-outils."""
    return t.strftime("%Y-%m-%dT%H:%M:%S.%f+00:00")


def date_python(t):
    """Format de date du serveur HTTP de Python : 07/Oct/2026 08:41:07."""
    return f"{t.day:02d}/{MOIS[t.month - 1]}/{t.year} {t:%H:%M:%S}"


def pid_de(hote, t):
    """L'API de sig-app-2 est redémarrée à la fin de l'incident : son PID change."""
    if hote == "sig-app-2" and t > INCIDENT[1] + timedelta(seconds=22):
        return 2817
    return HOTES[hote]


def chemin_au_hasard(alea, communes_ponderees):
    tirage = alea.random()
    if tirage < 0.45:
        return "GET", "/signalements"
    if tirage < 0.70:
        commune = alea.choice(communes_ponderees)
        encodee = commune.replace(" ", "%20").replace(",", "%2C").replace("'", "%27")
        encodee = encodee.replace("é", "%C3%A9")
        return "GET", f"/signalements?commune={encodee}"
    if tirage < 0.85:
        return "GET", f"/signalements/{alea.randint(10000, 13999)}"
    if tirage < 0.93:
        return "POST", "/signalements"
    return "GET", f"/pieces-jointes/{alea.getrandbits(64):016x}.jpg"


def statut_au_hasard(alea, methode, hote, t):
    if hote == "sig-app-2" and INCIDENT[0] <= t <= INCIDENT[1]:
        return 503 if alea.random() < 0.8 else 200
    tirage = alea.random()
    if tirage < 0.004:
        return 500
    if tirage < 0.03:
        return 404
    if methode == "POST":
        return 201
    return 304 if tirage < 0.08 else 200


def generer(racine, volume):
    alea = random.Random(20261005)
    communes_ponderees = [nom for nom, _, _, poids in COMMUNES for _ in range(poids)]
    clients = [f"192.0.2.{n}" for n in range(1, 60)] + [f"198.51.100.{n}" for n in range(1, 40)]
    syslog = {hote: [] for hote in HOTES}
    jsonl = {hote: [] for hote in HOTES}
    exports = {}

    for jour in range(JOURS):
        debut_jour = DEBUT + timedelta(days=jour)
        exports[debut_jour.date()] = []
        for hote in HOTES:
            lignes, objets = syslog[hote], jsonl[hote]
            # Vérification de santé du répartiteur : une par minute, non journalisée en JSON.
            # /sante ne consulte pas la base : elle répond 200 même pendant l'incident.
            for minute in range(24 * 60):
                t = debut_jour + timedelta(minutes=minute, seconds=7, microseconds=alea.randrange(10**6))
                lignes.append((t, f"python3[{pid_de(hote, t)}]: {REPARTITEUR} - - [{date_python(t)}] "
                                  '"GET /sante HTTP/1.1" 200 -'))
            # Requêtes des usagers, plus nombreuses en journée.
            instants = []
            for heure in range(24):
                intensite = 40 if 8 <= heure < 19 else 6
                instants += [debut_jour + timedelta(hours=heure, seconds=alea.randrange(3600),
                                                    microseconds=alea.randrange(10**6))
                             for _ in range(int(intensite * volume))]
            if hote == "sig-app-2" and debut_jour.date() == INCIDENT[0].date():
                # Pendant l'incident, les applications mobiles réessaient en boucle.
                duree = int((INCIDENT[1] - INCIDENT[0]).total_seconds())
                instants += [INCIDENT[0] + timedelta(seconds=alea.randrange(duree),
                                                     microseconds=alea.randrange(10**6))
                             for _ in range(int(120 * volume))]
            for t in instants:
                methode, chemin = chemin_au_hasard(alea, communes_ponderees)
                statut = statut_au_hasard(alea, methode, hote, t)
                client = alea.choice(clients)
                duree = int(alea.lognormvariate(3.4, 0.6))
                if statut == 503:
                    duree = 30000 + alea.randrange(20)
                lignes.append((t, f"python3[{pid_de(hote, t)}]: {REPARTITEUR} - - [{date_python(t)}] "
                                  f'"{methode} {chemin} HTTP/1.1" {statut} -'))
                objet = {"horodatage": t.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
                         "niveau": "info" if statut < 500 else "error", "hote": hote,
                         "requete": {"id": f"{alea.getrandbits(48):012x}", "methode": methode,
                                     "chemin": chemin, "statut": statut, "duree_ms": duree},
                         "client": {"ip": client, "agent": alea.choice(AGENTS)}}
                if statut == 503:
                    objet["erreur"] = {"type": "OperationalError",
                                       "message": 'connection to server at "sig-db" failed: '
                                                  "FATAL: sorry, too many clients already"}
                elif statut == 500:
                    objet["erreur"] = {"type": "KeyError", "message": "'commune'"}
                objets.append((t, objet))
                if methode == "POST" and statut == 201:
                    commune = alea.choice(communes_ponderees)
                    exports[debut_jour.date()].append((t, alea.choice(TYPES), commune))
            # Sondes de vulnérabilités et tentatives SSH venues d'internet.
            for _ in range(12):
                t = debut_jour + timedelta(seconds=alea.randrange(86400))
                lignes.append((t, f"python3[{pid_de(hote, t)}]: {REPARTITEUR} - - [{date_python(t)}] "
                                  f'"GET {alea.choice(SONDES)} HTTP/1.1" 404 -'))
            for _ in range(30 if hote == "sig-app-1" else 8):
                t = debut_jour + timedelta(seconds=alea.randrange(86400), microseconds=alea.randrange(10**6))
                ip = alea.choice(["203.0.113.77", "203.0.113.77", "203.0.113.150", "198.51.100.23"])
                compte = alea.choice(["admin", "ubuntu", "oracle", "test", "root"])
                port, spid = alea.randrange(32768, 61000), alea.randrange(20000, 60000)
                if compte == "root":
                    lignes.append((t, f"sshd[{spid}]: Connection closed by authenticating user root "
                                      f"{ip} port {port} [preauth]"))
                else:
                    lignes.append((t, f"sshd[{spid}]: Invalid user {compte} from {ip} port {port}"))
                    lignes.append((t + timedelta(milliseconds=310), f"sshd[{spid}]: Connection closed by "
                                   f"invalid user {compte} {ip} port {port} [preauth]"))
            t = debut_jour + timedelta(hours=9, minutes=12)
            lignes.append((t, f"sshd[4120{jour}]: Accepted publickey for deploiement from 172.16.8.30 "
                              "port 40112 ssh2: ED25519 SHA256:q3Vb0Exemple0Empreinte0Fictive0Kz8"))
            # Paquets refusés par le pare-feu de l'hôte, et tâche planifiée horaire.
            for _ in range(6):
                t = debut_jour + timedelta(seconds=alea.randrange(86400))
                lignes.append((t, "kernel: [UFW BLOCK] IN=ens2 OUT= MAC=de:00:00:5a:1b:2c:de:00:00:5a:1b:01:08:00 "
                                  f"SRC={alea.choice(clients[:5])} DST=51.15.{alea.randrange(1, 255)}."
                                  f"{alea.randrange(1, 255)} LEN=60 TOS=0x00 PREC=0x00 TTL={alea.randrange(40, 60)} "
                                  f"ID={alea.randrange(65536)} DF PROTO=TCP SPT={alea.randrange(32768, 61000)} "
                                  f"DPT={alea.choice([5432, 6379, 3306, 22])} WINDOW=64240 RES=0x00 SYN URGP=0"))
            for heure in range(24):
                t = debut_jour + timedelta(hours=heure, minutes=17, seconds=1)
                lignes.append((t, f"CRON[{alea.randrange(2000, 9000)}]: (root) "
                                  "CMD (cd / && run-parts --report /etc/cron.hourly)"))

    # L'incident : saturation du pool, puis redémarrage de l'API sur sig-app-2.
    debut, fin = INCIDENT
    jsonl["sig-app-2"].append((debut - timedelta(seconds=4), {
        "horodatage": (debut - timedelta(seconds=4)).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
        "niveau": "warning", "hote": "sig-app-2", "message": "pool de connexions saturé",
        "pool": {"taille": 10, "en_attente": 37}}))
    syslog["sig-app-2"] += [
        (fin + timedelta(seconds=20), "systemd[1]: Stopping signalements.service - API Signalements..."),
        (fin + timedelta(seconds=21), "systemd[1]: signalements.service: Deactivated successfully."),
        (fin + timedelta(seconds=21), "systemd[1]: Stopped signalements.service - API Signalements."),
        (fin + timedelta(seconds=22), "systemd[1]: Started signalements.service - API Signalements.")]

    for hote in HOTES:
        repertoire = racine / "journaux" / f"{hote}.{DOMAINE}"
        repertoire.mkdir(parents=True)
        with open(repertoire / "syslog.log", "w", encoding="utf-8") as f:
            for t, texte in sorted(syslog[hote], key=lambda e: e[0]):
                f.write(f"{horodatage(t)} {hote} {texte}\n")
        with open(repertoire / "api.jsonl", "w", encoding="utf-8") as f:
            for _, objet in sorted(jsonl[hote], key=lambda e: e[0]):
                f.write(json.dumps(objet, ensure_ascii=False, separators=(",", ":")) + "\n")

    # Les identifiants suivent l'ordre de création, à partir de 12000.
    signalements, prochain_id = [], 12000
    for jour in sorted(exports):
        for t, sorte, commune in sorted(exports[jour]):
            signalements.append((t, prochain_id, sorte, commune))
            prochain_id += 1

    (racine / "exports").mkdir()
    for jour in sorted(exports):
        lignes = [s for s in signalements if s[0].date() == jour]
        with open(racine / "exports" / f"signalements-{jour}.csv", "w", encoding="utf-8") as f:
            f.write("id,type,commune,date\n")
            for t, ident, sorte, commune in lignes:
                if "," in commune:
                    commune = f'"{commune}"'
                f.write(f"{ident},{sorte},{commune},{t:%Y-%m-%d}\n")

    referentiel = racine / "referentiel"
    referentiel.mkdir()
    with open(referentiel / "communes.csv", "w", encoding="utf-8") as f:
        f.write("code,nom,population\n")
        for nom, code, population, _ in sorted(COMMUNES, key=lambda c: c[1]):
            f.write(f'{code},"{nom}",{population}\n' if "," in nom else f"{code},{nom},{population}\n")

    config = racine / "config"
    config.mkdir()
    (config / "app.conf").write_text(
        "# /etc/signalements/app.conf : configuration de l'API Signalements\n"
        "# Géré à la main sur chaque serveur. Voir docs/serveurs/sig-app-1.md.\n"
        "\n"
        "[base]\n"
        f"hote = sig-db.{DOMAINE}\n"
        "port = 5432\n"
        "nom = signalements\n"
        "utilisateur = signalements\n"
        "# taille_pool = 5\n"
        "taille_pool = 10\n"
        "\n"
        "[api]\n"
        "ecoute = 0.0.0.0:8000\n"
        "travailleurs = 4\n"
        "delai = 30\n"
        "journal_json = oui\n"
        "\n"
        "[stockage]\n"
        "seau = sig-photos\n"
        "region = fr-par\n", encoding="utf-8")

    api = racine / "api"
    api.mkdir()
    premiers = signalements[:3]
    page = {"total": len(signalements), "page": 1, "par_page": 3,
            "suivant": "/signalements?page=2&par_page=3",
            "resultats": [{"id": ident, "type": sorte, "commune": commune,
                           "cree_le": t.strftime("%Y-%m-%dT%H:%M:%SZ"),
                           "position": {"lat": round(48.70 + alea.random() / 10, 5),
                                        "lon": round(2.20 + alea.random() / 10, 5)},
                           "pieces_jointes": [f"{alea.getrandbits(64):016x}.jpg"
                                              for _ in range(alea.randrange(3))],
                           "etat": alea.choice(["nouveau", "en_cours", "resolu"])}
                          for t, ident, sorte, commune in premiers]}
    (api / "signalements-page1.json").write_text(
        json.dumps(page, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

    serveurs = []
    for nom, zone, etat, prive in [("sig-app-1", "fr-par-1", "running", "172.16.8.11"),
                                   ("sig-app-2", "fr-par-2", "running", "172.16.8.12"),
                                   ("sig-outils", "fr-par-1", "running", "172.16.8.30"),
                                   ("sig-app-3", "fr-par-1", "stopped", "172.16.8.13")]:
        serveurs.append({
            "id": f"{alea.getrandbits(32):08x}-{alea.getrandbits(16):04x}-4{alea.getrandbits(12):03x}-"
                  f"8{alea.getrandbits(12):03x}-{alea.getrandbits(48):012x}",
            "name": nom, "commercial_type": "PRO2-XXS" if nom != "sig-outils" else "PRO2-XS",
            "state": etat, "zone": zone,
            "tags": ["app=signalements", "env=prod"] + (["role=api"] if "app" in nom else []),
            "image": {"name": "Ubuntu 24.04 Noble Numbat" if "app" in nom else "Debian 13 (Trixie)"},
            "public_ips": [] if nom == "sig-outils" or etat == "stopped"
            else [{"address": f"51.15.{alea.randrange(1, 255)}.{alea.randrange(1, 255)}",
                   "dynamic": False}],
            "private_nics": [{"private_network_id": "pn-signalements", "ip": prive}],
            "creation_date": "2026-0" + str(alea.randrange(3, 9)) + "-1" + str(alea.randrange(10))
                             + "T09:30:00.000000+00:00"})
    (racine / "scw").mkdir()
    (racine / "scw" / "serveurs.json").write_text(
        json.dumps(serveurs, indent=2) + "\n", encoding="utf-8")


def main():
    args = sys.argv[1:]
    volume = 1
    if len(args) == 3 and args[1] == "--volume" and args[2].isdigit() and int(args[2]) >= 1:
        volume = int(args[2])
    elif len(args) != 1:
        sys.exit("Usage : preparer-donnees RÉPERTOIRE [--volume N]")
    racine = pathlib.Path(args[0])
    if racine.exists() and any(racine.iterdir()):
        sys.exit(f"preparer-donnees : {racine} existe et n'est pas vide")
    racine.mkdir(parents=True, exist_ok=True)
    generer(racine, volume)


if __name__ == "__main__":
    main()

Lancez-le sur un répertoire neuf, puis placez-vous dedans pour toute la suite :

$ python3 preparer-donnees ~/essais-texte
$ cd ~/essais-texte
$ find . -type f | sort
./api/signalements-page1.json
./config/app.conf
./exports/signalements-2026-10-05.csv
./exports/signalements-2026-10-06.csv
./exports/signalements-2026-10-07.csv
./exports/signalements-2026-10-08.csv
./journaux/sig-app-1.pn-signalements.internal/api.jsonl
./journaux/sig-app-1.pn-signalements.internal/syslog.log
./journaux/sig-app-2.pn-signalements.internal/api.jsonl
./journaux/sig-app-2.pn-signalements.internal/syslog.log
./referentiel/communes.csv
./scw/serveurs.json

Le générateur est déterministe : il tire ses valeurs au hasard à partir d'une graine fixe (random.Random(20261005)), et produit donc exactement les mêmes octets à chaque exécution, chez vous comme chez nous, avec Python 3.12 comme avec 3.13. C'est ce qui permet au cours de montrer des sorties que vous retrouverez à l'identique. L'option --volume 10 multiplie le trafic par dix, pour les mesures de durée ; on ne l'utilise pas ici. Le script refuse d'écrire dans un répertoire qui n'est pas vide : pour repartir de zéro, supprimez ~/essais-texte et relancez-le.

L'arborescence reproduit celle de sig-outils : rsyslog y range les journaux reçus dans un répertoire par machine émettrice, nommé d'après son nom complet (cours d'administration, leçon 12). Chaque serveur envoie deux fichiers : syslog.log, le journal système au format texte (l'API y écrit une ligne par requête, à côté des messages de sshd, systemd, CRON et du noyau), et api.jsonl, un journal structuré que l'API écrit en JSON, une requête par ligne, et qui attendra la leçon 7.

Prendre la mesure

Avant d'afficher quoi que ce soit, on mesure :

$ du -sh *
8.0K	api
8.0K	config
28K	exports
3.2M	journaux
8.0K	referentiel
8.0K	scw
$ cd journaux
$ wc -l */syslog.log
   8222 sig-app-1.pn-signalements.internal/syslog.log
   8183 sig-app-2.pn-signalements.internal/syslog.log
  16405 total

Deux fichiers d'un peu plus de 8 000 lignes. wc -l avec plusieurs fichiers ajoute une ligne total : pratique à l'écran, gênant dans un script qui voudrait lire le résultat (wc -l < fichier, avec une redirection, n'affiche que le nombre, sans nom de fichier).

Puis on regarde le début et la fin, pour connaître la forme des lignes et la période couverte :

$ head -n 3 sig-app-2.pn-signalements.internal/syslog.log
2026-10-05T00:00:07.579957+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [05/Oct/2026 00:00:07] "GET /sante HTTP/1.1" 200 -
2026-10-05T00:01:07.744806+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [05/Oct/2026 00:01:07] "GET /sante HTTP/1.1" 200 -
2026-10-05T00:02:07.136356+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [05/Oct/2026 00:02:07] "GET /sante HTTP/1.1" 200 -
$ tail -n 2 sig-app-2.pn-signalements.internal/syslog.log
2026-10-08T23:58:07.204138+00:00 sig-app-2 python3[2817]: 172.16.8.20 - - [08/Oct/2026 23:58:07] "GET /sante HTTP/1.1" 200 -
2026-10-08T23:59:07.304267+00:00 sig-app-2 python3[2817]: 172.16.8.20 - - [08/Oct/2026 23:59:07] "GET /sante HTTP/1.1" 200 -

Ce que l'on apprend en cinq lignes :

  • La période va du lundi 5 octobre à 0 h au jeudi 8 octobre à minuit, en UTC (+00:00).
  • Chaque ligne commence par un horodatage RFC 3339 à la microseconde, écrit par rsyslog, puis le nom court de l'hôte, puis l'étiquette du programme et son PID entre crochets.
  • La première ligne de la journée est une requête GET /sante du répartiteur de charge (172.16.8.20), qui vérifie toutes les minutes que l'API répond. L'adresse du client réel n'apparaît pas : c'est toujours celle du répartiteur.
  • Le PID de l'API a changé entre le début (790) et la fin (2817) sur sig-app-2. Le processus a donc redémarré pendant la période. Premier indice.

Les horodatages au format ISO 8601 ont une propriété précieuse : leur ordre alphabétique est l'ordre chronologique, à condition que tous soient dans le même fuseau. On s'en servira pour découper par heure avec cut -c, trier et fusionner.

Qui écrit dans ces journaux

Le troisième champ, séparé par des espaces, est l'étiquette du programme. On l'extrait, on retire le PID entre crochets, et l'on compte :

$ cut -d' ' -f3 */syslog.log | cut -d'[' -f1 | sort | uniq -c | sort -rn
  15880 python3
    281 sshd
    192 CRON
     48 kernel:
      4 systemd

Décortiquons, étape par étape :

  • cut -d' ' -f3 */syslog.log : pour chaque ligne des deux fichiers, le troisième champ délimité par une espace. Les lignes de ces journaux n'ont jamais deux espaces consécutives avant ce champ, donc cut convient.
  • cut -d'[' -f1 : tout ce qui précède le premier [, c'est-à-dire le nom sans le PID. Le crochet est entre guillemets simples pour que le shell ne le prenne pas pour le début d'un motif. Une ligne sans [ (ici kernel:) est recopiée entière : c'est le comportement de cut quand le délimiteur est absent, sauf avec l'option -s qui supprime ces lignes.
  • sort | uniq -c : regrouper les valeurs identiques, puis les compter.
  • sort -rn : trier par nombre, du plus grand au plus petit.

Quatre messages de systemd seulement : on les regardera plus loin. 281 lignes de sshd sur des serveurs où personne ne se connecte, sinon pour déployer : à creuser aussi. Et 48 lignes du noyau.

Les codes de réponse de l'API

On ne garde que les lignes de l'API, puis le code HTTP. Dans une ligne de requête, découpée sur les espaces, le code est le douzième champ (comptez : horodatage, hôte, python3[790]:, adresse, -, -, [05/Oct/2026, 00:00:07], "GET, chemin, HTTP/1.1", code) :

$ grep -F 'python3[' sig-app-1.pn-signalements.internal/syslog.log | cut -d' ' -f12 | sort | uniq -c | sort -rn
   7503 200
    180 201
     98 404
     92 304
      7 500
$ grep -F 'python3[' sig-app-2.pn-signalements.internal/syslog.log | cut -d' ' -f12 | sort | uniq -c | sort -rn
   7512 200
    171 201
    109 404
    101 503
    101 304
      6 500

grep -F 'python3[' garde les lignes qui contiennent la chaîne littérale python3[ : -F désactive les expressions régulières, sans quoi le crochet ouvrant serait le début d'une classe de caractères (la leçon 2 en fait le tour). C'est notre règle « filtrer d'abord » : le douzième champ d'une ligne de sshd ne veut rien dire.

La différence saute aux yeux : 101 réponses 503 sur sig-app-2, aucune sur sig-app-1. Le code 503 (Service Unavailable) signifie que le serveur ne peut pas traiter la requête pour l'instant. Les 500, rares et répartis sur les deux serveurs, sont une autre histoire.

Retirer le bruit de la supervision

Les 7 500 réponses 200 sont surtout des vérifications de santé : une par minute, 1 440 par jour et par serveur. Elles noient le trafic réel. On les retire avec grep -v (garder les lignes qui ne contiennent pas le motif) :

$ grep -F 'python3[' sig-app-2.pn-signalements.internal/syslog.log | grep -vF 'GET /sante ' | cut -d' ' -f12 | sort | uniq -c | sort -rn
   1752 200
    171 201
    109 404
    101 503
    101 304
      6 500

L'espace après /sante évite d'écarter une hypothétique route /santeXYZ. Sur le trafic réel, les 503 représentent environ 4,5 % des requêtes de la semaine sur ce serveur, concentrées, on va le voir, sur quelques minutes.

Quand : par heure, puis par minute

Les horodatages ont une largeur fixe : les caractères 1 à 13 donnent la date et l'heure (2026-10-07T14), 1 à 16 ajoutent les minutes. cut -c découpe par position :

$ grep -h '" 503 -$' */syslog.log | cut -c1-13 | uniq -c
    101 2026-10-07T14
$ grep -h '" 503 -$' */syslog.log | cut -c1-16 | uniq -c
      5 2026-10-07T14:02
     10 2026-10-07T14:03
      3 2026-10-07T14:04
      3 2026-10-07T14:05
      6 2026-10-07T14:06
      7 2026-10-07T14:07
      6 2026-10-07T14:08
      6 2026-10-07T14:09
      5 2026-10-07T14:10
      9 2026-10-07T14:11
      4 2026-10-07T14:12
      7 2026-10-07T14:13
      5 2026-10-07T14:14
      6 2026-10-07T14:15
      1 2026-10-07T14:16
      8 2026-10-07T14:17
      7 2026-10-07T14:18
      3 2026-10-07T14:19

Toutes les erreurs tombent le mercredi 7 octobre entre 14:02 et 14:19 UTC. Trois remarques sur la commande :

  • grep -h supprime le préfixe du nom de fichier que grep ajoute quand il cherche dans plusieurs fichiers ; sans lui, cut -c1-13 découperait le nom du fichier.
  • Le motif '" 503 -$' ancre le code en fin de ligne ($), derrière le guillemet fermant de la requête : il ne trouvera pas un 503 dans un chemin ou un identifiant. Toujours viser la position du champ, jamais seulement sa valeur.
  • Il n'y a pas de sort avant uniq -c, et c'est voulu : le fichier est déjà dans l'ordre chronologique, donc les lignes de la même minute sont consécutives. uniq ne demande pas que l'entrée soit triée, mais que les doublons soient adjacents. Ici, l'ordre naturel suffit, et l'on garde la chronologie au lieu de la perdre dans un tri.

Combien de requêtes ces minutes-là, en tout ? Comparons le trafic réel des deux serveurs, heure par heure, autour de l'incident :

$ for h in sig-app-1 sig-app-2; do echo "== $h"; grep -F 'python3[' $h.*/syslog.log | grep -vF '/sante ' | grep '^2026-10-07T1[2-6]' | cut -c1-13 | uniq -c; done
== sig-app-1
     40 2026-10-07T12
     40 2026-10-07T13
     40 2026-10-07T14
     40 2026-10-07T15
     41 2026-10-07T16
== sig-app-2
     40 2026-10-07T12
     41 2026-10-07T13
    161 2026-10-07T14
     41 2026-10-07T15
     40 2026-10-07T16

Le trafic de sig-app-2 a quadruplé entre 14 h et 15 h, pas celui de sig-app-1. Hypothèse : des clients (les applications mobiles ?) réessaient quand ils reçoivent une erreur, ce qui ajoute de la charge à un serveur déjà en difficulté. C'est un comportement classique, et dangereux ; le journal JSON, qui contient l'agent de chaque client, permettra de le vérifier à la leçon 7.

Une chronologie unique des deux serveurs

Pour comprendre la fin de l'incident, on veut voir les deux serveurs sur une même ligne de temps. Les deux fichiers sont chacun dans l'ordre chronologique : il suffit de les fusionner, sans les retrier. C'est le rôle de sort -m (merge), qui lit les fichiers en parallèle et écrit toujours la plus petite des lignes en tête, comme on fusionne deux paquets de cartes déjà triés. Mais vérifions d'abord que les fichiers sont triés, avec sort -c (check) :

$ sort -c sig-app-2.pn-signalements.internal/syslog.log
sort: sig-app-2.pn-signalements.internal/syslog.log:5349: disorder: 2026-10-07T14:20:01.000000+00:00 sig-app-2 systemd[1]: Stopped signalements.service - API Signalements.

Le fichier n'est pas trié ! En regardant la ligne 5349 et la précédente, on comprend : deux lignes ont le même horodatage (14:20:01.000000), et la seconde (Stopped...) est, octet par octet, plus petite que la première (signalements.service: ..., car S majuscule précède s minuscule). Les lignes sont dans l'ordre du temps, pas dans l'ordre de la ligne entière. Il faut dire à sort de ne regarder que l'horodatage, c'est-à-dire le premier champ, avec une clé :

$ sort -c -k1,1 sig-app-2.pn-signalements.internal/syslog.log
sort: sig-app-2.pn-signalements.internal/syslog.log:5349: disorder: 2026-10-07T14:20:01.000000+00:00 sig-app-2 systemd[1]: Stopped signalements.service - API Signalements.
$ sort -s -c -k1,1 sig-app-2.pn-signalements.internal/syslog.log; echo "code : $?"
code : 0

-k1,1 ne suffit pas, et la raison est instructive. Le manuel de GNU sort explique que lorsque deux lignes ont des clés égales, sort applique une comparaison de dernier recours sur la ligne entière, pour que le résultat soit toujours le même. -s (stable) la désactive : deux lignes de clés égales restent dans leur ordre d'origine. Avec -s -k1,1, le fichier est bien reconnu comme trié. La fusion se fait avec les mêmes options :

$ sort -s -m -k1,1 */syslog.log | grep '^2026-10-07T14:\(19:0[6-9]\|19:1\|20:0\)' | cut -d' ' -f1-3,9,10,12 | cut -c12-19,33-
14:19:06 sig-app-2 python3[790]: "GET /signalements 503
14:19:07 sig-app-1 python3[812]: "GET /sante 200
14:19:07 sig-app-2 python3[790]: "GET /sante 200
14:19:18 sig-app-2 python3[790]: "GET /signalements 200
14:20:00 sig-app-2 systemd[1]:
14:20:01 sig-app-2 systemd[1]:
14:20:01 sig-app-2 systemd[1]:
14:20:02 sig-app-2 systemd[1]:
14:20:07 sig-app-1 python3[812]: "GET /sante 200
14:20:07 sig-app-2 python3[2817]: "GET /sante 200

(Le grep sélectionne la fenêtre de 14:19:06 à 14:20:09 ; sa syntaxe d'alternance \| est l'objet de la leçon 2. Les deux cut gardent l'horodatage, l'hôte, le programme, la méthode, le chemin et le code, puis raccourcissent l'horodatage à l'heure.)

Trois faits ressortent, et ils feront le cœur du compte rendu d'incident :

  1. À 14:19:07, pendant l'incident, /sante répondait 200 sur sig-app-2. La vérification de santé du répartiteur ne voyait rien : elle ne teste visiblement pas la base de données. C'est pour cela que sig-app-2 est resté dans le pool, et que personne n'a été alerté. Le terme a une entrée au glossaire : vérification de santé.
  2. À 14:20:00, systemd arrête puis relance le service ; à 14:20:07, l'API répond avec un nouveau PID (2817). Quelqu'un, ou quelque chose, a redémarré l'API. Les journaux d'audit diront qui.
  3. Les lignes de systemd ont perdu leur message : cut -d' ' -f1-3,9,10,12 a pris les champs 9, 10 et 12 d'une ligne qui n'a pas la forme d'une requête. Découper des lignes hétérogènes par position est la limite de cut ; awk saura traiter chaque forme de ligne à sa façon.

Les messages complets de systemd, avec grep seul :

$ grep -h 'systemd\[1\]' */syslog.log
2026-10-07T14:20:00.000000+00:00 sig-app-2 systemd[1]: Stopping signalements.service - API Signalements...
2026-10-07T14:20:01.000000+00:00 sig-app-2 systemd[1]: signalements.service: Deactivated successfully.
2026-10-07T14:20:01.000000+00:00 sig-app-2 systemd[1]: Stopped signalements.service - API Signalements.
2026-10-07T14:20:02.000000+00:00 sig-app-2 systemd[1]: Started signalements.service - API Signalements.

Pour retrouver le dernier redémarrage dans un gros journal, sans tout lire, tac lit le fichier à l'envers et grep -m 1 s'arrête au premier résultat :

$ tac sig-app-2.pn-signalements.internal/syslog.log | grep -m 1 'Started signalements'
2026-10-07T14:20:02.000000+00:00 sig-app-2 systemd[1]: Started signalements.service - API Signalements.

Sur un fichier ordinaire, GNU tac lit les blocs en partant de la fin : la réponse est immédiate même sur plusieurs gigaoctets, là où grep ... | tail -n 1 lit tout le fichier.

Les chemins demandés

Quelles routes ont été appelées ? Le chemin est le dixième champ :

$ grep -hF 'python3[' */syslog.log | grep -vF '/sante ' | cut -d' ' -f10 | sort | uniq -c | sort -rn | head -n 5
   2273 /signalements
    579 /signalements?commune=Exempleville
    190 /signalements?commune=Saint-Exemple%2C%20centre
    149 /signalements?commune=Val-d%27Essai
     89 /signalements?commune=Bourg-T%C3%A9moin
$ grep -hF 'python3[' */syslog.log | grep -vF '/sante ' | cut -d' ' -f10 | sort -u | wc -l
890

890 chemins différents : chaque identifiant de signalement (/signalements/12345) et chaque pièce jointe font un chemin à part. Pour raisonner par route, on normalise : on retire la chaîne de requête (tout ce qui suit ?), puis on ne garde que le premier segment du chemin :

$ grep -hF 'python3[' */syslog.log | grep -vF '/sante ' | cut -d' ' -f10 | cut -d'?' -f1 | cut -d/ -f2 | sort | uniq -c | sort -rn
   3980 signalements
    284 pieces-jointes
     23 admin
     21 wp-login.php
     20 phpmyadmin
     19 .git
     13 .env

Les deux premières lignes sont les routes de l'API. Les cinq suivantes n'existent pas sur Signalements : wp-login.php (WordPress), phpmyadmin, .git, .env sont les cibles classiques des sondes automatiques qui parcourent internet à la recherche d'un fichier de configuration oublié ou d'une interface d'administration. Elles ont reçu des 404, ce qui est la bonne réponse ; la leçon 2 les isolera proprement. Notez la technique : cut -d/ -f2 fonctionne parce que le chemin commence par /, donc le premier champ est vide et le premier segment est le deuxième champ.

Trier vraiment : clés et ordres

Jusqu'ici, sort -rn a trié sur le nombre en tête de ligne. Dès que l'on veut un ordre plus précis, il faut des clés. Comptons les signalements par type sur la semaine, à partir des exports :

$ cd ../exports
$ tail -q -n +2 signalements-*.csv | cut -d, -f2 | sort | uniq -c | sort -rn
     74 signalisation
     73 lampadaire
     73 depot-sauvage
     71 nid-de-poule
     60 graffiti
  • tail -n +2 affiche à partir de la ligne 2, donc tout sauf la ligne d'en-tête id,type,commune,date. Avec plusieurs fichiers, tail intercale des titres ==> fichier <== ; -q (quiet) les supprime.
  • cut -d, -f2 : le deuxième champ séparé par des virgules, le type.

lampadaire et depot-sauvage sont à égalité, et sort -rn les a rangés dans un ordre qui dépend de la comparaison de dernier recours, inversée par -r : lampadaire avant depot-sauvage. Pour un rapport, on veut un ordre défini : par nombre décroissant, puis par nom croissant. On le demande par deux clés, chacune avec ses options :

$ tail -q -n +2 signalements-*.csv | cut -d, -f2 | sort | uniq -c | sort -k1,1nr -k2,2
     74 signalisation
     73 depot-sauvage
     73 lampadaire
     71 nid-de-poule
     60 graffiti

La syntaxe -k DÉBUT,FIN mérite d'être lue lentement :

  • -k1,1nr : la clé commence au champ 1 et finit au champ 1 ; elle est comparée numériquement (n) et à l'envers (r). Les options collées à une clé ne valent que pour elle.
  • -k2,2 : en cas d'égalité sur la première clé, comparer le champ 2, en ordre normal.
  • Sans la fin, -k2 signifie « du champ 2 jusqu'à la fin de la ligne », d'après le manuel de GNU sort et la norme POSIX. Sur une ligne à deux champs, aucune différence ; sur une ligne de journal, -k2 compare tout le reste de la ligne, ce qui est rarement voulu. Écrivez toujours la fin de la clé.
  • Sans -t, les champs de sort sont séparés par des suites de blancs, et les blancs qui précèdent un champ font partie de ce champ. --debug le montre :
$ tail -q -n +2 signalements-*.csv | cut -d, -f2 | sort | uniq -c | sort -k1,1nr -k2,2 --debug 2>&1 | head -n 8
sort: text ordering performed using ‘C.UTF-8’ sorting rules
sort: leading blanks are significant in key 2; consider also specifying 'b'
sort: note numbers use ‘.’ as a decimal point in this locale
     74 signalisation
     __
       ______________
_____________________
     73 depot-sauvage

--debug souligne, sous chaque ligne, la partie utilisée par chaque clé (ici 74, puis signalisation avec son espace initiale), puis la ligne entière pour la comparaison de dernier recours. Et il prévient : les blancs initiaux comptent dans la clé 2, ajoutez b (-k2,2b) pour les ignorer. Ici, tous les noms sont précédés d'une seule espace, la comparaison reste juste ; avec des largeurs variables, elle ne le serait plus. C'est le premier outil à sortir quand un tri surprend.

Les autres ordres utiles :

$ printf '%s\n' 9 10 100 | sort
10
100
9
$ printf '%s\n' 9 10 100 | sort -n | paste -sd' '
9 10 100
$ printf '%s\n' 1.10.0 1.9.2 1.2.0 | sort -V
1.2.0
1.9.2
1.10.0
$ cd .. && du -sh * | sort -h
8.0K	api
8.0K	config
8.0K	referentiel
8.0K	scw
28K	exports
3.2M	journaux
  • Par défaut, sort compare des chaînes : 10 précède 9 parce que le caractère 1 précède 9. Pour des nombres, -n.
  • -V (GNU) compare des numéros de version, segment par segment : 1.10.0 est plus récent que 1.9.2. Utile pour trier des étiquettes d'images ou des noms de paquets.
  • -h (GNU) comprend les suffixes de du -h et df -h (K, M, G). Il compare d'abord le suffixe, puis le nombre.
  • paste -sd' ' colle toutes les lignes de l'entrée en une seule, séparées par une espace : pratique pour afficher une petite liste sur une ligne.

Les exports CSV, et le premier piège de format

La mairie veut des chiffres par commune. Le troisième champ des exports est la commune :

$ head -n 4 exports/signalements-2026-10-07.csv
id,type,commune,date
12197,nid-de-poule,Bourg-Témoin,2026-10-07
12198,graffiti,Exempleville,2026-10-07
12199,nid-de-poule,Exempleville,2026-10-07
$ tail -n +2 exports/signalements-2026-10-07.csv | cut -d, -f3 | sort | uniq -c | sort -rn
     46 Exempleville
     18 "Saint-Exemple
     14 Val-d'Essai
      5 Bourg-Témoin
      3 Les Essarts-du-Test

"Saint-Exemple : la commune s'appelle Saint-Exemple, centre, et comme son nom contient une virgule, l'export l'entoure de guillemets, selon la RFC 4180. cut -d, ne connaît pas les guillemets : il a coupé le nom en deux. Le compte est juste par chance (aucune autre commune ne commence par "Saint-Exemple), mais le libellé est faux, et tout ce qui suit sur ces lignes est décalé. Le quatrième champ le montre :

$ tail -q -n +2 exports/signalements-*.csv | cut -d, -f4 | sort | uniq -c
     52  centre"
     80 2026-10-05
     89 2026-10-06
     68 2026-10-07
     62 2026-10-08

52 lignes ont pour « date » centre". Pour la date, qui est le dernier champ, il existe une parade : la prendre en partant de la fin. rev inverse chaque ligne caractère par caractère ; le dernier champ devient le premier, on le découpe, on inverse à nouveau :

$ tail -q -n +2 exports/signalements-*.csv | rev | cut -d, -f1 | rev | uniq -c
     96 2026-10-05
    101 2026-10-06
     86 2026-10-07
     68 2026-10-08

C'est une astuce, pas une solution : elle ne marcherait pas pour un champ du milieu. Le même piège frappe sort -t,. Trions le référentiel des communes par population :

$ tail -n +2 referentiel/communes.csv | sort -t, -k3,3nr
99101,Exempleville,48210
99103,Val-d'Essai,8730
99104,Bourg-Témoin,3105
99105,Les Essarts-du-Test,1520
99102,"Saint-Exemple, centre",12034

Saint-Exemple, centre, deuxième commune par sa population, est rangée en dernier : son troisième champ est centre", qui ne commence pas par un nombre, et sort -n le compte pour zéro. Aucun message d'erreur. Aucun outil de cette leçon ne lit correctement du CSV général. Les solutions viendront à la leçon 6 (awk avec une fonction de découpage qui comprend les guillemets) et à la leçon 8 (Miller, Python). Pour l'instant, retenez le réflexe : compter les champs avant de faire confiance à un découpage. Une ligne qui en a plus que l'en-tête signale un séparateur à l'intérieur d'une valeur.

Le même réflexe vaut pour les fins de ligne. Un export ouvert puis enregistré par un tableur sous Windows reviendrait en CRLF :

$ printf 'id,type\r\n12000,graffiti\r\n' > crlf.csv
$ cat -A crlf.csv
id,type^M$
12000,graffiti^M$
$ cut -d, -f2 crlf.csv | od -c | head -n 2
0000000   t   y   p   e  \r  \n   g   r   a   f   f   i   t   i  \r  \n
0000020
$ tr -d '\r' < crlf.csv | cat -A
id,type$
12000,graffiti$

cat -A affiche le \r sous la forme ^M et marque chaque fin de ligne par $. od -c montre les octets un à un : le \r est bien dans le champ extrait. tr -d '\r' le supprime. tr ne lit que son entrée standard, d'où la redirection < : il n'accepte aucun nom de fichier.

Comparer deux listes avec comm

281 lignes de sshd sur deux serveurs que seule la chaîne de déploiement devrait contacter. Quelles adresses ont ouvert une connexion SSH ? Les messages de fermeture de connexion ont tous la même forme, avec l'adresse en dixième champ :

$ cd journaux
$ grep -h 'sshd\[.*Connection closed by' */syslog.log | head -n 2
2026-10-05T00:43:10.230500+00:00 sig-app-1 sshd[53753]: Connection closed by invalid user ubuntu 203.0.113.77 port 54603 [preauth]
2026-10-05T01:51:00.371022+00:00 sig-app-1 sshd[56266]: Connection closed by invalid user oracle 203.0.113.150 port 58300 [preauth]
$ grep -h 'sshd\[.*Connection closed by' */syslog.log | cut -d' ' -f10 | sort | uniq -c | sort -rn
     67 203.0.113.77
     50 203.0.113.150
     35 198.51.100.23

invalid user : quelqu'un essaie des noms de compte au hasard (ubuntu, oracle, admin...), depuis des adresses publiques. Les instances ont donc leur port 22 ouvert sur internet, ce qui n'était pas prévu ; c'est un groupe de sécurité trop ouvert, à corriger (cours Scaleway en pratique). Les connexions sans [preauth] sont acceptées :

$ grep -h 'Accepted publickey' */syslog.log | cut -d' ' -f2,9 | sort | uniq -c
      4 sig-app-1 172.16.8.30
      4 sig-app-2 172.16.8.30

Seule 172.16.8.30 (sig-outils, pour les déploiements) s'est authentifiée. Pour formaliser le contrôle, on tient une liste d'autorisation des adresses légitimes, et l'on cherche les adresses vues dans les journaux qui n'y figurent pas. C'est exactement ce que fait comm, qui compare deux fichiers triés et répartit les lignes en trois colonnes : propres au premier, propres au second, communes aux deux. Les options -1, -2, -3 suppriment les colonnes correspondantes :

$ printf '%s\n' 172.16.8.30 > ../autorisees.txt
$ grep -h 'sshd\[.*port' */syslog.log | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3} port' | cut -d' ' -f1 | sort -u > ../vues.txt
$ comm -23 ../vues.txt ../autorisees.txt
198.51.100.23
203.0.113.150
203.0.113.77

comm -23 : supprimer les colonnes 2 (propres à la liste d'autorisation) et 3 (communes), donc ne garder que les adresses vues et non autorisées. (grep -oE extrait toute adresse suivie de port ; la leçon 2 détaille l'option -o et ce motif.) La commande ne dépend ni du format exact des messages de refus, ni de leur nombre : elle répond à la vraie question, « qui a parlé à notre SSH sans en avoir le droit ».

comm exige que les deux fichiers soient triés selon la même collation que la sienne. Voici ce qui arrive quand ce n'est pas le cas. Deux petites listes de communes, triées en fr_FR.UTF-8 (par exemple par un collègue dont le terminal est en français), puis comparées dans un script en C.UTF-8 :

$ printf '%s\n' Exempleville bourg-témoin "Val-d'Essai" > a.txt
$ printf '%s\n' Exempleville Bourg-Témoin "Val-d'Essai" > b.txt
$ LC_ALL=fr_FR.UTF-8 sort a.txt > a.tri
$ LC_ALL=fr_FR.UTF-8 sort b.txt > b.tri
$ comm a.tri b.tri; echo "code : $?"
	Bourg-Témoin
	Exempleville
	Val-d'Essai
bourg-témoin
comm: file 1 is not in sorted order
Exempleville
Val-d'Essai
comm: input is not in sorted order
code : 1

Exempleville et Val-d'Essai, présentes dans les deux listes, apparaissent comme propres à l'une et à l'autre : le résultat est faux. GNU comm le signale et renvoie 1, mais seulement parce qu'il vérifie l'ordre par défaut ; avec --nocheck-order, ou avec un comm qui ne vérifie pas, le résultat faux sortirait en silence. La correction : trier et comparer dans la même locale, donc fixer LC_ALL une fois pour tout le script.

Rapprocher deux fichiers avec join

join fait pour deux fichiers ce qu'une jointure fait en SQL : il réunit les lignes qui ont la même valeur dans un champ choisi, la clé. On veut savoir lesquelles des adresses qui ont tenté de se connecter figurent sur une liste de réputation transmise par un CERT. On prépare les deux fichiers : le nombre de tentatives par adresse, et la liste connue (ici écrite à la main) :

$ grep -h 'sshd\[.*Connection closed by' */syslog.log | cut -d' ' -f10 | sort | uniq -c > ../tentatives.txt
$ cat ../tentatives.txt
     35 198.51.100.23
     50 203.0.113.150
     67 203.0.113.77
$ printf '%s\n' '203.0.113.77 liste-noire-cert' '198.51.100.23 liste-noire-cert' '172.16.8.30 sig-outils' > ../connues.txt
$ cd ..
$ join -1 2 -2 1 tentatives.txt connues.txt; echo "code : $?"
join: connues.txt:2: is not sorted: 198.51.100.23 liste-noire-cert
203.0.113.77 67 liste-noire-cert
join: input is not in sorted order
code : 1

-1 2 -2 1 : la clé est le champ 2 du premier fichier (l'adresse, après le nombre) et le champ 1 du second. Mais connues.txt n'est pas trié sur sa clé, et join ne trouve qu'une correspondance sur deux : comme comm, il avance dans les deux fichiers en même temps, en supposant l'ordre. On trie, et l'on demande au passage de garder les adresses du premier fichier sans correspondance (-a 1), avec une valeur par défaut (-e) et un format de sortie explicite (-o) :

$ sort -k1,1 connues.txt > connues.tri
$ join -1 2 -2 1 -a 1 -e inconnue -o 0,1.1,2.2 tentatives.txt connues.tri
198.51.100.23 35 liste-noire-cert
203.0.113.150 50 inconnue
203.0.113.77 67 liste-noire-cert
  • -o 0,1.1,2.2 : afficher la clé (0), le champ 1 du fichier 1 (le nombre), le champ 2 du fichier 2 (la provenance).
  • -e inconnue : la valeur à afficher pour un champ absent, ici la provenance de 203.0.113.150, qui n'est sur aucune liste. D'après le manuel de coreutils, -e ne remplace que les champs désignés par -o : sans format de sortie explicite, il serait sans effet.
  • tentatives.txt n'a pas eu besoin d'être retrié : il sort de sort | uniq -c, donc ses adresses sont déjà dans l'ordre. Mais son premier champ est précédé de blancs, et c'est sans importance ici, parce que sans -t, join découpe sur les suites de blancs et ignore ceux de début de ligne.

join est précieux pour de petits rapprochements ponctuels. Pour une jointure sur des données réelles (plusieurs clés, CSV, valeurs manquantes), awk sera plus souple (leçon 6).

Garder une trace avec tee

Une enquête produit des résultats intermédiaires que l'on veut garder (pour le compte rendu) tout en continuant le traitement. tee recopie son entrée dans un fichier et sur sa sortie :

$ grep -h '" 503 -$' journaux/*/syslog.log | tee incident-503.log | wc -l
101
$ wc -l < incident-503.log
101

Les 101 lignes sont conservées dans incident-503.log, et le décompte s'affiche. tee -a ajoute au lieu d'écraser.

La feuille d'enquête

Toutes ces commandes, avec ce qu'elles ont appris, vont dans un fichier de notes du dépôt signalements-outils, notes/incident-2026-10-07.md. Pas un script : une feuille d'enquête, où chaque question est suivie de la commande qui y répond et du résultat. Quand quelqu'un demandera « comment le sait-on ? », la réponse sera rejouable. Un extrait :

## Combien d'erreurs, où, quand ?

101 réponses 503, toutes sur sig-app-2, le 2026-10-07 de 14:02:16 à 14:19:06 UTC.

```bash
export LC_ALL=C.UTF-8
grep -h '" 503 -$' journaux/*/syslog.log | cut -d' ' -f2 | sort | uniq -c
grep -h '" 503 -$' journaux/*/syslog.log | cut -c1-16 | uniq -c
```

## Pourquoi le répartiteur a-t-il continué d'envoyer du trafic à sig-app-2 ?

/sante répondait 200 pendant l'incident (14:19:07). À vérifier : que teste /sante ?

Le début de ce document répond déjà à trois des questions du matin. Les leçons suivantes compléteront l'enquête et commenceront le rapport de la mairie.

Sous le capot

Les commandes d'un tube tournent en même temps

cut ... | sort | uniq -c | sort -rn ne lance pas cut, puis sort quand cut a fini, et ainsi de suite. Le shell crée les tubes, puis un processus par commande avec fork, et les lance tous en même temps (leçon 6 de Premiers pas, partie Sous le capot). Chaque processus lit dès que des données arrivent sur son entrée et écrit dès qu'il a quelque chose à écrire. Sur une machine à plusieurs cœurs, un tube de cinq filtres occupe jusqu'à cinq cœurs.

Cette concurrence est réglée par le tube lui-même, qui est un tampon dans le noyau. La page de manuel pipe(7) en donne la capacité : depuis Linux 2.6.11, seize pages, soit 65 536 octets sur une machine à pages de 4 Kio. Quand le tampon est plein, l'écrivain est bloqué jusqu'à ce que le lecteur ait consommé des données ; quand il est vide, c'est le lecteur qui attend. Un filtre rapide suivi d'un filtre lent ne remplit donc pas la mémoire : il ralentit au rythme du plus lent. C'est la contre-pression, et c'est elle qui permet de traiter des fichiers plus gros que la mémoire.

Deux filtres échappent à ce rythme : ceux qui doivent tout lire avant d'écrire. sort ne produit rien tant que son entrée n'est pas terminée ; tout ce qui le suit attend. C'est pourquoi, sur tail -F journal | grep ... | sort, rien ne s'affiche jamais : tail -F ne se termine pas, sort attend toujours la fin.

La mise en tampon de la sortie

Un autre tampon, cette fois dans le programme, surprend davantage. Pour éviter un appel système write par ligne, la bibliothèque C (et la plupart des langages) accumule la sortie dans un tampon et ne l'écrit que par blocs de plusieurs kilooctets, sauf quand la sortie est un terminal, auquel cas elle écrit à chaque fin de ligne. Ce comportement, la mise en tampon (buffering), est invisible dans un terminal, et devient flagrant dans un tube qui traite un flux continu. Démonstration, avec un producteur qui écrit une ligne par seconde et une boucle qui note l'instant d'arrivée de chaque ligne :

$ producteur() { for i in 1 2 3; do echo "requete $i 503"; sleep 1; done; }
$ debut=$SECONDS; producteur | grep 503 | while IFS= read -r l; do echo "+$((SECONDS - debut)) s : $l"; done
+3 s : requete 1 503
+3 s : requete 2 503
+3 s : requete 3 503
$ debut=$SECONDS; producteur | grep --line-buffered 503 | while IFS= read -r l; do echo "+$((SECONDS - debut)) s : $l"; done
+0 s : requete 1 503
+1 s : requete 2 503
+2 s : requete 3 503

Dans le premier cas, grep écrit dans un tube : il garde les trois lignes dans son tampon et ne les livre qu'à sa terminaison, au bout de trois secondes. Sur tail -F syslog.log | grep 503 | ..., qui ne se termine pas, les lignes peuvent attendre des minutes, le temps de remplir le tampon. --line-buffered force grep à écrire chaque ligne aussitôt. Pour les programmes qui n'ont pas d'option équivalente, stdbuf -oL (coreutils) change la mise en tampon de la bibliothèque C du programme lancé :

$ debut=$SECONDS; producteur | stdbuf -oL cut -d' ' -f1,2 | while IFS= read -r l; do echo "+$((SECONDS - debut)) s : $l"; done
+0 s : requete 1
+1 s : requete 2
+2 s : requete 3

stdbuf ne fonctionne qu'avec les programmes qui utilisent les entrées-sorties de la bibliothèque C sans les régler eux-mêmes ; le manuel de coreutils le précise. Il est sans effet sur un programme qui règle lui-même sa mise en tampon : c'est le cas de tee, que le manuel cite en exemple (il n'en a pas), et de Python, qui a ses propres tampons (on le lance avec python3 -u). mawk a son option -W interactive, gawk vide son tampon à chaque ligne quand la sortie est un terminal ; on y reviendra avec awk.

SIGPIPE, ou pourquoi un tube réussi renvoie 141

Que se passe-t-il quand le lecteur s'arrête avant l'écrivain ? head -n 1 lit une ligne, l'affiche, et se termine. L'extrémité de lecture du tube est fermée. La prochaine fois que l'écrivain appelle write, le noyau lui envoie le signal SIGPIPE (pipe(7)), dont l'action par défaut, d'après signal(7), est de terminer le processus. C'est voulu : l'écrivain n'a plus de raison de continuer, et le mécanisme arrête proprement un grep sur un fichier de 50 Go dès que head a ce qu'il veut.

Le shell rapporte la mort par un signal par le code 128 + numéro du signal. SIGPIPE est le signal 13 : 141. Sans l'option pipefail, le code d'un tube est celui de la dernière commande (head, donc 0) et personne ne voit rien. Avec pipefail, que la leçon 9 du cours Bash recommande, le code du tube est celui de la dernière commande qui a échoué :

$ set -o pipefail
$ grep -h 'python3\[' */syslog.log | head -n 1 > /dev/null; echo "code : $? ; PIPESTATUS : ${PIPESTATUS[*]}"
code : 141 ; PIPESTATUS : 141 0
$ kill -l 141
PIPE

PIPESTATUS donne le code de chaque commande : grep a été tué par SIGPIPE (141), head a réussi (0). Dans un script sous set -euo pipefail, ce tube arrête le script, sans message, alors qu'il a parfaitement fonctionné. Et le phénomène est capricieux : si grep a fini d'écrire avant que head ne ferme le tube, par exemple parce que tout tenait dans le tampon de 64 Kio, il n'y a pas de signal. Le même script peut donc réussir sur un petit fichier de test et échouer en production. Trois corrections, selon le cas :

  • Faire s'arrêter le producteur lui-même : grep -m 1 au lieu de grep | head -n 1, sed -n '1p;1q' ou awk 'NR == 1 { print; exit }'. C'est la meilleure, quand elle existe.
  • Ne pas lire plus que nécessaire : head -n 1 fichier plutôt que cat fichier | head -n 1.
  • Tolérer explicitement le 141 pour ce tube précis, quand on sait qu'il est inoffensif, plutôt que de désactiver pipefail pour tout le script.

Le tri externe

sort doit tout lire avant d'écrire, et un journal de 20 Go ne tient pas en mémoire. GNU sort trie donc par morceaux : il remplit un tampon en mémoire, le trie, l'écrit dans un fichier temporaire, recommence, puis fusionne tous les fichiers temporaires, exactement comme le fait sort -m. C'est un tri externe. Le manuel de coreutils en décrit les réglages :

  • -S TAILLE fixe la taille du tampon en mémoire (-S 2G, -S 50% de la mémoire physique). Par défaut, sort choisit une taille selon la mémoire disponible et la taille de l'entrée.
  • Les fichiers temporaires vont dans $TMPDIR, ou /tmp à défaut ; -T RÉPERTOIRE les envoie ailleurs. Or, depuis Debian 13, /tmp est un tmpfs, c'est-à-dire un système de fichiers en mémoire, plafonné par défaut à la moitié de la RAM d'après les notes de publication. Sur sig-outils, un gros tri dont les fichiers temporaires vont dans /tmp consomme donc de la mémoire, et peut échouer avec No space left on device alors que le disque est vide. Pour un gros volume, sort -T /srv/donnees/tmp.
  • --parallel=N fixe le nombre de fils de tri ; par défaut, le nombre de processeurs disponibles, plafonné à 8.

Une conséquence de sécurité, aussi : ces fichiers temporaires contiennent les données triées, en clair. Si l'on trie des journaux qui contiennent des adresses IP ou des identifiants d'usagers, des copies partielles existent, le temps du tri, dans le répertoire temporaire.

Le coût de la locale

Comparer selon les règles du français coûte plus cher que comparer des octets : il faut, pour chaque comparaison, transformer les deux chaînes selon les tables de collation de la glibc. Sur notre poste (16 cœurs), le tri de 547 830 lignes de journal (le bac à sable --volume 10, concaténé dix fois) sur le chemin puis l'horodatage, sort -k10,10 -k1,1, a pris :

LocaleDurée (8 fils)Durée (--parallel=1)
C0,40 s0,47 s
C.UTF-80,47 s0,57 s
fr_FR.UTF-80,75 s1,2 à 1,3 s

Refaites la mesure chez vous : les valeurs absolues dépendent de la machine, l'ordre de grandeur reste. fr_FR.UTF-8 est environ deux fois plus lent ici ; sur des clés plus longues et des volumes plus gros, l'écart se creuse. La locale change aussi les résultats, ce qui est plus grave que la vitesse. Voici le même petit fichier trié dans trois locales :

$ printf '%s\n' Exempleville 'Écluse-Basse' bourg-témoin Bourg-Témoin "Val-d'Essai" 'Les Essarts-du-Test' eglise > noms.txt
$ LC_ALL=C sort noms.txt | paste -sd'|'
Bourg-Témoin|Exempleville|Les Essarts-du-Test|Val-d'Essai|bourg-témoin|eglise|Écluse-Basse
$ LC_ALL=C.UTF-8 sort noms.txt | paste -sd'|'
Bourg-Témoin|Exempleville|Les Essarts-du-Test|Val-d'Essai|bourg-témoin|eglise|Écluse-Basse
$ LC_ALL=fr_FR.UTF-8 sort noms.txt | paste -sd'|'
bourg-témoin|Bourg-Témoin|Écluse-Basse|eglise|Exempleville|Les Essarts-du-Test|Val-d'Essai

En C et C.UTF-8, les majuscules passent avant les minuscules et É après tout l'alphabet. En fr_FR.UTF-8, l'ordre est celui d'un dictionnaire. Le séparateur décimal change aussi le tri numérique :

$ printf '%s\n' 2.7 2,5 | LC_ALL=C.UTF-8 sort -n | paste -sd' '
2,5 2.7
$ printf '%s\n' 2.7 2,5 | LC_ALL=fr_FR.UTF-8 sort -n | paste -sd' '
2.7 2,5

En C.UTF-8, 2,5 se lit 2 (la virgule arrête le nombre) ; en français, c'est 2.7 qui se lit 2.

Octets ou caractères : les outils ne sont pas égaux

LC_CTYPE décide qu'en UTF-8, é est un caractère. Encore faut-il que l'outil le gère. Dans les versions de coreutils de ce cours, ce n'est pas le cas de tous :

$ echo 'Bourg-Témoin' | wc -c
14
$ echo 'Bourg-Témoin' | wc -m
13
$ echo 'Bourg-Témoin' | LC_ALL=C wc -m
14
$ echo 'Bourg-Témoin' | tr '[:lower:]' '[:upper:]'
BOURG-TéMOIN
$ echo 'Bourg-Témoin' | cut -c1-8
Bourg-T�
  • wc -m compte des caractères selon la locale (13 en UTF-8, 14 en C, où chaque octet est un caractère) ; wc -c compte toujours des octets.
  • tr travaille octet par octet : il ne sait pas mettre é en majuscule, et ne doit jamais recevoir de caractères multioctets dans ses ensembles.
  • cut -c découpe, dans coreutils 9.4 (Ubuntu 24.04) et 9.7 (Debian 13), par octets, comme -b : le huitième « caractère » est le premier octet de é, et la ligne affichée se termine par un octet invalide. D'après le fichier NEWS de coreutils, cut ne gère les caractères multioctets que depuis la version 9.11, publiée en avril 2026. Avec cut -c, restez sur des positions de texte ASCII, comme les horodatages.

Pièges courants

uniq sans tri préalable. uniq ne fusionne que les doublons adjacents : sur une entrée non triée, il compte des séries, pas des valeurs, et ne signale rien. sort | uniq -c, ou bien une entrée dont l'ordre naturel regroupe déjà les valeurs (un journal chronologique découpé par minute), en le sachant.

Le tri alphabétique d'une colonne de nombres. sort sans -n range 10 avant 9. Après uniq -c, l'alignement des nombres masque souvent le problème tant qu'ils ont le même nombre de chiffres. sort -rn, ou une clé -k1,1nr.

La clé qui court jusqu'à la fin de la ligne. -k2 compare du champ 2 à la fin de la ligne. Écrivez -k2,2. Et sort --debug montre ce que chaque clé compare réellement.

L'égalité réordonnée. Sans -s, des lignes de clés égales sont départagées par la ligne entière : sort -k1,1 d'un journal peut réordonner des messages de la même microseconde, et sort -c -k1,1 refuser un fichier chronologique. -s pour garder l'ordre d'origine.

cut -d' ' sur des colonnes alignées. Deux espaces consécutives délimitent un champ vide. Sur une sortie de uniq -c, de ps ou de ls -l, le numéro du champ dépend de la largeur du nombre qui précède :

$ tail -q -n +2 exports/*.csv | cut -d, -f2 | sort | uniq -c | head -n 2 | cut -d' ' -f1,2 | cat -A
 $
 $
$ tail -q -n +2 exports/*.csv | cut -d, -f2 | sort | uniq -c | head -n 2 | tr -s ' ' | cut -d' ' -f2,3
73 depot-sauvage
60 graffiti

tr -s ' ' compresse les suites d'espaces en une seule ; il reste l'espace initiale, d'où les champs 2 et 3. Avec un nombre à trois chiffres, uniq -c aurait mis une espace de moins devant : sans tr -s, le numéro de champ changerait avec les données.

Le séparateur dans la valeur. cut -d,, sort -t, et join -t, ignorent les guillemets du CSV. Une ligne qui a plus de champs que l'en-tête trahit le problème ; on le traite avec un vrai lecteur CSV.

Rediriger vers le fichier qu'on lit. sort fichier > fichier laisse un fichier vide : le shell tronque fichier en préparant la redirection, avant même de lancer sort. La démonstration, sur une copie :

$ cp referentiel/communes.csv c.csv
$ sort -t, -k3,3n c.csv > c.csv
$ wc -c c.csv
0 c.csv
$ cp referentiel/communes.csv c.csv
$ sort -t, -k3,3n -o c.csv c.csv
$ head -n 2 c.csv
99102,"Saint-Exemple, centre",12034
code,nom,population

sort -o lit toute l'entrée avant d'ouvrir le fichier de sortie, ce qui rend sort -o f f sûr, d'après son manuel (qui conseille quand même d'écrire dans un autre fichier, en cas de panne pendant l'écriture). Notez au passage, dans le résultat, l'en-tête trié comme une donnée (population vaut zéro pour -n) et Saint-Exemple en tête pour la même raison : deux pièges de plus.

comm et join sur des listes triées dans une autre locale. Le résultat est faux, avec au mieux un avertissement. Une seule LC_ALL pour tout le traitement.

Le tube interrompu sous pipefail. commande | head peut renvoyer 141 et arrêter un script sous set -e, selon la taille des données. grep -m, head -n N fichier, ou un producteur qui s'arrête de lui-même.

tail -F | grep | ... qui n'affiche rien. Mise en tampon : grep --line-buffered, stdbuf -oL. Et aucun sort dans un tube qui ne se termine pas.

wc -l d'un fichier sans saut de ligne final. Une ligne de moins que prévu. tail -c 1 fichier | od -c montre le dernier octet.

Le \r invisible. Un fichier CRLF fait échouer les comparaisons et les dédoublonnages sur le dernier champ. cat -A pour le voir, tr -d '\r' pour l'enlever.

Sécurité

  • Les journaux contiennent des données choisies par des inconnus. Le chemin d'une requête, le nom d'utilisateur d'une tentative SSH, l'agent d'un navigateur sont écrits par le client, donc potentiellement par un attaquant. Afficher un journal brut dans un terminal, c'est laisser un tiers envoyer des séquences d'échappement à ce terminal : des suites d'octets qui commencent par le caractère ESC et que l'émulateur de terminal interprète comme des commandes (effacer l'écran, changer les couleurs, modifier le titre de la fenêtre, et, sur certains émulateurs anciens ou mal configurés, bien pire). Une ligne de journal fabriquée peut ainsi effacer l'écran et afficher « tout va bien » en vert. La documentation de Python indique que le serveur HTTP de sa bibliothèque ne nettoie les caractères de contrôle de ses journaux que depuis Python 3.12 ; d'autres programmes ne le font pas du tout. Pour regarder des données douteuses, rendez les octets de contrôle visibles :
$ cat -v hostile.log
2026-10-07T03:12:44.000000+00:00 sig-app-1 python3[812]: 172.16.8.20 - - [07/Oct/2026 03:12:44] "GET /^[[2J^[[1;32mtout-va-bien HTTP/1.1" 404 -
$ tr -cd '[:print:]\n' < hostile.log
2026-10-07T03:12:44.000000+00:00 sig-app-1 python3[812]: 172.16.8.20 - - [07/Oct/2026 03:12:44] "GET /[2J[1;32mtout-va-bien HTTP/1.1" 404 -

(hostile.log est une ligne fabriquée pour la démonstration : elle contient les séquences ESC [2J, qui efface l'écran, et ESC [1;32m, qui passe en vert gras.) cat -v affiche ESC sous la forme ^[ ; tr -cd '[:print:]\n' supprime (-d) tout ce qui n'est pas (-c, complément) imprimable ou saut de ligne. less affiche lui aussi les caractères de contrôle de façon visible par défaut ; son option -R, qui laisse passer les séquences de couleur, est à éviter sur des données que vous ne maîtrisez pas.

  • Ce que vous copiez hors du serveur. Les journaux de Signalements contiennent des adresses IP de clients (dans le journal JSON), qui sont des données personnelles au sens du RGPD. Une commande d'enquête qui écrit incident-503.log dans votre répertoire personnel, ou un tee vers /tmp, crée une copie qui échappe aux durées de conservation et aux droits d'accès des journaux d'origine. Travaillez sur le serveur, dans un répertoire protégé (umask 077), supprimez les fichiers intermédiaires à la fin, et anonymisez avant toute transmission (leçon 3). Les fichiers temporaires de sort posent le même problème, en plus discret.

  • Les noms de fichiers dans les tubes. Les commandes de cette leçon passent des contenus de fichiers dans les tubes, pas des noms. Dès qu'un tube transporte des noms de fichiers (find | xargs, ls | ...), les règles de la leçon 5 du cours Bash s'appliquent : octet nul comme séparateur (find -print0, sort -z, xargs -0).

  • L'épuisement des ressources. sort, tac et wc lisent tout. Sur une entrée hostile ou démesurée (un journal gonflé par une attaque), sort remplit /tmp, donc la mémoire sur Debian 13. Bornez ce qui peut l'être (head -n, fenêtre de temps avec grep avant le tri), et dirigez les fichiers temporaires vers un volume prévu pour (-T).

  • Les preuves. Les journaux d'un incident de sécurité, comme les tentatives SSH, peuvent devenir des preuves. Ne les modifiez jamais sur place : travaillez sur des copies, conservez l'original intact avec son empreinte (sha256sum), et notez les commandes utilisées dans la feuille d'enquête.

En production

  • Fixer la locale. Un script qui trie, compare ou compte commence par export LC_ALL=C.UTF-8. Une tâche planifiée hérite de la locale du système, un terminal de celle de l'utilisateur : sans réglage explicite, le même script donne des résultats différents selon qui le lance. On n'utilise une locale linguistique que pour la présentation finale, et explicitement (LC_ALL=fr_FR.UTF-8 sort sur la seule commande qui produit la liste destinée à la mairie).
  • set -o pipefail et ses conséquences. Indispensable pour qu'un échec au milieu d'un tube ne passe pas inaperçu, il oblige à traiter le cas des tubes interrompus volontairement (head). Testez les scripts avec des volumes réalistes : le 141 n'apparaît qu'au-delà du tampon de 64 Kio.
  • Ne pas tout lire quand on peut éviter. Sur un journal de 10 Go, cat journal | grep lance un processus pour rien ; grep motif journal lit directement. Réduisez le volume le plus tôt possible dans le tube (filtre par date, par programme), et gardez les opérations coûteuses (sort) pour la fin, sur ce qui reste. tac | grep -m 1 pour le dernier événement.
  • Les journaux tournés et compressés. En production, les fichiers d'hier sont renommés et compressés par la rotation des journaux (syslog.log.1, syslog.log.2.gz...). zcat, ou zgrep, lisent les fichiers compressés sans les décompresser sur le disque ; la leçon 2 y revient.
  • Le volume temporaire. Sur sig-outils, où /tmp est en mémoire, un tri de plusieurs gigaoctets prend -T /srv/donnees/tmp (un répertoire créé pour cela, propriété du compte de service, nettoyé par systemd-tmpfiles). Vérifiez l'espace avant (df -h).
  • Fusionner plutôt que retrier. Les journaux sont déjà chronologiques : sort -s -m -k1,1 fusionne les fichiers de plusieurs serveurs en une passe, sans fichier temporaire ni mémoire proportionnelle au volume.
  • Savoir quand passer la main. Les petits outils répondent vite à des questions simples. Dès qu'il faut traiter différemment plusieurs formes de lignes, calculer (taux, moyennes), joindre sur plusieurs clés ou lire du CSV avec guillemets, une chaîne de cinq filtres devient illisible et fragile : c'est le moment d'awk (leçon 5). Pour du JSON, jq (leçon 7). Et la feuille d'enquête n'est pas un outil de production : ce qui doit tourner chaque semaine deviendra un script, rapport-hebdo, à la leçon 8.

Exercices

Tous les exercices se font dans le bac à sable ~/essais-texte, avec export LC_ALL=C.UTF-8.

1. Prendre la température d'un serveur (niveau 100). Pour sig-app-1, affichez les trois heures de la journée (toutes dates confondues) qui ont reçu le plus de requêtes réelles de l'API, hors vérifications de santé, avec leur nombre, triées par nombre décroissant puis par heure croissante.

Solution
$ cd ~/essais-texte/journaux
$ grep -F 'python3[' sig-app-1.*/syslog.log | grep -vF 'GET /sante ' | cut -c12-13 | sort | uniq -c | sort -k1,1nr -k2,2 | head -n 3
    163 08
    163 16
    162 10
  • grep -F 'python3[' puis grep -vF 'GET /sante ' : filtrer d'abord, les lignes de l'API seulement, sans la supervision.
  • cut -c12-13 : les caractères 12 et 13 de l'horodatage, l'heure (2026-10-05T08...).
  • Cette fois, le sort avant uniq -c est indispensable : les heures de quatre jours différents ne sont pas consécutives.
  • -k1,1nr -k2,2 : nombre décroissant, puis heure croissante pour départager 08 et 16.

Le trafic est très régulier entre 8 h et 19 h : les données d'essai sont synthétiques. Sur de vraies données, cette commande révèle les pics d'usage, utiles pour choisir une fenêtre de maintenance.

2. Les comptes visés (niveau 100). Quels noms de compte les tentatives SSH ont-elles essayés, et combien de fois chacun ? Les lignes utiles sont de la forme sshd[53753]: Invalid user ubuntu from 203.0.113.77 port 54603.

Solution
$ grep -hF 'Invalid user' */syslog.log | cut -d' ' -f6 | sort | uniq -c | sort -k1,1nr -k2,2
     34 oracle
     31 admin
     31 ubuntu
     25 test

Le nom du compte est le sixième champ (horodatage, hôte, sshd[...]:, Invalid, user, nom). Les tentatives sur root n'apparaissent pas ici : elles produisent une ligne Connection closed by authenticating user root, sans Invalid user, puisque le compte existe. Le choix des comptes (oracle, admin, ubuntu, test) est typique des robots qui essaient des comptes par défaut. Sur des serveurs où l'authentification par mot de passe est désactivée, ces tentatives ne peuvent pas aboutir ; elles signalent quand même un port exposé sans raison.

3. Prévoir sans exécuter (niveau 100). Le fichier n.txt contient, dans cet ordre, les lignes b 2, a 10, b 2, a 9. Prévoyez la sortie de chaque commande, puis vérifiez. (a) uniq -c n.txt ; (b) sort n.txt | uniq -c ; (c) sort -k2 n.txt ; (d) sort -k2,2n n.txt ; (e) sort -u -k1,1 n.txt ; (f) cut -d' ' -f2 n.txt | sort | paste -sd+.

Solution

(a) Quatre lignes, chacune comptée une fois : les deux b 2 ne sont pas adjacentes. (b) 1 a 10, 1 a 9, 2 b 2 (alignés à droite sur sept caractères) : le tri rend les doublons adjacents. a 10 précède a 9 dans l'ordre alphabétique. (c) La clé va du champ 2 à la fin de la ligne et se compare comme du texte, espace initiale comprise : a 10, b 2, b 2, a 9. (d) Comparaison numérique du seul champ 2 : b 2, b 2, a 9, a 10. (e) Une ligne par valeur de la clé : a 10 et b 2. Avec -u, sort ne garde que la première des lignes de clés égales, d'après son manuel, et désactive la comparaison de dernier recours ; a 9 disparaît bien qu'elle soit différente de a 10. C'est utile (une ligne par commune) et piégeux (ce n'est pas sort | uniq). (f) 10+2+2+9 : paste -s joint les lignes avec le séparateur +. Avec | bc, si le paquet bc est installé, on obtient la somme. awk fera ce calcul sans outil supplémentaire.

4. Les signalements par jour, proprement (niveau 200). Produisez le nombre de signalements par jour sur la semaine, en une colonne date et une colonne nombre séparées par une tabulation, en lisant le champ date des exports (pas leur nom de fichier). Expliquez pourquoi cut -d, -f4 ne convient pas, et donnez deux façons de faire sans awk.

Solution

cut -d, -f4 donne centre" pour les 52 lignes de Saint-Exemple, centre, dont le nom entre guillemets contient une virgule. La date étant le dernier champ, on peut la prendre à partir de la fin :

$ cd ~/essais-texte
$ tail -q -n +2 exports/signalements-*.csv | rev | cut -d, -f1 | rev | uniq -c
     96 2026-10-05
    101 2026-10-06
     86 2026-10-07
     68 2026-10-08

ou l'extraire par un motif ancré en fin de ligne : grep -o '[^,]*$' (tout ce qui suit la dernière virgule). Pour obtenir les deux colonnes dans l'ordre voulu, séparées par une tabulation, on supprime les blancs de tête et l'on remplace l'espace restante :

$ tail -q -n +2 exports/signalements-*.csv | grep -o '[^,]*$' | uniq -c | tr -s ' ' | cut -d' ' -f2,3 | tr ' ' '\t'
96	2026-10-05
101	2026-10-06
86	2026-10-07
68	2026-10-08

Cette dernière forme donne nombre<TAB>date, et non date<TAB>nombre : cut n'inverse pas l'ordre des champs, il les écrit toujours dans l'ordre du fichier. Pour inverser, il faut deux cut et un paste (paste <(... | cut -f2) <(... | cut -f1)), ce qui devient lourd. C'est exactement le genre de travail où une ligne d'awk ({ print $2 "\t" $1 }) est plus claire ; la leçon 5 le fera. Enfin, uniq -c sans sort suppose les exports dans l'ordre des dates : c'est vrai ici parce que le motif signalements-*.csv est développé dans l'ordre alphabétique, qui est l'ordre chronologique pour des dates ISO.

5. Le script qui échoue en silence (niveau 200). Ce script doit afficher la première erreur 503 trouvée dans un répertoire de journaux. Lancé sur le bac à sable, il n'affiche le plus souvent rien et se termine avec le code 141 ; de temps en temps, il fonctionne. Expliquez, puis corrigez sans retirer pipefail.

#!/usr/bin/env bash
set -euo pipefail
racine=${1:?répertoire de journaux attendu}
premiere=$(grep -h '" 503 -$' "$racine"/*/syslog.log | head -n 1)
printf 'première erreur 503 : %s\n' "${premiere:0:32}"
Solution

head -n 1 lit la première ligne puis se termine. grep, qui a encore des lignes à écrire (101 lignes, plus de 14 Ko, écrites en plusieurs blocs), reçoit SIGPIPE à l'écriture suivante et meurt avec le code 141. Avec pipefail, le code du tube est 141 ; avec set -e, l'affectation qui contient cette substitution de commande arrête le script, avant le printf. Aucun message, puisqu'un SIGPIPE n'en produit pas. Le résultat dépend d'une course entre les deux processus : si grep a tout écrit avant que head ne ferme le tube, il n'y a pas de signal et le script réussit. Sur notre poste, environ une exécution sur quatre réussissait. C'est ce qui rend ce défaut si difficile à reproduire.

Correction : faire s'arrêter grep de lui-même.

premiere=$(grep -h -m 1 '" 503 -$' "$racine"/*/syslog.log | head -n 1)
$ ./premiere-erreur journaux; echo "code : $?"
première erreur 503 : 2026-10-07T14:02:16.408158+00:00
code : 0

-m 1 s'applique à chaque fichier : grep écrit au plus une ligne par fichier, quelques centaines d'octets qui tiennent dans le tampon du tube, et se termine normalement ; head -n 1 garde la première. Deux remarques. D'abord, « la première » est ici la première du premier fichier contenant une 503, pas la plus ancienne de toutes : pour la plus ancienne, il faudrait fusionner (sort -s -m -k1,1) avant, ce qui ramène le problème du tube interrompu. Ensuite, s'il n'y a aucune 503, grep renvoie 1, et set -e arrête encore le script en silence : la leçon 2 traite ce cas, où « pas trouvé » n'est pas une erreur.

6. Les clients qui frappent à la mauvaise porte (niveau 200). Le pare-feu des serveurs a bloqué des paquets (lignes kernel: [UFW BLOCK] ... SRC=adresse ... DPT=port). Trouvez les adresses sources bloquées qui sont aussi des clients de l'API (champ "ip":"..." du journal JSON), et les ports qu'elles visaient. Utilisez comm. Que concluez-vous ?

Solution
$ cd ~/essais-texte/journaux
$ grep -h 'UFW BLOCK' */syslog.log | grep -o 'SRC=[^ ]*' | cut -d= -f2 | sort -u > ../ufw.txt
$ grep -oh '"ip":"[^"]*"' */api.jsonl | cut -d'"' -f4 | sort -u > ../clients.txt
$ comm -12 ../ufw.txt ../clients.txt
192.0.2.1
192.0.2.2
192.0.2.3
192.0.2.4
192.0.2.5
$ grep -h 'UFW BLOCK' */syslog.log | grep -o 'DPT=[0-9]*' | sort | uniq -c
      6 DPT=22
     11 DPT=3306
     16 DPT=5432
     15 DPT=6379

comm -12 ne garde que la colonne des lignes communes. Les cinq adresses bloquées utilisent aussi l'API, et elles ont tenté de joindre directement PostgreSQL (5432), MySQL (3306), Redis (6379) et SSH (22) sur l'adresse publique des serveurs. Deux lectures possibles, à départager avec plus d'informations : un client légitime mal configuré, ou quelqu'un qui utilise l'API et sonde en même temps les services voisins. Dans les deux cas, le pare-feu de l'hôte a fait son travail, et la défense en profondeur justifie de le garder même derrière un groupe de sécurité.

Extraire "ip":"..." avec grep -o fonctionne sur ce fichier, parce que l'application l'écrit sur une ligne, sans espace, avec toujours la même clé. Ce n'est pas une façon fiable de lire du JSON (un autre objet pourrait contenir une clé ip ailleurs, l'ordre ou l'espacement pourraient changer) : la leçon 7 refera cette extraction avec jq.

Récapitulatif

  • Un filtre lit des lignes, en écrit d'autres sans décoration, et signale son résultat par son code de sortie. On répond à une question en composant des filtres, construits de gauche à droite en vérifiant chaque étape : filtrer les lignes d'abord, découper les champs ensuite.
  • Une ligne se termine par \n ; méfiez-vous de la dernière ligne sans saut de ligne et du \r des fichiers CRLF (cat -A, tr -d '\r'). Un champ est délimité soit par un caractère strict (cut -d, sort -t), soit par une suite de blancs (sort et join par défaut, awk) ; aucun de ces outils ne comprend les guillemets du CSV.
  • La locale change l'ordre de tri, les classes de caractères, le séparateur décimal et la vitesse. export LC_ALL=C.UTF-8 dans tout script qui trie ou compare, et la même locale pour sort puis comm ou join.
  • sort : clés -kDÉBUT,FIN avec leurs options (-k1,1nr -k2,2), -t pour un séparateur, -n, -h, -V, -u, -s pour garder l'ordre des égalités, -c pour vérifier, -m pour fusionner des fichiers triés, -o pour écrire dans le fichier lu, --debug pour comprendre.
  • uniq, comm et join exigent des doublons adjacents ou des entrées triées ; seuls comm et join vérifient.
  • cut -c, tr et wc -c travaillent par octets dans coreutils 9.4 et 9.7 ; wc -m compte les caractères.
  • Les commandes d'un tube tournent en parallèle, reliées par des tampons de 64 Kio ; un filtre qui écrit dans un tube retient sa sortie (--line-buffered, stdbuf -oL) ; un lecteur qui s'arrête tue l'écrivain par SIGPIPE, code 141 sous pipefail.
  • sort trie les gros volumes par fichiers temporaires dans $TMPDIR ou /tmp, qui est en mémoire sur Debian 13 : -T, -S.
  • Les journaux contiennent des données hostiles (séquences d'échappement : cat -v) et personnelles (copies, fichiers temporaires) : on les traite comme telles.
  • Premier bilan de l'enquête : 101 erreurs 503 sur sig-app-2 le 7 octobre de 14:02 à 14:19 UTC, un trafic quadruplé pendant ce temps, une vérification de santé restée à 200, un redémarrage à 14:20, et, au passage, un port SSH exposé à internet.

Pour aller plus loin

  • Le manuel de GNU coreutils, chapitres Operating on sorted files (sort, uniq, comm, join) et Operating on fields (cut, paste) : chaque option y est décrite avec ses cas limites, et la section sur sort explique en détail les clés et la comparaison de dernier recours.
  • La norme POSIX pour sort, uniq, comm et join, pour savoir ce qui est portable et ce qui est une extension GNU (-h, -V, --debug).
  • Les pages de manuel pipe(7) et signal(7) pour le fonctionnement des tubes et de SIGPIPE.
  • The UNIX Programming Environment, de Brian Kernighan et Rob Pike (1984), dont le chapitre sur les filtres a formé des générations d'administrateurs : les outils ont évolué, la méthode est restée.
  • La leçon suivante, grep et les expressions régulières, apprend à sélectionner exactement les lignes voulues : les tentatives SSH, les sondes de vulnérabilités et la fenêtre de l'incident, sans rien de plus.
+10 XP Carte du ciel →Mon cosmonaute →

Sources