Building Hermes, Part 7: Postmortem: The Agent Server OOM Incident Was Not Caused by New Code

Jul 17 2026 · 6 min · Sieon

When a server slows down and starts dropping connections right after a new feature ships, the first suspect is usually the code that just went out. That was my first reaction too. After finishing work around the Planner and Executor, the agent server suddenly became sluggish. Connections dropped. Responses were delayed. Everything looked suspiciously tied to the deployment.

At first, I thought, "Did the new code introduce a memory leak?" The timing made that theory feel plausible. But the actual root cause was not the new feature. It was a set of orphaned interactive sessions left behind inside the container.

Symptoms: Slow, Disconnected, Then Even Slower

The issue started on a small VPS with very little memory headroom. I had been opening the agent CLI repeatedly while testing. At some point, server responses became noticeably slower. SSH was unstable, and launching the agent no longer felt smooth.

The worst part was the feedback loop.

When the server slows down, the user assumes the session died and reconnects. Reconnecting opens a new interactive session. If the previous session did not actually exit, memory pressure grows faster. The server gets slow, the user retries, and the retry makes the server even slower.

Instrumentation Showed It Was Not the New Code

Guessing in this situation is dangerous, so I started with basic instrumentation: memory usage, swap usage, load average, process lists, and OOM logs.

The result was clear. Both RAM and swap were exhausted, and the system was close to thrashing. The OOM killer was not killing the agent process. It was killing other services on the same server. In other words, memory pressure from one area was pulling neighboring services down with it.

The process list gave the more direct clue. Interactive CLI sessions launched with docker exec -it were still alive inside the container after the terminal or SSH connection had disappeared. Several TTY sessions had accumulated, and each one was spawning its own MCP servers.

That is where the issue became serious. A single session was not just one CLI process. Each session brought several MCP subprocesses with it, creating a memory cost of hundreds of megabytes per session. On a small VPS, only a few orphaned sessions were enough to become fatal.

The Lifecycle Trap in docker exec

The most important trap in this incident was the lifecycle behavior of docker exec.

Intuitively, it feels like when the local terminal or SSH connection drops, the process launched from that connection should be cleaned up too. But a process started inside a container with docker exec may not die just because the client connection disappeared. From inside the container, it can remain alive, still attached to a TTY.

When that behavior meets an agent CLI that starts several backend processes inside each session, the cost multiplies. This was not a single-process leak. It was a cost model of "one full MCP server set per interactive session." Every reconnect added another copy of that cost.

It was natural to suspect the new feature at first. But the measurements pointed somewhere else: not the newly deployed code path, but the operational pattern and process lifecycle around it.

Response 1: An Orphan Session Reaper

The first response was tactical. I built a reaper that periodically cleans up orphaned interactive sessions.

A naive "kill old processes" approach would have been risky. Active sessions, gateways, dashboards, and other long-running processes must not be touched. So the criteria were intentionally narrow: terminate only TTY sessions that no longer have a corresponding docker exec client on the host side. Background services without a TTY were excluded.

The reaper also collects MCP processes that were reparented to PID 1 after their session died. The key was not "kill anything old." The key was "clean up only interactive sessions whose client has disappeared." After testing, I deployed it on a schedule.

Response 2: Memory Limits to Contain the Blast Radius

The second response was blast-radius reduction.

In this incident, the OOM killer took down other services on the same server. That means a problem originating inside the agent container had spread to the host as a whole. I added memory limits to the container, both to the currently running container and to the deployment definition, so a similar leak could not take unrelated services down next time.

Memory limits do not fix the root cause. But in production operations, they matter. Any service can leak or run away eventually. Boundaries keep one failure from becoming a whole-server outage.

Response 3: Moving to a Shared MCP Proxy

The third response was structural.

In the previous architecture, every CLI session started its own MCP servers. One session meant one backend set. Ten sessions meant ten backend sets. In a small environment, that architecture was inherently risky.

So I changed the stdio-based MCP backends to run once as a separate service. CLI sessions now connect to a shared MCP proxy over HTTP instead of spawning their own subprocesses. As a result, each CLI session no longer starts its own MCP backend set. One shared backend set carries the memory cost, and the per-session overhead becomes much smaller.

This was the biggest structural improvement. The reaper cleans up accumulated damage. The shared MCP proxy removes the repeated per-session cost in the first place.

Response 4: Idle Auto-Shutdown

Finally, I added a preventive guard.

If an interactive TUI session has no user input and no active agent turn, it now exits cleanly after a configured idle period. Instead of hard-killing it, the system sends SIGTERM so the normal shutdown path can run. The default is conservative, and the timeout can be adjusted through an environment variable.

I also verified the behavior with a short timeout. This was not just "it should exit someday." I confirmed that it actually exits when idle and not working.

Lessons Learned

The incident left me with three main lessons.

First, an incident that happens right after a deployment is not necessarily caused by the new code. Timing is a strong hint, but it is not evidence. Basic measurements like memory, swap, load average, process lists, and OOM logs are much more reliable.

