— Artikel — № 128

128 —Migration

Symfony 6-intranetmigratie: elf weekenden, nul downtime

Elf weekenden, één strangler-proxy en een urenformulier dat nooit uitviel. Hoe een intern team een PHP-intranet uit 2011 op Symfony 6 zette.

Bureau van bovenaf met inkttekening, manilamap, wandkalender, gestempeld urenbriefje, messing plaat, vulpen en rode lakzegel.
Hero · gestileerd stilleven№ 128

Het intranet werd in 2011 gebouwd. Eén index.php in de root, een config.inc.php met hardgecodeerde DB-credentials en een map genaamd /lib/ waarvan iedereen te horen had gekregen er nooit aan te komen. De opdracht, elf weekenden voordat we begonnen, was helder: zet het over op Symfony 6, doe het in eigen tijd, en het urenformulier moet blijven werken omdat de buitendienstmonteurs het op zondagavond invullen.

Het Nederlandse ops-team dat ermee werkte (22 mensen, voornamelijk planners en buitendienstmonteurs) gebruikte het systeem voor drie dingen: het urenformulier, een interne adreslijst en een projectoverzicht dat inmiddels uit zo'n 9.400 regels bestond. Niets daarvan zou in één lang weekend herschreven worden.

De lead engineer stuurde ons in februari een bericht. PHP 8.2 EOL kwam in zicht, de oorspronkelijke ontwikkelaar was in 2018 met pensioen gegaan, en een recente security audit had 41 plekken aangewezen waar niet-geëscapete $_GET-waarden direct in SQL werden geïnterpoleerd. Elf weekenden later was de overstap naar Symfony 6 voltooid. Dit is wat we deden, en wat we niet opnieuw zouden doen.

De vorm van de codebase uit 2011

Voordat we iets gingen plannen, brachten we de eerste zaterdagochtend door met lezen. Het intranet bestond uit 38.000 regels PHP, verdeeld over 217 bestanden. Er was geen router; index.php verdeelde requests op basis van $_GET['page'] via een switch-statement van 600 regels. Authenticatie was een sessievariabele $_SESSION['logged_in'] die op de string 'yes' werd gezet. De MySQL-driver was mysql_query(), sinds PHP 7.0 uit de taal verwijderd.

De database was gezonder dan de code. 31 tabellen, grotendeels in derde normaalvorm, en de urentabel (tijdregistratie) had een composite index op (medewerker_id, week_nummer, jaar) waar iemand duidelijk over had nagedacht. Dat was het haakje: bouw de applicatielaag opnieuw, laat het schema voorlopig met rust.

Strangler, geen rewrite

Een rewrite vanaf nul was duidelijk de verkeerde keuze. Het bedrijf had het tussen 2017 en 2021 al twee keer geprobeerd en beide pogingen gestaakt. De tweede sneuvelde toen niemand het eens kon worden of medewerker_id nou naar een Doctrine-entity Employee of Staff moest mappen. Dat gingen we niet overdoen.

In plaats daarvan gebruikten we het strangler fig-patroon: een dunne reverse proxy voor de oude app, met nieuwe Symfony 6-routes ernaast gemount. Elke request komt eerst bij de proxy. Bestaat de route in Symfony, dan handelt Symfony hem af. Anders valt hij door naar de legacy site. In elf weekenden verplaatsten we routes één voor één van legacy naar Symfony, en de proxy schoof rustig met ze mee.

De Apache-config die dit regelde was bijna gênant klein:

RewriteEngine On

# Nieuwe Symfony-routes winnen
RewriteCond %{REQUEST_URI} ^/(api|directory|admin|reports)
RewriteRule ^(.*)$ /symfony/public/index.php [L]

# De rest valt door naar legacy
RewriteRule ^(.*)$ /legacy/index.php [L]

Meer was het niet. Geen Nginx, geen Traefik, geen service mesh. Apache 2.4 met mod_rewrite, dezelfde setup waar het intranet sinds 2014 al op draaide.

Weekend één: de proxy en de canary

