041 —Operations
9GB site herstellen: kapotte dump en drie FTP-shards
Een middag besteed aan het samenrapen van een 9GB WordPress-site uit één afgekapte mysqldump en drie half kapotte FTP-backups. Dit is het herstelverslag.
Het telefoontje van 14:12
Een studio in Utrecht waar we mee samenwerken belde ons net na de lunch met een herstel dat ze zelf niet rond kregen. Hun stagingkopie van de verouderde site van een klant was stilletjes de laatste werkende kopie van alles geworden. Een WordPress-installatie van 9GB, vastgeklikt aan WooCommerce, met een custom plugin die niemand sinds 2018 nog had durven aanraken. Productie was 's nachts read-only gegaan nadat de hosting halverwege een back-up tegen de schijfquota aanliep. De cron-aangestuurde mysqldump schreef nog af, het FTP-backupscript startte, en geen van beide rondde netjes af.
Wat om 14:12 op onze gedeelde schijf landde, was een tar.gz met de naam db-2026-05-21.sql.gz die zonder klagen uitpakte en vervolgens halverwege de wp_postmeta-insert afbrak. Daarnaast stonden drie FTP-backupmappen van drie verschillende dagen, elk met andere ontbrekende stukken. Eén miste de complete wp-content/uploads/2024/-tree. Eén miste wp-content/plugins/. De derde had de plugins nog wel, maar de wp-config.php was een bestand van 0 bytes. De site moest om 18:00 weer online staan. We hadden drie uur en achtenveertig minuten.
Eerst inventariseren, dan handelen
Het eerste instinct wanneer er een herstel van 9GB op je laptop landt, is om meteen dingen te gaan fixen. Dat instinct klopt niet. Twintig minuten besteden aan precies in kaart brengen wat er stuk is, zijn twintig minuten die voorkomen dat je om 17:30 je eigen voortgang weer terugdraait.
We pakten een kladblok en deden dit:
gzip -t db-2026-05-21.sql.gz; echo $?
# 0. De gzip-container is in orde. Het SQL erin is het probleem.
zcat db-2026-05-21.sql.gz | tail -c 4096
# Laatste bytes midden in een statement: "...VALUES (1842,'_edit_lock','1716"
zcat db-2026-05-21.sql.gz | wc -l
# 1.204.883 regels
zcat db-2026-05-21.sql.gz | grep -n "^-- Table structure for" | tail
# Laatste gedumpte tabel: wp_postmeta. wp_posts klaar. wp_users klaar.
# wp_woocommerce_order_items en consorten zijn nooit begonnen.
We hadden dus structuur en data voor alles tot en met de eerste 60% van wp_postmeta, en niets daarna. De WooCommerce-ordertabellen, de sessietabel en de queue van action scheduler ontbraken volledig in de dump. Dat laatste was een kleine zegen: action scheduler is gebouwd om te herstellen van een leeggeveegde queue.
We openden ook wp_options voor een snelle check. De siteurl- en home-waarden waren aanwezig, de active_plugins-array stond intact, en de woocommerce_db_version kwam overeen met die op de tweede node van de studio. Juist dat soort mismatch bijt je dinsdagochtend als een klant meldt dat productvariaties zich raar gedragen en je drie uur kwijt bent aan een probleem dat bij het herstel in de dump werd gebakken.
Voor de bestandsboom draaiden we drie rsync -an --stats dry-runs tegen elke backup om een delta te krijgen tegenover wat productie zou moeten zijn. De output gaf ons een matrix:
- Backup A (19 mei): alle PHP intact,
uploads/2024/ontbreekt,uploads/2023/aanwezig. - Backup B (17 mei): alle
uploads/intact,plugins/ontbreekt. - Backup C (20 mei): plugins intact,
wp-config.phpnul bytes, mu-plugins ontbreken.
Niets was onherstelbaar. Alle stukken bestonden ergens nog. Het werk was reassemblage.
De database opnieuw opbouwen
Je kunt een afgekapte mysqldump niet zomaar in MySQL inschuiven. De laatste INSERT geeft een error, en afhankelijk van hoe de dump geschreven is, krijg je ook daarvoor geen schone transactiegrenzen meer. De veilige aanpak is: knip de dump af bij het laatst bekende geldige statement, herstel dat, en bouw de ontbrekende tabellen daarna opnieuw op uit wat je verder nog hebt.
We zochten het laatste complete statement door vanaf EOF terug te lopen naar een regel die eindigde op ; en gevolgd werd door een lege regel:
zcat db-2026-05-21.sql.gz \
| awk '/^);$/ {last=NR} END {print last}'
# 1.204.612
zcat db-2026-05-21.sql.gz \
| head -n 1204612 \
> db-truncated-clean.sql
Dat leverde een syntactisch geldig SQL-bestand op dat eindigde bij de laatst volledig geschreven INSERT. We laadden het in een verse lokale MySQL met --init-command="SET autocommit=0, foreign_key_checks=0, unique_checks=0;" en een verstandige innodb_buffer_pool_size. De officiële documentatie over bulk loading van InnoDB is een bookmark waard; alleen de autocommit-toggle al halveerde onze hersteltijd op deze dataset ongeveer.
Voor wp_postmeta, dat halverwege een insert was geëindigd, hadden we nu een gedeeltelijke tabel. Ongeveer 60% van de rijen aanwezig. De resterende 40% moest worden afgeleid. Twee bronnen hielpen:
- De plugin in Backup C had een interne cachetabel die postmeta voor SEO-velden spiegelde, beschreven door een hook die elke nacht draaide.
- WooCommerce slaat op
wp_posts(post_type = 'product') en op de child-posts van variaties genoeg op om voorraad- en prijsmeta deterministisch te reconstrueren.
We schreven twee herstel-queries en draaiden ze in die volgorde. De eerste speelde de SEO-cache terug in wp_postmeta met INSERT IGNORE, zodat de rijen die al uit de dump bestonden bewaard bleven. De tweede liep door de productposts en re-emitteerde _stock, _price en _regular_price vanuit de variatietabel waar de rij van de parent ontbrak. Elke afgeleide rij logden we in een aparte audittabel, zodat we ze later konden nakijken.
Voor we de database lieten rusten, draaiden we nog een snelle consistentiecheck. SELECT COUNT(*) FROM wp_posts WHERE post_status NOT IN ('publish','draft','pending','private','trash','auto-draft','inherit','future'); gaf nul terug, dus er was niets in een half-geschreven staat blijven hangen. We controleerden wp_postmeta ook op orphans tegen wp_posts met één LEFT JOIN. Ongeveer veertigduizend rijen kwamen terug. Die waren normaal: meta van WooCommerce-variaties die nog op inmiddels verwijderde parents leven, het soort vuil dat elke oude shop met zich meedraagt. We logden het aantal en gingen door.
Voor de WooCommerce-ordertabellen hadden we niets. De eigen logs van de plugin (die op disk leefden, niet in de database) gaven ons een lijst met order-ID's die in de laatste 36 uur waren aangemaakt. Die exporteerden we, we schreven een stub om ze als on-hold te markeren, en mailden de customer-success-persoon van de studio met het verzoek de betrokken kopers 's ochtends te bellen. De verleiding is groot om bij een gedeeltelijke dataset plausibele waarden in te vullen. Gegenereerde timestamps. Afgeleide klant-ID's. Niet doen. Elke verzonnen rij is een rij die je over zes maanden voor echt aanziet als je iets anders aan het debuggen bent. Schrijf de audittabel. Mail de success-persoon. Pak de telefoon.
De bestandsboom samenrapen
Met de database bijna compleet, ging onze aandacht naar het filesystem. De truc met drie half kapotte FTP-backups is om er één als basis te kiezen en de andere de gaten te laten vullen, in een vaste volgorde, zonder dat de basis overschreven wordt.
We kozen Backup A als basis omdat de PHP-laag compleet was en aansloot op het schema dat we net hadden herbouwd. Daarna:
# 1. Vul ontbrekende uploads aan vanuit Backup B.
rsync -av --ignore-existing \
/backups/B/wp-content/uploads/ \
/restore/wp-content/uploads/
# 2. Vul mu-plugins aan vanuit Backup B (A had ze, maar die van B waren nieuwer).
rsync -av --update \
/backups/B/wp-content/mu-plugins/ \
/restore/wp-content/mu-plugins/
# 3. Plugins: A heeft ze. C overslaan, wp-config daar is rommel.
# 4. wp-config.php: die van A pakken. Voor de zekerheid diffen met C.
diff /backups/A/wp-config.php /backups/C/wp-config.php
# C is leeg. Overslaan.
De vlag --ignore-existing maakte dit veilig. Die garandeert dat de basis wint voor elk bestand dat in beide mappen voorkomt. --update doet juist het tegenovergestelde, en die gebruikten we in de mu-plugins-stap waar we wisten dat B nieuwer was. De twee combineren in één commando is hoe je om 17:50 per ongeluk het verkeerde overschrijft.
De Apache-documentatie heeft de helderste referentie op de precedentieregels die gelden voor .htaccess bij herstel. We legden de .htaccess van de basis naast de laatst bekende productie-regels van de klant en vonden één ontbrekend blok: een rewrite die een verouderde galerij-URL naar een nieuwere permalinkstructuur mapte. Die zetten we terug:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^gallery/([^/]+)/?$ /portfolio/$1/ [R=301,L]
</IfModule>
Zonder dat zou de site weer online zijn gekomen, maar elke oude inbound-link vanuit Pinterest en oude nieuwsbrieven zou een 404 hebben gegeven. De klant had het binnen een uur gemerkt en wij hadden de avond op Slack gezeten.
De wp-config-landmijn
De wp-config.php in onze basis had de juiste databasecredentials voor staging, niet voor de productiereplica waarin we aan het herstellen waren. Erger nog: het bestand verwees naar twee constanten die de studio jaren eerder had toegevoegd en die nergens waren gedocumenteerd:
define('WP_CACHE_KEY_SALT', 'utrecht-prod-2021');
define('UPLOADS', 'wp-content/uploads-cdn');
De eerste vonden we door de plugin-source te greppen op WP_CACHE_KEY_SALT. De tweede vonden we op de langzame manier: de site kwam om 17:34 online, alle afbeeldingen gaven 404, en een van ons zei "uploads-streepje-wat". De UPLOADS-constante overschrijft het standaardpad voor uploads, en de CDN-sync schreef al twee jaar naar uploads-cdn/. Een patch van twee regels in wp-config.php en één symlink loste het op:
ln -s /var/www/site/wp-content/uploads-cdn \
/var/www/site/wp-content/uploads
De diff-dans
Om 17:51 reageerde de site op de staginghostname en zag er goed uit. Dat is het moment waarop de verleiding om het klaar te noemen het sterkst is, en de discipline van nog één ronde het meest telt.
We deden drie checks:
wp db checkop elke tabel, om te bevestigen dat InnoDB er niet stilletjes eentje als crashed had gemarkeerd.- Een diff op rij-aantallen tussen de herstelde database en het nachtelijke metrics-rapport van de studio van de week ervoor. Posts en gebruikers kwamen binnen 4 rijen overeen. Orders waren het bekende gat van 36 uur.
- Een
curl -Iop de top 50 URL's uit de analytics-export van de maand ervoor. Achtenveertig gaven 200 terug. Twee gaven een 301 naar dezelfde canonical, wat klopte.
De derde check telt meer dan hij lijkt. Een site die op de homepage laadt is geen werkende site. WordPress in het bijzonder heeft tientallen rand-URL's (REST-endpoints, admin-ajax, de sitemap, feed-URL's) die stilletjes falen als een plugin of een constante niet klopt. We trokken de top 50 uit analytics in plaats van te gokken; die export is een proxy voor de URL's waar echte mensen op landen, en dat bepaalt of je de avond op Slack zit of niet.
Om 17:58 zetten we het herstel naar productie en keken we tien minuten naar de access log. De eerste echte bestelling kwam binnen om 18:11. Het eerste telefoontje van de klant naar de studio kwam om 09:14 de volgende ochtend, met de vraag waarom twee bestellingen van dinsdagmiddag verdwenen waren. De success-persoon van de studio had de lijst al klaarliggen en belde de kopers terug.
Action scheduler kwam om 18:03 weer in gang, draaide zijn inhaalslag, en stuurde de abandoned cart-mails alsnog uit die hij had gemist. Met één oog keken we de queue leeglopen in wp_actionscheduler_actions, met het andere beantwoordden we de vragen van de studio. Er lekte niets dubbel terug in de mailqueue, wat we wel hadden gevreesd na de reparatie van wp_postmeta. We bevestigden ook dat WP-Cron weer was opgepakt waar hij was gestopt, door vijf minuten wp cron event list te tailen en de next-run-timestamps vooruit te zien tikken.
Wat dit werk eigenlijk is
Een verouderde site herstellen vanuit kapotte backups is geen heldendaad. Het is inventariseren, dan reassembleren in een vaste volgorde, dan verifiëren. De fouten die uren kosten worden bijna altijd in de eerste twintig minuten gemaakt, wanneer je begint met fixen voordat je klaar bent met tellen.
Toen we Pier bouwden, liepen we precies tegen dit patroon aan op sites van klanten en wilden we een snellere manier om de schade in kaart te brengen en stuk voor stuk terug te spelen. Met de MySQL-editor in de app laden we een half kapotte dump in, lopen we hem tabel voor tabel door en draaien we de ontbrekende stukken opnieuw met een echte undo bij elke stap, en de versiegeschiedenis betekent dat de vraag "wat hebben we om 17:34 veranderd" een antwoord heeft in plaats van een gok.
Doe na het lezen vanavond één ding: voeg één regel toe aan je backup-cron en pipe de mysqldump-output door tee >(md5sum > dump.md5), zodat de checksum naast de dump terechtkomt. Over een half jaar, als jou iets vergelijkbaars overkomt, weet je binnen tien seconden of het bestand dat je hebt ook het bestand is dat je geschreven hebt.
— Vragen —
Kun je een WordPress-site herstellen vanuit een afgekapte mysqldump?
Ja, mits je de dump afknipt bij het laatste complete statement en ontbrekende tabellen opnieuw opbouwt uit andere bronnen. Reken op datagaten en documenteer ze, in plaats van ze te verstoppen.
Wat is de veilige manier om meerdere FTP-backups samen te voegen?
Kies er één als basis en gebruik rsync --ignore-existing om gaten op te vullen uit de andere. Het mengen van --update en --ignore-existing in één commando is hoe je het verkeerde overschrijft.
Hoe weet ik of een gegzipte SQL-dump compleet is?
gzip -t verifieert de container, maar het SQL erin kan alsnog afgekapt zijn. Doe een zcat op het bestand en tail de laatste paar KB om te controleren dat het laatste statement op een puntkomma eindigt.