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

Also available in English.

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 problĂšme est de confondre ce que le container dĂ©clare avec ce que le processus applicatif utilise rĂ©ellement.

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ßnes, 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".

Donc 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.

On aurait aussi pu initialiser la locale du processus avec setlocale(LC_ALL, ''). Ce n’est pas le choix retenu ici : le correctif supprime la dĂ©pendance Ă  LC_CTYPE plutĂŽt que de la configurer ailleurs.

« Il suffit de lire le Dockerfile, non ? »

Avant de toucher Ă  AWS : 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 ?

D’aprùs l’analyse statique :

  • .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. Mais PHP n’a pas initialisĂ© sa locale LC_CTYPE Ă  partir de cette variable.

PHP dĂ©marre avec les catĂ©gories de locale dans leur Ă©tat initial. Les variables LANG, LC_ALL, etc. prĂ©sentes dans l’environnement ne signifient donc pas, Ă  elles seules, que LC_CTYPE du processus PHP a Ă©tĂ© configurĂ© avec ces valeurs. Un appel Ă  setlocale(LC_ALL, '') demande explicitement au processus de synchroniser sa locale avec 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. Elle reste distincte de la locale ICU utilisĂ©e par ext-intl, qui rĂ©sout sa propre locale par dĂ©faut indĂ©pendamment de LC_CTYPE. iconv(), lui, dĂ©pend directement de la locale du processus fixĂ©e par la libc.

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. C’est donc bien une absence d’initialisation de LC_CTYPE, pas un override cachĂ© dans l’application.

L’analyse statique permet donc de vĂ©rifier la configuration dĂ©clarĂ©e. Mais elle ne dit pas encore ce que PHP utilise rĂ©ellement.

Pour ça, il faut regarder dans le container.
Un container local aurait permis de reproduire le comportement, Ă  condition de reconstruire exactement la mĂȘme image et le mĂȘme environnement. Mais le bug concernait la recette : je voulais d’abord vĂ©rifier ce que faisait rĂ©ellement le PHP dĂ©jĂ  dĂ©ployĂ©.

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'

NOTE

Le problĂšme observĂ© en recette est C.UTF-8. Pour reproduire le comportement dĂ©faillant de l’ancien iconv, le test force LC_CTYPE=C, qui constitue le cas le plus strict et permet de reproduire le dĂ©faut documentĂ©.

On voit que le vieux comportement Ă  base d’iconv brut reproduit bien le bug sous LC_CTYPE=C et que u()->ascii() normalise correctement, avec ou sans locale forcĂ©e.

Le code ne dépend plus de LC_CTYPE. Un changement de locale du process ne réintroduira pas ce bug.

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

Le test dans le container permet de vérifier le correctif sur le déploiement réel. Il ne protÚge pas contre une régression future.

Le projet n’a pas de tests fonctionnels avec kernel HTTP. Ce n’est pas un problĂšme ici : forcer la locale ne nĂ©cessite ni base de donnĂ©es ni container, seulement setlocale().

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 car il reproduit 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 demande quelques précautions.

Dans ce cas précis :

  • le diagnostic Ă©tait en lecture seule ;
  • 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. Ce qui peut avoir des consĂ©quences que ne justifie pas ce diagnostic.

Conclusion

Le sujet de fond n’était donc pas ECS Exec, mais l’écart entre une configuration dĂ©clarĂ©e et ce que l’application utilise rĂ©ellement.

Ici, la variable de locale Ă©tait bien prĂ©sente dans le container. PHP ne l’utilisait simplement pas pour LC_CTYPE.

Quelques commandes dans le container ont permis de le constater. Le test de non-rĂ©gression permet maintenant de s’assurer que le code n’en dĂ©pend plus.

La configuration dit ce qu’on veut. Le runtime dit ce qui se passe.