← Blog

AI agents

S3-egress: hoe een AI-refactor onze rekening verdubbelde

De Slack-melding kwam dinsdag om 14:02: een AWS budget alarm sloeg aan, en de rsync-wrapper die stil had moeten zijn, had de S3-egress stilletjes verdubbeld.

Jacob Molkenboer· Oprichter · A Brand New Company· 6 jun 2026· 8 min
Messing watermeter met naald voorbij rode zone, gevouwen factuur, groene plaknotitie, messing kraan, ivoren linnen, lakzegel.

De Slack-melding kwam op een dinsdag in mei om 14:02: een AWS budget alarm sloeg aan op het eu-west-1 account waar het media-archief van een publieke omroep in Groningen op staat. De uitgaven liepen op 2,1x het rollende gemiddelde over veertien dagen, vrijwel allemaal S3-egress uit één bucket. De nachtelijke archive sync was zonder fouten afgerond. Niets in CloudWatch zag er verkeerd uit. De logs zeiden "complete".

Vier uur later wisten we wat er was gebeurd. Iemand had de vrijdag ervoor een AI-assistent gevraagd om de rsync-wrapper op te schonen die de nachtelijke sync aanstuurde. De diff was klein, goed becommentarieerd en in elf minuten gereviewd. Hij introduceerde ook opnieuw een bugpatroon dat een eerdere engineer twee jaar geleden juist had weggewerkt. Dit is de walkthrough.

De wrapper, daarvoor

Het script stond op /usr/local/bin/archive-sync op één Hetzner-bak in het Groningse kantoor van de omroep. Hij duwde de dagelijkse media drops, ruwweg 800 GB MXF en ProRes per nacht, naar s3://archive-prod/YYYY/MM/DD/. De ingest-bak schreef de hele dag door bestanden naar /mnt/ingest/$DAY/. Om 02:00 Amsterdamse tijd draaide archive-sync.

Het originele script, zonder commentaarregels:

#!/usr/bin/env bash
set -euo pipefail

DAY="$(date -u -d 'yesterday' +%Y/%m/%d)"
LOCAL="/mnt/ingest/${DAY}"
BUCKET="archive-prod"

MANIFEST_LOCAL="${LOCAL}/.manifest.sha256"
MANIFEST_S3="s3://${BUCKET}/${DAY}/.manifest.sha256"

# 1. Build local manifest of today's files
( cd "$LOCAL" && find . -type f ! -name '.manifest.*' \
    -print0 | xargs -0 sha256sum > "$MANIFEST_LOCAL" )

# 2. Pull previous manifest from S3 (one GET, a few KB)
aws s3 cp "$MANIFEST_S3" /tmp/old-manifest 2>/dev/null || : > /tmp/old-manifest

# 3. Upload only the files whose hash changed
comm -23 <(sort "$MANIFEST_LOCAL") <(sort /tmp/old-manifest) \
  | awk '{ print $2 }' \
  | while read -r f; do
      aws s3 cp "${LOCAL}/${f}" "s3://${BUCKET}/${DAY}/${f}"
    done

# 4. Publish the new manifest
aws s3 cp "$MANIFEST_LOCAL" "$MANIFEST_S3"

Vier bewegende delen. Het manifest is het dragende onderdeel. Het script berekent SHA-256 lokaal, haalt het vorige manifest uit S3 (één GET, een paar kilobytes), vergelijkt ze en kopieert alleen de bestanden waarvan de hash daadwerkelijk is veranderd. Op een typische nacht verandert ongeveer 5% van de bestanden. De egress van de verificatiestap was zo groot als één klein JSON-bestand, nooit de data zelf.

Bovenaan stond een Nederlandstalig commentaar van de oorspronkelijke auteur uit 2024: # NIET vervangen door 'aws s3 sync'. Geprobeerd. Doet HEAD op elk object.

Dat commentaar is bij de refactor verwijderd.

De refactor

De on-prem engineer vroeg een AI-assistent om "dit op te schonen, error handling toevoegen, de stappen duidelijker maken". De assistent leverde een versie van 110 regels af. Met elke leesbaarheidsmaat erop was hij beter. Functies hadden namen. De manifest-logica was opgesplitst in build_manifest en fetch_previous_manifest. Er was een verify_upload-stap aan het einde die het origineel niet had.

Zo zag verify_upload eruit:

verify_upload() {
  local day="$1"
  local bucket="$2"
  local tmp
  tmp="$(mktemp -d)"
  trap 'rm -rf "$tmp"' RETURN

  # Pull each object back, recompute the hash, compare to the local manifest.
  aws s3 sync "s3://${bucket}/${day}/" "${tmp}/" --only-show-errors
  ( cd "$tmp" && sha256sum -c "${LOCAL}/.manifest.sha256" )
}

Lees het twee keer. De intentie is eerlijk: haal terug wat we net hebben geüpload, herbereken de hash, bevestig dat hij klopt. Riem en bretels.

