Le Dockerfile disait fr_FR.UTF-8. PHP voyait C.UTF-8.

Le Dockerfile dĂ©crit ce que le container devrait avoir. Il ne prouve pas ce que l’application utilise rĂ©ellement.

Un bug de locale a ceci de trompeur qu’on croit toujours connaĂźtre la configuration du serveur (aprĂšs tout, c’est nous qui l’avons Ă©crite, dans un Dockerfile versionnĂ©). Le piĂšge n’est pas un manque de visibilitĂ© sur l’environnement. Il est dans l’écart entre ce qu’un container dĂ©clare et ce qu’un processus applicatif consomme rĂ©ellement de cette dĂ©claration.

Ce billet raconte comment cet Ă©cart a Ă©tĂ© mis en Ă©vidence, puis comment un correctif a Ă©tĂ© vĂ©rifiĂ© directement dans une tĂąche ECS Fargate qui tourne en recette, via ECS Exec, sans dĂ©ploiement dĂ©diĂ© ni environnement de staging sĂ©parĂ©. La mĂ©canique d’ECS Exec elle-mĂȘme (retrouver sa tĂąche, prĂ©requis IAM, quoting) est dĂ©taillĂ©e Ă  part dans un article dĂ©diĂ©. Ici, l’accent est mis sur ce que le diagnostic a rĂ©vĂ©lĂ©.

Le bug

Une fonction de normalisation de chaßne, utilisée pour dédupliquer des entrées avant un flush() Doctrine, retirait les accents avec la méthode classique :

iconv('UTF-8', 'ASCII//TRANSLIT', $string);

//TRANSLIT fait partie des extensions de comportement proposĂ©es par les implĂ©mentations d’iconv (une extension GNU, absente de la norme POSIX). Sous Linux, le rĂ©sultat dĂ©pend notamment de l’implĂ©mentation utilisĂ©e et de la locale LC_CTYPE du process qui l’exĂ©cute : un rapport de bug glibc documente prĂ©cisĂ©ment ce cas, le mĂȘme appel iconv -f utf-8 -t ascii//TRANSLIT rĂ©ussissant sous en_US.utf8 et Ă©chouant sous C.UTF-8. Sous une locale C stricte, la translittĂ©ration Ă©choue silencieusement : "MatĂ©riel" devient "Mat?riel" au lieu de "Materiel".

ConsĂ©quence concrĂšte : la clĂ© de dĂ©duplication calculĂ©e cĂŽtĂ© PHP Ă©tait censĂ©e correspondre Ă  la collation ci de MySQL, insensible Ă  la casse et aux accents. Sous locale C, ça ne marchait pas. Un doublon Ă©chappait donc Ă  la dĂ©duplication applicative et finissait par percuter une contrainte d’unicitĂ© en base au moment du flush() (un crash en aval d’un problĂšme de normalisation en amont).

Le correctif retenu remplace iconv par le composant String de Symfony :

use function Symfony\Component\String\u;

u($string)->ascii()->toString();

u()->ascii() effectue la translittĂ©ration au niveau du composant Symfony, sans dĂ©pendre de la locale LC_CTYPE de PHP pour ce traitement. Le composant expose aussi un AsciiSlugger dĂ©diĂ© Ă  la gĂ©nĂ©ration de slugs (URLs, identifiants) ; ici, le besoin est une clĂ© de dĂ©duplication, pas un slug, donc u()->ascii() seul suffit, sans les rĂšgles de casse et de sĂ©parateurs qu’ajouterait le slugger. Les rĂšgles de translittĂ©ration ne sont cela dit pas garanties identiques Ă  celles d’iconv //TRANSLIT : c’est une bibliothĂšque diffĂ©rente, avec ses propres tables Unicode.

« Il suffit de lire le Dockerfile, non ? »

Avant de toucher Ă  AWS, la question s’est posĂ©e lĂ©gitimement : le repo a un pipeline CI/CD versionnĂ© (.github/workflows/ci.yml, .docker/Dockerfile, une task definition ECS .aws/pro.json). Est-ce qu’on ne peut pas simplement lire ces fichiers pour connaĂźtre la configuration du serveur, sans exĂ©cuter quoi que ce soit dessus ?

On peut effectivement connaĂźtre la configuration OS/image de cette façon, mais ça ne dit pas ce que PHP voit Ă  l’exĂ©cution.

Ce que l’analyse statique rĂ©vĂšle, sans le moindre accĂšs AWS :

  • .github/workflows/ci.yml : le dĂ©clencheur on: push: branches: ["main"] dĂ©ploie sur le service ECS pro (prod), la branche develop dĂ©ploie sur pro-rct (recette). MĂȘme Dockerfile, mĂȘme image : seul le service ECS cible change.

  • .docker/Dockerfile fixe une locale française complĂšte dans une instruction ENV :

    ENV LANG="fr_FR.UTF-8" \
        LANGUAGE="fr_FR:fr" \
        LC_ALL="fr_FR.UTF-8" \
        ...
    RUN ... && echo "fr_FR.UTF-8 UTF-8" > /etc/locale.gen && locale-gen fr_FR.UTF-8 ...
    

    et installe l’extension ext-intl.

  • La task definition ECS ne contient aucun override de LANG/LC_ALL/LC_CTYPE pour le container php. Rien ne vient donc contredire, au niveau ECS, ce que fixe le Dockerfile.

L’environnement OS du container est donc configurĂ© en fr_FR.UTF-8, avec ext-intl disponible.

Un premier test en direct dans le container (mĂ©thode dĂ©taillĂ©e dans l’article sur ECS Exec) a mesurĂ©, depuis un script PHP, la locale LC_CTYPE courante du process. ComparĂ©e aux variables d’environnement Ă  chaque Ă©tage, on voit le problĂšme :

Dockerfile           LC_ALL=fr_FR.UTF-8
        │
        ▌
PID 1 (entrypoint)    LC_ALL=fr_FR.UTF-8
        │
        ▌
Session ECS Exec      LC_ALL=fr_FR.UTF-8
        │
        ▌
PHP (LC_CTYPE)        C.UTF-8   ← rupture

La variable d’environnement est correctement propagĂ©e Ă  chaque Ă©tage (Dockerfile, process d’entrĂ©e du container, session ECS Exec elle-mĂȘme) jusqu’à PHP, qui n’en tient pas compte.

Ce n’est pas une anomalie de PHP, c’est le comportement documentĂ© de la bibliothĂšque C sous-jacente que PHP enveloppe : un programme dĂ©marre par dĂ©faut dans la locale POSIX C et ne synchronise pas automatiquement ses catĂ©gories de locale avec les variables d’environnement. setlocale(LC_ALL, '') demande explicitement Ă  PHP de les lire (la chaĂźne vide signifiant « prends la valeur dans l’environnement »).

La valeur observĂ©e ici, C.UTF-8, n’est d’ailleurs pas la locale C nue : c’est une variante UTF-8 de la locale POSIX, distincte Ă  la fois de C, de LANG/LC_ALL=fr_FR.UTF-8 dĂ©clarĂ©e dans le Dockerfile, et de la locale ICU utilisĂ©e sĂ©parĂ©ment par ext-intl — non concernĂ©e ici, puisque iconv() ne passe pas par ICU.

Un grep -r setlocale sur le code applicatif n’a trouvĂ© que deux appels, tous les deux scopĂ©s Ă  LC_TIME (formatage de dates), jamais LC_CTYPE, jamais de setlocale(LC_ALL, '') global.

L’analyse statique (CI/CD, Dockerfile, task definition) est donc la premiĂšre Ă©tape — mais elle ne dit rien du comportement rĂ©el du processus applicatif. Ça, seule l’introspection depuis l’intĂ©rieur du container le montre.

Vérifier le correctif dans le container réel

Le script de vĂ©rification chargeait l’autoloader Composer de l’application (require '/var/www/vendor/autoload.php';) puis appelait la mĂ©thode privĂ©e Ă  tester via ReflectionMethod, exactement comme le ferait un test unitaire PHPUnit (la mĂȘme technique, exĂ©cutĂ©e sur le dĂ©ploiement rĂ©el plutĂŽt que dans la suite de tests).

aws ecs execute-command \
  --cluster <nom-du-cluster> --task <task-id> --container php --region <REGION> \
  --interactive \
  --command "/bin/sh -lc 'echo $B64 | base64 -d | php'"

Le script transite en base64 plutĂŽt qu’en argument brut. La commande traverse trois couches de shell successives (le shell local, la chaĂźne --command, puis /bin/sh -c dans le container), et un script PHP multi-lignes avec guillemets et accents finit toujours par casser les quotes imbriquĂ©es. Un $(... || ...) avait ainsi Ă©chouĂ© avec Syntax error: "(" unexpected, non pas Ă  cause d’une erreur de logique shell mais de l’empilement des couches de quoting. Le dĂ©tour par base64 supprime le problĂšme entiĂšrement.

Résultat : le correctif tient

