AI Agents · Docker

AI 에이전트마다
컨테이너 하나씩

코딩 에이전트 여러 개가 머신 하나를 같이 쓰면, 보통은 약속으로 서로를 떼어 놓는다. 작업마다 락을 하나씩 걸고, 스크립트마다 포트를 정하고, 정리 명령이 엉뚱한 프로세스를 죽이지 않기를 바란다. 에이전트마다 컨테이너를 하나씩 주면 격리하는 단위와 회수하는 단위가 같아진다. 그 설계를 정리하고, 컨테이너가 과할 때 쓸 cgroup만으로 하는 가벼운 방법도 같이 적었다.

내 작업용 머신은 8코어에 메모리 약 10GB인 WSL 한 대다. 여기서 AI 코딩 에이전트 여러 개가 동시에 돈다. 대화형 세션에서 띄우는 서브에이전트도 있고, 매시간 헤드리스 세션을 띄우는 예약 작업도 있다. 모두 같은 사용자로, 같은 파일시스템에서, 같은 Docker 데몬을 쓴다.

이들을 떼어 놓는 건 대부분 약속이다. 예약 작업마다 파일 락을 걸어 자기 자신과는 겹치지 않게 했지만, 서로 다른 두 작업이 브라우저 테스트를 동시에 돌리는 건 막지 못한다. 포트를 고정한 개발 서버는 에이전트 둘이 띄우는 순간 부딪친다. 그 포트를 잡은 프로세스를 죽이는 손쉬운 정리 방법은 다른 에이전트의 서버까지 죽인다. 이미 죽은 프로세스가 락을 며칠씩 쥐고 있던 적이 있다. 테스트 스위트 5개를 병렬로 돌렸더니, 저마다 데이터베이스 컨테이너를 띄우다가 Docker 데몬이 멈춘 적도 있다. 자기 git 워크트리에서만 일해야 할 에이전트가 메인 체크아웃에 파일을 써 넣은 일도 있었다.

그래서 옮겨 갈 구조를 정리해 둔다. 에이전트마다 컨테이너를 하나씩 주는 구조다. 아직 적용하지는 않았다. 이 정도 크기의 머신에서는 준비 비용이 얻는 것보다 커 보여서다. 아래 내용 중 일부는 이 머신에서 직접 시험했고, 그런 곳은 따로 밝혔다.

격리와 회수를 한 단위로

목표는 보안 샌드박스가 아니다. 에이전트가 띄운 모든 것(셸, 개발 서버, 브라우저, 테스트용 데이터베이스)을 자원 상한이 있는 경계 하나 안에 두고, 명령 하나로 전부 걷어 내는 것이다. 컨테이너마다 cgroup이 하나씩 붙기 때문에 둘 다 된다. docker rm -f는 컨테이너 안의 프로세스를 모두 죽인다. 스스로 데몬이 된 프로세스나 새 세션을 연 프로세스도 빠지지 않는다.

docker run --rm --init \
  --name agent-a1 \
  --label agent.id=a1 --label agent.budget=3600 \
  --cpuset-cpus 2,3 \
  --memory 2500m --memory-swap 2500m \
  --pids-limit 512 --shm-size 1g \
  --network agents \
  -v "$HOME/work/a1":/work -w /work \
  -v "$HOME/.agent-secrets/a1":/run/secrets:ro \
  agent-browser:2026.10 \
  sh -c 'CLAUDE_CODE_OAUTH_TOKEN=$(cat /run/secrets/oauth-token) exec claude -p "$1"' sh "$TASK"
  • --cpus(위 명령에는 넣지 않았다)는 CPU 사용 시간 총량을 제한하고, --cpuset-cpus는 에이전트를 특정 코어에 묶는다. 브라우저 프레임 시간처럼 시간을 재는 검사가 있으면 코어를 묶는 쪽이 낫다. 총량 제한으로는 에이전트들이 여전히 같은 코어를 나눠 쓰고, 몫을 다 쓴 에이전트는 그 스케줄링 주기가 끝날 때까지 멈춰 서므로 측정값이 흔들린다. 코어를 나눠 주면 서로를 흔들지 않는다.
  • --memory와 --memory-swap을 같은 값으로 주면 스왑이 꺼진다. 메모리가 새는 에이전트는 머신 전체를 느리게 만드는 대신 자기 상한에서 죽는다.
  • --pids-limit는 프로세스를 끝없이 띄우는 루프를 멈춘다.
  • --shm-size는 브라우저에 필요하다. Docker는 /dev/shm을 기본 64MB로 주는데, Chromium은 여기서 모자라 죽을 수 있다. --ipc=host로도 해결되지만 격리를 일부 포기하게 된다.
  • --init을 주면 아주 작은 init 프로세스가 PID 1이 되어 좀비를 거둔다. 이게 없으면 부모를 잃은 자식이 끝난 뒤에도 컨테이너가 없어질 때까지 defunct로 남는다.
  • 라벨이 있으면 정리 작업이 무엇이 어느 에이전트 것인지, 얼마나 돌아도 되는지 알 수 있다.