Second, the lifecycle of interactive operational tools matters more than it seems. Convenient tools like docker exec -it need to be understood all the way through process cleanup after disconnection. During development, an orphan session can look harmless. On a server, it keeps costing memory.

Third, a good response is layered rather than a single patch. In this case, the layers were an orphan session reaper, container memory limits, a shared MCP proxy, and idle auto-shutdown. Each layer has a different job: clean up, isolate, restructure, and reduce recurrence.

In the end, this was not a bug in the new feature. It was a gap in the operational structure. Problems like that are better handled by measuring first, narrowing the failure mode, and separating tactical mitigation from structural improvement. When a server is dying, the first job is not to name the culprit. It is to find where the system is actually breaking down.

한국어 버전

에이전트 서버 OOM 사고 회고: 범인은 새 코드가 아니었다

새 기능을 배포한 직후 서버가 느려지고 끊기기 시작하면, 가장 먼저 의심하는 것은 방금 배포한 코드다. 나도 그랬다. Planner와 Executor 쪽 작업을 마친 뒤 에이전트 서버가 갑자기 버벅였고, 접속이 끊기거나 응답이 늦어지는 일이 반복됐다.

처음에는 “새 코드가 메모리를 새고 있나?”라고 생각했다. 타이밍이 너무 그럴듯했기 때문이다. 하지만 실제 원인은 새 기능이 아니었다. 문제는 컨테이너 안에 남아 있던 고아 인터랙티브 세션들이었다.

증상: 느려지고, 끊기고, 더 느려졌다

문제는 작은 VPS에서 시작됐다. 메모리가 넉넉하지 않은 환경이었고, 에이전트 CLI를 여러 번 열어 테스트하던 중이었다. 어느 순간부터 서버 응답이 눈에 띄게 느려졌다. SSH 연결도 불안정했고, 에이전트 실행도 매끄럽지 않았다.

더 안 좋은 점은 악순환이었다.

서버가 느려지면 사용자는 세션이 죽었다고 생각하고 다시 접속한다. 다시 접속하면 새 인터랙티브 세션이 열린다. 그런데 이전 세션이 실제로는 죽지 않고 남아 있다면, 메모리는 더 빠르게 줄어든다. 느려져서 재시도하고, 재시도해서 더 느려지는 구조였다.

계측해보니 새 코드가 아니었다

이런 상황에서 감으로 고치기 시작하면 위험하다. 그래서 먼저 기본적인 계측부터 확인했다. 메모리 사용량, 스왑 사용량, load average, 프로세스 목록, OOM 로그를 차례로 봤다.

결과는 꽤 명확했다. RAM과 스왑이 모두 고갈되어 있었고, 시스템은 스래싱 상태에 가까웠다. OOM killer는 에이전트 프로세스가 아니라 같은 서버의 다른 서비스를 죽이고 있었다. 즉, 한쪽에서 생긴 메모리 압박이 이웃 서비스까지 끌고 내려가는 상태였다.

프로세스 목록을 보니 더 직접적인 단서가 나왔다. docker exec -it로 띄운 인터랙티브 CLI 세션들이 터미널이나 SSH 연결이 끊긴 뒤에도 컨테이너 안에 남아 있었다. 여러 개의 TTY 세션이 누적되어 있었고, 각 세션은 MCP 서버들을 따로 스폰하고 있었다.

문제는 여기서 커졌다. 세션 하나가 단순히 CLI 프로세스 하나만 차지하는 게 아니었다. 세션마다 여러 개의 MCP 서브프로세스가 붙었고, 세션당 수백 MB 단위의 메모리 비용이 생겼다. 세션이 몇 개만 쌓여도 작은 VPS에서는 충분히 치명적이었다.

docker exec의 라이프사이클 함정

이번 사고에서 가장 중요한 함정은 docker exec의 라이프사이클이었다.

직관적으로는 로컬 터미널이나 SSH 연결이 끊기면 그 안에서 실행한 프로세스도 함께 정리될 것처럼 느껴진다. 하지만 docker exec로 컨테이너 안에 띄운 프로세스는 클라이언트 연결이 끊겼다고 자동으로 죽지 않을 수 있다. 컨테이너 안에서는 여전히 살아 있고, TTY를 붙잡은 채 남는다.

이게 에이전트 CLI처럼 세션 내부에서 여러 백엔드 프로세스를 띄우는 구조와 만나면 비용이 커진다. 단일 프로세스 누수가 아니라, “인터랙티브 세션 하나당 MCP 서버 세트 하나”라는 비용 구조가 생긴다. 그리고 사용자가 재접속할 때마다 그 비용이 누적된다.

처음에 새 기능을 의심한 것은 자연스러운 반응이었다. 하지만 계측 결과를 보면 원인은 배포된 코드 경로가 아니라 운영 방식과 프로세스 생명주기 쪽에 있었다.

대응 1: 고아 세션 reaper

첫 번째 대응은 전술적 픽스였다. 고아가 된 인터랙티브 세션을 주기적으로 정리하는 reaper를 만들었다.

