One outage, four root causes
The API went down behind a Cloudflare Tunnel error and stayed down through a reboot, an interrupted OS upgrade, a stale connection pool, and a box too small for its own workload. None of the four were the same bug.
Update, 11 October 2026: the API no longer runs on the setup described here. It moved to FastAPI Cloud after the VM’s public IP turned out not to be free, and the worker now runs from a scheduled GitHub Actions workflow. This post is left as it was written.
api.pacestreak.com started returning Cloudflare’s Error 1033 - “Cloudflare
is currently unable to resolve” the tunnel. That single symptom turned out to
have four unrelated causes stacked on top of each other, each one hiding the
next until the one under it got fixed. None of them were exotic. All of them
were the kind of thing that’s obvious in hindsight and invisible while it’s
happening, because the error on screen never once described the actual
problem.
The box behind all of it is a GCE e2-micro: one shared vCPU, 1 GB of RAM,
running api, worker, and cloudflared under Docker Swarm. That
undersizing turns out to be the thread that ties the whole thing together,
but it took four separate investigations to see it.
First cause: a swapping host takes the tunnel down with it
Cloudflare Tunnel error 1033 means one thing at the protocol level: the
cloudflared connector isn’t reachable from Cloudflare’s edge. The
instinctive read is networking - a firewall rule, a DNS record, a dead
container. None of that was it.
docker service ls showed cloudflared at 1/1 and api at 0/1, with the
API’s last few tasks exited 137 - killed, not crashed. free -m explained
why: 953 MB of RAM, 481 MB used, and 511 MB of swap in active use on a box
with barely a gigabyte to give. The system was swapping hard enough that
Docker’s own internal DNS resolver started timing out against the metadata
server:
level=warning msg="[resolver] connect failed" error="dial udp 169.254.169.254:53: i/o timeout"
level=error msg="failed to get longest-active member" error="failed to find longest active peer" raft_id=41b5702ccdca7c0c
That second line is Docker Swarm’s single-node raft store losing its own
heartbeat to itself - not because there’s another node to talk to, but
because the health-monitor goroutine couldn’t get scheduled promptly enough
under memory pressure to hit its own deadline. Once the daemon’s internal
bookkeeping is that starved, cloudflared’s outbound QUIC connection to
Cloudflare’s edge is not a priority the kernel is going to protect. The
tunnel drops, and the error that surfaces publicly - 1033, unreachable
connector - has nothing in it that points back to swap.
The fix at this layer was a clean reboot, plus two changes to
compose.gcp.yaml while already in there: forcing cloudflared onto
--protocol http2 instead of the QUIC default (one fewer UDP-based thing to
go wrong under pressure), and changing the restart policy from on-failure
to any, so a task that exits cleanly but wrong still gets restarted instead
of sitting there.
Second cause: an OS upgrade that stopped at the worst point
While diagnosing the tunnel, it came out that a do-release-upgrade to
Ubuntu 26.04 had been run on this same box earlier and had died partway
through - the browser running it closed, and the command couldn’t finish.
The system was already reporting VERSION_ID="26.04" in /etc/os-release,
which is the trap: the OS identifies as upgraded well before the package
database agrees.
sudo dpkg --audit
listed grub-efi-amd64-signed, shim-signed, cloud-init,
google-guest-agent, ubuntu-server, and eight other packages unpacked but
never configured. Two of those are the entire reason a cloud VM boots at
all - the signed bootloader chain and the metadata-driven SSH key agent - and
the running kernel still expected the old ones. A reboot in that state is a
coin flip between a working boot and neither.
dpkg --configure -a is the right command and also, on a box this small,
the wrong thing to run attached to an SSH session with a client-side
timeout: several attempts were silently killed mid-run when the local
timeout wrapper expired and took the SSH connection with it, which - as
far as dpkg is concerned - looks identical to the power going out. Each
half-run left the package database in a worse state than before, not a
better one. The fix was mechanical once diagnosed: detach the process from
the SSH session entirely -
setsid nohup dpkg --configure -a > /tmp/dpkg-configure.log 2>&1 < /dev/null &
disown
- and poll the log from a separate connection instead of trusting a
single long-lived one to survive.
grub-installandshim-signedboth completed cleanly for thex86_64-efiplatform once given the room to finish,dpkg --auditcame back empty,apt full-upgradehad nothing left queued, and only then was a reboot safe. It came back clean.
Third cause: a stale front-end on the database pooler
Reboot done, upgrade done, cloudflared and worker both healthy - and
api still wouldn’t start. Its entrypoint runs alembic upgrade head before
serving anything, and that call just hung. No error, no timeout, nothing in
the logs past entrypoint: running migrations.
The instinct here was DNS again, since that’s what caused problem one. It
was a dead end, and a useful one to rule out fast:
SELECT pid, state, wait_event FROM pg_stat_activity against the Neon
Postgres instance showed nothing blocking, no waiting locks in pg_locks,
and a raw TCP connect from inside the container to the database’s IP
succeeded in 22 milliseconds. The network path was fine. The database itself
was fine. And yet a real connection attempt - SSL handshake included, via
the actual postgresql+asyncpg URL the app uses - hung indefinitely from
inside the exact same container.
The detail that cracked it: the worker service, running the identical
image against the identical DATABASE_URL, had one connection open and
idle the whole time, working perfectly. Same code, same credentials, same
network. The only difference was that worker’s connection was already
established before things went wrong, and api needed a new one.
Neon’s -pooler hostname routes through PgBouncer in front of the actual
Postgres backend. pg_stat_activity shows backend-level state - it has no
visibility into the pooler’s own front-end connection table, which is where
a fresh connection queues before it ever reaches a backend worthy of
showing up in that view. An hour of container churn - every restart of
api during the first two causes above opening and then getting SIGKILLed
mid-connection - had left that front-end table full of half-open sockets
the pooler hadn’t reaped yet. New connection attempts weren’t refused; they
were queued behind garbage that was never going to clear itself.
The fix took one call: restarting the Neon compute endpoint, which drops and rebuilds the pooler’s connection table from nothing.
POST /projects/{id}/branches/{id}/endpoints/{id}/restart
Migrations connected immediately afterward. This is worth remembering as its own lesson, separate from the three around it: a pooler sitting in front of a database is a stateful thing with its own failure mode, and “the database is fine” and “you can get a new connection to the database” are different claims that this incident made look identical until they weren’t.
Fourth cause: the box was still too small for what it was running
api could now migrate and start - and then got killed by its own
healthcheck, repeatedly, non-deterministically. Sometimes it made it to
Uvicorn running on http://0.0.0.0:8000 before dying. Sometimes it didn’t
get past printing that it was starting. dmesg had no OOM kills logged
anywhere, which ruled out the obvious explanation and took longer to rule
out than it should have.
ps aux --sort=-%cpu was the tool that actually found it. The Python
process itself was sitting in D state - uninterruptible sleep, blocked on
I/O, not on CPU - with kswapd0 actively working in the background. That’s
not a slow process; it’s a process that has handed control to the kernel and
is waiting for a page to come back from swap before it can do anything else
at all, including answer a health check on a socket it already has open.
free -m at the same moment: 108 MB free, 321 MB in swap, on a box also
running dockerd, containerd, snapd, and two separate Google Cloud
observability agents (otelopscol and fluent-bit) as fixed background
cost before a single application container gets to run.
Buried in the process list was a second, unrelated finding: an earlier
docker service scale command, issued from a session that had itself
timed out locally, was still running server-side six minutes later - Swarm
CLI invocations aren’t guaranteed to die with the SSH connection that
started them, so a command that looks abandoned from one end can still be
live and consuming a share of a single shared vCPU on the other. It got
killed by hand.
The actual fix here wasn’t a configuration change, because there wasn’t a
bug to fix - there was a resource budget that didn’t add up. Scaling api
to zero replicas stopped the crash loop from itself feeding the contention
(every failed start was another process competing with the next one for the
same fractional CPU), stopping the two non-essential logging agents freed
a real if modest amount of RAM, and scaling back up to a single clean
attempt - with nothing else contending for the CPU at that exact moment -
was what finally let one boot sequence run start to finish without getting
starved partway through.
api.pacestreak.com/health: 200
What actually explains all four
Every one of these had a plausible, wrong first explanation: 1033 looks like
networking, a hung migration looks like DNS, a health-check crash loop looks
like an application bug. Every one of those first guesses got tested and
ruled out with a specific piece of evidence - pg_locks, dmesg, a raw TCP
connect from inside the failing container - before moving to the next
hypothesis. That discipline mattered more than any individual fix, because
the four causes didn’t share a mechanism. They shared a symptom, and shared
a host.
The host is the real finding. An e2-micro is one shared vCPU and 1 GB of
RAM, and this stack asks it to run a Swarm manager, two application
containers, a tunnel daemon, and two logging agents at once, with nothing
held in reserve for an OS upgrade, a burst of container restarts, or a
database reconnect to happen at the same time as everything else. Each of
the four causes above is the kind of thing that happens occasionally on a
box with headroom - a swap spike, an interrupted upgrade, a pooler that
needs a kick, a slow boot. On this box, they happened in the same hour,
because there was no slack anywhere for any one of them to be absorbed
without becoming the next one’s precondition.
Moving off e2-micro is now the first item on the infrastructure list, not
because any single cause here demands it, but because all four of them
would have been shorter incidents - some of them non-incidents - on a host
with room to breathe.
AlzyWelzy
Building PaceStreak. GitHub
Related
Security & PrivacyThe API is live, and deploys itself
The API's hosting decision is made: a free-tier GCP VM, Neon and Upstash, zero-downtime rolling updates on a single-node Swarm - and why the VM pulls its own updates instead of GitHub Actions pushing them.
5 min
InfrastructureWhat the free tier actually buys, checked against the account
The API runs on a GCP e2-micro because that's what Always Free covers - one instance, 30 GB of standard disk, an ephemeral IP. It turns out to be sitting exactly at that ceiling, which explains more of the outage two posts back than any single bug did.
3 min
Security & PrivacyFixing the dead ends in sign-up and verification
Three real bugs found by using the OTP flow the way a person actually would: a stuck login, a false 'already registered' error, and a confirmation screen that didn't lead anywhere. Plus a status page that finally watches the whole stack.
3 min