AI가 방금 짠 코드, 저희 서버에서 돌아갑니다
Kernel 만들기 (1): 사용자 코드를 안전하게 실행할 별도 환경이 필요했던 이유
Eddy🦊 • Tech Cell Software Engineer
- Backend
안녕하세요, 채널톡 백엔드 테크셀에서 사용자 코드 실행을 제어하는 Kernel 서비스를 만들고 있는 엔지니어 에디입니다!
채널팀에서는 최근 노트북과 CoS라는 제품을 출시했습니다.
노트북에서는 사용자가 Python 코드를 자유롭게 실행하며 데이터를 분석할 수 있습니다. CoS는 사용자의 요청을 해결하는 데 필요한 코드를 직접 만들고 실행합니다.
여기서 질문이 하나 있습니다.
사용자와 AI가 만든 코드는 어디에서 실행될까요?
답은 저희가 운영하는 서버입니다. 사용자가 직접 작성한 코드도, AI가 방금 만들었고 아무도 리뷰하지 않은 코드도 저희 서버에서 실행됩니다.
코드를 실행하는 것 자체는 어렵지 않습니다. 파이썬이라면 exec() 한 줄로도 시작할 수 있습니다. 하지만 임의의 사용자에게 코드 실행 서비스를 제공한다면, 문제는 달라집니다.
코드가 무한히 CPU와 메모리를 사용하면 누가 끊을 것인가?
실행 중인 프로세스가 다른 고객의 데이터에 접근할 수 있는가?
요청에 담긴 인증 정보가 사용자 코드에서 보이지 않는가?
프로세스가 죽어도 남겨야 하는 상태는 무엇인가?
격리가 깨졌을 때 실행 프로세스만 잃는가, 호스트 전체를 잃는가?
노트북과 CoS는 사용자가 작성하거나 AI가 생성한 코드를 실제 서비스 환경에서 계속 실행해야 합니다. 실행할 코드를 미리 알 수도 없고, 허용할 시스템 콜이나 패키지를 고정할 수도 없습니다.
일반적인 서비스는 배포 전에 코드를 리뷰하고 테스트합니다. 문제가 생기면 배포를 되돌립니다. 하지만 저희가 실행할 코드는 요청이 들어온 뒤에야 생깁니다. 코드의 생성과 실행 사이에 사람이 검토할 시간도 없습니다.
비슷한 위험은 올해 초 OpenClaw 사고에서도 드러났습니다. ClawHub 마켓플레이스에는 정보 탈취 코드를 숨긴 악성 스킬이 퍼졌고, 외부에 노출된 인스턴스와 원격 코드 실행 취약점도 연이어 발견됐습니다.
사고의 원인은 하나가 아니었습니다. 에이전트가 실행한 코드, 에이전트가 가진 인증 정보, 에이전트가 동작한 인프라가 서로 가까이 붙어 있었습니다. 한 영역이 침해되자 다음 자원으로 이동할 수 있었습니다.
Barracuda는 Microsoft 연구진의 표현을 빌려 이런 에이전트를 “지속되는 인증 정보를 가진 신뢰할 수 없는 코드 실행 환경으로 취급하라”고 정리했습니다.
기존 애플리케이션은 신뢰할 수 없는 코드를 안전하게 실행하도록 만들어진 환경이 아니었습니다. 인증을 마친 요청과 여러 고객의 데이터, 서비스 운영 권한을 사용하는 곳이기 때문입니다.
여기에 코드 실행 기능을 붙이면 요청을 인증하고 데이터를 읽는 프로세스가 사용자 코드까지 실행합니다. 사용자 코드가 프로세스의 메모리나 파일 시스템을 들여다보면 서비스가 가진 권한과 데이터에도 접근할 수 있습니다.
그래서 저희에게는 기존 애플리케이션과 분리된 안전한 코드 실행 환경이 필요했습니다. 사용자 코드에는 실행에 필요한 최소한의 권한만 주고, 문제가 생기면 해당 환경만 폐기할 수 있어야 했습니다.
이 요구를 어떻게 구현할지 판단하려면 먼저 기존 선택지를 살펴봐야 했습니다.
Jupyter만으로 해결할 수 없었던 이유
노트북과 가장 가까운 실행 모델은 Jupyter였습니다. 여러 셀을 이어 실행하고 앞선 결과를 다음 분석에서 사용하는 경험은 이미 Jupyter 내지 Colab에서 풀었던 문제였습니다.
하지만 Jupyter의 인증과 권한 부여는 누가 실행을 요청할 수 있는지는 통제해도, 실행이 허용된 코드 실행 환경 자체를 격리하지는 않습니다. 코드는 Jupyter kernel에서 동작합니다.
JupyterHub는 사용자마다 별도의 서버를 만들 수 있지만, 실제 격리 수준은 선택한 실행 환경에 달려 있습니다. 연속된 코드를 실행하는 경험 자체는 가져올 수 있어도, 결국 사용자 코드를 어떤 환경에서 실행할 것인가는 별도로 해결해야 했습니다.
컨테이너와 Kubernetes만으로 부족했던 이유
저희는 이미 모든 워크로드를 컨테이너로 만들고 Kubernetes에서 운영하고 있었습니다. 자연스럽게 사용자 코드를 별도의 Pod에서 실행하는 방법부터 검토했습니다.
Linux namespace는 프로세스와 네트워크, 마운트 등의 자원을 격리합니다. cgroup은 CPU와 메모리의 사용량을 제한합니다. seccomp는 시스템 콜을 걸러내고, capability는 root 권한을 나눠 커널 내부의 권한 검사를 제한합니다.
하지만 컨테이너 안의 코드가 파일을 열거나 네트워크로 나가려면 여전히 호스트 커널에 시스템 콜을 보냅니다. namespace와 cgroup이 나뉘어도 시스템 콜을 처리하는 커널은 같은 노드의 다른 컨테이너와 공유합니다.
그림 1. Pod와 컨테이너가 나뉘어도 시스템 콜은 같은 호스트 커널이 처리하는 구조
위 그림에서 두 컨테이너는 서로 다른 namespace와 cgroup 안에 있습니다. 각자 볼 수 있는 프로세스와 파일을 나누고 자원 사용량도 제한할 수 있지만, 두 경로는 마지막에 같은 호스트 커널로 모입니다.
seccomp와 Linux capability는 악의적인 코드에도 적용되는 중요한 방어선입니다. 다만 실행할 코드와 패키지를 미리 알 수 없는 범용 실행 환경에서는 호환성을 위해 비교적 넓은 커널 표면을 허용해야 할 수 있었습니다.
gVisor 공식 문서는 이 규칙을 집행하는 주체도 같은 호스트 커널이므로 애플리케이션과 호스트 침해 사이의 거리가 여전히 “시스템 콜 하나”라고 설명합니다. Pod Security Admission에서 Restricted 기준을 강제해도, 허용된 Pod의 시스템 콜은 일반 컨테이너 실행 환경에서 같은 호스트 커널이 처리합니다.
공격 표면을 줄이는 실행 환경
저희가 처음 결정한 것은 특정 취약점을 하나씩 막는 방법이 아니었습니다. 사용자 코드가 닿을 수 있는 공격 표면 자체를 줄이는 것이었습니다.
제로데이 취약점은 앞으로도 발견될 것입니다. 패치와 seccomp 허용 목록은 일부 악용 가능성을 낮추지만, 허용된 시스템 콜로 도달할 수 있는 취약점까지 모두 막는다고 보장할 수는 없습니다. 실행할 코드와 패키지를 미리 알 수 없는 저희 환경에서는 허용 기능을 극단적으로 줄일 수도 없었습니다.
그래서 다음 두 가지 조건을 먼저 정했습니다.
샌드박스 프로세스가 요청한 Linux 시스템 콜을 원래 인자 그대로 호스트 커널이 실행하지 않을 것
실행 환경에는 실제 데이터베이스 접속 정보와 스토리지 원본 키를 두지 않을 것
이 조건을 만족하는 안전한 실행 환경을 만들고 운영하기 위해 Kernel이라는 시스템을 만들었습니다.
Kernel은 사용자 코드를 직접 실행하는 프로세스가 아닙니다. 요청을 인증하고 권한을 확인한 뒤, 어떤 환경에서 실행할지와 그 환경을 언제 만들고 없앨지를 결정합니다.
그림 2. 기존 애플리케이션에 붙어 있던 코드 실행 책임을 Kernel과 샌드박스로 분리한 과정
위 그림처럼 Kernel은 신뢰하는 애플리케이션과 사용자 코드가 실행되는 환경을 분리합니다. 샌드박스에는 실행에 필요한 최소한의 요청만 전달하고, 인증과 권한, 실행 환경의 수명은 Kernel이 관리합니다.
Kernel이 관리하는 실행 영역
Kernel을 만들면서 기존 서버가 하던 일 가운데 사용자 코드 가까이에 두면 안 되는 책임을 골라냈습니다. 전체 실행 경로를 신뢰하는 제어 영역과 사용자 코드 실행 영역으로 나눴습니다.
그림 3. Kernel이 관리하는 제어 영역과 사용자 코드 실행 영역
위 그림에서 Kernel은 요청을 인증하고, 사용자가 할 수 있는 일을 확인하고, 실행 경로와 환경의 수명을 결정합니다. 실제 데이터베이스 접속 정보는 데이터 연결 서비스만 소유합니다.
샌드박스 실행 환경은 전달받은 코드를 실행하고 결과를 돌려주는 일만 맡습니다. 실제 데이터베이스 접속 정보나 스토리지 원본 키는 소유하지 않습니다. 실행 환경이 설사 유출되더라도, 다른 시스템으로 이동할 수단을 최소화하기 위해서입니다.
이렇게 나누자, 설계 질문도 달라졌습니다.
“실행 환경에서 데이터베이스에 어떻게 접속할까?“보다 “실행 환경이 실제 접속 정보를 가져야 하는가?“를 먼저 물었습니다.
“한 프로세스 안에서 고객을 어떻게 구분할까?“보다 “서로 다른 고객의 코드를 같은 실행 환경에 두어도 되는가?“를 먼저 물었습니다.
“프로세스를 어떻게 오래 살릴까?“보다 “프로세스가 사라지기 전에 어떤 상태를 밖으로 꺼내야 하는가?“를 먼저 물었습니다.
Kernel이라는 제어 계층이 생기면서 이 질문들의 답을 한곳에서 강제할 수 있었습니다. 각 실행 기능이 저마다 인증과 권한, 수명 관리를 구현하는 대신 Kernel이 실행 환경을 관리하게 했습니다.
데이터베이스 접속 정보를 실행 환경에서 분리하기
노트북과 CoS에서는 사용자가 연결한 데이터베이스의 데이터를 분석할 수 있습니다. 가장 간단한 방법은 접속 주소와 계정, 비밀번호나 토큰을 실행 환경에 넘기고 사용자 코드가 직접 질의하게 하는 것입니다.
하지만 편한 만큼 이는 위험했습니다. 사용자 코드는 자신이 도는 Python 프로세스의 메모리와 환경 변수, 요청 객체를 자유롭게 살펴볼 수 있습니다. 물론 관련된 코드를 실행 구현 단계에서 막을 수는 있겠지만, 같은 프로세스, 커널을 공유한다는 상황 속에서, 접속 정보는 언제나 노출될 수 있는 것으로 여기는 것이 필요했습니다. 접속 정보가 한 번 노출되면 실행 환경 밖에서도 고객 데이터베이스에 다시 접근하는 데 쓰일 수 있습니다.
저희는 사용자 코드가 실행되는 환경은 언제든 침해될 수 있다고 가정했습니다. 그래서 실제 데이터베이스 접속 정보는 Kernel이나 샌드박스에 두지 않기로 했습니다.
기존 요청도 그대로 전달하지 않았습니다. Kernel이 샌드박스용 요청을 새로 만들고, 데이터베이스 접속 정보를 포함해 실행에 필요하지 않은 항목을 제외한 뒤 넘기도록 요청 구조를 바꿨습니다.
데이터베이스 쿼리 수행은 접속 정보를 소유한 데이터 연결 서비스가 대신 실행합니다. Kernel은 권한을 확인한 쿼리 요청만 전달하고, 샌드박스에는 요청 결과만 내려보냅니다.
그림 4. 실제 데이터베이스 접속 정보를 실행 환경 밖에 둔 질의 경로
위 그림처럼 샌드박스에서 실행할 작업과 외부 데이터베이스 질의를 Kernel에서 나눕니다. 샌드박스에는 데이터베이스 접속 정보가 없는 요청을 보내고, 외부 질의는 접속 정보를 소유한 데이터 연결 서비스가 대신 실행합니다.
사용자 코드가 실행되는 환경에는 실제 데이터베이스 접속 정보가 한 번도 들어가지 않습니다. 실행 환경이 침해되더라도 고객 데이터베이스에 직접 다시 접속할 수 없게 하려는 결정이었습니다.
하나의 사용 경험과 여러 실행 프로세스
실행 환경을 나누면 내부 구성 요소는 늘어납니다. 그렇다고 사용자에게 여러 실행 환경을 직접 선택하게 할 수는 없었습니다. 사용자는 어떤 구성 요소가 코드를 실행했는지가 아니라, 앞에서 만든 결과를 다음 실행에서도 이어 쓸 수 있는지를 기대합니다.
그래서 사용자에게 보이는 실행 단위와 내부 프로세스 구성을 분리했습니다. 사용자는 하나의 세션으로 작업하지만, Python 실행과 외부 질의는 서로 다른 프로세스와 신뢰 수준의 실행 환경에서 처리할 수 있습니다. 어느 경로로 실행하든 결과는 같은 세션 아래에 남습니다.
이 결정 덕분에 지원하는 런타임을 더 쉽게 늘릴 수 있었습니다. 런타임이 서로 독립적이다 보니 동시에 여러 요청을 처리하는 것도 가능해졌습니다.
반대로 새로운 숙제도 생겼습니다. 프로세스를 분리하고 필요할 때만 띄우면서도, 사용자가 앞에서 하던 작업은 이어져야 했습니다.
AWS Lambda식 요청 모델로는 보장할 수 없는 연속성
코드를 필요할 때만 실행한다고 하면 가장 먼저 떠올릴 수 있는 것은 AWS Lambda입니다. 요청 하나를 받아 처리하고 끝내는 작업에는 잘 맞지만, 저희 서비스에 그대로 적용할 수 없었습니다.
여기서 비교하려는 대상은 AWS Lambda 자체가 아니라, 각 요청을 이전 실행 환경과 독립적으로 처리하는 모델입니다. AWS Lambda는 새 실행 환경을 만들 수도 있고 기존 환경을 재사용할 수도 있습니다. 재사용된 환경에는 메모리나 /tmp의 데이터가 남을 수 있지만, 다음 호출에도 같은 환경이 배정된다는 보장은 없습니다.
노트북은 첫 번째 셀에서 만든 변수와 데이터를 다음 셀에서도 사용할 수 있어야 합니다. 셀 실행을 서로 독립된 요청으로 처리하면 이전 프로세스의 메모리를 연속성의 근거로 삼을 수 없습니다.
반대로 세션마다 프로세스를 계속 켜 두면 사용자가 떠난 뒤에도 자원을 차지하고, 신뢰할 수 없는 코드가 들어 있는 실행 환경도 계속 살아 있습니다.
따라서 사용자에게는 하나의 작업이 이어지는 것처럼 보이되, 복구해야 할 상태는 실행 환경 밖에 저장하고 유휴 실행 환경은 종료할 수 있어야 했습니다.
그림 5. 이전 실행 환경의 상태를 보장하지 않는 AWS Lambda식 요청과 세션 상태를 실행 환경 밖에 두는 Kernel 방식
위 그림에서 계속 남는 것은 실행 프로세스가 아니라 Kernel이 복구 대상으로 정해 밖에 저장한 세션 상태입니다. 코드가 연달아 실행되는 동안에는 같은 환경을 활용할 수 있지만, 제품이 믿는 상태를 그 프로세스에만 두지 않습니다. 실행 환경이 사라져도 다음 요청에서 작업을 이어 갈 수 있어야 합니다.
이 구조는 노트북과 Kernel의 요구를 함께 만족시킵니다. 노트북은 셀 사이의 연속성을 유지하고, Kernel은 실행 환경을 격리한 채 필요할 때 만들고 내릴 수 있습니다.
어떤 상태를 밖에 저장했고, 언제 실행 환경을 내렸으며, 다시 실행할 때 어떻게 복구했는지는 이후에 좀 더 자세히 다루고자 합니다.
안전한 실행 환경을 실제로 만드는 방법
여기까지 정한 것은 CoS에서 필요한 환경의 모습이었습니다. 실제 데이터베이스 접속 정보 없이 사용자 코드만 실행하고, 프로세스가 사라져도 세션을 이어 갈 수 있어야 했습니다.
하지만 요구사항을 정하는 것만으로 안전한 실행 환경이 생기지는 않습니다. 일반 컨테이너처럼 사용자 코드의 시스템 콜을 호스트 커널이 그대로 처리한다면 공격 표면을 충분히 줄일 수 없습니다.
그래서 다음 질문은 하나로 좁혀졌습니다.
사용자 코드와 호스트 커널 사이를 실제로 어떻게 격리할 것인가?
다음 편에서는
커널의 환경을 실제로 구현할 기술을 고른 과정을 다루고자 합니다. 상용에서 주로 사용하는 에이전트 샌드박스 구조를 비교하고, 저희가 내린 기술적 선택에 대해서 이야기 나누고자 합니다.
커널은 오늘도 사용자와 AI가 만든 코드를 실행하고 있습니다. 그 코드를 어디까지 믿을지 정하는 일은 제품 화면에 드러나지 않지만, 제품이 감당할 수 있는 위험의 크기를 좌우합니다.
신뢰할 수 없는 코드를 위한 실행 환경, 실제 데이터베이스 접속 정보가 남지 않는 구조, 여러 고객을 안전하게 나누는 실행 인프라를 함께 만들어 보고 싶다면 채널톡 엔지니어링 팀의 문을 두드려 주세요!
👉 채널톡 지원하기