Wat hij in werkelijkheid doet: aan het einde van iedere nachtelijke run het hele dagarchief (800 GB) downloaden van S3 naar een tijdelijke map, lokaal de checksums verifiëren en het weer verwijderen. Elke nacht. Elke byte die we net hadden geüpload, kwam meteen weer terug.

Tegen het standaard internet-egress-tarief van AWS voor eu-west-1 is 800 GB per nacht goed voor enkele duizenden euro's per maand alleen aan transferkosten. Het origineel kostte ongeveer veertig euro per maand voor dezelfde workload, omdat de manifest-vergelijking lokaal gebeurde en S3 enkel ooit één klein JSON-bestand serveerde. De volledige S3 pricing page legt het uit: bytes die de bucket via het internet verlaten worden gefactureerd, bytes die binnen de regio blijven niet.

In de diff

De PR-beschrijving was woord voor woord overgenomen uit het antwoord van de assistent:

Cleaned up archive-sync.sh: extracted manifest logic into named functions, added structured error handling, added post-upload verification. No behaviour change.

"No behaviour change" is de zin waar je voor moet opletten. De functie-bodies waren trouwe vertalingen. De error handling was echt. De verificatiestap was nieuw, en de reviewer (een in-house ops lead, geen engineer van beroep) keurde hem goed omdat verificatie goed is.

De bug zat niet in een enkele regel. De bug was dat verificatie in het origineel bewust was weggelaten, omdat de manifest-vergelijking hem overbodig maakte, en het opnieuw introduceren met aws s3 sync in de verkeerde richting de dataflow van het script stilletjes omkeerde. Die intentie leefde in één regel Nederlands commentaar en in niemands hoofd. De assistent kan niet lezen wat niet in het bestand staat. De reviewer ving het niet, omdat de diff code toevoegde, en toegevoegde code voelt veiliger dan verwijderde code.

Dit patroon, een AI-gegenereerde refactor die een bug opnieuw introduceert die een eerdere engineer expliciet had weggewerkt, is de vorm achter de bredere discussie die de afgelopen maand op Hacker News loopt over rsync-regressies. Goedbedoelde wijzigingen die stilletjes invarianten schenden die de originele code impliciet hield.

De vier uur

13:48. AWS Budget Actions sloeg aan, postte naar #alerts.

14:02. Eerste menselijke ogen (ops on-call).

14:09. Cost Explorer toonde dat de piek geïsoleerd was tot één bucket. Egress, geen storage, geen requests.

14:15. VPC flow logs uitgesloten (geen EC2 in eu-west-1 voor deze bucket).

14:38. CloudTrail data events lieten GetObject-calls zien vanaf het Groningse kantoor-IP, in lange bursts die aansloten op 02:00 tot 04:30 Amsterdamse tijd, elke nacht een week lang.

15:02. archive-sync.sh van de bak getrokken. Gelezen. verify_upload gespot.

15:14. Bevestigd door de wrapper in dry-run-modus te draaien en de geplande downloads te bekijken.

15:30. Hotfix: verify_upload verwijderd, opnieuw uitgerold.

17:50. AWS Support case geopend, niet voor restitutie, voor duidelijkheid over het facturatievenster.

Vier uur. De reden dat het vier uur duurde, was dat het symptoom (egress) en de oorzaak (een verificatiestap die sync de verkeerde kant op aanriep) in ons hoofd twee bestanden uit elkaar lagen. We keken naar netwerk-configs, CloudFront-gedrag en IAM-policies voordat we keken naar het script waar de wijziging echt was geland. Lees altijd eerst het recent gewijzigde script. We wisten dit. We deden het niet.

De diff die we hadden moeten zien

Achteraf was het bewijs één omkering van richting. Het origineel verplaatste bytes één kant op, van lokaal naar S3. De refactor voegde een functie toe die bytes de andere kant op verplaatste, van S3 naar lokaal. In een script waarvan het hele doel één richting is, is iedere regel die data de andere kant op verplaatst verdacht.

Drie review-heuristieken die we nu toepassen op elke refactor van een I/O-script:

  1. Vind elke call die bytes verplaatst. rsync, scp, aws s3 cp, aws s3 sync, curl, wget. Noteer bron en bestemming voor elk.
  2. Vergelijk de bron/bestemming-tabel met het origineel. Elke nieuwe regel, elke omgewisselde bron en bestemming, krijgt een geschreven onderbouwing in de PR-tekst.
  3. Let op verwijderde commentaarregels. Commentaar dat zegt "NIET" of "dit hebben we geprobeerd" beschrijft beperkingen die de code zelf niet vertelt. Heeft een AI-refactor er een gewist, dan is de diff niet af.

Onspectaculair. Het kost misschien negentig seconden per script. Het had deze fout opgevangen binnen het venster van elf minuten dat de PR daadwerkelijk had.

Onze review-fixes

We hebben drie dingen gedaan, geen ervan ging over AI-refactors verbieden. We zetten AI-assistenten elke dag in voor refactors en blijven dat doen. We hebben de manier waarop we ze reviewen veranderd.