normalizeString("Matériel") under current locale: materiel
normalizeString("Matériel") forced under LC_CTYPE=C: materiel
raw iconv("Matériel") under LC_CTYPE=C (old, pre-fix behaviour): 'Mat?riel'

u()->ascii() normalise correctement, avec ou sans locale forcĂ©e. Le vieux comportement Ă  base d’iconv brut, lui, reproduit bien le bug sous LC_CTYPE=C (la preuve que le problĂšme Ă©tait rĂ©el, pas seulement thĂ©orique, sur ce mĂȘme environnement).

Et la locale par dĂ©faut rĂ©ellement vue par PHP (C.UTF-8), dĂ©couplĂ©e de la variable dĂ©clarĂ©e dans le Dockerfile (fr_FR.UTF-8), n’est pas un cas isolĂ© : le jour oĂč un process (cron, worker, ou ce mĂȘme container aprĂšs un changement d’image de base) tourne sous une locale encore plus stricte, le code ne dĂ©pend plus de la locale du processus.

Verrouiller le correctif : un test de non-régression

Le diagnostic dans le container confirme le correctif Ă  un instant donnĂ©, sur ce dĂ©ploiement prĂ©cis. Il ne protĂšge pas d’une rĂ©gression future, par exemple un retour accidentel Ă  iconv, ou un nouveau traitement de chaĂźne qui rĂ©introduirait la mĂȘme dĂ©pendance implicite Ă  LC_CTYPE.

L’absence de harnais fonctionnel dans le repo (uniquement des TestCase PHPUnit purs, sans kernel HTTP) n’est pas un obstacle ici : forcer la locale ne nĂ©cessite ni base de donnĂ©es ni container, seulement setlocale(), exactement comme lors du diagnostic sur la tĂąche rĂ©elle.

final class StringNormalizerTest extends TestCase
{
    public function testAsciiNormalizationIsLocaleIndependent(): void
    {
        $original = setlocale(LC_CTYPE, '0');

        try {
            setlocale(LC_CTYPE, 'C');

            self::assertSame(
                'Materiel',
                StringNormalizer::normalize('Matériel')
            );
        } finally {
            setlocale(LC_CTYPE, $original);
        }
    }
}

Le finally restaure la locale d’origine aprĂšs le test, pour ne pas polluer le reste de la suite. Ce test aurait dĂ©tectĂ© le bug avant sa mise en production — il reproduit exactement la condition qui provoquait le bug : LC_CTYPE=C.

Pourquoi c’est sĂ»r

Faire tourner une commande dans un container de recette ou de prod fait peur, Ă  raison. Ici, le script tournait en CLI, dans un process isolĂ© du pool de workers qui sert le trafic rĂ©el (Apache/mod_php) : aucune requĂȘte en cours n’était affectĂ©e. Il ne touchait ni au filesystem, ni Ă  la base de donnĂ©es, ni Ă  quoi que ce soit de partagĂ© — setlocale() appelĂ© dans ce process ponctuel n’a d’effet que sur ce process. Seule rĂ©serve : ECS Exec exĂ©cute avec les privilĂšges root, ce qui justifie de garder la technique au diagnostic ponctuel en lecture seule, pas Ă  un usage rĂ©gulier.

Cette conclusion vaut seulement si ECS Exec Ă©tait dĂ©jĂ  actif sur la tĂąche. L’activer, si ce n’est pas le cas, force un redĂ©ploiement complet du service — une Ă©tape Ă  part, Ă  traiter avec prudence, pas dans l’urgence d’un diagnostic.

Conclusion

Le sujet de fond n’était pas ECS Exec, mais l’écart entre une configuration dĂ©clarĂ©e au niveau du conteneur et l’état rĂ©ellement consommĂ© par l’application. Ici, une variable d’environnement de locale, prĂ©sente et correcte Ă  chaque Ă©tage, mais ignorĂ©e par PHP faute d’un setlocale(LC_ALL, '') dans le code.

L’analyse statique du pipeline reste la premiĂšre Ă©tape. Elle donne la configuration dĂ©clarĂ©e, pas le comportement observĂ©. Entre les deux, il n’y a que l’introspection depuis l’intĂ©rieur du runtime pour trancher — ici une locale PHP, ailleurs un fuseau horaire, un encodage par dĂ©faut, une variable que l’application ne lit tout simplement jamais.

Ce diagnostic valide ce dĂ©ploiement, ce process CLI, Ă  cet instant prĂ©cis. Pas les workers en cours, pas les autres tĂąches, pas les prochains dĂ©ploiements — juste la preuve que, sur cet environnement-lĂ , le correctif tient.