10GB에서는 계산이 빡빡하다. 헤드리스 브라우저와 개발 서버, CLI까지 띄운 에이전트 하나가 2~2.5GB를 쓰니 동시에 3칸 정도가 한계다. 컨테이너를 써도 동시에 도는 에이전트 수는 따로 막아야 한다. 다만 칸마다 넘을 수 없는 상한이 생긴다.

에이전트끼리는 네트워크로 말한다

컨테이너마다 네트워크 네임스페이스가 따로 생기고, 이것만으로 포트 문제가 풀린다. 모든 에이전트가 개발 서버를 5173에 띄워도 된다. 5173이 저마다 다른 네임스페이스에 있기 때문이다. 포트를 박아 둔 스크립트도 더는 위험하지 않다.

서로 협업해야 하는 에이전트는 사용자 정의 브리지 네트워크로 묶는다. 이 네트워크에서는 Docker 내장 DNS가 컨테이너 이름을 풀어 준다. 나라면 에이전트마다 메일함을 하나씩 둔 작은 조정자 서비스를 쓰겠다. 에이전트는 상대 메일함에 메시지를 POST하고, 자기 메일함은 롱 폴링으로 기다린다. 코드는 git으로 오간다. 공유 원격 저장소에 브랜치를 푸시하는 식이다. 같이 쓰는 쓰기 가능 디렉터리는 두지 않는다. 디렉터리를 같이 마운트하면 에이전트끼리 서로의 파일을 덮어쓰는 일이 다시 생긴다. (아래 mailbox와 egress-proxy는 직접 만들 작은 서비스를 가리키는 이름이고, 공개된 이미지가 아니다.)

docker network create --internal agents
docker network create egress

docker run -d --name proxy --network egress egress-proxy
docker network connect agents proxy

docker run -d --name mailbox --network agents mailbox

# inside agent a1
curl -s -X POST http://mailbox:8080/agents/a2 \
  -d '{"from":"a1","body":"schema is ready on branch a1/schema"}'
curl -s "http://mailbox:8080/agents/a1?wait=60"

에이전트 네트워크는 --internal로 만들어 인터넷에 직접 나가지 못하게 한다. 그래도 모델 API, git 호스트, 패키지 레지스트리에는 닿아야 하니, 내부 네트워크와 일반 네트워크에 함께 붙은 프록시 하나를 두고 허용 목록에 있는 호스트로만 내보낸다. 에이전트에는 HTTPS_PROXY=http://proxy:3128를 준다. 프롬프트 인젝션에 넘어간 에이전트가 할 수 있는 일도 이걸로 줄어든다. 아무 호스트로나 데이터를 보낼 수 없기 때문이다.

컨테이너 안의 컨테이너는 필요할 때만

대부분의 에이전트는 Docker가 필요 없다. Testcontainers로 통합 테스트를 돌리거나 이미지를 빌드하는 에이전트는 필요한데, 방법마다 격리와 비용을 맞바꾸는 지점이 달라서 하나를 골라야 한다.

