Skip to content

fix(backups): GOMEMLIMIT so a two-database backup run fits the 512 MiB cap - #108

Merged
jayleaton merged 1 commit into
mainfrom
fix/backup-oom
Oct 23, 2026
Merged

jayleaton merged 1 commit into
mainfrom
fix/backup-oom

Conversation

@jayleaton

Copy link
Copy Markdown
Owner

Bug

The backup service (apps/backup, capped at 512 MiB in deploy/platform/graph.go) is OOM-killed whenever it backs up two databases (app + admin). Each database's age scrypt step (packages/backup, work factor 18, so 128·2¹⁸·8 B = 256 MiB) allocates about 256 MiB that becomes garbage right after. With no soft memory limit, the Go GC lets the second database's allocation stack on the first, and the kernel kills the run (and the long-running service with it) after app/ is stored. A derived project hit this on its first in-Railway run.

Reproduction

Local, same cap, no Railway. The image is built from main (docker build -f apps/backup/Dockerfile), and a throwaway postgres:18-alpine holds an app and an admin database:

docker run --rm --memory=512m --memory-swap=512m --network container:<pg> \
  -e APP_ENV=production -e BACKUP_DIR=/store -e BACKUP_ENCRYPTION_KEY=<32+ chars> \
  -e DATABASE_URL=postgres://…/app -e ADMIN_DATABASE_URL=postgres://…/admin \
  -v <dir>:/store <image> backup run

Before (main), 2 runs out of 2:

msg="backup: stored" id=app/20261023T085423Z.dump.age bytes=23069 took=1.864s
EXIT=137      # killed before admin/

After (this branch), 3 runs out of 3:

msg="backup: stored" id=app/20261023T085750Z.dump.age bytes=23069 took=1.706s
msg="backup: stored" id=admin/20261023T085751Z.dump.age bytes=23077 took=1.545s
EXIT=0

Control: the fixed image with -e GOMEMLIMIT=off exits 137 after app/ again, so the limit is what makes the difference.

Fix

ENV GOMEMLIMIT=384MiB in apps/backup/Dockerfile's final stage, with the reason in a comment. The 512 MiB line in deploy/platform/graph.go now notes that the Dockerfile's limit stays below it. There's no code change: the limit is soft, so a larger pg_dump can still use the whole cap.

Verified

  • The before/after/control runs above.
  • make check-fast: ok. make generate: no diff. make lint and make deploy-check: 0 issues.
  • make test: on this busy shared host, packages/auth's -race binary was signal: killed inside the full run twice (the user cgroup's memory.events shows oom_kill; other sessions were compiling at the same time). It passes alone (go test -race ./packages/auth/: ok, 3.6 s). This PR changes no Go code, and the identical root-module code passed the full make test (-race, Postgres, coverage floors) on the base commit earlier today. CI runs it again.

Risk

Low. A backup that genuinely needs more than 384 MiB of live heap still runs; the GC just works harder near the limit.

🤖 Generated with Claude Code

…B cap

Each database's age scrypt step (work factor 18) allocates ~256 MiB that is
garbage right after; without a soft limit the GC let the second database's
allocation stack on the first and the 512 MiB container was OOM-killed
(exit 137) after app/ was stored, killing the service with it.
GOMEMLIMIT=384MiB makes the GC reclaim first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jayleaton

Copy link
Copy Markdown
Owner Author

Rebased onto main (0553a2b). The only conflict was graph.go: main now names the cap const backupMemoryMB = 512 and computes the bytes from it, so I kept that and moved this PR's note onto the constant. The Dockerfile change (ENV GOMEMLIMIT=384MiB and its comment) is unchanged. Still needed on main: main's backup image sets no GOMEMLIMIT, age's scrypt default is work factor 18 (about 256 MiB per database, packages/backup/backup.go), and the cap is still 512 MiB. Not reproduced again locally: the dev box has a no-docker-builds rule. CI's release smoke builds the image.

@jayleaton
jayleaton merged commit 4523a36 into main Oct 23, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant