跳至主要内容

程式碼沙箱部署

Aivory 的 Python 程式碼執行能力由一個獨立的沙箱服務提供。本頁講清它的 sidecar 架構、compose 中每個關鍵配置項的含義、安全邊界,以及如何把沙箱獨立部署到另一臺機器。終端使用者視角的使用說明見Python 沙箱指南

架構:sidecar 控制面 + 一次性會話容器

沙箱由兩個映象組成:

映象角色
ghcr.io/hjxwz123/aivory-sandbox-sidecar控制服務(sidecar):接收後端的 HTTP 請求,驅動 Docker daemon 建立/回收會話容器
ghcr.io/hjxwz123/aivory-sandbox執行時映象:每個會話一個鎖定後的容器,真正執行使用者程式碼
┌──────────┐ POST /sessions /exec /files ┌───────────────┐ docker exec ┌────────────────┐
│ app 後端 │ ────────────────────────────► │ sidecar 控制面 │ ────────────► │ 會話容器 │
└──────────┘ SANDBOX_BASE_URL └───────────────┘ │ aivory-sandbox │
└────────────────┘

sidecar 本身不執行任何使用者程式碼,它通過掛載的 /var/run/docker.sock 操作宿主機 Docker daemon,為每個會話拉起一個兄弟容器(sibling container)。會話內的 /workspace 在同一會話的多次執行之間保留,pip 安裝的包、生成的檔案都還在,行為對齊 ChatGPT Code Interpreter。

Docker-in-Docker 的實現方式

預設方案是把宿主機的 /var/run/docker.sock 掛進 sidecar 容器,會話容器實際由宿主機 daemon 建立。如果你的環境不允許掛載宿主 socket,也可以給 sidecar 配一個獨立的 dind(docker:dind)daemon,代價是多一層巢狀與儲存開銷。無論哪種方式,sidecar 都必須能訪問一個 Docker daemon,這是硬性要求。

執行時映象內建了資料科學棧(numpy、pandas、scipy、scikit-learn、matplotlib、seaborn、plotly 等)、文件處理庫(python-pptx、python-docx、openpyxl、reportlab、weasyprint 等)以及 Noto Sans CJK 字型,matplotlib 已預配置中文字型,圖表裡的中文不會顯示成方框。

內建沙箱:預設零配置

