-
MCP를 전부 컨테이너에 가뒀다, 딱 하나만 빼고 — 샌드박스 담에 문을 내는 법과 그 대가IT 2026. 8. 7. 23:00
지난 글에서 에이전트 본체와 MCP 서버를 통째로 컨테이너에 넣는 배치를 다뤘다. MCP(Model Context Protocol, AI 모델과 외부 도구를 잇는 표준 규격 — 흔히 "AI의 USB-C 포트"에 비유된다) 서버는 결국 남이 만든 코드를 내 컴퓨터에서 상주 실행하는 프로세스라, 전부 담 안에 넣고 나면 마음이 놓인다. 그런데 실제로 다 넣고 운영을 시작하면 며칠 안에 이런 생각이 온다 — "이 서버 하나만은 밖에서 돌리고 싶은데." 홈서버의 사진 데몬에 붙는 서버, 호스트 디스크를 정리하는 서버, 그리고 무엇보다 API 키가 있어야 일하는 서버들이다.
이게 나 혼자만의 변덕이 아니라는 걸 최근 오픈소스 생태계에서 확인했다. NVIDIA가 공개한 에이전트 프로젝트에서는 샌드박스를 "비밀값 제로" 원칙으로 설계했더니 번들로 제공되는 스킬 51개가 통째로 먹통이 되는 문제가 공식 이슈로 올라왔다. 격리를 지키자니 기능이 죽고, 기능을 살리자니 키를 담 안에 반입해야 하는 정면 충돌이다. 이 글은 그 충돌을 푸는 커뮤니티의 해법 — 신뢰하는 MCP만 호스트에 남기고, 샌드박스에서 네트워크로 불러 쓰는 다리 놓기 — 을 따라가고, 그 다리가 만들어내는 새로운 위험까지 마저 본다.
왜 '하나만 밖'이 필요한가 — 담 안에서 원리적으로 안 되는 일
먼저 컨테이너 안의 MCP 서버가 무엇을 못 하는지부터 정리한다. 여기서 중요한 건 "권한을 더 주면 되는 일"과 "구조적으로 불가능한 일"을 구분하는 것이다.
다이어그램 설명. 컨테이너 안에서 도는 MCP 서버가 부딪히는 세 가지 벽을 보여준다. "호스트 파일시스템·디바이스 접근"은 마운트로 일부 뚫을 수 있지만, 뚫는 만큼 격리가 얇아지는 트레이드오프가 따른다. "로컬 데몬 호출"의 벽은 네트워크 이름공간 분리에서 온다 — 컨테이너 안에서 localhost를 부르면 컨테이너 자신을 가리키므로, 호스트에서 도는 데몬은 그 주소로 존재하지 않는다. 마지막 "API 키" 벽이 가장 단단하다. 앞의 둘은 설정으로 구멍을 낼 수라도 있지만, 키 반입은 설정 문제가 아니라 설계 원칙과의 충돌이기 때문이다.
왜 키 반입이 원칙 충돌인지는 한 발만 물러서면 보인다. 에이전트를 컨테이너에 가두는 이유는 프롬프트 인젝션(웹페이지·문서 속에 숨긴 지시로 AI의 행동을 조종하는 공격)이나 오염된 의존성으로 에이전트가 폭주해도 피해를 담 안으로 한정하기 위해서다. 그런데 그 담 안에 API 키를 넣어 두면, 오염된 에이전트가 가장 먼저 하는 일이 그 키를 읽어 허용된 네트워크 통로로 내보내는 것이 된다. 담을 넘을 필요도 없이 정문으로 걸어 나간다. 그래서 진지하게 설계된 샌드박스일수록 "비밀값은 절대 안으로 들이지 않는다"를 고정 원칙으로 박아 두고, 그 대가로 키가 필요한 MCP가 전부 죽는 상황을 감수한다. 51개 스킬 먹통은 그 원칙의 비용이 눈에 보이게 드러난 사례다.
stdio는 담을 넘지 못한다 — 전송 방식이 곧 경계다
"그럼 그 MCP 서버만 호스트에서 돌리면 되잖아"가 자연스러운 다음 생각인데, 여기서 MCP의 전송(transport) 방식이 발목을 잡는다. MCP 표준은 클라이언트와 서버가 대화하는 방식을 두 가지로 정의한다. 하나는 stdio(표준입출력 — 프로세스의 기본 입출력 통로)이고, 다른 하나는 Streamable HTTP(일반 웹 통신처럼 HTTP 요청으로 주고받는 방식)다. 로컬에서 MCP를 써 봤다면 거의 전부 stdio였을 것이다. 그리고 stdio에는 구조적인 전제가 하나 숨어 있다.
▲ 방식 1 — stdio: 같은 프로세스 트리 안의 대화
stdio 방식에서 클라이언트는 서버를 자기 자식 프로세스로 직접 띄운다. 설정 파일에 적는 것도 접속 주소가 아니라 실행 명령이다. 둘은 stdin·stdout 파이프라는 운영체제 수준의 연결로 묶이는데, 이 파이프는 부모-자식 관계에서만 성립한다. 즉 stdio는 "클라이언트와 서버가 같은 격리 경계 안에 산다"를 암묵적 전제로 깔고 있는 전송 방식이다. 에이전트가 컨테이너 안에 있는 한, stdio로 띄우는 MCP 서버는 반드시 같은 컨테이너 안에서 태어난다. 담 밖의 서버를 stdio로 부를 방법은 애초에 없다.
▲ 방식 2 — Streamable HTTP: 네트워크 너머의 대화
Streamable HTTP는 반대로 처음부터 네트워크를 전제한다. 서버는 어딘가에서 HTTP 엔드포인트로 떠 있고, 클라이언트는 주소만 알면 접속한다. 서버가 어디서 어떤 권한으로 도는지는 클라이언트가 관여하지 않는다. MCP 표준도 2025년 3월 개정판에서 원격 통신 방식을 이 Streamable HTTP로 정리했다. 여기서 이 글의 첫 번째 핵심이 나온다 — "MCP를 컨테이너 밖에서 돌린다"는 배치 결정은 곧 "전송을 stdio에서 HTTP로 바꾼다"는 결정과 같은 말이다. 프로세스 파이프는 격리 경계를 넘지 못하므로, 경계를 넘는 순간 대화는 네트워크가 된다. 처음 질문 — "그러면 네트워크로 로컬 MCP를 부르게 되는 것인가?" — 의 답은 그래서 "그렇다, 필연적으로"다.
다리 놓기 — 호스트 프록시 하나로 격리와 기능을 동시에 잡는다
문제는 쓰고 싶은 MCP 서버 대부분이 stdio 전용으로 만들어져 있다는 점이다. 그래서 커뮤니티의 해법은 서버를 고치는 게 아니라 중간에 변환 계층을 하나 세우는 것이다. 호스트에 작은 프록시를 두고, 안쪽 얼굴로는 HTTP 엔드포인트를 열고, 바깥 얼굴로는 stdio 서버를 자식 프로세스로 품는다. 앞서 말한 NVIDIA 프로젝트의 공식 제안이 정확히 이 구조이고, 독립 오픈소스로는 mcp-proxy 같은 변환 도구가, 제품 수준으로는 Docker의 MCP Gateway가 같은 역할을 한다.
다이어그램 설명. 다리가 놓인 뒤의 전체 배치다. "에이전트 + MCP 클라이언트"는 여전히 컨테이너 안에 있고, 컨테이너 안에서 호스트를 가리키는 특수 주소인 host.docker.internal을 통해 "브리지 프록시"에 HTTP로 접속한다. 프록시는 그 요청을 stdio로 변환해 자기가 품은 "신뢰하는 MCP 서버"에 전달한다. 이 구조의 정교함은 비밀값의 위치에 있다 — API 키는 호스트의 프록시 계층에서 환경변수로 상속되므로 컨테이너 안에는 끝까지 들어가지 않고, 컨테이너 안의 클라이언트가 아는 것은 접속 주소 하나뿐이다. 해당 제안의 설계 원칙 문장이 이를 압축한다. "API 키는 절대 샌드박스에 들어가지 않는다. 프록시가 호스트 환경에서 키를 상속한다." 놓치기 쉬운 함정은 프록시의 바인딩 범위다 — 127.0.0.1(같은 컴퓨터 안에서만 접근 가능한 주소)에만 열어야 외부 네트워크에서 이 다리로 직행하는 길이 차단된다.
실제로 세우는 데 필요한 건 명령 한 줄 수준이다.
# 1단: 호스트에서 stdio 전용 MCP 서버를 HTTP 엔드포인트로 승격 # --host 127.0.0.1 — 같은 컴퓨터 밖에서는 아예 도달 불가하게 바인딩 # API 키는 이 셸의 환경변수에서 상속된다 — 컨테이너에는 안 들어감 uvx mcp-proxy --host 127.0.0.1 --port 8096 -- npx -y @example/trusted-mcp-server # 2단: 컨테이너 안 MCP 클라이언트 설정에는 실행 명령 대신 주소만 등록 # { "url": "http://host.docker.internal:8096/mcp" } # 3단: 샌드박스의 나가는 통로(egress) 허용목록에 host.docker.internal:8096 한 줄 추가 # — 서버별로 통로를 좁게 여는 것이 이 패턴의 안전핀이다코드 설명. stdio 전용 서버를 HTTP로 승격하는 변환 프록시 실행, 컨테이너 쪽 클라이언트에 주소 등록, 나가는 통로 허용이라는 세 단계가 전부다. 핵심 트레이드오프는 세 번째 줄에 있다 — 기본이 전면 차단(deny-all)인 샌드박스의 나가는 통로에 구멍을 하나 내는 행위이므로, 허용 범위를 "이 포트 하나"로 좁게 유지해야 한다. 컨테이너를 만들 때 네트워크만 잊어서 아무 데나 나갈 수 있게 두는 것이 격리 실무에서 가장 흔한 실수로 꼽힌다.
문을 낸 대가 — 신뢰는 코드를 향하지, 호출자를 향하지 않는다
여기까지만 보면 다 얻은 것 같다. 격리는 유지했고, 키는 담 밖에 있고, 기능은 살아났다. 하지만 이 다리에는 두 번째 핵심 문제가 붙어 있다. "신뢰하는 MCP만 밖에서 돌린다"라고 말할 때, 그 신뢰는 서버의 코드를 향한 것이다 — 이 서버는 악성이 아니고, 뒤로 데이터를 빼돌리지 않는다는 신뢰다. 그런데 다리가 놓이는 순간 중요해지는 것은 코드가 아니라 호출자다. 그 다리로 요청을 보내는 주체는 누구인가? 샌드박스 안의 에이전트다. 그리고 우리가 에이전트를 샌드박스에 넣은 이유 자체가, 에이전트는 오염될 수 있다고 전제했기 때문이다.
다이어그램 설명. 다리가 역방향 공격 통로가 되는 흐름이다. "오염된 입력"이 에이전트의 컨텍스트에 들어오면, 에이전트는 그 숨은 지시를 자기가 세운 계획인 것처럼 실행에 옮긴다. 이때 브리지 프록시로 나가는 도구 호출은 형식상 평소의 정상 호출과 구분이 안 된다 — 컨테이너 탈옥도, 권한 상승도, 프로토콜 위반도 없다. 호스트 권한을 가진 MCP 서버는 자기 일을 충실히 했을 뿐인데 결과적으로 공격자의 손발이 된다. 보안 분야는 이 구도를 confused deputy(혼동된 대리인 — 정당한 권한을 가진 대리인이 권한 없는 요청자를 대신해 그 권한을 써 버리는 문제, 1988년에 정식화된 고전적 개념)라고 부른다. 놓치기 쉬운 오해는 "격리를 뚫렸다"고 생각하는 것이다 — 격리는 멀쩡하다. 우리가 낸 문으로, 우리가 허용한 형식의 요청이 나갔을 뿐이다.
그래서 이 패턴의 완성은 다리를 놓는 것이 아니라 다리 위에 검문을 세우는 것이다. 방향은 세 가지로 정리된다. 첫째, 밖에 내놓는 서버의 도구 표면을 최소로 깎는다 — 읽기 도구만 필요한데 쓰기·삭제 도구까지 노출돼 있으면, 오염된 에이전트에게 그만큼의 손발을 더 쥐여 주는 셈이다. 둘째, 호스트 쪽 MCP 서버 자체를 최소 권한으로 돌린다 — 호스트에서 돈다는 것이 호스트의 모든 권한으로 돈다는 뜻일 필요는 없다. 셋째, 127.0.0.1 바인딩의 한계를 안다 — 그 바인딩은 다른 컴퓨터의 접근만 막고, 같은 호스트에서 도는 다른 프로세스의 접근은 못 막는다. 여러 사용자·여러 서비스가 섞인 호스트라면 다리에 인증 토큰을 붙이는 단계까지 가야 한다. 실제로 서버별 권한 프로필을 선언하고 예외를 명시적으로 허가하게 만드는 도구들이 이 지점을 제품화하고 있다.
정리 — 문을 내는 것과 문을 잊는 것은 다르다
정리하면 이렇다. 에이전트와 MCP를 통째로 격리하면 API 키·호스트 자원이 필요한 서버는 구조적으로 죽고, 이를 살리는 표준적인 해법은 신뢰하는 서버만 호스트에 남기고 stdio를 HTTP로 승격하는 브리지 프록시로 잇는 것이다 — 키는 끝까지 담 밖에 머문다. 다만 그 다리는 곧바로 역방향의 위험을 만든다. 신뢰는 서버의 코드를 향한 것이지 담 안에서 오는 호출자를 향한 것이 아니므로, 도구 표면 축소·최소 권한·좁은 통로라는 검문을 다리 위에 같이 세워야 비로소 패턴이 완성된다. 격리 설계에서 실패는 문을 내는 데서 오지 않고, 문을 냈다는 사실을 잊는 데서 온다. 나도 내 홈서버의 다리에 어떤 검문이 빠져 있는지부터 다시 세어 보려고 한다.
참고한 공개 자료:
- NVIDIA NemoClaw — MCP server bridge for host-to-sandbox credential isolation 제안: https://github.com/NVIDIA/NemoClaw/issues/566
- Model Context Protocol 명세 — Transports (stdio·Streamable HTTP): https://modelcontextprotocol.io/specification/2025-03-26/basic/transports
- sparfenyuk/mcp-proxy — Streamable HTTP와 stdio 사이의 변환 브리지: https://github.com/sparfenyuk/mcp-proxy
- Docker MCP Gateway 공식 문서: https://docs.docker.com/ai/mcp-catalog-and-toolkit/mcp-gateway/
- stacklok/toolhive — 서버별 권한 프로필 선언: https://github.com/stacklok/toolhive
- Trend Micro — Using Containers to Secure Your MCP Infrastructure: https://www.trendmicro.com/vinfo/us/security/news/cybercrime-and-digital-threats/using-containers-to-secure-your-mcp-infrastructure
- Norm Hardy — The Confused Deputy (1988): https://dl.acm.org/doi/10.1145/54289.871709
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
남의 AI를 내 안에서 실행한다 — Claude Code를 붙이는 subprocess 하나와 두 개의 브리지 (0) 2026.08.08 격리된 내 AI에 Claude Code를 붙이는 네 갈래 — 그리고 'ACP'라는 이름이 둘이었다 (0) 2026.08.08 샌드박스 정책에 반드시 들어가는 세 줄 — 그리고 각 줄이 막는 공격 (0) 2026.08.07 130개의 보안 경고를 가진 에이전트를 집에서 안전하게 돌리는 법 — OpenShell로 케이지에 넣기 (0) 2026.08.07 에이전트를 샌드박싱한다 — 그 한 문장이 숨기는 세 개의 경계 (0) 2026.08.07 cherry-pick과 revert — 브랜치 전체가 아니라 커밋 하나만 다루고 싶을 때 (0) 2026.08.06 충돌은 왜 rebase에서 더 자주 만날까 — merge는 한 번, rebase는 커밋마다 (0) 2026.08.06 rebase가 위험하다는 말의 진짜 의미 — 커밋은 이동하지 않고 복제된다 (0) 2026.08.06 fast-forward의 정체 — 어떤 날은 merge commit이 생기고 어떤 날은 안 생기는 이유 (0) 2026.08.06 브랜치를 합치는 세 가지 방법 — merge, rebase, squash는 히스토리 모양이 다르다 (0) 2026.08.06