程式碼沙箱部署
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。
預設方案是把宿主機的 /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:
app與sandbox兩個服務讀同一個SANDBOX_API_KEY(預設aivory-bundled-sandbox)。因為沙箱不對外暴露,這個預設值可用;但只要你打算把沙箱埠暴露給任何其它網路,必須換成強隨機值。 - 啟動即拉映象:
SANDBOX_PULL_ON_START=1讓 sidecar 冷啟動時先拉取執行時映象,健康檢查的start_period: 120s就是為這次拉取預留的。 - 軟依賴:
app不depends_on沙箱。沙箱不可用時只有程式碼執行功能報錯,應用其餘部分照常工作。
compose 關鍵配置項逐個講
以下均為 compose 中 sandbox 服務的環境變數,列出的是生產 compose 的預設值:
| 變數 | 預設值 | 說明 |
|---|---|---|
SANDBOX_API_KEY | aivory-bundled-sandbox | Bearer 鑑權 key,必須與 app 側一致。sidecar 無 key 拒絕啟動,校驗使用常量時間比較 |
SANDBOX_IMAGE | ghcr.io/hjxwz123/aivory-sandbox:latest | 每會話執行時映象 |
SANDBOX_PULL_ON_START | 1 | 啟動時預拉執行時映象,保證第一次執行不失敗 |
SANDBOX_NETWORK | none | 會話容器網路。none 完全斷網;設為 bridge 才能聯網(如執行時 pip install) |
SANDBOX_MEMORY | 2g | 單個會話容器記憶體上限 |
SANDBOX_CPUS | 1 | 單個會話容器 CPU 上限 |
SANDBOX_MAX_SESSIONS | 16 | 同時存活的會話容器數量上限 |
SANDBOX_EXEC_TIMEOUT_CAP_MS | 600000 | 單次執行時長的運維硬上限(10 分鐘) |
SANDBOX_IDLE_TTL_CAP_SECONDS | 86400 | 空閒回收視窗的運維硬上限(24 小時) |
SANDBOX_READ_ONLY_ROOTFS | 1 | 會話容器根檔案系統只讀,防磁碟填滿攻擊 |
SANDBOX_WORKSPACE_SIZE | 512m | 只讀模式下 /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 後代碼可以聯網,但要清楚代價:
聯網的沙箱意味著使用者程式碼可以把會話裡的資料(上傳的檔案、生成的中間結果)發往任意外部地址,也可以主動掃描、攻擊你內網中沙箱容器可達的服務。除非明確需要執行時安裝包或抓取外部資料,保持 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:
| 路徑 | 大小來源 | 預設 |
|---|---|---|
/workspace | SANDBOX_WORKSPACE_SIZE | 512m |
/tmp | SANDBOX_TMPFS_SIZE | 256m |
使用者 $HOME | SANDBOX_TMPFS_SIZE | 256m |
因此單個會話無論怎麼寫檔案都不可能填滿宿主機磁碟。使用者上傳的檔案放在 /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 容器內可讀),進一步收窄核心攻擊面。
架構上 sidecar 必須能驅動 Docker daemon,而 /var/run/docker.sock 在宿主機上是 root 等價物:任何能訪問 sidecar 服務的人都等於拿到了宿主機。三條紀律:
- 永遠不要把 sidecar 埠暴露到公網。內建部署已經做到(無 ports 釋出);獨立部署時繫結內網介面或走 VPN。
SANDBOX_API_KEY必須是強隨機值(內建棧的私網預設值除外)。- 生產環境建議在裸 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(aivory-bundled-sandbox)只在"沙箱無埠釋出、僅私網可達"的前提下才可接受。一旦沙箱監聽了真實網絡卡埠,任何拿到 key 的人都能驅動那臺宿主機的 Docker。獨立部署必須:用 openssl rand -hex 24 生成新 key;用防火牆/安全組把 48217 限制為僅主應用伺服器可訪問;兩臺機器之間儘量走內網或 VPN 鏈路。
故障排查
| 現象 | 排查方向 |
|---|---|
| 第一次執行程式碼就失敗 | 冷啟動時執行時映象還沒拉完,看 docker compose logs sandbox;健康檢查預留了 120s 的 start_period |
| 健康檢查一直 unhealthy | sidecar 連不上 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 對沙箱是軟依賴,只有程式碼執行功能會報錯;若整站異常,問題在別處 |