10분

격리를 만들었는데, 운영에서는 아무 일도 하지 않았다

외부 MCP 서버를 OS 수준에서 격리하는 기능을 하루 만에 만들고, 그다음 날 통째로 버렸습니다. 고른 도구가 운영 호스트에서는 조용히 아무 일도 하지 않는다는 걸 알았기 때문입니다.

  • MCP
  • 샌드박스
  • 보안
  • Docker
설치 가능한 MCP 서버가 나열된 OpenMake MCP 카탈로그 화면
카탈로그에서 설치한 서버는 결국 우리 호스트에서 프로세스로 뜹니다. 문제는 그 프로세스가 무엇을 볼 수 있느냐입니다.

SHIPPED / EVIDENCE

이번에 배포한 것

외부 MCP stdio 서버를 권한을 전부 떨어뜨린 비루트 Docker 컨테이너에서 실행합니다. 호스트 경로는 마운트하지 않고, 명시한 환경변수만 넘기며, 네트워크는 서버별로 정합니다. 격리가 불가능한 서버는 숨기지 않고 비격리로 표시하고, 임의 코드를 실행하는 서버는 이미지에 미리 구워 넣어 네트워크가 끊긴 컨테이너로 옮겼습니다.

첫 구현의 실제 격리
0건
다시 쓰기까지
8시간
컨테이너 격리
14개 중 12개
임의 코드 서버 네트워크
none

01

시작은 격리가 아니라 누출이었다

외부 MCP 서버는 사용자가 카탈로그에서 설치하고 승인하면 우리 서버가 자식 프로세스로 띄웁니다. 그 spawn 코드가 호스트의 환경변수를 통째로 물려주고 있었습니다. 데이터베이스 접속 문자열, JWT 서명 키, 토큰 암호화 키, LLM API 키까지 전부입니다.

고치는 방법 자체는 간단했습니다. MCP SDK의 stdio 전송은 이미 PATH나 HOME 같은 안전한 부분집합을 기본값으로 깔아 주기 때문에, 우리가 그 위에 호스트 환경변수 전체를 덮어쓸 이유가 없었습니다. 서버가 명시적으로 설정한 값만 넘기도록 바꿨습니다.

다만 이건 의도적인 동작 변경입니다. 그동안 암묵적으로 호스트 값을 받아 쓰던 서버는 이제 필요한 키를 설정에 직접 적어야 합니다. 편의를 깨고 명시성을 택했습니다.

02

정책이 도구를 골랐다

환경변수를 막아도 프로세스는 여전히 호스트 파일시스템 위에서 돕니다. 저장소도 읽을 수 있고 비밀 파일도 열 수 있습니다. 다음 층이 필요했습니다.

여기서 Docker를 쓰지 않기로 한 기존 정책이 걸렸습니다. 컨테이너는 인프라에만 쓴다는 원칙이 있어서, 애플리케이션이 스스로 컨테이너를 띄우는 방식을 피하려 했습니다. 그래서 bubblewrap을 골랐습니다. 파일시스템과 프로세스, 선택적으로 네트워크까지 격리하면서 데몬이 필요 없는 도구입니다.

구현은 방어적으로 짰습니다. 플래그가 꺼져 있거나, 리눅스가 아니거나, bwrap 바이너리가 없으면 원래 명령을 그대로 실행하고 경고만 남깁니다. 격리가 안 된다고 서비스를 죽이지는 않겠다는 판단이었습니다. 유닛 테스트 8건과 MCP 회귀 52건이 통과했습니다.

03

그 방어적인 설계가 격리를 0으로 만들었다

다음 날 아침에 드러난 사실은 단순합니다. bubblewrap은 리눅스 전용이고, 운영 호스트는 macOS입니다. Mac mini에서 돌아가는 서비스였습니다.

그러니까 우리가 넣은 안전장치가 매번 발동하고 있었습니다. 리눅스가 아니므로 조용히 원래 명령을 실행하고, 경고 한 줄을 남기고 넘어갑니다. 코드는 있고, 테스트도 통과하고, 실제 격리는 0이었습니다.