방법격리회수
호스트 Docker 소켓 마운트없다. 소켓에 접근하면 호스트 root와 같다자식 컨테이너가 형제로 떠서 에이전트 상한 밖에 있다
Docker-in-Docker(--privileged, 자체 데몬)데몬과 스토리지가 따로지만 컨테이너가 특권을 갖는다docker rm -f -v 한 번에 이미지 캐시까지 전부 사라진다. 공식 이미지는 /var/lib/docker를 익명 볼륨에 두므로 -v 없는 rm -f는 그걸 남긴다
Sysbox 런타임(--runtime=sysbox-runc)--privileged 없이 안쪽 데몬을 띄운다Docker-in-Docker와 같다. 호스트에 런타임을 설치해야 한다
컨테이너 안의 rootless Podman좋고 데몬도 없다. 다만 /dev/fuse와 느슨한 seccomp 프로필이 필요하고, Testcontainers를 쓰려면 podman system service를 띄워야 한다자식 프로세스가 에이전트 cgroup 안에 머문다

가장 싼 건 소켓 마운트다. 여기에 --cgroup-parent를 쓰면 자원 상한을 되찾을 수 있다. systemd cgroup 드라이버에서는 이 옵션에 슬라이스 이름을 준다. --cgroup-parent=agent-a1.slice로 띄운 컨테이너가 /agent.slice/agent-a1.slice 아래에 붙는 것을 확인했다. 에이전트 자신의 컨테이너와 그 에이전트가 띄우는 컨테이너가 모두 같은 슬라이스를 쓰면, 슬라이스에 건 상한이 전부에 적용되고 슬라이스를 멈추면 함께 회수된다. 슬라이스 속성을 바꾸려면 root가 필요하다.

docker run --rm --init \
  --cgroup-parent agent-a1.slice \
  -v /var/run/docker.sock:/var/run/docker.sock \
  --group-add "$(stat -c %g /var/run/docker.sock)" \
  --network agents \
  -e TESTCONTAINERS_RYUK_DISABLED=true \
  ...

sudo systemctl set-property agent-a1.slice MemoryMax=4G CPUQuota=300%
sudo systemctl stop agent-a1.slice

약점은 강제할 수 없다는 점이다. 소켓은 어떤 API 호출이든 받으니, 에이전트가 이 옵션 없이 컨테이너를 띄우면 그만이다. 강제하려면 소켓 앞에 프록시를 두고 컨테이너 생성 요청을 고쳐 써야 한다. 내가 아는 소켓 프록시들은 어떤 엔드포인트를 허용할지만 거르고, 요청에 필드를 더해 주지는 않는다.

에이전트 컨테이너 안에서 Testcontainers를 쓰려면 자기가 띄운 컨테이너에 닿을 길도 있어야 한다. Testcontainers는 기본적으로 호스트 게이트웨이를 거쳐 호스트에 열린 포트를 찾는데, 앞의 --internal 네트워크에서는 거기로 가는 길이 없다. 내 머신에서는 첫 단계인 정리용 컨테이너(Ryuk) 연결에서 바로 실패했다. 통한 방법은 모든 것을 agents 네트워크 안에 두는 것이었다. TESTCONTAINERS_RYUK_DISABLED=true로 Ryuk를 끄고, 데이터베이스를 네트워크 별칭을 붙여 agents에 띄우고, 기본 포트 확인 대신 로그 한 줄을 기다리게 했다. 그러자 에이전트가 별칭과 내부 포트로 데이터베이스에 붙었다.

대신 테스트와 정리 작업에 할 일이 생긴다. 테스트는 Testcontainers가 알려 주는 호스트와 매핑 포트 대신 별칭과 내부 포트로 접속해야 한다. 별칭에는 에이전트 id를 넣어야 한다(db 말고 a1-db). 모든 에이전트가 이 네트워크를 같이 쓰기 때문에, 같은 별칭을 단 컨테이너가 둘이면 둘 다 그 이름으로 응답한다. 그리고 Ryuk가 없으니, 죽은 에이전트의 테스트가 남긴 컨테이너는 정리 작업이 지워야 한다.