In het eerste weekend migreerden we geen echte functionaliteit. De hele zaterdag en zondag gingen op aan het opzetten van de proxy en het aantonen dat de fall-through onder belasting werkte. De canary-route was bewust onnozel: een Symfony-controller op /api/healthcheck die een JSON-object teruggaf met daarin het legacy session ID. Dat ene endpoint dwong ons drie dingen tegelijk op te lossen.

  • Symfony die de legacy PHP-sessie kon inlezen, wat betekende dat session.save_path en session.name in php.ini gelijk moesten zijn.
  • De routeringsbeslissing van de proxy moest snel genoeg zijn dat niemand het merkte.
  • Doctrine die verbond met dezelfde MySQL-instantie via een read-only user, zodat we nog niet per ongeluk via de nieuwe code konden schrijven.

Op zondagavond stond er een Symfony 6.4-app naast het legacy intranet, met gedeelde sessies, gedeelde database en precies één endpoint actief. De buitendienstmonteurs vulden hun zondagse uren in en merkten niets. Dat was het hele idee.

Het plan van elf weekenden

De volgorde van de migratie werd bepaald door twee dingen: hoe onafhankelijk elke module was, en hoeveel pijn het zou doen als we hem stuk maakten.

  1. Weekend 1. Strangler-proxy en healthcheck-canary.
  2. Weekend 2. Interne adreslijst (read-only, geen wijzigingen aan auth). De makkelijkste route in de codebase. We bouwden hem twee keer om het patroon te bewijzen.
  3. Weekend 3. Authenticatie. We vervingen de $_SESSION['logged_in'] = 'yes'-check door een Symfony-firewall die dezelfde cookie las. Beide apps vertrouwden voortaan op dezelfde login.
  4. Weekend 4. De lijstweergave van het projectoverzicht. Eerste route die vanuit Symfony naar de database schreef. Door de week hadden we de constraints van de read-only DB-user aangescherpt.
  5. Weekenden 5 en 6. Detail- en bewerkpagina's van het projectoverzicht. De eerste pagina's waar Symfony-forms de oude vervingen.
  6. Weekend 7. Rapportages. Puur lezen, maar zware SQL. Alles verhuisd naar Doctrine DBAL met prepared statements, audit findings op dit oppervlak weggewerkt.
  7. Weekenden 8 en 9. Admin-pagina's. Gevoelig, omdat ze over meerdere tabellen heen schreven. We hebben hier een write-through audit trail toegevoegd die de oude code nooit had.
  8. Weekend 10. Het urenformulier. De ene route die niemand offline mocht zien. Hieronder uitgelegd.
  9. Weekend 11. Uitfaseren van de legacy /legacy/index.php-fall-through, broncode archiveren, de laatste security-pass draaien.

Het urenformulier omgooien

Het urenformulier was de route waar het hele project om draaide. Buitendienstmonteurs vulden op zondag tussen 18:00 en 22:00 zo'n 180 urenstaten in. Doordeweeks hadden we tussen 03:00 en 07:00 een venster van vier uur zonder verkeer, maar geen ervan konden we gebruiken voor een harde cutover, omdat het formulier af en toe te laat werd ingevuld en de monteurs chagrijnig werden als hun invoer kwijtraakte.

Dus deden we shadow-routing. In de twee weken voor weekend 10 ging elke POST naar /timesheet/save gewoon naar de legacy handler, maar een kopie van de request body werd ook doorgestuurd naar een nieuw Symfony-endpoint op /api/timesheet/save-shadow. Het nieuwe endpoint valideerde, draaide dezelfde businesslogica en schreef naar een parallelle tabel tijdregistratie_shadow. Niemand zag het shadow-pad. Het legacy-pad bleef leidend.

Elke maandagochtend draaiden we een diff tussen de twee tabellen.

SELECT t.id, t.uren, s.uren, t.medewerker_id
FROM tijdregistratie t
JOIN tijdregistratie_shadow s
  ON s.legacy_id = t.id
WHERE t.uren != s.uren
   OR t.project_id != s.project_id
LIMIT 50;

De eerste maandag vonden we 14 verschillen. Allemaal terug te voeren op één bug in de legacy code, waar weekenduren stilletjes naar beneden werden afgerond op het kwartier. De nieuwe code deed dat niet. We hebben besloten de nieuwe code niet te 'fixen' om de bug na te bootsen; in plaats daarvan markeerden we het verschil, lichtten we het team in, en die waren stiekem blij dat het nieuwe formulier ze ongeveer 11 minuten per week per persoon zou teruggeven.