이건 버그가 아니라 설계대로 동작한 결과라서 더 나쁩니다. 테스트는 게이트가 제대로 열리고 닫히는지를 검증했지, 운영 환경에서 그 게이트가 항상 닫혀 있다는 사실은 검증하지 않았습니다.

04

하루 만에 다시 썼다

Docker Desktop의 컨테이너는 그 안의 리눅스 VM에서 돌기 때문에, macOS를 포함해 docker가 있는 어느 호스트에서도 실제로 격리가 동작합니다. 정책을 지키려고 고른 도구가 정작 아무것도 지키지 못하고 있었으니, 정책 쪽을 고쳤습니다. 애플리케이션 자체를 컨테이너에 넣는 것이 아니라 애플리케이션이 spawn하는 외부 프로세스를 격리하는 것이라고 범위를 다시 적었습니다.

bubblewrap 구현은 테스트와 설계 문서까지 함께 지웠습니다. 하루짜리 코드를 남겨 두면 다음 사람이 둘 중 어느 쪽이 진짜인지 헷갈립니다.

새 구현은 명령을 컨테이너 실행으로 감쌉니다. 권한을 전부 떨어뜨리고, 권한 상승을 막고, 비루트로 돌리고, 프로세스 수와 메모리에 상한을 겁니다. 호스트 파일시스템은 한 곳도 마운트하지 않아 비밀 파일에 닿을 경로 자체가 없습니다. 환경변수는 서버 설정에 적힌 것만 컨테이너로 주입합니다.

  • 네트워크는 서버별로 bridge 또는 차단 중 하나를 고릅니다
  • 컨테이너 안에서 127.0.0.1을 가리키는 설정은 호스트 주소로 자동 치환해, 호스트에서 도는 데이터베이스에 붙는 서버가 그대로 동작합니다
  • 런타임 이미지는 node와 uv를 함께 담은 하나로 두어 npx 계열과 uvx 계열 서버를 모두 덮습니다
  • 이미지가 없는 상태로 켜면 spawn이 실패하므로 기본값은 꺼짐입니다

05

실제로 켜 보니 두 가지가 막았다

테스트를 통과한 것과 운영에서 뜨는 것은 다른 문제였고, 이번에는 그 차이를 바로 확인했습니다.

첫째, 이미지 빌드가 실패했습니다. 베이스 이미지에 이미 uid 1000을 쓰는 사용자가 있는데 같은 번호로 사용자를 또 만들려 했기 때문입니다. 새로 만들지 않고 기존 사용자를 그대로 쓰도록 바꿨습니다.

둘째, 컨테이너가 떠도 패키지 캐시에 쓰지 못했습니다. 이름 붙은 볼륨이 root 소유로 생성되는데 컨테이너는 비루트로 돌기 때문입니다. 이미지 안에 캐시 디렉터리를 해당 사용자 소유로 미리 만들어 두면 빈 볼륨이 처음 마운트될 때 그 소유권을 물려받습니다.

이 둘을 고치자 표준 서버 열 개가 컨테이너에서 정상 연결됐고, PostgreSQL 서버는 주소 치환 덕분에 호스트 데이터베이스에 그대로 붙었습니다.

06

격리하지 못하는 서버를 숨기지 않았다

세 개 서버가 남았습니다. 호스트에 직접 설치된 바이너리에 의존해서 범용 런타임 이미지 안에서는 실행되지 않는 것들이었습니다.

여기서 두 가지 나쁜 선택지가 있었습니다. 하나는 격리를 켜고 이 서버들을 죽게 두는 것, 다른 하나는 실패를 조용히 삼켜 격리된 척하는 것입니다. 앞선 실수가 정확히 두 번째 유형이었기 때문에, 대신 세 번째를 만들었습니다.

서버마다 갖고 있던 네트워크 정책 컬럼에 값을 하나 더 추가했습니다. 이 값이면 컨테이너를 아예 쓰지 않고 호스트에서 직접 실행하며, 격리 플래그가 켜져 있든 말든 무시합니다. 비격리 상태가 설정에 명시적으로 적혀 있고, 로그에서도 격리 적용 목록에 나타나지 않아 눈으로 구분됩니다.