내 기본값은 이렇다. 보통은 Docker를 주지 않는다. 테스트용 데이터베이스를 띄우는 에이전트에는 소켓과 슬라이스를 준다. 이미지를 빌드하거나 자기 데몬이 필요한 에이전트에는 Sysbox를 쓴다. Sysbox 지원표에 WSL2는 없고, 나도 거기서 시험해 보지는 않았다. 공유 데몬 하나에서 에이전트 5개가 데이터베이스 컨테이너를 띄우다 데몬을 멈춰 세운 적이 있다. 에이전트마다 데몬을 따로 주면 그런 고장이 그 고장을 낸 에이전트 안에서 끝난다.

인증 정보는 나눠 주되 이미지에 굽지 않는다

에이전트에 필요한 인증 정보는 많아야 세 가지다. 모델 API, git 호스트, 가끔 클라우드 계정이다. 어느 것도 이미지 레이어에 들어가면 안 된다.

  • 모델 인증. CLI의 대화형 로그인은 OAuth 토큰을 파일에 저장하고, 갱신할 때마다 그 파일에 다시 쓴다. 이 파일을 여러 컨테이너에 마운트하면 여러 프로세스가 토큰 하나를 서로 갱신하려고 다툰다. claude setup-token으로 만든 장기 토큰이나 API 키를 컨테이너마다 넘기면 같은 파일에 쓸 일이 없다.
  • 환경 변수 말고 파일로. 환경 변수는 docker inspect에도, 모든 자식 프로세스에도 보인다. /run/secrets 아래 읽기 전용 파일로 넘기면 docker inspect에는 드러나지 않는다. 다만 CLI는 setup-token 토큰을 CLAUDE_CODE_OAUTH_TOKEN 환경 변수로만 받는다. 그래서 위 명령은 파일을 읽어 CLI 프로세스에만 변수를 넘긴다. 그래도 에이전트가 띄우는 자식 프로세스는 그 변수를 물려받는다. API 키라면 CLI가 키를 받으러 실행하는 스크립트인 apiKeyHelper를 써서 환경 변수에서 아예 뺄 수 있다.
  • 수명이 짧은 git 토큰. 모든 저장소에 닿는 개인 토큰 하나를 돌려 쓰는 대신, GitHub App 키를 쥔 조정자가 에이전트마다 설치 토큰을 발급한다. 범위는 그 에이전트가 일하는 저장소로 좁힌다. 이 토큰은 1시간 뒤 만료된다. 그래서 에이전트가 죽으면 따로 폐기하지 않아도 그 토큰은 만료와 함께 못 쓰게 된다.
  • SSH. 키 대신 SSH 에이전트 소켓(SSH_AUTH_SOCK)을 마운트한다.
  • 설정. 공용 설정과 에이전트 정의는 읽기 전용으로 넣는다. 홈 디렉터리는 컨테이너마다 쓰기 가능한 것을 따로 줘서 세션 상태와 캐시가 섞이지 않게 한다.

이미지는 바뀌는 빈도로 나눈다

모든 걸 담은 이미지 하나는 커지고 다시 빌드하기도 느리다. 부분마다 얼마나 자주 바뀌는지에 따라 나눠서, Dockerfile 하나의 빌드 타깃으로 두겠다.

FROM node:22-bookworm-slim AS node
RUN apt-get update \
 && apt-get install -y --no-install-recommends git ca-certificates \
 && rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code@2.1.289
USER node
WORKDIR /work