生產 compose 棧已捆綁 sandbox 服務,docker compose up -d 一條命令連沙箱一起拉起,不需要任何額外配置:

  • 不釋出任何宿主埠:只有 app 通過私有 internal 網路訪問它(SANDBOX_BASE_URL=http://sandbox:8000),公網永遠摸不到。
  • 共享內部 API key:appsandbox 兩個服務讀同一個 SANDBOX_API_KEY(預設 aivory-bundled-sandbox)。因為沙箱不對外暴露,這個預設值可用;但只要你打算把沙箱埠暴露給任何其它網路,必須換成強隨機值。
  • 啟動即拉映象:SANDBOX_PULL_ON_START=1 讓 sidecar 冷啟動時先拉取執行時映象,健康檢查的 start_period: 120s 就是為這次拉取預留的。
  • 軟依賴:appdepends_on 沙箱。沙箱不可用時只有程式碼執行功能報錯,應用其餘部分照常工作。

compose 關鍵配置項逐個講

以下均為 compose 中 sandbox 服務的環境變數,列出的是生產 compose 的預設值:

變數預設值說明
SANDBOX_API_KEYaivory-bundled-sandboxBearer 鑑權 key,必須與 app 側一致。sidecar 無 key 拒絕啟動,校驗使用常量時間比較
SANDBOX_IMAGEghcr.io/hjxwz123/aivory-sandbox:latest每會話執行時映象
SANDBOX_PULL_ON_START1啟動時預拉執行時映象,保證第一次執行不失敗
SANDBOX_NETWORKnone會話容器網路。none 完全斷網;設為 bridge 才能聯網(如執行時 pip install)
SANDBOX_MEMORY2g單個會話容器記憶體上限
SANDBOX_CPUS1單個會話容器 CPU 上限
SANDBOX_MAX_SESSIONS16同時存活的會話容器數量上限
SANDBOX_EXEC_TIMEOUT_CAP_MS600000單次執行時長的運維硬上限(10 分鐘)
SANDBOX_IDLE_TTL_CAP_SECONDS86400空閒回收視窗的運維硬上限(24 小時)
SANDBOX_READ_ONLY_ROOTFS1會話容器根檔案系統只讀,防磁碟填滿攻擊
SANDBOX_WORKSPACE_SIZE512m只讀模式下 /workspace 可寫 tmpfs 的大小
SANDBOX_LOCAL_STORAGE_DIR/var/lib/aivory/sandbox-archives本地工作區歸檔目錄,配合持久卷實現零配置持久化

SANDBOX_API_KEY:與 app 共享的鑑權憑證

sidecar 暴露的是"驅動宿主機 Docker"的能力,等價於宿主機 RCE,因此鑑權是強制的:key 為空時 sidecar 直接拒絕啟動(唯一例外是顯式設定 SANDBOX_ALLOW_NO_AUTH=1,僅限可信的本機開發環境)。app 服務與 sandbox 服務必須配置同一個值。生成強 key:

openssl rand -hex 24

SANDBOX_NETWORK:預設斷網,聯網有代價

預設 none,會話容器完全沒有網路,使用者程式碼既不能 pip install 也不能發任何請求。改成 bridge 後代碼可以聯網,但要清楚代價:

開啟 bridge 網路前想清楚

聯網的沙箱意味著使用者程式碼可以把會話裡的資料(上傳的檔案、生成的中間結果)發往任意外部地址,也可以主動掃描、攻擊你內網中沙箱容器可達的服務。除非明確需要執行時安裝包或抓取外部資料,保持 none

資源上限:MEMORY / CPUS / MAX_SESSIONS

SANDBOX_MEMORY(預設 2g)和 SANDBOX_CPUS(預設 1)限制單個會話容器;SANDBOX_MAX_SESSIONS(預設 16)限制同時存活的會話總數。按最壞情況估算宿主機容量:MAX_SESSIONS x MEMORY 是會話容器可能佔用的記憶體總量。另有 SANDBOX_MAX_CONCURRENT_EXECS(預設 4)限制全域性併發執行數,SANDBOX_PIDS_LIMIT(預設 256)防 fork bomb。

sidecar 控制面自己也被 compose 限制在 mem_limit: 1g / pids_limit: 512。歸檔時它會在記憶體中緩衝最大 200MiB 的工作區 tar 包(SANDBOX_MAX_ARCHIVE_BYTES),mem_limit 要保持在這個值之上留有餘量。

兩個"硬上限":EXEC_TIMEOUT_CAP_MS 與 IDLE_TTL_CAP_SECONDS

這兩個變數是運維層的天花板,與管理後臺的可調設定配合工作:

  • 管理員在後臺設定的單次執行超時(sandbox_exec_timeout_sec)會被鉗制到 SANDBOX_EXEC_TIMEOUT_CAP_MS(預設 600000ms,即 10 分鐘)以內。
  • 管理員設定的空閒回收視窗(sandbox_idle_ttl_sec)會被鉗制到 SANDBOX_IDLE_TTL_CAP_SECONDS(預設 86400s,即 24 小時)以內。後臺未下發時,sidecar 使用兜底值 30 分鐘回收空閒會話。

也就是說:管理員可以在天花板之下隨意調短,但永遠越不過運維在 compose 裡定的上限。想全域性收緊,改這兩個環境變數即可。

磁碟防線:READ_ONLY_ROOTFS / WORKSPACE_SIZE

SANDBOX_READ_ONLY_ROOTFS=1(預設開啟)讓會話容器的根檔案系統只讀,只有三個位置可寫,且都是有大小上限的 tmpfs:

路徑大小來源預設
/workspaceSANDBOX_WORKSPACE_SIZE512m
/tmpSANDBOX_TMPFS_SIZE256m
使用者 $HOMESANDBOX_TMPFS_SIZE256m

因此單個會話無論怎麼寫檔案都不可能填滿宿主機磁碟。使用者上傳的檔案放在 /workspace/uploads/,程式碼產物寫入 /workspace/outputs/,都受 WORKSPACE_SIZE 約束。需要更大的工作區(如處理大數據集)時調大 SANDBOX_WORKSPACE_SIZE,注意 tmpfs 佔用的是記憶體。

SANDBOX_LOCAL_STORAGE_DIR:工作區歸檔持久化

會話容器被回收(空閒超時或顯式銷燬)時,sidecar 會把 /workspace 打成 tar 歸檔存起來;同一個對話下次執行程式碼時自動恢復。歸檔以對話 id 為鍵,所以即使會話容器換了一茬,工作區內容也能跨回收存續。

SANDBOX_LOCAL_STORAGE_DIR 指定本地歸檔目錄,compose 已把它掛到名為 sandbox-archives 的持久捲上,實現零配置持久化,不需要 S3/OSS/MinIO。注意:

  • 該變數只能由運維通過環境變數設定,永遠不接受來自請求或管理後臺的值(sidecar 以 root 執行並持有 docker.sock,允許遠端指定寫入路徑等於開放宿主機寫入面)。
  • 留空則本地歸檔不生效,回收即丟失(reaped = gone)。
  • 僅適用於單節點:普通 Docker 卷不跨副本共享,多副本部署必須改用 S3/OSS 後端。
  • 歸檔是 best-effort:超過 200MiB 的工作區會跳過歸檔並記日誌,歸檔/恢復失敗不會導致執行請求失敗。

管理後臺可調項

除了 compose 環境變數,部分行為可在管理後臺的站點設定中線上調整,無需重啟:

後臺設定作用約束
單次執行超時(sandbox_exec_timeout_sec)每次程式碼執行的時長上限SANDBOX_EXEC_TIMEOUT_CAP_MS 鉗制
空閒回收時間(sandbox_idle_ttl_sec)會話空閒多久後回收容器SANDBOX_IDLE_TTL_CAP_SECONDS 鉗制
儲存後端(storage_provider)工作區歸檔存到哪local(預設,即上文本地卷)/ s3 / aliyun_oss
沙箱地址與 key(sandbox_base_url / sandbox_api_key)覆蓋環境變數指向的沙箱留空則環境變數生效

儲存後端選 s3 時填入 endpoint、bucket 與憑據即可;MinIO 及任意 S3 相容服務同樣選 s3,填自定義 endpoint 後會自動切換為 path-style 定址 + SigV4 簽名,無需額外開關。

安全邊界

沙箱採用容器級隔離,每個會話容器都帶滿一套鎖定項:

  • 非 root 執行,--cap-drop ALL,--security-opt no-new-privileges
  • 預設無網路(--network none)。
  • 只讀根檔案系統 + 有上限的 tmpfs(見上文),外加 memory/cpu/pids/nofile 限制。
  • 同一會話內的執行序列,全域性併發受限;stdout/stderr 截斷到 32KB;產物單檔案 20MB、單次最多 20 個、總計 50MB。
  • 可選:通過 SANDBOX_SECCOMP_PROFILE 為會話容器固定 seccomp profile(路徑需在 sidecar 容器內可讀),進一步收窄核心攻擊面。
docker.sock 等價於宿主機 root

架構上 sidecar 必須能驅動 Docker daemon,而 /var/run/docker.sock 在宿主機上是 root 等價物:任何能訪問 sidecar 服務的人都等於拿到了宿主機。三條紀律:

  1. 永遠不要把 sidecar 埠暴露到公網。內建部署已經做到(無 ports 釋出);獨立部署時繫結內網介面或走 VPN。
  2. SANDBOX_API_KEY 必須是強隨機值(內建棧的私網預設值除外)。
  3. 生產環境建議在裸 socket 前放一個 docker-socket-proxy,只放行 container create/start/exec/kill/inspect 與 image pull,其餘 API 全部拒絕,然後讓 sidecar 走 DOCKER_HOST: tcp://socket-proxy:2375 而不掛載裸 socket。

這是容器級隔離,足以支撐單機自部署,但不是 gVisor/microVM 級別。sidecar 的 HTTP 協議是穩定契約,更高安全要求的部署可以把執行後端替換為 gVisor、Firecracker 等,而 app 側無需任何改動。

另外,sidecar 重啟後會自動發現帶 aivory.sandbox=1 標籤的存量容器,繼續追蹤並按 TTL 回收,不會留下孤兒容器。

獨立部署:沙箱放到另一臺機器

程式碼執行是 CPU/記憶體密集型負載,把沙箱拆到獨立機器可以避免它與主應用搶資源。沙箱服務有獨立的公開倉庫與映象,拉取即用,無需構建:

1. 在沙箱機上部署

git clone https://github.com/hjxwz123/aivory-sandbox.git
cd aivory-sandbox

export OWNER=hjxwz123
export SANDBOX_API_KEY=$(openssl rand -hex 24)
printf 'SANDBOX_API_KEY=%s\n' "$SANDBOX_API_KEY" # 儲存這個值

docker compose pull
docker compose up -d

獨立 compose 會把 sidecar 釋出在宿主機 48217 埠(容器內仍是 8000)。驗證:

curl -H "Authorization: Bearer $SANDBOX_API_KEY" http://localhost:48217/healthz

返回 {ok, docker, image} 即就緒。

2. 讓主應用指過去

在主應用的 .env 中修改兩個變數,並從主棧中移除(或不再啟動)內建的 sandbox 服務:

SANDBOX_BASE_URL=http://<沙箱機內網地址>:48217
SANDBOX_API_KEY=<第 1 步生成的同一個值>

也可以不改環境變數,直接在管理後臺填 sandbox_base_url / sandbox_api_key,後臺值優先於環境變數。

換機必換強 key,埠只走內網

內建棧的預設 key(aivory-bundled-sandbox)只在"沙箱無埠釋出、僅私網可達"的前提下才可接受。一旦沙箱監聽了真實網絡卡埠,任何拿到 key 的人都能驅動那臺宿主機的 Docker。獨立部署必須:用 openssl rand -hex 24 生成新 key;用防火牆/安全組把 48217 限制為僅主應用伺服器可訪問;兩臺機器之間儘量走內網或 VPN 鏈路。

故障排查

現象排查方向
第一次執行程式碼就失敗冷啟動時執行時映象還沒拉完,看 docker compose logs sandbox;健康檢查預留了 120s 的 start_period
健康檢查一直 unhealthysidecar 連不上 Docker daemon,確認 /var/run/docker.sock 掛載存在且宿主 daemon 正常
sidecar 啟動即退出SANDBOX_API_KEY 為空,sidecar 設計為無 key 拒絕啟動
程式碼裡 pip install 失敗預設 SANDBOX_NETWORK=none 無網路,評估風險後改 bridge
寫檔案報磁碟滿撞到 /workspace 的 tmpfs 上限,調大 SANDBOX_WORKSPACE_SIZE
回收後工作區丟失SANDBOX_LOCAL_STORAGE_DIR 未設定或對應卷未掛載;或後臺 storage_provider 被清空
應用其它功能受影響不應發生:app 對沙箱是軟依賴,只有程式碼執行功能會報錯;若整站異常,問題在別處