Chạy code không tin cậy an toàn: OpenSandbox trên k3s + gVisor
July 29, 2026

Vấn đề: chạy code của người lạ mà không mất máy
Hãy tưởng tượng bạn xây một nền tảng cho phép người dùng gửi lên đoạn code bất kỳ — một AI agent sinh ra Python, một dịch vụ "code interpreter", một CI chạy script của khách hàng. Bạn không tin đoạn code đó. Nó có thể cố đọc file của tiến trình khác, escape ra host kernel, quét mạng nội bộ, hay lén gửi dữ liệu ra ngoài. Câu hỏi đặt ra không phải "làm sao chạy được code" — điều đó dễ — mà là "làm sao chạy được code mà nếu nó độc hại thì thiệt hại vẫn bị nhốt trong một cái hộp".
Container thường (runc) chia sẻ chung kernel với host. Một lỗ hổng escape container là đủ để kẻ tấn công chạm tới kernel thật. Với untrusted code, ta cần một lớp cô lập mạnh hơn — và đó là lúc sandbox runtime như gVisor bước vào.
Sandbox runtime: một runtime chạy container nhưng chèn thêm một lớp cô lập giữa workload và host kernel, thay vì để container gọi thẳng system call xuống kernel thật. gVisor làm điều này bằng một "application kernel" chạy trong userspace; Kata/Firecracker làm bằng một microVM. Mục tiêu chung: thu hẹp attack surface xuống mức tối thiểu.
gVisor (
runsc): sandbox runtime của Google. Nó chặn mọi system call của container và xử lý chúng bằng một kernel viết lại bằng Go chạy trong userspace (gọi là Sentry), nên container gần như không bao giờ chạm trực tiếp vào host kernel. Đổi lại là một chút overhead ở các workload nặng system call.
Bài viết này dựng gì
Đây là ký sự end-to-end của việc dựng một nền tảng sandbox hoàn chỉnh — được test thật trên một cluster k3s single-node arm64 (Lima) chạy trên MacBook Apple Silicon, không phải lý thuyết suông. Ta sẽ đi từ nền móng lên tới các lớp bảo mật production:
+-----------------------------------------------------------+
| 1. Cluster k3s + gVisor trong Linux VM (Apple Silicon)|
| 2. Platform OpenSandbox: server + controller + sandbox |
| 3. Ingress reverse proxy vao service ben trong sandbox|
| 4. Routing nhieu app, path/host tuy chinh (Traefik) |
| 5. Egress allowlist FQDN + Credential Vault (MITM) |
| 6. Auth Entra OAuth2 SSO + authz theo app |
+-----------------------------------------------------------+
OpenSandbox: một nền tảng mã nguồn mở biến Kubernetes thành một dịch vụ tạo sandbox theo yêu cầu. Bạn gọi REST API "tạo cho tôi một sandbox", nó tạo pod (có thể ép chạy trên gVisor), rồi bạn chạy lệnh / đọc-ghi file / expose service bên trong sandbox đó qua SDK hoặc CLI. Nó gồm một server (REST lifecycle API), một controller (operator reconcile các CRD), và các sandbox pod — nơi code thật sự chạy.
Mỗi phần đều có lệnh chạy được và kết quả đã verify. Đến cuối bài bạn sẽ có bức tranh đầy đủ: từ một chiếc MacBook trắng tới một nền tảng chạy untrusted code có kiểm soát mạng ra/vào và có đăng nhập SSO trước cửa.
Một lưu ý xuyên suốt: không phải tính năng nào cũng chạy trên mọi runtime. gVisor cực
mạnh về cô lập nhưng netstack của nó thiếu vài thứ (cụ thể là iptables nat table), nên có
những tính năng phải đánh đổi runtime. Ta sẽ chỉ rõ ràng buộc này ở đúng chỗ nó xuất hiện —
vì hiểu nó là chìa khóa để không mất hàng giờ debug những lỗi khó hiểu.
Phần 1 — Dựng cluster k3s + gVisor trên Apple Silicon
Trước khi có nền tảng sandbox, ta cần một Kubernetes cluster mà pod có thể chạy dưới gVisor. Đây là bước gây bối rối nhất trên máy Mac, nên hãy bắt đầu từ ràng buộc cốt lõi.
Vì sao phải có một Linux VM
Cả k3s lẫn gVisor đều chỉ chạy Linux. k3s là một binary Linux cần Linux kernel; runsc
của gVisor cũng đòi host Linux. Không cái nào chạy native trên macOS, nên trên Apple Silicon
(arm64) bạn buộc phải chạy chúng bên trong một Linux VM.
k3s: một bản phân phối Kubernetes nhẹ, đóng gói thành một binary duy nhất, kèm sẵn containerd, Traefik (ingress) và ServiceLB (load balancer). Rất hợp cho single-node và môi trường dev/lab.
Tin tốt cho arm64:
- gVisor có hỗ trợ arm64 (250/294 syscall được hỗ trợ đầy đủ/một phần).
- Platform mặc định của nó —
systrap— không cần/dev/kvm, điều rất quan trọng vì nested KVM thường không có bên trong VM trên Mac.
ℹ️ Có một hiểu lầm phổ biến rằng "k3s cần VM vì nó đòi systemd/OpenRC". Điều đó sai — k3s chạy được không cần systemd. Lý do thật đơn giản là: nó là binary Linux cần Linux kernel. Kết luận (bạn cần một VM) vẫn đúng, chỉ là lý do khác.
Chọn lớp VM
| Tool | Vai trò | Hợp với k3s + gVisor? |
|---|---|---|
| Lima | Linux VM nhẹ; có sẵn template k3s | ✅ Tốt nhất — k3s thật, full quyền root/containerd |
| Colima | Wrapper của Lima; --kubernetes cho k3s | ✅ Ổn — một lệnh, ít quyền cấu hình trực tiếp hơn |
| multipass | VM Ubuntu thuần; tự cài k3s | ✅ Được — thủ công nhất, kiểm soát nhất |
| k3d | k3s bên trong Docker | ⚠️ Tránh cho gVisor — Docker lồng nhau xung đột việc chỉnh containerd |
| Rancher Desktop / UTM | App VM/desktop đầy đủ | ➖ Nặng; dùng được nhưng thừa |
Khuyến nghị: dùng template k3s có sẵn của Lima cho con đường nhanh và đúng nhất.
brew install lima
limactl start template:k3s # cluster k3s single-node trong một Linux VM
# trỏ kubectl (trên host Mac) tới cluster:
export KUBECONFIG=$(limactl list k3s --format 'unix://{{.Dir}}/copied-from-guest/kubeconfig.yaml')
kubectl get nodes
Đăng ký gVisor làm RuntimeClass
Các lệnh dưới đây chạy bên trong Linux VM. Trước hết cài runsc từ apt repo của gVisor:
curl -fsSL https://gvisor.dev/archive.key | \
sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | \
sudo tee /etc/apt/sources.list.d/gvisor.list > /dev/null
sudo apt-get update && sudo apt-get install -y runsc
Lệnh này cài runsc và shim containerd-shim-runsc-v1.
Tiếp theo là bước then chốt: đăng ký runsc vào containerd của k3s. k3s dùng
containerd riêng của nó, với config render tại
/var/lib/rancher/k3s/agent/etc/containerd/config.toml.
⚠️ Đừng sửa trực tiếp file đó — k3s ghi đè nó mỗi lần khởi động lại. Thay vào đó, đặt một file override dạng Go-template cùng thư mục. Với containerd 2.x (k3s gần đây) là
config-v3.toml.tmpl:
sudo tee /var/lib/rancher/k3s/agent/etc/containerd/config-v3.toml.tmpl > /dev/null <<'EOF'
{{ template "base" . }}
[plugins."io.containerd.cri.v1.runtime".containerd.runtimes.runsc]
runtime_type = "io.containerd.runsc.v1"
EOF
sudo systemctl restart k3s # k3s re-render config kèm phần bạn thêm
💡 Có thể k3s đã làm hộ bạn. Các bản k3s gần đây (~v1.30+) tự dò
runsctrong$PATHlúc khởi động và tự thêm block runtime lẫn tạo luôn RuntimeClassgvisor— miễn làrunscđược cài trước khi k3s khởi động. Kiểm tra trước khi tự tạo:bashkubectl get runtimeclass # tìm dòng: gvisor runsc
Nếu chưa có, tạo RuntimeClass thủ công. handler phải khớp chính xác tên runtime trong
containerd:
# gvisor-runtimeclass.yaml
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor
handler: runsc # PHẢI trùng đúng tên runtime ở bước trên
RuntimeClass: một đối tượng Kubernetes cho phép chọn runtime nào sẽ chạy pod. Đặt
runtimeClassName: gvisortrên pod spec là cách "opt-in" một pod vào sandbox gVisor; pod không khai báo thì vẫn chạy runc như thường.
Kiểm chứng: pod thật sự đang chạy trong gVisor
kubectl run nginx-gvisor --image=nginx \
--overrides='{"spec":{"runtimeClassName":"gvisor"}}'
# container chạy trong gVisor sẽ báo Sentry kernel trong dmesg:
kubectl exec nginx-gvisor -- dmesg | grep -i gvisor
kubectl exec nginx-gvisor -- uname -a
# Linux ... 4.19.0-gvisor ... aarch64 <- kernel gVisor, KHONG phai host kernel
Thấy 4.19.0-gvisor và Starting gVisor... trong dmesg là bằng chứng pod đang chạy dưới
sandbox, không phải trực tiếp trên host kernel.
Một cạm bẫy đáng nhớ: port-forward
⚠️
kubectl port-forwardKHÔNG hoạt động ổn định với pod gVisor. Port-forward quay sốlocalhost:<port>bên trong sandbox, mà netstack cô lập của gVisor từ chối kết nối loopback đó (connect: connection refused). Để expose service, dùng NodePort, LoadBalancer hoặc Ingress. Đây là chi tiết ta sẽ gặp lại khi nói về ingress của OpenSandbox.
Tới đây bạn đã có nền móng: một cluster k3s với RuntimeClass gvisor sẵn sàng. Toàn bộ
phần còn lại của bài dựng trên chính cluster này.
Phần 2 — Cài OpenSandbox lên cluster
Cluster đã sẵn sàng. Giờ ta cài OpenSandbox lên trên và biến cluster thành một dịch vụ tạo sandbox theo yêu cầu.
Mô hình tư duy: hai lớp
Điểm quan trọng nhất cần nắm: OpenSandbox có hai lớp, và chỉ sandbox pod mới cần gVisor — control plane chạy trên runtime thường (runc):
| Thành phần | Namespace | Runtime | Vai trò |
|---|---|---|---|
| Server | opensandbox-system | runc | REST lifecycle API (tạo/liệt kê/xóa sandbox) |
| Controller (operator) | opensandbox-system | runc | Reconcile các CRD BatchSandbox / Pool / SandboxSnapshot |
| Sandbox pods | opensandbox | gvisor / runsc | Nơi untrusted code của bạn thật sự chạy |
gVisor không phải là một cờ bật theo từng request. Bạn set nó một lần trong config của
server ([secure_runtime]), rồi server tự chèn runtimeClassName: gvisor vào mọi sandbox
pod nó tạo. SDK/API caller không cần thay đổi gì.
+-------------------- opensandbox-system (runc) --------------------+
| |
| [ Server ] --REST API--> tao BatchSandbox CR |
| | | |
| | v |
| [ Controller ] --reconcile--> tao Pod (runtimeClassName=gvisor) |
| | |
+---------------------------------------|---------------------------+
v
+--------- opensandbox (gvisor) ---------+
| [ Sandbox Pod ] <- untrusted code |
+----------------------------------------+
Chuẩn bị values file
Chart all-in-one kubernetes/charts/opensandbox gói cả controller, server và CRD. Chỉ có
block [secure_runtime] là khác so với mặc định — đó là công tắc bật gVisor:
opensandbox-server:
server:
replicaCount: 1 # single node; mặc định chart là 2, thừa
resources:
requests: { cpu: "250m", memory: 512Mi }
limits: { cpu: "1", memory: 2Gi }
configToml: |
[server]
host = "0.0.0.0"
port = 80
api_key = "sk-osb-CHANGE-ME" # client gửi qua header OPEN-SANDBOX-API-KEY
[runtime]
type = "kubernetes"
execd_image = "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.20"
[kubernetes]
namespace = "opensandbox" # nơi sandbox pod được tạo
workload_provider = "batchsandbox"
# >>> Block nay bat gVisor cho MOI sandbox server nay tao ra <<<
[secure_runtime]
type = "gvisor"
k8s_runtime_class = "gvisor"
opensandbox-controller:
controller:
replicaCount: 1
snapshot:
# BUG da biet (chart v0.2.0): chart truyen --containerd-socket-path nhung
# binary v0.2.0 khong nhan co nay -> CrashLoopBackOff. De rong de bo co.
containerdSocketPath: ""
Sinh API key:
echo "sk-osb-$(openssl rand -hex 16)". Đểapi_key = ""là tắt auth — ổn cho test nhanh qua port-forward, không ổn cho bất cứ thứ gì expose ra ngoài.
Cài đặt
export KUBECONFIG="/Users/tcx/.lima/k3s/copied-from-guest/kubeconfig.yaml"
kubectl create namespace opensandbox # namespace cho sandbox workload
cd OpenSandbox/kubernetes/charts
helm dependency build opensandbox # kéo hai sub-chart local
helm install opensandbox ./opensandbox \
--namespace opensandbox-system --create-namespace \
-f /path/to/opensandbox-gvisor-values.yaml
Kiểm tra control plane và xác nhận server thấy RuntimeClass:
kubectl get pods -n opensandbox-system
# opensandbox-server-... 1/1 Running
# opensandbox-controller-manager-... 1/1 Running
# Server validate RuntimeClass lúc khởi động và từ chối chạy nếu thiếu:
kubectl logs -n opensandbox-system deploy/opensandbox-server | grep -i "RuntimeClass"
# -> "Kubernetes RuntimeClass 'gvisor' is available."
Smoke-test API
Không có Ingress mặc định, nên port-forward server (đây là control plane chạy runc, không phải sandbox gVisor — nên port-forward hoạt động bình thường):
kubectl port-forward -n opensandbox-system svc/opensandbox-server 8080:80
KEY="sk-osb-CHANGE-ME"
curl -s http://localhost:8080/health # {"status":"healthy"}
curl -s -o /dev/null -w "%{http_code}\n" \
http://localhost:8080/v1/sandboxes # 401 (auth được enforce)
curl -s -H "OPEN-SANDBOX-API-KEY: $KEY" \
http://localhost:8080/v1/sandboxes # 200 -> {"items":[...]}
Tạo một sandbox và chứng minh nó thật sự chạy gVisor:
POD=$(kubectl get pods -n opensandbox -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n opensandbox $POD -c sandbox -- uname -a
# Linux ... 4.19.0-gvisor ... aarch64 <- Sentry kernel cua gVisor
Dùng từ Python SDK: cái bẫy use_server_proxy
Có một cạm bẫy mạng mà ai chạy trên Mac cũng vấp. IP của sandbox pod (10.42.x) nằm bên
trong VM k3s và không route được từ máy Mac. SDK mặc định kết nối thẳng tới sandbox, nên
sẽ timeout. Giải pháp: route traffic sandbox qua server (thứ đang reachable qua
port-forward):
from opensandbox.config import ConnectionConfig
config = ConnectionConfig(
domain="localhost:8080",
api_key="sk-osb-CHANGE-ME",
use_server_proxy=True, # <-- BẮT BUỘC khi nói chuyện với cluster từ laptop
)
Với cấu hình đó, ví dụ đa ngôn ngữ đi kèm repo (main.py) chạy Python, Java, Go và
TypeScript bên trong sandbox và in kết quả ra — tất cả bên trong pod gVisor, không có dòng
code nào chạy trên máy bạn hay trên host kernel.
Bảng cạm bẫy nhanh
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
controller CrashLoopBackOff, in ra usage | chart truyền --containerd-socket-path, binary từ chối | controller.snapshot.containerdSocketPath: "" |
create trả POD_READY_TIMEOUT, pod biến mất | image lớn không pull kịp trong 60s | pre-pull image trên node rồi tạo lại |
SDK SandboxReadyTimeout, "cannot reach sandbox" | pod IP không route được từ laptop | ConnectionConfig(use_server_proxy=True) |
trong sandbox pip báo "externally-managed-environment" | Python do uv quản (PEP 668) | thêm --break-system-packages |
| create bị từ chối khi có networkPolicy | egress sidecar dùng NAT DNS, không tương thích gVisor | bỏ networkPolicy trên sandbox gVisor |
Dòng cuối cùng của bảng chính là ràng buộc runtime ta đã hứa ở phần mở đầu — và nó sẽ là nhân vật chính của Phần 4.
Phần 3 — Ingress: chạm vào service bên trong sandbox
Sandbox chạy được rồi, nhưng thường ta muốn nhiều hơn là "chạy lệnh và đọc file". Ta muốn một service HTTP chạy bên trong sandbox (một web app, một API, một notebook) và truy cập được từ bên ngoài pod. Đó là việc của ingress gateway.
Ingress gateway (của OpenSandbox): một reverse proxy HTTP/WebSocket phía cluster (
opensandbox/ingress, viết bằng Go) định tuyến request từ ngoài vào đúng sandbox. Nó watch các CRBatchSandbox, đọc Pod IP từ annotationsandbox.opensandbox.io/endpoints, rồi proxy tớipodIP:<port>.
Điểm mấu chốt — và là lý do ingress khác hẳn egress/vault ở Phần 4:
✅ Ingress gateway chạy trên MỌI runtime. Nó chỉ là reverse proxy trên Pod IP, không đụng iptables
nattable, nên hoạt động dưới runc, gVisor lẫn kata. Nhớ lại ở Phần 1:kubectl port-forwardchết trên pod gVisor — ingress gateway chính là cách "đúng" để chạm vào service trong sandbox gVisor.
Ba chế độ routing
Gateway có ba cách định tuyến (đặt qua gateway.route.mode):
| Mode | Hình dạng request | Cần gì |
|---|---|---|
uri | http://<gw>/<sandbox-id>/<port>/<path> | không cần gì — đơn giản nhất |
header | header OpenSandbox-Ingress-To: <id>-<port> | kiểm soát được header |
wildcard | <id>-<port>.<wildcard-domain> | bản ghi DNS *.domain |
Ta dùng uri — không cần wildcard DNS, không phải nhét header.
Bật gateway
Thêm block gateway dưới opensandbox-server.server trong values file:
opensandbox-server:
server:
gateway:
enabled: true
host: "localhost:8081" # địa chỉ được nhúng vào endpoint URL; ta port-forward gw Service ở đây
gatewayRouteMode: "uri" # uri | header | wildcard
Bật enabled: true khiến chart (a) ghi [ingress] mode="gateway" + gateway.route.mode
vào config server, và (b) deploy Deployment + ClusterIP Service opensandbox-ingress-gateway.
helm upgrade opensandbox ./opensandbox -n opensandbox-system -f /path/to/values.yaml
# đổi config -> restart server để đọc lại config.toml (mount subPath, không có checksum annotation)
kubectl rollout restart -n opensandbox-system deploy/opensandbox-server
kubectl port-forward -n opensandbox-system svc/opensandbox-ingress-gateway 8081:80 &
curl -s http://localhost:8081/status.ok # gateway health
Với một service chạy trên :8000 bên trong sandbox, ở uri mode bạn chạm vào nó bằng
http://localhost:8081/<sandbox-id>/8000/<path> — không cần auth ở tầng gateway (hãy đặt
auth của riêng bạn phía trước ở production; ta làm đúng điều đó ở Phần 5).
Phần 3b — Routing nhiều app bằng path/host dễ đọc
uri mode đưa ra URL kiểu /<sandbox-id>/8000/, mà sandbox-id từ API là một UUID ngẫu
nhiên — không đọc nổi. Với một nền tảng chạy nhiều app, ta muốn /<tên-thân-thiện> hoặc
app.localhost. Hai ý tưởng làm điều đó khả thi:
- Tự đặt tên sandbox. Tạo app bằng
BatchSandboxCR trực tiếp (không qua API server) với một cái tên thân thiện. Gateway phân giải id bằngBatchSandbox.Name == sandboxId, nên nó sẽ route/<tên-thân-thiện>/<port>/. - Đặt Traefik trước gateway (Traefik có sẵn trong k3s) để có path công khai tùy ý và
giấu đi đoạn
/<port>.
BatchSandbox: một Custom Resource (CRD) của OpenSandbox mô tả một sandbox long-lived với pod template inline. Tạo CR này trực tiếp cho phép bạn chọn
metadata.name— chính cái tên đó trở thành route prefix ở gateway.
Traefik: reverse proxy / ingress controller đi kèm mặc định trong k3s, nghe sẵn ở
:80/:443của node. Nó dùng các CRDIngressRoutevàMiddlewaređể định tuyến và biến đổi request (ví dụ viết lại path).
client --/web/...--> Traefik IngressRoute --rewrite--> OpenSandbox gateway --> nginx sandbox pod
--/api/...-> (co san trong k3s) /nginx/80/... /httpbin/8080/...
Cách sạch nhất: host-based *.localhost (không cần sửa /etc/hosts)
macOS (và hầu hết hệ điều hành) tự phân giải *.localhost về 127.0.0.1, nên mỗi app một
hostname là lựa chọn gọn gàng nhất. Trên Lima, Mac chạm được Traefik vì Lima tự forward
0.0.0.0:80 của node về 127.0.0.1:80 — không cần port-forward.
Ta dùng Ingress chuẩn networking.k8s.io/v1, gắn một Traefik AddPrefix middleware qua
annotation để chèn /<name>/<port> mà gateway cần:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata: { name: prefix-nginx, namespace: opensandbox-system }
spec: { addPrefix: { prefix: "/nginx/80" } }
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app-nginx
namespace: opensandbox-system
annotations:
# <namespace>-<middleware-name>@kubernetescrd
traefik.ingress.kubernetes.io/router.middlewares: opensandbox-system-prefix-nginx@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: nginx.localhost
http:
paths:
- { path: /, pathType: Prefix,
backend: { service: { name: opensandbox-ingress-gateway, port: { number: 80 } } } }
kubectl apply -f ingress-localhost.yaml
curl http://nginx.localhost/ # -> trang welcome cua nginx
Cái bẫy absolute-link khi route theo path chung
Nếu thay vì mỗi app một host, bạn muốn dùng chung apps.localhost/nginx, apps.localhost/httpbin,
sẽ gặp một vấn đề tinh vi. Gateway luôn strip /<name>/<port>, nên app nhận path sạch
(/cache/60) và không biết nó đang sống dưới /httpbin. App nào phát ra link/redirect/asset
root-absolute (/style.css, → /login) sẽ đẩy browser tới http://apps.localhost/style.css
— mất tiền tố /httpbin → 404 hoặc nhầm app.
Để dùng app dưới path prefix chung, app phải "base-path aware": nó phải tự sinh URL dưới prefix của mình, và ingress phải giao prefix đó cho nó. Hai yêu cầu:
- Cấu hình base path của app. Đa số app thật đều có: go-httpbin có
-prefix=/httpbin; framework cóSCRIPT_NAME/ROOT_PATH(WSGI/FastAPI),basePath(Next.js),--base-href(Angular),server.servlet.context-path(Spring). - Giao prefix qua gateway. Vì gateway strip
/<name>/<port>, rewrite phải chèn lại prefix app — một double prefix:^/httpbin/?(.*)→/httpbin/8080/httpbin/$1. Gateway ăn/httpbin/8080; app nhận/httpbin/...và link của nó giữ nguyên/httpbin/....
Nếu một app không thể "base-path aware", đừng route nó theo path chung — hãy dùng host-based routing (mỗi app một
*.localhost), nơi app phục vụ từ/và mọi absolute link tự phân giải đúng dưới hostname riêng. Đơn giản nhất và không cần cấu hình app.
Đây là bài học kiến trúc quan trọng: routing không chỉ là chuyện của proxy — nó là hợp đồng giữa proxy và app. Nắm được điều này giúp bạn chọn đúng kiểu routing ngay từ đầu thay vì đập đầu vào những lỗi 404 bí ẩn.
Phần 4 — Kiểm soát mạng ra: Egress & Credential Vault
Cô lập bằng gVisor bảo vệ host khỏi sandbox. Nhưng còn chiều ngược lại: làm sao ngăn code trong sandbox gửi dữ liệu ra ngoài tới nơi ta không cho phép, hoặc đánh cắp credential mà ta buộc phải cấp cho nó để gọi API? Đó là việc của hai tính năng: egress sidecar và Credential Vault.
Ràng buộc runtime — đọc phần này trước
Đây chính là cái đánh đổi đã hứa từ đầu bài. Cả hai tính năng đều được cài bên trong egress
sidecar, thứ chặn DNS bằng một rule iptables nat-table REDIRECT. Và:
⚠️ Netstack của gVisor KHÔNG cài đặt bảng
nat. Nên trên sandbox gVisor, tạo sandbox cónetworkPolicysẽ bị từ chối ngay ở API:
networkPolicy is not compatible with runtime 'gvisor': gVisor does not support the iptables nat table required by the egress sidecar.
| Runtime | Egress sidecar / Credential Vault |
|---|---|
| runc | ✅ hỗ trợ |
| gVisor | ❌ không (thiếu bảng iptables nat) |
| Kata (qemu/clh/fc) | ✅ hỗ trợ |
Nói cách khác: bạn phải chọn. gVisor cho cô lập kernel mạnh nhất nhưng không có egress control; runc/kata có egress control nhưng cô lập yếu hơn (runc) hoặc nặng hơn (kata). Với phần này ta tạm chuyển server sang runc (kata trên Lima Apple Silicon là nested virtualization, thường bất khả thi). Đây là một quyết định kiến trúc, không phải bug.
egress sidecar: một sidecar per-sandbox (chia sẻ network namespace của pod, cần
CAP_NET_ADMIN) enforce một allowlist chiều ra. Lớp 1 là DNS proxy trảNXDOMAINcho domain bị chặn; lớp 2 (dns+nft) thêm IP đã phân giải vào allow-set của nftables để một default-deny giữ vững ở tầng IP.
Chuyển server sang runc
Runtime bảo mật là setting cấp server. Trong values file:
opensandbox-server:
configToml: |
[secure_runtime]
type = "" # "" = runc (không chèn RuntimeClass). Trước đó là "gvisor".
# LUU Y: khi type = "", PHAI bo k8s_runtime_class, khong server crash-loop
[egress]
image = "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:v1.1.3"
mode = "dns+nft" # dns+nft BAT BUOC cho Credential Vault (no tu choi DNS-only)
helm upgrade opensandbox ./opensandbox -n opensandbox-system -f /path/to/values.yaml
# GOTCHA: config-only change KHONG restart server (mount subPath, khong checksum). Ep no:
kubectl rollout restart -n opensandbox-system deploy/opensandbox-server
GOTCHA — nếu set
type = ""nhưng để lạik8s_runtime_class = "gvisor", server crash-loop với:docker_runtime and k8s_runtime_class must be omitted when secure_runtime.type is empty.
Test egress (đã verify)
Tạo một sandbox default_action="deny" chỉ cho phép example.com, rồi probe từ bên trong,
rồi hot-patch policy để thêm www.google.com:
policy = NetworkPolicy(default_action="deny",
egress=[NetworkRule(action="allow", target="example.com")])
sandbox = await Sandbox.create(..., network_policy=policy)
# ... chay probe ben trong; roi patch policy nong:
await sandbox.patch_egress_rules([NetworkRule(action="allow", target="www.google.com")])
Kết quả:
-- ALLOWED host (example.com) -- REACHED 200
-- DENIED host (www.google.com) -- BLOCKED Name or service not known
-- www.google.com after patch -- REACHED 200
Host được phép thì tới; host bị chặn fail ngay ở phân giải DNS (NXDOMAIN); và một policy patch lúc runtime có hiệu lực ngay lập tức mà không cần tạo lại sandbox.
Credential Vault: bí mật thật không bao giờ vào sandbox
Credential Vault: bí mật thật không bao giờ đi vào sandbox. SDK phía host ghi nó vào vault của sidecar; tiến trình trong sandbox chỉ thấy một giá trị giả/rỗng. Khi sandbox gửi một HTTPS request được phép khớp một binding (scheme/host/port/method/path), sidecar MITM trong suốt sẽ chèn header auth thật vào lúc gói tin đi ra. Đọc vault chỉ trả về metadata đã che (redacted).
Vì MITM ký lại TLS, sandbox phải tin CA của sidecar. OpenSandbox tự export nó ra path chung
/opt/opensandbox/mitmproxy-ca-cert.pem bên trong sandbox — trỏ client của bạn vào đó
(SSL_CERT_FILE, --cacert, NODE_EXTRA_CA_CERTS) nếu không muốn handshake HTTPS fail.
+--------------------- Sandbox pod (runc) ---------------------+
| [ code ] env DEMO_TOKEN = "fake-token" (chi thay gia gia) |
| | GET https://httpbingo.org/get |
| v |
| [ egress sidecar / MITM ] <- vault giu SECRET that |
| | khop binding? -> chen header X-Demo-Token: <that> |
+-----|--------------------------------------------------------+
v
upstream nhan header voi SECRET that
Kết quả (đã verify):
SANDBOX_ENV_DEMO_TOKEN = fake-token-inside-sandbox # sandbox khong bao gio giu secret that
BOUND GET /get -> X-Demo-Token = REAL-SECRET-... # duoc chen tren duong day
UNBOUND POST /post -> X-Demo-Token = <absent> # ngoai pham vi binding thi khong chen
Secret thật chỉ tới được upstream đúng trên method+path đã bind, trong khi môi trường sandbox không giữ gì ngoài giá trị giả. Đây là một pattern rất mạnh cho AI agent: bạn cấp cho agent "quyền gọi API X" mà không hề đưa cho nó API key — nếu agent bị prompt-inject để lộ secret, nó cũng chẳng có gì để lộ.
Bảng tóm tắt
| Mục | Chi tiết |
|---|---|
| Vị trí tính năng | Cả hai nằm trong egress sidecar (per-sandbox, CAP_NET_ADMIN) |
| Hỗ trợ runtime | runc ✅, kata ✅, gVisor ❌ (thiếu bảng iptables nat) |
| Bật Credential Vault | [egress].mode="dns+nft" + networkPolicy(deny) + credentialProxy.enabled=true |
| CA MITM trong sandbox | /opt/opensandbox/mitmproxy-ca-cert.pem — client phải tin |
| Cập nhật policy runtime | patch_egress_rules() / osb egress patch — có hiệu lực ngay |
| Service in-cluster khi deny | phải allow cả FQDN lẫn dải ClusterIP CIDR của nó |
Phần 5 — Lớp auth ở edge: Entra OAuth2 + phân quyền theo app
Tới đây, ai gọi được tới Traefik là chạm được vào các app trong sandbox. Với một nền tảng thật, ta cần một câu trả lời cho hai câu hỏi: "Bạn là ai?" (authentication) và "Bạn được vào app nào?" (authorization). Phần này thiết kế một lớp auth đặt trước toàn bộ routing ở Phần 3 — không đụng vào gateway của OpenSandbox hay image của các app.
Đây là một spec bản draft: authz-service đã được deploy và test trên cluster; oauth2-proxy đã dựng khung, còn chờ Entra credentials và TLS. Ta trình bày kiến trúc và quyết định thiết kế, không phải một kết quả đã chạy end-to-end như các phần trước.
Microsoft Entra ID: dịch vụ danh tính đám mây của Microsoft (tên cũ: Azure AD). Ở đây nó đóng vai OIDC provider — nơi người dùng đăng nhập, và là nguồn "user thuộc security group nào" để ta ra quyết định phân quyền.
oauth2-proxy: một reverse proxy off-the-shelf lo toàn bộ luồng OAuth2 Authorization Code + OIDC: chuyển hướng người dùng chưa đăng nhập tới Entra, xử lý callback, đặt cookie phiên, và trả lại danh tính (email, group) qua các header
X-Auth-Request-*.
Quyết định thiết kế: shared host + path routing
Ta chọn một host chung (https://apps.localhost/<app>/…) thay vì mỗi app một host.
Lý do: một origin duy nhất nghĩa là một Entra redirect URI (Entra không hỗ trợ wildcard
redirect URI), một cookie phiên (SSO tự động), và một endpoint /oauth2/ — độ phức tạp
auth thấp nhất. Đánh đổi chấp nhận: mọi app chung một browser origin (cô lập giữa các app
yếu hơn), nên mô hình này chỉ dành cho các app tin cậy.
Kiến trúc
+------------- Microsoft Entra ID (OIDC) -------------+
| app registration - client secret - groups claim |
+----------------------------^------------------------+
| Authorization Code + OIDC
Browser --HTTPS(443)--> Traefik (host apps.localhost)
router(/oauth2/*) --------------------------------------> oauth2-proxy
router(/<app>/*) chuoi middleware:
1. mw-auth-signin loi 401 -> oauth2-proxy /oauth2/sign_in?rd=...
2. mw-authn forwardAuth -> oauth2-proxy /oauth2/auth (set X-Auth-Request-*)
3. mw-authz forwardAuth -> authz-service /check (200/403)
4. rewrite-<app> replacePathRegex -> /<sandbox-name>/<port>/...
|
v
OpenSandbox ingress gateway (uri mode) --> BatchSandbox pod (<app>)
forwardAuth: một middleware của Traefik hỏi một service ngoài "request này có được phép không?" trước khi cho đi tiếp. Service trả
2xxlà cho qua,401/403là chặn. Đây là cách ta cắm cả xác thực (mw-authn) lẫn phân quyền (mw-authz) vào mà không sửa app.
Hai thành phần
oauth2-proxy (off-the-shelf) lo authentication. Các cờ chính: provider oidc, issuer
https://login.microsoftonline.com/<tenant>/v2.0, --set-xauthrequest (bơm danh tính ra
header), --oidc-groups-claim=groups, --cookie-domain=apps.localhost,
--redirect-url=https://apps.localhost/oauth2/callback.
authz-service (mã tự viết — thứ duy nhất phải code) lo authorization. Nó là một HTTP
server stdlib chạy trên python:3.12-alpine, script mount từ ConfigMap — không build
image. Hợp đồng forwardAuth của nó rất gọn:
- Input (header từ Traefik + mw-authn):
X-Forwarded-Uri,X-Auth-Request-Groups(CSV),X-Auth-Request-Email. - Phân giải app: segment đầu của
X-Forwarded-Uri(/httpbin/get→httpbin). - Quyết định:
200nếugroup_của_user ∩ policy[app] ≠ ∅; ngược lại403. App lạ →403(default-deny). - Nguồn policy: ConfigMap
authz-policy→policy.json({app: [groups]}), đọc mỗi request nên sửa policy có hiệu lực ngay, không cần restart.
Ba luồng request
Chua dang nhap: /nginx/ -> mw-authn goi /oauth2/auth -> 401
-> mw-auth-signin redirect toi Entra login -> callback set cookie
-> replay -> duoc phep
Da dang nhap + duoc phep: cookie hop le -> mw-authn 202 + header danh tinh
-> mw-authz 200 -> rewrite -> gateway -> nginx pod -> 200
Da dang nhap + KHONG duoc phep: mw-authn 202 -> mw-authz 403 -> dung lai o Traefik
Mô hình dữ liệu phân quyền
| Thực thể | Ở đâu | Ví dụ |
|---|---|---|
| Định danh app | path prefix của Ingress + tên BatchSandbox | nginx, httpbin |
| Principal được phép | ConfigMap authz-policy policy.json | {"nginx":["sbx-nginx","admins"]} |
| User → groups | security group của Entra (trong groups claim) | sbx-nginx, admins |
Thêm một app mới chỉ tốn: một BatchSandbox, một Ingress rule, một dòng policy, và một Entra group. Không redeploy app, không sửa gateway.
Những điểm bảo mật đáng nhớ
- HTTPS bắt buộc — cookie OAuth phải
Secure; redirect phải là https. Terminate TLS ở Traefik (dev dùng mkcert; prod dùng cert-manager/Let's Encrypt). - Same-origin (shared host) — mọi app chung
apps.localhost; một app bị chiếm có thể đọc cookie/DOM của app khác. Chỉ chấp nhận cho app tin cậy; app không tin cậy/đa tenant cần cô lập theo host riêng. - Trust boundary — app chỉ được tin
X-Auth-Request-*vì Traefik/oauth2-proxy đặt chúng; gateway/pod không reachable trực tiếp từ ngoài (chỉ qua Traefik). - Groups claim quá lớn — membership Entra nhiều có thể làm tràn cookie; nếu vậy bật một session store (Redis) cho oauth2-proxy.
Tiêu chí nghiệm thu
| # | Kịch bản | Kỳ vọng |
|---|---|---|
| A1 | Chưa đăng nhập GET /nginx/ | 302 → Microsoft login |
| A2 | User thuộc sbx-nginx sau login → /nginx/ | 200 (trang nginx) |
| A3 | User không thuộc sbx-nginx/admins → /nginx/ | 403 |
| A4 | Cùng phiên → /httpbin/get (user thuộc sbx-httpbin) | 200, không login lại (SSO) |
| A5 | Path không có policy /secret/ | 403 (default-deny) |
| A6 | Sửa policy.json thêm group → trong ~1 phút | quyết định mới, không restart |
Lớp này khép lại bức tranh: cô lập bằng gVisor bảo vệ host, egress/vault kiểm soát dữ liệu ra, và giờ auth kiểm soát ai được vào — một nền tảng có phòng thủ nhiều tầng chứ không chỉ một hàng rào.
Kết luận
Ta đã đi từ một chiếc MacBook Apple Silicon trắng tới một nền tảng chạy untrusted code có phòng thủ nhiều tầng. Nhìn lại toàn bộ ngăn xếp:
+-----------------------------------------------------------------+
| Auth Entra OAuth2 SSO + authz theo app (default-deny) |
+-----------------------------------------------------------------+
| Egress allowlist FQDN + Credential Vault (bi mat khong vao) |
+-----------------------------------------------------------------+
| Routing nhieu app, path/host de doc qua Traefik |
+-----------------------------------------------------------------+
| Ingress reverse proxy vao service ben trong sandbox |
+-----------------------------------------------------------------+
| Platform OpenSandbox: server + controller + sandbox pods |
+-----------------------------------------------------------------+
| Cluster k3s + gVisor trong Linux VM (Lima, arm64) |
+-----------------------------------------------------------------+
Những điều rút ra
-
Cô lập là nhiều tầng, không phải một hàng rào. gVisor bảo vệ host khỏi sandbox; egress kiểm soát dữ liệu đi ra; vault giữ bí mật khỏi tay code; auth kiểm soát ai được vào. Bỏ bất kỳ tầng nào cũng để lại một lỗ hổng mà các tầng khác không lấp được.
-
Ràng buộc runtime là quyết định kiến trúc, không phải bug. gVisor cho cô lập mạnh nhất nhưng netstack của nó thiếu bảng iptables
nat, nên egress sidecar và Credential Vault không chạy trên gVisor — phải dùng runc hoặc kata. Ngược lại, ingress gateway là reverse proxy thuần nên chạy trên mọi runtime. Biết tính năng nào cần gì giúp bạn chọn runtime đúng ngay từ đầu. -
Trên Mac luôn có "hai lớp". k3s/gVisor chạy trong Linux VM, còn máy bạn thì không — nên IP pod không route được (cần
use_server_proxy),port-forwardchết trên pod gVisor (dùng ingress), và Lima tự forward port*.localhostgiúp routing "just work". Phần lớn lỗi khó hiểu trên Mac đều bắt nguồn từ ranh giới VM này. -
Bảo mật tốt nhất là thứ trong suốt với app. Không một tầng nào trong bài yêu cầu sửa image của app: gVisor set qua RuntimeClass, routing/auth cắm qua Traefik middleware, và Credential Vault inject bí mật ở tầng mạng. App cứ chạy như bình thường; bảo mật bọc quanh nó từ bên ngoài.
Bước tiếp theo
- Để tránh chọn giữa cô lập và egress control, thử kata (microVM) trên hạ tầng có
nested virtualization — nó vừa cô lập mạnh vừa có iptables
natđầy đủ. - Hoàn thiện lớp auth: cấp Entra credentials + TLS thật, rồi chạy hết bộ tiêu chí nghiệm thu ở Phần 5.
- Bake sẵn dependency vào một custom image
FROM code-interpreterđể sandbox khởi động nhanh, hoặc dùngPoolCRD giữ sẵn sandbox pre-warmed.
Nếu bạn muốn hiểu sâu hơn vì sao gVisor và microVM đánh đổi khác nhau về attack surface và performance — nền tảng cho mọi lựa chọn runtime trong bài này — hãy đọc tiếp bài so sánh gVisor vs Firecracker.
References
- gVisor — Kubernetes quick start
- gVisor — containerd quick start
- gVisor — platforms
- gVisor — arm64 compatibility
- gVisor — performance
- Lima — Kubernetes examples
- OpenSandbox trên GitHub
- oauth2-proxy
- Traefik — Kubernetes IngressRoute & Middleware
- Microsoft Entra ID — OpenID Connect
Related posts
- gVisor vs Firecracker: Hai triết lý sandbox container — So sánh sâu hai cách tiếp cận isolation, bổ trợ trực tiếp cho lựa chọn runtime trong bài này.
- Container Networking from Scratch — Nền tảng network namespace/veth/bridge giúp hiểu vì sao egress sidecar cần iptables
nat. - Kubernetes CNI Overview — Bức tranh tổng quan về mạng trong Kubernetes.
