108 —Migration
Joomla 3-beeldbibliotheek naar S3: 22GB-migratielog
Hoe we in één weekend een Joomla 3-beeldbibliotheek van 22GB naar S3 verplaatsten, zonder één artikelreferentie te breken of een trilling in het access log.
De Loom kwam binnen om 23:41 op een dinsdag. Twaalf minuten van een agency lead die in gedimd licht zijn scherm deelde met een Joomla 3.10-admin. De site was een regionale nieuwsuitgever met zo'n 14.000 artikelen, 22GB aan beelden onder /images/, en een hostingrekening die in vier jaar stilletjes verdrievoudigd was. Hun laatste back-up was voor de achtste nacht op rij gefaald. De shared host gaf halverwege de tarball op.
De opdracht was helder: de hele beeldbibliotheek naar S3 verplaatsen, elke <img>-tag in elk artikel laten werken, en de site niet plat leggen. De uitgever had nog een ontwikkelaarsrelatie uit 2014 die ze niet wilden reanimeren.
Dit is het logboek van wat we deden, in de volgorde waarin we het deden.
Waarom /images/ brak bij 22GB
De site draaide op één Hetzner-VPS met een 40GB-SSD. PHP 7.4, MariaDB 10.3, Joomla 3.10.12. De beeldbibliotheek stond op /var/www/html/images/, de standaard Joomla-locatie. Ongeveer 18GB daarvan waren hero-foto's bij artikelen op volle DSLR-resolutie, nooit verkleind, teruggaand tot 2015. Nog eens 3GB was inline content uit images/stories/. De rest was thumbnails, slecht gegenereerd, in meerdere concurrerende mappen.
Het breekpunt was niet disk. Het was inode-druk en het back-upvenster. find /var/www/html/images -type f | wc -l gaf 487.213 terug. De nachtelijke tar liep door tot diep in de ochtend en locte bestanden midden in de stream. De hostingmaatschappij van de klant was bovendien beginnen te throttlen boven 200GB uitgaand verkeer per maand, een grens die de hot-set aan beelden inmiddels in zijn eentje overschreed.
S3 met een CDN lost alle drie de problemen op. Het lastige is dat Joomla 3, net als de meeste legacy CMS'en, image-paden in artikelcontent als ondoorzichtige strings behandelt. Er is geen attachments-tabel. Er is geen canonieke media-ID. Een <img src="images/2018/04/header.jpg"> die in 2018 is geschreven, is in 2026 een breekbare letterlijke waarde waarvan de redacteur nooit doorhad dat hij breekbaar was.
Elke referentie in kaart brengen
Voor we iets aanraakten, wilden we een telling. De audit-queries zijn dezelfde die je vanuit mysql -uroot zou draaien. De referentievlakken in een Joomla 3-installatie zitten op vier plekken:
-- Articles
SELECT id, title FROM jos_content
WHERE introtext LIKE '%src="images/%'
OR fulltext LIKE '%src="images/%'
OR images LIKE '%images/%';
-- Custom HTML modules
SELECT id, title FROM jos_modules
WHERE content LIKE '%src="images/%';
-- Menu params (intro images on category menus)
SELECT id, title FROM jos_menu
WHERE params LIKE '%images/%';
-- K2 if installed
SELECT id, title FROM jos_k2_items
WHERE introtext LIKE '%images/%'
OR fulltext LIKE '%images/%'
OR image_caption LIKE '%images/%';
De uitgever had geen K2, maar wel custom fields van de JCE-editor en een sidebar met statische modules waarin galerij-markup met de hand was geplakt. De audit gaf 38.902 unieke referenties verspreid over vier tabellen. Ongeveer 6 procent van de artikelen gebruikte relatieve paden zonder het prefix images/, zoals src="2017/05/photo.jpg", die Joomla resolvede tegen een per-categorie basispad ingesteld in een template override die niemand gedocumenteerd had.
We logden van elke referentie het pad en de tabel waar hij in stond. Die CSV was het enige artefact dat we voor de rest van de migratie vertrouwden.
De Apache-proxy die ons een weekend opleverde
De volgende keuze was de nuttigste die we maakten. In plaats van eerst de database te herschrijven en daarna naar S3 te syncen, deden we het andersom: we zetten Apache als transparante proxy vóór S3, zodat https://site.example/images/whatever.jpg stilletjes vanuit de bucket gelezen werd, ongeacht wat de database zei. Daarmee ontkoppelden we de file move van het herschrijven van referenties. We konden eerst syncen, verifiëren dat de proxy werkte, en pas dan in de buurt van de SQL komen.
Het .htaccess-blok dat we aan de document root toevoegden:
RewriteEngine On
# Serve /images/ from S3 via CloudFront, transparently.
RewriteCond %{REQUEST_URI} ^/images/
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-f
RewriteRule ^images/(.*)$ https://d2x9k1example.cloudfront.net/images/$1 [P,L]
# Cache the proxied responses for a day at the edge.
<IfModule mod_headers.c>
Header set Cache-Control "public, max-age=86400" "expr=%{REQUEST_URI} =~ m#^/images/#"
</IfModule>
De !-f-conditie is de belangrijkste. Hij zegt: als het bestand nog op disk staat, serveer het van disk en sla de proxy over. Zo niet, proxy via CloudFront. Dat gaf ons een veilige uitrol. We konden bestanden in batches van de VPS verwijderen en zien dat er niets brak.
mod_proxy en mod_proxy_http moesten aan staan, op Apache 2.4 één regel in de host-config. De documentatie van Apache mod_rewrite behandelt de [P]-flag en het samenspel met substitutie in RewriteRule, en precies daar gaan de meeste productiefouten mis.
22GB syncen zonder rsync-drift
Met de proxy op zijn plek werd de sync saai, en dat is precies wat je wilt. We gebruikten aws s3 sync in plaats van rclone omdat de bron één mount was zonder exotische rechten. Twee passes:
# First pass: bulk copy, ignore modifications.
aws s3 sync /var/www/html/images/ s3://example-cdn/images/ \
--size-only \
--storage-class STANDARD_IA \
--acl public-read \
--exclude "*.tmp" --exclude ".DS_Store"
# Second pass, 36 hours later: catch the drift.
aws s3 sync /var/www/html/images/ s3://example-cdn/images/ \
--size-only \
--acl public-read
De eerste pass duurde 11 uur over de 100Mbit-uplink van de uitgever, wat je voor 22GB ook zou voorspellen gegeven TLS-overhead en de lange staart aan piepkleine thumbnails. We draaiden het onder tmux en keken vanaf een telefoon mee. De tweede pass duurde acht minuten en verplaatste 312 bestanden, precies de artikelen die de redactie die dag had aangeraakt.
We verifieerden het aantal in de bucket tegen het aantal op het bestandssysteem met één commando per kant:
find /var/www/html/images -type f ! -name '.DS_Store' | wc -l
aws s3 ls --recursive s3://example-cdn/images/ | wc -l
De aantallen kwamen op vier bestanden na overeen, en dat waren .tmp-uploadresten die we expliciet hadden uitgesloten. Goed genoeg.
De cutover-SQL
Dit is het deel waar mensen bang van worden, en terecht. Je gaat UPDATE-statements draaien tegen vier tabellen op een live database. De redactionele workflow van de uitgever liep maandag tot en met vrijdag, dus deden we de cutover om 02:00 op een zondag, met een volledige mysqldump genomen om 01:55.
De eigenlijke rewrites:
-- Inline content in articles.
UPDATE jos_content
SET introtext = REPLACE(introtext,
'src="images/',
'src="https://cdn.example.com/images/'),
fulltext = REPLACE(fulltext,
'src="images/',
'src="https://cdn.example.com/images/');
-- The JSON field on jos_content.images (slashes are escaped in storage).
UPDATE jos_content
SET images = REPLACE(images,
'images\/',
'https:\/\/cdn.example.com\/images\/')
WHERE images LIKE '%images\/%';
-- Custom HTML modules.
UPDATE jos_modules
SET content = REPLACE(content,
'src="images/',
'src="https://cdn.example.com/images/')
WHERE content LIKE '%src="images/%';
Twee dingen om op te letten. De kolom jos_content.images is een JSON-blob met geëscapete forward slashes, dus de REPLACE moet matchen op images\/ en niet op images/. De JRegistry-serialiser van Joomla geeft bij insert dubbel geëscapete slashes uit, maar de daadwerkelijk opgeslagen waarde gebruikt enkele backslashes. Draai eerst SELECT images FROM jos_content WHERE id = X tegen een bekend artikel en kopieer de letterlijke bytes.
Het tweede zijn die 6 procent artikelen met kale relatieve paden. Die hebben we aangepakt met een gerichte query die matchte op het per-categorie basispad dat de template override er stilletjes voor zette, en daarna die specifieke patronen herschreef. Er waren 47 verschillende basispaden. De CSV uit de auditfase was wat ons vertelde welke 47.
Nadat de UPDATEs gedraaid hadden (12 seconden in totaal tegen een geïndexeerde tabel), draaiden we een paranoïde telling:
SELECT COUNT(*) FROM jos_content
WHERE introtext LIKE '%src="images/%'
OR fulltext LIKE '%src="images/%';
-- Expected: 0
Hij gaf 3 terug. Drie artikelen hadden image-tags in een <noscript>-blok die de redacteur met de hand had geschreven, met een spatie vóór het src-attribuut (src ="images/...). Die hebben we handmatig gepatcht en zijn doorgegaan.
Edge cases die ons alsnog beten
De Apache-proxy had ons voor de meeste rampen behoed, maar twee edge cases vroegen toch aandacht.
Custom field types
De uitgever gebruikte een geforkte versie van het image-fieldtype van JCE voor een sidebar met gerelateerde foto's. Die sloeg zijn waarde op als een geserialiseerde PHP-array, niet als rauwe HTML. Daardoor was REPLACE onveilig: door de lengte van de string te veranderen brak de serialise-prefix en weigerde PHP de rij te unserialisen. We schreven een wegwerp-PHP-script dat per rij las, unserialisede, het pad verving, opnieuw serialisede en terugschreef. Het script draaide in 90 seconden tegen zo'n 4.000 rijen en leverde per rij een diff-log dat we als bewijs bewaarden.
Template overrides
De override op templates/publisher/html/com_content/article/default.php had een hardcoded fallback die /images/articles/ voor elke afbeelding in het JSON-veld images van het artikel zette. Na de migratie produceerde die fallback voor een handvol artikelen https://cdn.example.com/images/articles/https://cdn.example.com/.... We hebben de fallback verwijderd (hij was in 2017 toegevoegd om een reden die niemand zich nog herinnerde) en op categoriepagina's een spot check op 50 artikelen gedaan. De volledige conventies voor overrides staan beschreven in de Joomla 3-gids voor layout-overrides, en template overrides zijn de meest voorkomende plek waar zo'n migratie stilletjes onderuit gaat.
Het access log na de cutover in de gaten houden
Het access log was de enige telemetrie die we vertrouwden om te bevestigen dat de rewrite was geland. We voegden een kleine marker aan de Apache-rewrite toe: een response header X-Image-Origin die op disk stond als het bestand vanaf schijf werd geserveerd en op cdn als de proxy in actie kwam. Vanaf zondagochtend volgden we het log met grep ' disk ' en zagen we de rate dalen.
Zondagmiddag was de disk-tak gezakt van een baseline van ongeveer 14 requests per seconde naar een stabiele 0,3, vrijwel allemaal favicons en een vergeten /images/logo.png die een templatefragment als absoluut pad laadde. De cache-hit ratio van CloudFront klom in de eerste dag van nul naar 71 procent en zat woensdag op 94 procent.
We waren klaar geweest om het SQL-blok terug te draaien als de disk-rate vlak bleef, met als rollback één REPLACE in tegengestelde richting op dezelfde vier tabellen. Dat bleek niet nodig. De cijfers vertelden ons dat de proxy het werk deed en dat de SQL de rijen had bereikt die hij moest bereiken.
Wat we zes weken op disk hielden
We hebben /var/www/html/images/ niet meteen na de cutover verwijderd. De proxyconditie serveerde nog steeds vanaf schijf als het bestand bestond, dus de kopie op disk bleef de stille vangrail. Na zes weken waarin geen enkele images/-request in het access log nog de disk-tak raakte, draaiden we in hetzelfde onderhoudsvenster een laatste aws s3 sync en rm -rf images/.
De maandelijkse uitgaande bandbreedte van de site daalde van 240GB op de host naar 18GB op de host plus 220GB op CloudFront, gefactureerd tegen ongeveer een achtste van het tarief. Het back-upvenster kromp van zes uur naar negentien minuten. De uitgever heeft er niets van gemerkt, en dat was het doel.
Toen we Pier bouwden, kwamen we precies deze vorm van migratie meer dan eens tegen, waarbij de bulkkopie het makkelijke deel was en de lange staart aan referenties in geserialiseerde kolommen, custom fields en template overrides het weekend opvrat. De manier waarop we het uiteindelijk aanpakten, was deze auditqueries draaien in de MySQL editor tegen een snapshot, met de version history van elke rewrite, zodat de operator van elke UPDATE het voor-en-na ziet voordat de live rij wordt aangeraakt.
Heb je een legacy site die onder zijn eigen /images/-map kreunt, dan is het kleinste dat je vandaag kunt doen: de auditquery draaien tegen jos_content en jos_modules en het resultaat naar een CSV schrijven. Zodra je de vorm van de referenties kent, wordt de migratie een sequentie. Tot dat moment is het gokken.
— Vragen —
Voegt de Apache-proxy merkbare latency toe aan image requests?
Op cache-miss requests wel: Apache haalt het op bij CloudFront en geeft het opnieuw uit. We maten ongeveer 40ms extra op koude paden, en op warme paden een paar milliseconden zodra de edge het bestand had gecached.
Waarom niet eerst de SQL herschrijven en de proxy helemaal overslaan?
Een verkeerde UPDATE op 38.000 referenties kan in één klap elk artikel breken. De proxy gaf ons een stille rollback: draai de SQL terug en de fallback op disk serveert de beelden gewoon weer.
Werkt dezelfde aanpak voor Joomla 4 of 5?
Het principe is identiek. De kolom jos_content.images is een echt JSON-type geworden en het htaccess-blok blijft hetzelfde. K2 wordt niet ondersteund op Joomla 4, dus audit elke K2-fork apart.