Eerst: elke refactor-PR die een script in een cron- of systemd-timer-pad raakt vereist nu een "data-flow diff". Een tabel naast elkaar van elke externe call, met pijlen. We genereren hem met een kleine wrapper rond git diff en de assistent zelf, en hij komt direct in de PR-beschrijving terecht. Dertig seconden, elke keer.

Ten tweede: we hebben een CI-stap toegevoegd die de CloudWatch egress-metric van de vorige run vergelijkt met de nieuwe voor de betreffende bucket. Landt er een refactor, dan houden we de run van de volgende nacht in de gaten. Springt egress meer dan 1,5x omhoog, dan wordt de wrapper automatisch teruggerold. Dit zijn de bretels, de data-flow-review is de riem. AWS publiceert in de docs over object integrity checksums al de server-side integriteitsprimitieven die je echt wilt voor verificatie: PutObject met --checksum-algorithm SHA256 betekent dat S3 de hash bij ontvangst berekent en jij die vergelijkt met je lokale hash, zonder ooit de bytes terug te trekken.

Ten derde: we hebben elke "NIET"-commentaarregel in de repo getild naar een top-level INVARIANTS.md, waar vanuit CODEOWNERS naar verwezen wordt. Refactors die een regel raken bij zo'n commentaar vereisen nu een tweede reviewer. Het commentaar staat op twee plekken, wat vervelend is, en dat is de prijs van een AI in de loop die niet weet wat we eerder hebben geprobeerd.

Takeaway

De gevaarlijke AI-refactor is niet die de build breekt. Het is die door de review komt, schoon draait en stilletjes een invariant schendt die de originele code impliciet hield. Vang hem met data-flow diffs, niet op gevoel.

De kleinere les

S3-egress is een van die AWS-kosten die niet opduiken in de architectuurreview en luid opduiken op de rekening. Elke GB die uit een eu-west-1 bucket naar het publieke internet gaat wordt gefactureerd, elke GB die binnen de regio blijft niet. Verificatie-scripts die data terugtrekken over het internet zijn een kostenval. Verificatie-scripts die S3 vragen de hash server-side te berekenen niet.

Het bredere patroon is waar de open-source-wereld de afgelopen veertien dagen over discussieert. AI-gedreven refactors landen op de snelheid van typen, en het reviewproces ervoor is nog grotendeels het reviewproces dat we bouwden voor diffs van mensenhand. Dat oudere proces gaat ervan uit dat een mens de wijziging schreef en de impliciete beperkingen in zijn hoofd hield. De assistent doet dat niet. De beperkingen moeten worden opgeschreven, en de reviews moeten zoeken naar de beperkingen die zijn verwijderd, niet alleen naar de regels die zijn toegevoegd.

Wanneer we AI-agents en automation bouwen voor klanten met kostengevoelige infrastructuur in Nederland en Thailand, is dit de faalmodus waar we als eerste op plannen. Waar we op dit Groningse archief tegenaan liepen, was dat de assistent niet kan zien wat er niet in het bestand staat. We lostten het op door de invarianten twee keer op te schrijven, één keer als commentaar en één keer als CI-checks die op de juiste metric afgaan.

Audit van vijf minuten die je nu meteen kunt doen: open de cron jobs op je meest kritische bak en zoek het script dat je in zes maanden niet hebt aangeraakt. Lees elk commando dat bytes verplaatst. Noteer de richting. Heeft een recente refactor er een omgekeerd zonder geschreven reden, dan weet je al wat je te doen staat.

Kern

De gevaarlijke AI-refactor is die door de review komt, schoon draait en stilletjes een invariant schendt die de originele code impliciet hield.

FAQ

Was dit de schuld van de AI-assistent?

Nee. De assistent leverde schone, netjes benoemde code. De bug zat in ons reviewproces. We keurden een diff goed die een functie toevoegde waarvan de datarichting het hele doel van het script tegensprak.

Hoe controleer je je eigen cron-scripts op dit patroon?

Open elke cron job op je meest kritische bak. Lees elke regel die bytes verplaatst (rsync, aws s3 cp, curl). Noteer bron en bestemming. Elke recente refactor die een richting omkeerde zonder geschreven reden heeft dezelfde vorm.

Waarom ving CloudWatch het niet op in de eerste nacht?

Dat deed het wel. Het budget alarm sloeg aan de ochtend na de eerste opgeblazen nacht. Niemand merkte het, tot de derde opstapelende dag. Stuur AWS Budget Actions-alerts naar een Slack-kanaal dat mensen echt lezen.

Wat is de juiste manier om een S3-upload te verifiëren?

Gebruik de server-side checksums van S3. Stuur PutObject met --checksum-algorithm SHA256, sla de geretourneerde checksum op en vergelijk die met je lokale hash. Er verlaat geen byte de bucket om de integriteit te bevestigen.

ai agentscase studyoperationstoolingarchitecture

Iets bouwen?

Start een project