Part 7 of 7 · Product image cleaner series ~8 min read

Engineering reference: the product image cleaner architecture

This is the product image cleaner with the friendly labels removed: the real function names, which one is a container image and why, the bucket prefixes and the event that fires, the job table’s keys, and the IAM scope that keeps each function to its own job. If you want to build it rather than understand it, start here.

Key takeaways

  • Six Lambda functions: one container image on arm64 with 6 GB for background removal, five small Python 3.14 zip functions for everything else.
  • One versioned S3 bucket, picr-images, with EventBridge notifications on; the inbox/ upload event is what starts the pipeline.
  • One DynamoDB table, on-demand, keyed by job_id with a status-history sort key and a GSI on status for the review sweep.
  • No Bedrock, no API Gateway, no NAT Gateway. The model runs inside the container (or behind a hosted-API key in Secrets Manager).
  • One region, eu-west-2, with per-function IAM roles scoped to exactly the prefixes, queues, and secrets each one touches.

The architecture, for engineers

This is the same system as Part 1 with the friendly labels removed and the real resources named. Everything is in one region, eu-west-2 (London), in one account. There is no API Gateway, no NAT Gateway, and nothing always-on; photos arrive through a watched-folder sync, an SES inbound rule, or a direct upload, all landing under one S3 prefix, and the bucket’s upload event — routed through EventBridge to SQS — is the only thing that starts work.

Engineering architecture: inbound lanes, the upload event, six Lambdas, one bucket, one job table Left column, three inbound lanes: a watched Drive folder, SES inbound for emailed photos, and a direct upload. All three write to the inbox slash prefix of a versioned S3 bucket picr-images. The bucket emits an Object Created event to EventBridge, which forwards it to an SQS queue picr-work, which triggers the picr-ingest Lambda. The centre column is the processing chain, top to bottom: picr-ingest, a zip function, normalises and opens a job; picr-cutout, a container-image Lambda on arm64 with 6 GB, removes the background and runs the confidence gate; picr-variants, a zip function, builds the channel images; picr-publish, a zip function, writes the set and notifies. Each stage hands to the next over SQS, sharing one dead-letter queue picr-dlq. On the right, the data stores: the same picr-images bucket holds work slash and out slash web, market, and social prefixes; a DynamoDB table picr-jobs keyed by job_id with sort key ts and a GSI on status; and notifications go out via SES and SNS. picr-cutout reads a hosted-model key from Secrets Manager and may call an external model; picr-drive-sync reads a Drive key from Secrets Manager. The container image is pulled from ECR. At the bottom, EventBridge Scheduler drives two scheduled functions: picr-drive-sync, which mirrors the Drive folder, and picr-sweep, which re-notifies jobs stuck in review. A dotted AWS account container wraps everything except the external Drive, email senders, and hosted model. AWS account · eu-west-2 Watched folder Drive, synced SES inbound emailed photo Direct upload into the prefix picr-images inbox/ · versioned EventBridge ObjectCreated picr-work SQS picr-ingest zip · normalise picr-cutout container · arm64 · 6 GB SQS picr-variants zip · Pillow pass picr-publish zip · write + notify Secrets Manager Drive + model keys Hosted model external, optional ECR image cutout weights baked in Notifications SES / SNS picr-images work/ · out/web, market, social lifecycle on work/ picr-jobs PK job_id, SK ts · GSI status EventBridge Scheduler 2 rules picr-drive-sync every few minutes picr-sweep daily, re-notify review picr-dlq shared dead-letter All zip Lambdas: Python 3.14, arm64 · CloudWatch Logs 7-day retention · AWS Budgets cost alarm · no Bedrock, no API Gateway
Fig 7. The product image cleaner drawn for engineers: three inbound lanes into one versioned bucket, an upload event through EventBridge and SQS, a chain of six Lambdas (one a 6 GB container), one job table, and two scheduled jobs. One region, one account.

Lambda functions

