안녕하세요! 오늘은 저희 챗봇 서비스에 외부 시스템(ERP, CRM 등)을 연동할 수 있는 'API 연동 플러그인' 기능을 개발하며 고민하고 해결했던 과정을 공유하고자 합니다. 목표는 사용자가 직접 작성하거나 AI가 생성한 코드를 우리 시스템 위에서 안전하게 실행하는 것이었고, 이 과정에서 Kubernetes를 어떻게 활용하여 보안과 격리, 동적 리소스 관리를 구현했는지 자세히 다뤄보겠습니다.
가장 큰 도전 과제는 "어떻게 사용자의 코드를 안전하게 실행할 것인가?" 였습니다. 사용자의 코드가 다른 사용자나 시스템 전체에 영향을 주어서는 안 되기 때문에, 강력한 샌드박스(Sandbox) 환경이 필수적이었습니다. 저희는 Kubernetes의 다양한 기능을 활용하여 여러 겹의 보안 계층을 쌓기로 했습니다.
가장 먼저, 플러그인 코드가 실행될 환경을 worker라는 별도의 Namespace로 분리하여 논리적 격리 단위를 만들었습니다. 그리고 NetworkPolicy를 적용하여 네트워크 트래픽을 엄격하게 제어했습니다.
console 서버와 같이 내부 시스템에서 API를 호출해야 하는 경우를 위해, 특정 Namespace(예: turing)로부터의 트래픽만 허용하도록 설정했습니다.# k8s-api-plugin.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: worker-internal-ingress-policy
namespace: worker
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
# 'turing' 네임스페이스의 Pod들만 접근 허용
- namespaceSelector:
matchLabels:
kubernetes.io.metadata.name: turing
ports:
- protocol: TCP
port: 80
egress:
- {} # 모든 아웃바운드 허용
Pod 레벨에서는 SecurityContext를 활용하여 컨테이너가 할 수 있는 작업을 최소한으로 제한했습니다. 이는 "최소 권한의 원칙"을 적용한 핵심적인 부분입니다.
runAsNonRoot: true 와 runAsUser: 1001 등으로 root가 아닌 일반 유저로만 컨테이너가 실행되도록 강제했습니다.allowPrivilegeEscalation: false로 컨테이너 내부 프로세스가 더 높은 권한을 획득하는 것을 막았습니다.readOnlyRootFilesystem: true로 설정하여, 악성 코드가 시스템 파일을 변경하거나 임의의 파일을 쓰는 행위를 원천 차단했습니다.capabilities: { drop: ["ALL"] }을 통해 컨테이너에 부여될 수 있는 모든 리눅스 커널 권한을 제거하여 공격 표면을 최소화했습니다.보안 환경을 구축한 뒤, 수많은 사용자들이 각자의 플러그인을 배포해야 하는 멀티테넌시 환경을 고려해야 했습니다. 처음에는 모든 플러그인이 하나의 ConfigMap을 공유하는 방식을 생각했지만, 이는 다른 사용자의 코드에 영향을 줄 수 있어 위험했습니다.
저희는 각 챗봇별로 고유한 리소스를 생성하는 방식으로 이 문제를 해결했습니다.
user-code-cm 대신, api-plugin-code-<chatbot-id>와 같이 각 챗봇별로 고유한 이름의 ConfigMap을 생성합니다.Deployment 역시 api-plugin-<chatbot-id> 형식의 이름을 가지며, 볼륨 설정에서 자신에게 맞는 동적 ConfigMap을 참조하도록 했습니다.이 로직은 Node.js의 @kubernetes/client-node 라이브러리를 사용하여 gke-deployment.js 모듈에 구현했습니다. 배포 시에는 chatbotUid와 userCode를 받아 고유한 이름의 ConfigMap과 Deployment를 생성하고, 삭제 시에는 관련된 모든 리소스를 함께 정리하여 리소스 유출을 방지합니다.
// gke-deployment.js의 일부
// GKE에 Workload/Service 배포
async function deployWorkload(chatbotUid, userCode) {
// ...
const appName = `api-plugin-${chatbotUid}`;
const codeName = `api-plugin-code-${chatbotUid}`; // 고유한 ConfigMap 이름 생성
// 매니페스트 생성
const configMapManifest = getConfigMapManifest(codeName, userCode);
const deploymentManifest = getDeploymentManifest(appName, codeName, /*...*/);
// ...
}
// GKE에서 Workload/Service 삭제
async function deleteWorkload(chatbotUid) {
// ...
const appName = `api-plugin-${chatbotUid}`;
const codeName = `api-plugin-code-${chatbotUid}`;
// Deployment, Service, ConfigMap 모두 삭제
// ...
}
개발을 진행하던 중, 사용자가 코드를 수정하고 재배포해도 실행 중인 Pod이 업데이트되지 않는 문제를 발견했습니다. Kubernetes Deployment는 참조하는 ConfigMap의 내용 변경을 감지하지 못하기 때문이었죠.
이 문제를 해결하기 위해 "Timestamp Annotation" 패턴을 도입했습니다.
deployWorkload 함수가 호출될 때마다 Date.now()를 사용하여 현재 시간의 타임스탬프를 생성합니다.Deployment의 Pod 템플릿(spec.template)의 annotations에 기록합니다.Deployment는 자신의 템플릿 어노테이션이 변경된 것을 감지하고, Pod들을 새 버전으로 교체하는 롤링 업데이트를 자동으로 시작합니다.이 간단한 방법으로, 재배포 시 항상 최신 코드가 담긴 ConfigMap을 사용하는 새로운 Pod이 실행되도록 보장할 수 있었습니다.
// gke-deployment.js의 일부
function getDeploymentManifest(appName, codeName, deployTimestamp) {
return {
// ...
"template": {
"metadata": {
"annotations": {
"redeploy/timestamp": deployTimestamp // 이 값이 바뀔 때마다 재배포!
}
},
// ...
}
}
}
async function deployWorkload(chatbotUid, userCode) {
// ...
const deployTimestamp = Date.now().toString(); // 재배포를 위한 타임스탬프 생성
const deploymentManifest = getDeploymentManifest(appName, codeName, deployTimestamp);
// ...
}
지금까지 Kubernetes 환경에서 외부 코드를 안전하게 실행하기 위한 멀티테넌트 플러그인 아키텍처를 구축한 경험을 공유해 드렸습니다. Namespace, NetworkPolicy, SecurityContext를 통해 강력한 보안 샌드박스를 구축하고, 동적 리소스 생성 및 정리로 테넌트 간 격리를 보장했으며, Annotation 패턴을 이용해 ConfigMap 변경 시 자동 재배포 문제를 해결했습니다.
이 과정을 통해 Kubernetes가 제공하는 다양한 기능들을 깊이 있게 활용하며, 안전하고 확장성 있는 시스템을 만드는 즐거움을 다시 한번 느낄 수 있었습니다. 이 글이 비슷한 고민을 하는 다른 개발자분들께 작은 도움이 되기를 바랍니다.
감사합니다.