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Ă©clencheuron: push: branches: ["main"]dĂ©ploie sur le service ECSpro(prod), la branchedevelopdĂ©ploie surpro-rct(recette). MĂȘme Dockerfile, mĂȘme image : seul le service ECS cible change. -
.docker/Dockerfilefixe une locale française complĂšte dans une instructionENV: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_CTYPEpour le containerphp. 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âancieniconv, le test forceLC_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.