Six functions. Five are small Python 3.14 zip packages on arm64; one — the cutout — is a container image, for the reasons in Part 3. Each does one job and hands to the next over SQS, sharing a single dead-letter queue (picr-dlq) after five attempts. All carry CloudWatch Logs at 7-day retention.

  • picr-ingest — zip, arm64, 1024 MB. Triggered by picr-work (the upload event). Validates the file is a real image, rotates by EXIF, converts HEIC, caps the longest edge, strips metadata, writes work/<job_id>/source.jpg, opens the job row, and enqueues the cutout. Rejects non-images here.
  • picr-cutoutcontainer image, arm64, 6144 MB, ~120s timeout. Runs the segmentation model (rembg / U^2-Net by default), composites the transparent cutout, and scores coverage and fragmentation. On pass, enqueues variants; on fail, marks the job needs review and notifies. The only function that may reach an external model and the only one that reads the model secret.
  • picr-variants — zip, arm64, 2048 MB. Trims to the alpha bounding box, then builds each channel variant (pad, resize, composite onto white, watermark where allowed) with Pillow, writes them under the out/ prefixes, and enqueues publish.
  • picr-publish — zip, arm64, 512 MB. Writes manifest.json, moves the job to done, and sends the single ready notification via SES or SNS.
  • picr-drive-sync — zip, arm64, 512 MB. Scheduled. Mirrors the Drive drop-folder into inbox/ and the out/ prefixes back into Drive; reads the Drive key from Secrets Manager.
  • picr-sweep — zip, arm64, 256 MB. Scheduled, daily. Queries the status GSI for jobs stuck in needs review past a day and re-notifies the owner.

Storage, events, and schedules

  • S3. One bucket, picr-images, versioning on, EventBridge notifications enabled. Prefixes: inbox/ (uploads), work/<job_id>/ (normalised source and cutout), and out/web/, out/market/, out/social/ (finished variants and the manifest). Lifecycle rules expire inbox/ raw files and the work/ intermediates after 30 days; the out/ set is kept.
  • Events. ObjectCreated on the inbox/ prefix is delivered to EventBridge; a single rule matches the prefix and targets the picr-work SQS queue, which triggers picr-ingest. The heavier stages are chained by SQS too, with the cutout queue given a visibility timeout longer than the function’s 120-second ceiling so a slow cutout isn’t redelivered mid-run.
  • DynamoDB (on-demand). One table, picr-jobs — PK job_id, SK ts for an append-only status history (ingested, cut, varied, published, or needs-review). Attributes: source_key, status, coverage, confidence, variants (list of keys), and manifest_key. A GSI on status lets picr-sweep find held jobs without a scan.
  • EventBridge Scheduler. Two rules — picr-drive-sync on a few-minute rate, and picr-sweep on a daily cron (early morning).
  • Secrets Manager. Two secrets — the Google Drive key (read by picr-drive-sync) and the hosted-model API key (read only by picr-cutout). Fetched at call time, never in env vars.
  • ECR. Holds the picr-cutout container image with the model weights baked in, deployed by digest. There is no Bedrock in this system; the only model is the one inside the container or behind the hosted-API key.

IAM scope and region

Each function gets its own execution role scoped to exactly what it touches, no wildcards. picr-ingest can read inbox/, write work/, put to picr-jobs, and send to the cutout queue; it cannot read out/ or any secret. picr-cutout can read and write work/, read only the hosted-model secret, publish a review notification, update picr-jobs, and send to the variants queue; it’s the only role allowed to reach the internet for the optional hosted model. picr-variants can read work/, write out/, and enqueue publish — nothing more. picr-publish can read work/ and out/, write the manifest, update picr-jobs, and send via SES/SNS, but cannot delete from any prefix. The scheduled functions hold only the narrow Drive and table permissions they need and have no inbound trigger surface at all. Everything runs in eu-west-2; there is no cross-Region path. An AWS Budgets alarm watches the monthly spend and notifies if it drifts above a few dollars — the early signal that an image is looping the cutout or a lane is misbehaving.

Design rules that shaped the build

  • One container, by necessity. Only the cutout needs a model and the memory to run it; everything else stays a small zip.
  • The upload is the only trigger. ObjectCreated on inbox/ → EventBridge → SQS → ingest; nothing polls.
  • One bucket, clear prefixes. Inbox, work, and per-channel out, with lifecycle on the throwaway prefixes.
  • Least privilege, per role. Only the cutout reads the model secret and reaches the internet; only ingest reads inbox/.
  • State in DynamoDB, pixels in S3. A single job table with a status GSI drives the review sweep; images never live in the table.
  • One region, no model service. eu-west-2 throughout; the model is in the container, not in Bedrock.
All posts