blogbyAndrew

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

July 29, 2026

Concentric security layers — auth, egress, and gVisor sandbox — wrapping a code core, over OpenSandbox on k3s + gVisor

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:

text
+-----------------------------------------------------------+
|  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:

ℹ️ 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

ToolVai tròHợp với k3s + gVisor?
LimaLinux VM nhẹ; có sẵn template k3sTốt nhất — k3s thật, full quyền root/containerd
ColimaWrapper của Lima; --kubernetes cho k3s✅ Ổn — một lệnh, ít quyền cấu hình trực tiếp hơn
multipassVM Ubuntu thuần; tự cài k3s✅ Được — thủ công nhất, kiểm soát nhất
k3dk3s bên trong Docker⚠️ Tránh cho gVisor — Docker lồng nhau xung đột việc chỉnh containerd
Rancher Desktop / UTMApp 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.

bash
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:

bash
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:

bash
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ò runsc trong $PATH lúc khởi động và tự thêm block runtime lẫn tạo luôn RuntimeClass gvisor — miễn là runsc được cài trước khi k3s khởi động. Kiểm tra trước khi tự tạo:

bash
kubectl 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:

yaml
# 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: gvisor trê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

bash
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-gvisorStarting 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-forward KHÔ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ầnNamespaceRuntimeVai trò
Serveropensandbox-systemruncREST lifecycle API (tạo/liệt kê/xóa sandbox)
Controller (operator)opensandbox-systemruncReconcile các CRD BatchSandbox / Pool / SandboxSnapshot
Sandbox podsopensandboxgvisor / runscNơ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ì.

text
+-------------------- 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:

yaml
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

bash
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:

bash
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):

bash
kubectl port-forward -n opensandbox-system svc/opensandbox-server 8080:80
bash
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:

bash
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):

python
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ứngNguyên nhânCách xử lý
controller CrashLoopBackOff, in ra usagechart truyền --containerd-socket-path, binary từ chốicontroller.snapshot.containerdSocketPath: ""
create trả POD_READY_TIMEOUT, pod biến mấtimage lớn không pull kịp trong 60spre-pull image trên node rồi tạo lại
SDK SandboxReadyTimeout, "cannot reach sandbox"pod IP không route được từ laptopConnectionConfig(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ó networkPolicyegress sidecar dùng NAT DNS, không tương thích gVisorbỏ 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 CR BatchSandbox, đọc Pod IP từ annotation sandbox.opensandbox.io/endpoints, rồi proxy tới podIP:<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 nat table, nên hoạt động dưới runc, gVisor lẫn kata. Nhớ lại ở Phần 1: kubectl port-forward chế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):

ModeHình dạng requestCần gì
urihttp://<gw>/<sandbox-id>/<port>/<path>không cần gì — đơn giản nhất
headerheader 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:

yaml
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.

bash
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:

  1. Tự đặt tên sandbox. Tạo app bằng BatchSandbox CR 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ằng BatchSandbox.Name == sandboxId, nên nó sẽ route /<tên-thân-thiện>/<port>/.
  2. Đặ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/:443 của node. Nó dùng các CRD IngressRouteMiddleware để định tuyến và biến đổi request (ví dụ viết lại path).

text
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:

yaml
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 } } } }
bash
kubectl apply -f ingress-localhost.yaml
curl http://nginx.localhost/                 # -> trang welcome cua nginx

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:

  1. 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).
  2. 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ó networkPolicy sẽ 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.

RuntimeEgress sidecar / Credential Vault
runc✅ hỗ trợ
gVisorkhô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ả NXDOMAIN cho 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:

yaml
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)
bash
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ại k8s_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:

python
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ả:

text
-- 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.

text
+--------------------- 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):

text
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ụcChi tiết
Vị trí tính năngCả hai nằm trong egress sidecar (per-sandbox, CAP_NET_ADMIN)
Hỗ trợ runtimerunc ✅, 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 runtimepatch_egress_rules() / osb egress patch — có hiệu lực ngay
Service in-cluster khi denyphả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

text
                +------------- 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ả 2xx là cho qua, 401/403 là 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:

Ba luồng request

text
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ểỞ đâuVí dụ
Định danh apppath prefix của Ingress + tên BatchSandboxnginx, httpbin
Principal được phépConfigMap authz-policy policy.json{"nginx":["sbx-nginx","admins"]}
User → groupssecurity 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ớ

Tiêu chí nghiệm thu

#Kịch bảnKỳ vọng
A1Chưa đăng nhập GET /nginx/302 → Microsoft login
A2User thuộc sbx-nginx sau login → /nginx/200 (trang nginx)
A3User không thuộc sbx-nginx/admins/nginx/403
A4Cùng phiên → /httpbin/get (user thuộc sbx-httpbin)200, không login lại (SSO)
A5Path không có policy /secret/403 (default-deny)
A6Sửa policy.json thêm group → trong ~1 phútquyế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:

text
+-----------------------------------------------------------------+
|  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

Bước tiếp theo

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