FROM mcr.microsoft.com/playwright:v1.62.0-jammy AS browser
RUN npm install -g @anthropic-ai/claude-code@2.1.289
USER pwuser
WORKDIR /work
  • 툴체인은 타깃을 나눈다. Node, JVM, Python 에이전트가 서로의 런타임을 짊어질 이유가 없다.
  • 브라우저 타깃은 Playwright 공식 이미지에서 시작하고, 프로젝트가 쓰는 Playwright 버전에 맞춰 고정한다. 버전이 다르면 이미지 안의 브라우저가 테스트 라이브러리가 기대하는 것과 어긋난다. 이 이미지가 내 머신에서 2.3GB쯤으로 단연 가장 크다.
  • CLI 버전도 이미지에 고정한다. 업그레이드는 다시 빌드하는 일이 되고, 돌고 있는 에이전트 밑에서 바뀌는 일은 없어진다.
  • 캐시는 볼륨에 둔다. npm 캐시는 내용 해시로 파일을 찾는 구조라 여러 프로세스가 동시에 써도 깨지지 않게 되어 있다. 그래서 이름 붙인 볼륨 하나를 같이 써도 된다. node_modules는 워크트리마다 따로, 그 에이전트에 딸린 볼륨에 둔다.
  • root가 아닌 사용자로 돌리고, 어느 레이어에도 비밀값을 넣지 않는다.

회수의 흐름과 남는 문제

생명 주기는 짧다. 래퍼가 --rm과 라벨을 붙여 컨테이너를 띄우고, 작업이 끝나면 컨테이너와 그 에이전트의 워크트리를 지운다. 타이머로 도는 정리 작업은 라벨에 적힌 시간 예산을 넘긴 것을 걷어 낸다. 시간 초과로 컨테이너를 지우면 프로세스 트리 전체가 같이 사라진다. 프로세스 하나에 건 timeout으로는 장담할 수 없는 일이다.

그래도 남는 문제가 셋 있다. 디스크 오류 같은 이유로 커널 I/O 대기(D 상태)에 빠진 프로세스는 사용자 공간에서 죽일 수 없고, 그 컨테이너를 지우는 명령도 같이 멈춘다. Docker 데몬은 모두가 같이 쓰는 단일 장애점이다. 데몬이 멈추면 돌던 컨테이너는 계속 돌지만, 새로 띄우거나 지울 수가 없다. 메모리 총량은 그대로라서 동시에 도는 에이전트 수는 계속 막아야 한다.

더 가벼운 방법, 에이전트마다 cgroup 하나

회수 효과의 대부분은 컨테이너보다 cgroup에서 나온다. systemd를 쓰면 이미지도 마운트도, 인증 정보를 넘기는 설정도 없이 에이전트마다 cgroup을 하나씩 만들 수 있다.

systemd-run --user --unit=agent-a1 \
  --same-dir --setenv=PATH="$PATH" \
  -p MemoryMax=2500M -p MemorySwapMax=0 \
  -p TasksMax=512 -p CPUQuota=200% \
  -p RuntimeMaxSec=3600 \
  sh -c 'CLAUDE_CODE_OAUTH_TOKEN=$(cat "$HOME/.agent-secrets/a1/oauth-token") exec claude -p "$1"' sh "$TASK"

systemctl --user stop agent-a1

이 머신에서 시험해 봤다. setsid로 빠져나간 자식도 유닛 안에 그대로 있었고, systemctl --user stop이 그것까지 지웠다. MemoryMax와 TasksMax는 적용됐다. CPUQuota는 적용되지 않았다. 이 머신의 사용자 세션(systemd 249)은 memory와 pids 컨트롤러만 위임받아서, CPU 상한은 오류 하나 없이 버려졌다. 켜려면 user@.service에 드롭인을 넣고 사용자 매니저를 다시 띄워야 한다. 다른 세션이 붙잡고 있으면 새로 로그인하는 것만으로는 안 되고, WSL에서는 배포판을 다시 시작해야 한다.

sudo mkdir -p /etc/systemd/system/user@.service.d
sudo tee /etc/systemd/system/user@.service.d/delegate.conf <<'EOF'
[Service]
Delegate=cpu cpuset io memory pids
EOF
sudo systemctl daemon-reload