단순히 오래된 프로세스를 죽이는 방식은 위험했다. 실제로 사용 중인 세션이나 gateway, dashboard 같은 장기 실행 프로세스를 건드리면 안 됐다. 그래서 기준을 좁혔다. 호스트 쪽에 대응되는 docker exec 클라이언트가 더 이상 붙어 있지 않은 TTY 세션만 골라 종료하도록 했다. TTY가 없는 백그라운드 서비스는 대상에서 제외했다.

또한 세션이 죽은 뒤 PID 1에 재부모화된 MCP 프로세스도 함께 수거하도록 했다. 핵심은 “대충 오래됐으니 죽인다”가 아니라, 클라이언트가 사라진 인터랙티브 세션만 정리하는 것이었다. 테스트 후 주기 실행으로 배포했다.

대응 2: 메모리 리밋으로 폭발 반경 제한

두 번째 대응은 폭발 반경 제한이었다.

이번에는 OOM killer가 같은 서버의 다른 서비스를 죽였다. 이것은 에이전트 컨테이너 안에서 발생한 문제가 호스트 전체로 번졌다는 뜻이다. 그래서 컨테이너에 메모리 한도를 걸었다. 실제 실행 중인 컨테이너와 배포 정의 양쪽에 모두 반영해, 다음에 비슷한 누수가 생겨도 호스트의 다른 서비스까지 죽이지 못하게 했다.

메모리 리밋은 근본 원인 해결은 아니다. 하지만 운영 환경에서는 매우 중요하다. 어떤 서비스든 언젠가는 새거나 폭주할 수 있다. 그때 전체 서버를 같이 무너뜨리지 않도록 경계를 세워야 한다.

대응 3: 공유 MCP proxy로 구조를 바꿨다

세 번째 대응은 구조적 픽스였다.

기존 구조에서는 CLI 세션마다 MCP 서버들을 별도로 띄웠다. 세션이 하나면 한 세트, 세션이 열 개면 열 세트가 뜨는 방식이었다. 작은 환경에서는 이 구조 자체가 위험했다.

그래서 stdio 기반 MCP 백엔드들을 별도 서비스가 한 번만 띄우고, 세션들은 HTTP를 통해 공유 MCP proxy에 붙도록 바꿨다. 결과적으로 각 CLI 세션은 더 이상 MCP 서브프로세스를 직접 스폰하지 않게 됐다. 공유 백엔드 세트 하나만 메모리를 쓰고, 세션당 추가 비용은 거의 사라졌다.

이 변화가 가장 큰 구조적 개선이었다. reaper는 쌓인 것을 청소하는 장치라면, 공유 MCP proxy는 애초에 세션마다 같은 비용이 반복되는 구조를 없애는 장치였다.

대응 4: idle 자동 종료

마지막으로 예방 장치를 넣었다.

인터랙티브 TUI 세션이 입력도 없고 에이전트 턴도 돌고 있지 않다면, 일정 시간이 지난 뒤 스스로 정상 종료하도록 했다. 강제로 죽이는 대신 SIGTERM을 보내 정상 종료 경로를 타게 했다. 기본값은 보수적으로 잡고, 환경 변수로 조절할 수 있게 했다.

이 기능은 짧은 타임아웃으로 실제 검증까지 했다. “언젠가 종료될 것이다”가 아니라, 입력이 없고 작업 중이 아닐 때 실제로 종료되는지 확인했다.

배운 점

이번 사고에서 가장 크게 배운 것은 세 가지다.

첫째, 배포 직후 장애라고 해서 반드시 새 코드가 원인은 아니다. 타이밍은 강한 힌트지만 증거는 아니다. 메모리, 스왑, load average, 프로세스 목록, OOM 로그 같은 기본 계측이 훨씬 정확하다.

둘째, 인터랙티브 운영 도구의 생명주기는 생각보다 중요하다. 특히 docker exec -it처럼 편리한 도구는 연결이 끊긴 뒤의 프로세스 정리까지 명확히 이해하고 써야 한다. 개발 중에는 사소해 보이지만, 서버에서는 고아 세션 하나가 계속 비용을 만든다.

셋째, 좋은 대응은 한 방짜리 패치가 아니라 레이어다. 이번에는 고아 세션을 치우는 reaper, 컨테이너 메모리 리밋, 공유 MCP proxy, idle 자동 종료를 순서대로 깔았다. 각각의 역할이 다르다. 청소하고, 격리하고, 구조를 바꾸고, 재발을 줄인다.

결국 이 사고는 새 기능 버그가 아니라 운영 구조의 빈틈이었다. 그리고 그런 문제일수록 감으로 고치기보다 계측으로 좁히고, 임시 처방과 구조적 개선을 나눠서 적용하는 편이 낫다. 서버가 죽어갈 때 가장 먼저 할 일은 범인을 단정하는 것이 아니라, 시스템이 실제로 어디에서 무너지고 있는지 확인하는 것이다.