Tegen weekend 10 kwam het shadow-pad negen dagen achter elkaar overeen met het legacy-pad. We draaiden de proxy-regel om, en de legacy POST-handler ging een redirect teruggeven naar de Symfony-route. Geen monteur die het opviel.

Wat de overstap echt heeft gekost

De elf weekenden waren niet heroïsch. Meestal twee engineers, ieder vier tot zes uur op zaterdag, en een kortere zondagochtendsessie om te checken dat maandag niet in de hens zou vliegen. Het langste weekend was nummer 6, dat samen op zo'n 22 werkuren uitkwam, omdat Doctrine bleef timeouten op de zwaarste query van het projectoverzicht (een left join over vijf tabellen die uitkwam op een NOT IN-subquery tegen 9.400 rijen). De oplossing was uiteindelijk een covering index op drie kolommen, niets bijzonders aan de ORM.

Totaal aan engineering: ruwweg 88 uur over twee mensen. Vergeleken met de rewrite-pogingen die het bedrijf had gestaakt (elk begroot op zo'n 600 uur, geen van beide opgeleverd) had de strangler-aanpak zich vóór weekend 4 al terugverdiend.

Wat we anders zouden doen

Drie dingen vallen op.

Ten eerste hadden we het shadow-pad eerder moeten optuigen. Bij het urenformulier deden we het omdat de belangen helder waren, maar het projectoverzicht had in weekend 5 baat gehad bij dezelfde aanpak. We hadden één korte storing op een zondagmiddag, omdat het nieuwe bewerkformulier 200 ms trager was dan het oude en bij iemand de browser halverwege het opslaan time-oute. Een shadow-week had dat opgevangen.

Ten tweede hadden we eerder een versiegeschiedenis moeten optuigen. Die hadden we pas vanaf weekend 7. Daarvoor was elke codewijziging op zaterdagmiddag een oefening in onthouden waar we precies aan hadden gezeten. Een snapshot per bestand in Git was al voldoende geweest; uiteindelijk hadden we een SFTP-bewuste tool die de snapshots automatisch maakte.

Ten derde hadden we de diff-query als eerste moeten schrijven, niet als laatste. De diff tussen tijdregistratie en tijdregistratie_shadow ving de afrond-bug, maar had ook twee eerdere problemen in de adreslijst en de rapportages gevangen, als we de discipline hadden gehad om hem vanaf weekend 2 voor elke gemigreerde tabel te schrijven.

Het werk en het tool dat ernaast leefde

Migraties als deze zijn grotendeels mechanisch: lees een route, port hem over, wijs de proxy ernaar. De moeilijke stukken zijn de plekken waar je een live codebase moet bewerken terwijl die nog echte gebruikers bedient, en waar je met één toetsaanslag terug moet kunnen wanneer een wijziging op zondag de maandagochtend stuk maakt. Toen we Pier bouwden, liepen we hier bij drie klantmigraties op rij precies tegenaan, dus zetten we de FTP-server, een versiegeschiedenis per bestand, een undo van één klik en een MySQL-editor in één workspace, zodat de shadow-diff queries naast de code stonden die ze opleverde.

Zit je nu zelf naar een codebase uit 2011 te staren en tikt de EOL-klok, dan is het kleinste nuttige dat je vandaag kunt doen: grep door het project op mysql_query en tel de hits. Dat getal is de scope van je eerste weekend.

— Vragen —

Waarom strangler en geen volledige rewrite?

Twee eerdere rewrites bij dit bedrijf waren mislukt. Met het strangler-patroon konden we elk weekend iets opleveren, in plaats van toewerken naar één risicovolle cutover waar niemand in geloofde.

Hoe houd je een formulier online terwijl je het migreert?

Shadow routing. Het oude endpoint blijft leidend, maar elke POST wordt ook doorgestuurd naar het nieuwe endpoint en weggeschreven naar een parallelle tabel. Diff de twee tabellen tot ze overeenkomen.

En als de oude en nieuwe database-writes van elkaar afwijken?

Dat is juist het idee van naast elkaar draaien. Verschillen leggen echte bugs bloot, soms in de oude code, soms in de nieuwe. Beslis per geval welk gedrag klopt voordat je omschakelt.