임시 유닛은 셸의 환경 변수와 작업 디렉터리를 물려받지 않는다. --same-dir과 --setenv가 그래서 있다. --setenv에 이름만 줘서 호출한 셸의 값을 가져오는 건 systemd 250부터다. 249에서는 "Invalid environment block"으로 실패하니 값을 직접 넘긴다. --setenv로 넘긴 값은 systemctl --user show에도 그대로 보인다. docker inspect에 환경 변수가 보이던 것과 같은 문제라서, 토큰은 유닛 안에서 파일로 읽는다. 옵션 없이 쓰면 systemd-run은 유닛이 시작되자마자 돌아온다. --wait로 끝날 때까지 기다리려면 사용자 D-Bus 세션이 있어야 하고, Ubuntu에서는 dbus-user-session 패키지가 그걸 준다.

그리고 cgroup은 네트워크도 파일시스템도 격리하지 않으니, 약속 세 가지를 같이 둬야 한다.

  • 동적 포트. 실행마다 포트를 고르고, 서버는 strict port 옵션으로 띄워 다음 포트로 슬쩍 옮겨 가는 대신 실패하게 한다. 정리는 포트가 아닌 유닛이나 프로세스 그룹 단위로 한다.
  • 에이전트마다 git 워크트리. 그래도 에이전트가 워크트리 밖에 쓸 수 있으니, 끝나면 메인 체크아웃도 확인한다.
  • 귀한 자원에는 칸을 둔다. 락 파일 N개를 카운팅 세마포어로 쓴다. 브라우저가 필요한 에이전트는 빈 칸을 잡고, 없으면 75로 끝나 예약 작업이 나중에 다시 시도하게 한다.
mkdir -p ~/.cache/agent-slots
for slot in 1 2 3; do
  exec 8>~/.cache/agent-slots/browser.$slot
  flock -n 8 && break
  exec 8>&-
done
[ -e /proc/self/fd/8 ] || { echo "no browser slot free"; exit 75; }
claude -p "$TASK" 8>&-

락은 프로세스가 아니라 열린 파일에 걸린다. 그래서 fd 8을 물려받은 자식은 모두 칸을 쥔 채로 남는다. 에이전트보다 오래 사는 개발 서버도 마찬가지다. 에이전트를 8>&-로 띄우면 락은 래퍼만 쥔다. 래퍼는 에이전트가 끝날 때까지 살아 있어야 하고, cgroup 방식에서는 systemd-run --wait가 그 역할을 한다.

에이전트마다 컨테이너에이전트마다 cgroup
자원 상한CPU, 메모리, 프로세스 수, 공유 메모리메모리, 프로세스 수(CPU는 위임 설정 뒤)
회수docker rm -f가 모든 프로세스를 지운다systemctl --user stop이 모든 프로세스를 지운다
포트 충돌없다(네트워크 네임스페이스가 따로)동적 포트가 필요하다
파일시스템마운트한 것만 보인다사용자가 닿는 곳은 다 보인다
에이전트 사이 통신DNS 이름이 있는 네트워크에이전트끼리 정한 방식
준비 비용이미지, 마운트, 인증 정보, 네트워크지금 쓰는 명령 앞에 명령 하나

나라면 cgroup부터 하겠다. 지금 쓰는 명령을 감싸기만 하면 되고, 가장 자주 겪는 고장인 "에이전트는 끝났는데 뭔가 남아서 돈다"를 바로 고친다. 컨테이너는 포트와 파일시스템 충돌이 준비 비용보다 비싸지기 시작할 때, 혹은 락 몇 개로는 떼어 놓기 어려울 만큼 많은 에이전트가 한꺼번에 돌 때 들여오면 된다.

더 볼 자료

Docker: resource constraints(메모리·CPU 옵션) · systemd.resource-control(MemoryMax, CPUQuota, TasksMax와 위임) · Sysbox(특권 컨테이너 없이 Docker를 중첩하는 런타임) · 컨테이너화된 환경의 개발자 경험(같은 컨테이너 계약을 사람 개발자 쪽에서 본 글) · 도구 출력이 에이전트를 조종하려 할 때(도구 출력에 섞여 드는 지시의 모양과, 에이전트가 닿는 범위가 왜 중요한지)