이번 시간에는 에이전트가 만든 코드를 넣는 컨테이너에 docker run 옵션을 걸어 보면서, 무엇이 실제로 막히는지를 시험 코드와 로컬 LLM이 만든 정상 코드로 측정하며 배워가는 시간입니다.
에이전트에 "코드 실행" 도구를 붙이면, 모델이 쓴 Python 코드를 어딘가에서 돌려야 해요. 가장 쉬운 방법은 docker run --rm python:3.12-slim python code.py입니다. 컨테이너에 넣었으니 안전하다고 여기기 쉽죠.
확인할 동작 8가지와 판정 방법
"위험한 코드"를 직접 만들기보다는, 위험으로 이어지는 경로가 열려 있는지 확인하는 짧은 Python 파일 8개를 준비했어요. 시험 코드마다 스스로 멈추는 상한을 넣었고, 상한까지 막힘없이 갔다면 그 경로가 열려 있다고 판정해요.
- 끝나지 않는 계산 루프: 프로세스 2개가 15초 동안 CPU를 씁니다. 끝날 때 cgroup의
cpu.stat에서 컨테이너가 쓴 CPU 시간을 읽어요. 15초 동안 CPU 시간 30초를 썼다면 CPU 2개를 다 쓴 거예요. - 메모리 계속 할당: 16MB씩 512MB까지 잡습니다. 512MB까지 가면 열려 있음, 중간에 종료 코드 137과
OOMKilled true로 끝나면 막힘이에요. - 자식 프로세스 계속 생성: 200개까지
fork합니다. 200개를 다 만들면 열려 있음이에요. - 큰 파일 쓰기: 루트 파일시스템의
/probe.bin에 1MB,/tmp/probe.bin에 64MB를 씁니다. - 외부 네트워크:
example.com의 이름 해석이 되는지만 봐요. 외부로 데이터를 보내지는 않습니다. - 호스트 서비스 접속:
host.docker.internal:7611로 요청을 보냅니다. 이 포트에는 Mac의 127.0.0.1에만 열어 둔 수신 서버가 있고, 받은 요청을 로그로 남겨요. - 넘겨준 환경 변수:
-e LAB_SECRET=not-a-real-secret으로 넘긴 가짜 값을 읽고, 수신 서버로 보내 봅니다. - 마운트한 호스트 디렉터리 쓰기:
/mnt/host에 마운트한 호스트 디렉터리에 파일을 만들어요. 호스트 쪽에 파일이 생겼는지로 판정합니다.
실습 준비
이 글의 명령은 모두 실습 코드의 agent-code-sandbox/ 디렉터리에서 실행합니다.
compose.yaml: 수신 서버와 Ollama 서비스 정의(프로젝트asbx)receiver.py: 받은 요청을 한 줄씩 찍는 호스트 수신 서버probes/: 동작 8가지 시험 코드(파일 8개)run.sh: 조합별 옵션과 실행 함수(combo_opts,sbx,probe,probes,received,normal)를 담은 zsh 함수 모음tasks-normal.json: 계산 과제 20개와 정답code/: 이 글에서qwen3:1.7b가 만든 정상 코드(파일 20개)grade.py: 실행 출력을 정답과 비교해 채점
그 밖의 파일(make_tasks.py, gen.py)은 과제와 정상 코드를 만들 때 쓴 스크립트로, 이 글에서는 실행하지 않아요.
compose로는 수신 서버와 Ollama만 띄워요. 시험 코드를 돌리는 컨테이너는 호스트의 셸 함수가 docker run으로 하나씩 띄우고 지웁니다.
compose.yaml은 수신 서버 receiver(127.0.0.1:7611)와 llm 프로필의 Ollama(ollama/ollama:0.34.4, 127.0.0.1:7610, 메모리 2500MB)를 정의해요. Ollama는 정상 코드를 만들 때 쓴 것이라 이 글에서는 띄우지 않아요. 수신 서버 receiver.py는 받은 요청의 메서드, 경로, 본문을 한 줄씩 출력합니다.
시험 코드는 probes/1_loop.py부터 probes/8_mount.py까지 번호가 위 동작 번호와 같아요. 동작 7이 보내는 요청 경로에는 PROBE_TAG(조합 이름)가 들어가서, 수신 서버 로그에서 어느 조합의 요청인지 가릴 수 있습니다.
실행기는 zsh 함수 모음 run.sh입니다. source run.sh로 불러 쓰시면 돼요. 조합별 옵션은 combo_opts가 배열로 돌려주고, sbx가 그 옵션으로 컨테이너 하나를 돌립니다. 종료 코드와 OOMKilled를 docker inspect로 읽어야 해서, --rm 대신 실행이 끝난 뒤 docker rm으로 직접 지워요.
먼저 수신 서버를 띄울게요.
mkdir -p hostdir
source run.sh
docker compose up -d receiver
실습 1: 기본 docker run에서는
조합 A는 옵션 없이 돌리는 기본 실행이에요. probes <조합>은 시험 코드 8개를 그 조합으로 하나씩 돌리고, received <조합>은 수신 서버 로그에서 그 조합이 보낸 요청만 골라 건수와 함께 찍어 줍니다.
probes A
received A
## A 1_loop.py
루프 15.0초 동안 돌고 끝남, CPU 사용 시간 30.0초
[종료 코드 0, OOMKilled false, 15.7초]
## A 2_memory.py
128MB 할당
256MB 할당
384MB 할당
512MB 할당
512MB까지 할당하고 끝남
[종료 코드 0, OOMKilled false, 1.0초]
## A 3_fork.py
자식 프로세스 200개를 만들고 끝남
[종료 코드 0, OOMKilled false, 0.8초]
## A 4_disk.py
/probe.bin: 1MB 씀
/tmp/probe.bin: 64MB 씀
[종료 코드 0, OOMKilled false, 0.5초]
## A 5_dns.py
example.com 이름 해석 성공: 주소 3개
[종료 코드 0, OOMKilled false, 0.9초]
## A 6_host.py
호스트 수신 서버 응답: 200
[종료 코드 0, OOMKilled false, 1.0초]
## A 7_env.py
LAB_SECRET 읽음: not-a-real-secret
수신 서버로 보냄: 200
[종료 코드 0, OOMKilled false, 0.7초]
## A 8_mount.py
/mnt/host/written-by-A.txt 쓰기 성공
[종료 코드 0, OOMKilled false, 0.4초]
수신 서버 로그(A): 2건
RECV GET /A/host
RECV POST /A/env not-a-real-secret
출력을 보면 8가지가 모두 열려 있어요.
- CPU: 15초 동안 CPU 시간 30초를 썼습니다. 프로세스 2개가 CPU 2개를 끝까지 썼어요.
- 메모리와 프로세스: 512MB, 200개까지 막힘없이 갔습니다.
- 파일: 루트 파일시스템과
/tmp에 모두 썼습니다. - 네트워크와 호스트: 이름 해석이 되고, Mac의 127.0.0.1에만 열어 둔 포트에 컨테이너가 닿았습니다. Mac에서 로컬로만 띄워 둔 개발용 DB나 관리 화면도 같은 길로 닿을 수 있다는 뜻이에요.
- 환경 변수: 넘겨준 값을 읽고 수신 서버로 보냈습니다. 로그에 값이 그대로 남았어요.
- 마운트 디렉터리: 호스트의
hostdir/에written-by-A.txt가 생겼습니다.
컨테이너는 네임스페이스로 프로세스, 파일시스템, 네트워크를 나눠 보여 주지만, 기본 설정에서는 이 경로들이 닫혀 있지 않아요.
실습 2: 옵션을 다 걸면
조합 F는 위협마다 막는 옵션을 모두 걸어요.
--network none: 컨테이너에는 루프백 인터페이스만 남습니다.--read-only: 컨테이너의 루트 파일시스템을 읽기 전용으로 마운트합니다.--tmpfs /tmp:size=16m:/tmp에 메모리 기반 파일시스템(tmpfs)을 붙이고 크기를 16MB로 제한해요.--memory 256m --memory-swap 256m: 메모리 상한이에요. 두 값을 같게 주면 스왑을 쓰지 않습니다.--pids-limit 64: 컨테이너 안에 동시에 있을 수 있는 프로세스(스레드 포함) 수예요.--cpus 1: 쓸 수 있는 CPU 양입니다.--cap-drop ALL: 기본으로 남아 있던 capability 14개를 모두 뺍니다. capability는 root의 권한을 기능별로 쪼갠 단위라고 보시면 됩니다.--security-opt no-new-privileges: 프로세스가 실행 중에 권한을 더 얻지 못하게 해요.--user 65534:65534:nobody사용자로 실행해요.python:3.12-slim의 기본 사용자는 root입니다.- 호스트 디렉터리는 읽기 전용(
:ro)으로 마운트하고, 명령은 컨테이너 안의timeout -s KILL 10으로 감싸 실행 시간을 10초로 제한합니다.
probes F
received F
## F 1_loop.py
[종료 코드 137, OOMKilled false, 10.6초]
## F 2_memory.py
128MB 할당
[종료 코드 137, OOMKilled true, 3.0초]
## F 3_fork.py
자식 프로세스 62개 뒤 생성 실패: [Errno 11] Resource temporarily unavailable
[종료 코드 0, OOMKilled false, 1.1초]
## F 4_disk.py
/probe.bin: 0MB 쓰고 실패: [Errno 30] Read-only file system: '/probe.bin'
/tmp/probe.bin: 16MB 쓰고 실패: [Errno 28] No space left on device
[종료 코드 0, OOMKilled false, 0.7초]
## F 5_dns.py
example.com 이름 해석 실패: [Errno -3] Temporary failure in name resolution
[종료 코드 0, OOMKilled false, 1.3초]
## F 6_host.py
호스트 수신 서버 접속 실패: <urlopen error [Errno -3] Temporary failure in name resolution>
[종료 코드 0, OOMKilled false, 4.1초]
## F 7_env.py
LAB_SECRET 읽음: not-a-real-secret
수신 서버로 보내기 실패: <urlopen error [Errno -3] Temporary failure in name resolution>
[종료 코드 0, OOMKilled false, 0.7초]
## F 8_mount.py
/mnt/host/written-by-F.txt 쓰기 실패: [Errno 30] Read-only file system: '/mnt/host/written-by-F.txt'
[종료 코드 0, OOMKilled false, 0.5초]
수신 서버 로그(F): 0건
동작 1(10초에 종료), 2, 3, 4, 5, 6, 7의 전송, 8이 막혔어요. 끝나지 않는 루프는 10초에 137로 끝났고, OOMKilled false라서 메모리 초과와 구분됩니다.
프로세스는 62개에서 멈췄어요. 컨테이너 안의 timeout이 프로세스 하나를 차지해서, 부모 프로세스까지 합치면 64개예요.
여기서 눈여겨볼 건 동작 7이에요. 막힌 것은 값을 보내는 단계뿐이고, LAB_SECRET은 그대로 읽혔어요. 컨테이너 안의 코드는 자기 프로세스의 환경 변수를 읽을 수 있고, 이걸 막는 docker run 옵션은 없어요.
네트워크를 끊어 두면 당장 밖으로 나가지는 못하지만, 값이 표준 출력에 찍히면 그 출력은 다시 모델에게 돌아가고 대화 기록과 로그에 남습니다. 그래서 지켜야 할 값은 코드 실행 컨테이너에 넣지 않아요.
실습 3: 다 걸어도 정상 코드는 도는가
옵션을 걸었을 때 정상 코드가 멈추는지 보려면, 비교할 정상 코드가 있어야겠죠. 그래서 로컬 LLM에게 짧은 과제 20개를 주고 코드를 받았어요.
과제는 정렬, 문자열 뒤집기, 소수 나열, 최대공약수, JSON 파싱처럼 네트워크와 파일이 필요 없고 답이 하나로 정해지는 것들입니다. gen.py는 과제마다 Ollama의 /api/chat을 한 번 부르고(temperature: 0, seed: 42), 응답에서 Python 코드 블록을 꺼내 code/normal/01.py처럼 저장해요.
normal <조합>은 코드마다 sbx로 컨테이너를 띄워 출력을 모은 뒤 grade.py로 정답과 비교해요. 기본 조합 A와 F에서 돌려 볼게요.
normal A
normal F
조합 A: 20개 중 15개 통과, 실패 01 02 04 08 15
조합 F: 20개 중 15개 통과, 실패 01 02 04 08 15
실패 5개는 모델이 코드를 잘못 짠 경우예요. 01은 정렬하지 않은 숫자를 그대로 찍었고, 04는 피보나치 반복 횟수가 하나 모자랐고, 08은 두 줄 대신 True 한 줄을 찍었어요. 02와 15는 실행 오류로 끝났습니다.
F에서도 A와 똑같이 15개가 통과했고 실패한 과제도 같아요. 이 20개는 계산만 하고 결과를 표준 출력에 찍는 코드라서, 쓰기 경로도 네트워크도 필요하지 않았거든요.
정리
범위: Docker Desktop(macOS, Apple Silicon)의 Docker Engine 27.5.1, python:3.12-slim, 옵션 조합 2개(기본 A, 전체 F), 시험 코드 8개, qwen3:1.7b가 만든 정상 코드 20개.
- 기본
docker run에서 시험 코드 8가지가 모두 막힘없이 돌았습니다(이름 해석, Mac의 127.0.0.1 서버 접속, 넘긴 환경 변수 전송, 마운트 디렉터리 쓰기, 메모리 512MB, 자식 프로세스 200개, CPU 2개). 컨테이너에 넣은 것만으로 격리됐다고 보지 마세요. - 옵션을 다 건 F는 동작 7의 전송까지 막았지만,
LAB_SECRET을 읽는 것은 막지 못했습니다. 지켜야 할 값은 코드 실행 컨테이너에 넣지 마세요. - 이 글에 싣지 않은 추가 측정에서는
--read-only만 건 조합과nobody로만 돈 조합에서도 마운트한 호스트 디렉터리에 파일이 생겼습니다(Docker Desktop(macOS)에서 확인한 결과로, 리눅스 호스트에서는 다를 수 있어요). 호스트 디렉터리는:ro로 마운트하거나 마운트하지 마세요. - 옵션을 다 건 F에서도 계산 과제 20개는 A와 같은 15개가 통과했습니다. 다만 이 글에 싣지 않은 추가 측정에서는 현재 디렉터리에 파일을 쓰는 과제 4개가 A에서 통과하고 F에서 실패했으며,
--workdir /tmp를 더하자 다시 통과했습니다. 루트 파일시스템을 막으면 작업 디렉터리를 크기를 정한/tmptmpfs로 옮겨 주세요.