재기동 후 실제 운영에서 14개 서버가 연결됐습니다. 11개는 컨테이너 격리, 3개는 호스트 비격리였습니다. 이 과정에서 fetch 서버가 존재하지 않는 npm 패키지를 가리키고 있던 것도 드러나 올바른 패키지로 정정했고, 그러자 이 서버도 컨테이너에서 정상 동작했습니다.

07

가장 위험한 서버부터 다시 안으로 넣었다

비격리로 남은 셋 중 하나가 Python REPL이었습니다. 임의 코드를 실행하는 서버입니다. 가장 위험한 것이 가장 보호받지 못하는 상태로 남은 셈이라 그대로 둘 수 없었습니다.

해법은 의존성을 호스트가 아니라 이미지에 두는 것이었습니다. 패키지를 이미지 빌드 시점에 설치해 독립된 실행 환경을 만들고, 런타임에 볼륨이 마운트되는 캐시 경로와 분리했습니다. 이렇게 하면 실행할 때 아무것도 내려받을 필요가 없어, 네트워크를 완전히 끊은 컨테이너에서도 동작합니다.

운영 설정에서 이 서버의 실행 명령을 호스트 절대경로에서 컨테이너 안의 이름으로 바꾸고, 네트워크 정책을 비격리에서 차단으로 옮겼습니다. 네트워크가 없는 오프라인 컨테이너에서 MCP 초기화가 정상 응답하는 것을 확인했고, 재기동 후 컨테이너 분포는 bridge 11개와 차단 1개가 됐습니다.

임의 코드를 실행하는 서버가 이제 OS 격리와 네트워크 차단을 동시에 받습니다.

08

컨테이너가 막지 못하는 것

컨테이너는 프로세스가 무엇을 볼 수 있는지를 제한합니다. 누가 그 도구를 부를 수 있는지는 제한하지 않습니다. 외부에 공개돼 있고 가입이 열려 있는 서비스에서는 이쪽이 더 급한 문제였습니다.

그래서 서버별 최소 역할 게이트를 넣었습니다. 임의 코드 실행 서버는 관리자만, 브라우저 자동화 서버는 로그인 사용자만 도구 목록에 노출됩니다. 게스트가 해당 서버를 명시적으로 켜고 코드 실행을 요청해도 실행 횟수는 0이었습니다.

모든 MCP 도구 호출은 감사 로그에 남깁니다. 게스트는 사용자 식별자가 없으므로 그 자리를 비우고 별도 필드에 표시해, 외래키를 어기지 않으면서도 누가 불렀는지 남게 했습니다.

  • CPU 상한을 걸어 한 서버가 호스트를 점유하지 못하게 했습니다
  • 캐시 볼륨을 서버별로 분리해, 한 서버의 패키지 캐시가 다른 서버를 오염시키지 못하게 했습니다
  • 호스트 주소 접근 권한은 실제로 127.0.0.1을 참조하는 서버 하나에만 줬습니다
  • 읽기 전용 루트 파일시스템은 선택 사항으로 두었습니다

09

남긴 것과 남은 것

의도적으로 하지 않은 것이 하나 있습니다. 패키지 버전 고정입니다. 서버 열네 개의 버전을 한 번에 박아 두면 어느 것이 깨지는지 모른 채 전부 멈출 수 있어, 공급망 위험을 알면서도 미뤘습니다. 대신 캐시를 서버별로 갈라 두어 오염이 번지는 경로는 먼저 끊었습니다.

지금도 두 개 서버는 호스트에서 비격리로 돕니다. 둘 다 배포된 패키지가 아니라 호스트 설치 바이너리에 의존해서, 범용 이미지로는 덮이지 않습니다. 전용 이미지를 따로 만드는 것이 남은 일이고, 그때까지는 설정과 로그에 비격리라고 적혀 있습니다.

이 작업에서 가장 오래 남은 교훈은 격리 방식 자체가 아닙니다. 안전장치가 조용히 발동하도록 설계하면, 그 안전장치가 항상 발동하고 있어도 아무도 모른다는 것입니다. 지금은 격리가 적용된 서버가 로그에 나열되고, 적용되지 않은 서버는 설정에 그렇게 적혀 있습니다.

근거 자료

근거 자료

개발일지로 돌아가기