團隊設定:多個服務、AWS 加辦公室內網、Sentry
給這樣的團隊的完整範例:產品由好幾個服務組成,dev 環境一半在 AWS、一半在辦公室內網,錯誤追蹤用 Sentry。內容涵蓋接上兩個網路、讓 agent 改過的服務用 dev 上其他服務認得的名字在箱子裡跑、沿用 Sentry、從另一個箱子用改過的服務,以及連不通時要看什麼。這裡寫的都是 ParallelSandbox 現在的行為;做不到的地方,這一頁會直接講。
整個團隊一個帳號
ParallelSandbox 裡的一切都屬於一個帳號:箱子、環境與它的連線、link、已發布的版本、image registry,以及點數。一個團隊用一個帳號:
- 每位隊友(或每個 agent)用快速開始那一行接上,在瀏覽器登入帳號一次(OAuth)就好,不用事先建立或分發任何東西。只有不支援 OAuth 的工具才需要
psbx_API key:帳號擁有者用 app 登入後的 session 走 REST 替每位隊友或 agent 各建一把(POST /v1/keys,取個名字;只顯示這一次),GET /v1/keys列出,DELETE /v1/keys/{id}撤銷,一分鐘內失效。API key 不能建立、列出或撤銷 key。 - 每把 key 都作用在整個帳號上:拿著其中任何一把的 agent 都看得到、操作得了這個帳號的每個箱子,能用它的環境開箱、link 到它的任何箱子、跑它的任何版本。link 與版本在同一個帳號的所有 key 之間通用,但絕不跨帳號。
- 所有 key 共用帳號的點數與方案。
- 只有這個帳號自己的登入打得開 app(箱子清單、接手、帳務)。隊友透過自己的連線工作,要試用改動的人只需要箱子的網址。
同一個帳號有好幾位隊友與 agent 時:
- 每把 key 的權限都一樣:任何一把都能停掉、接線或接手帳號裡的任何箱子,包括別的 agent 開的箱子。沒有依 key 區分的權限;每位隊友或每個 agent 各用一把 key,是為了能單獨撤銷其中一把。
- 點數只有一池。每個 agent 的箱子都從這裡扣,
sandbox_status.credits對每個 agent 顯示的都是同一個餘額。 - 箱數上限也是整個帳號的:Free 3、Pro 5、Max 20 個同時存在的箱子,凍結的也算。到了上限,每個人的
sandbox_start都會失敗並回plan <plan> allows <n> boxes at once and <m> are running or frozen; frozen boxes still hold a slot, so list them with GET /v1/boxes and sandbox_stop one you no longer need。 - 隊友用
sandbox_list找彼此的箱子:它列出帳號裡的每個箱子,不管是誰開的,帶名字、目的、狀態、網址、服務(link 的fromBox、版本)以及 link 到它的箱子,而且不會喚醒任何箱子。用它找要 link 的箱子的 id、看隊友在跑什麼、找出被忘掉的箱子,以及接手別的對話留下的箱子(工具參考)。 - 所以每個 agent 用完的箱子都應該
sandbox_stop,不要留著凍結,除非用sandbox_review交給人:凍結的箱子不扣點,但會一直佔著名額直到被停掉(沒設idleTimeoutMin的話是最後一次使用後 24 小時)。停掉的箱子立刻讓出名額。兩個都沒做就留著的箱子,在人的 app 裡顯示為「AI 停手了」,等人決定怎麼處理。 - 箱子記的是開它的 MCP client(
clientName),不是 API key,所以只有箱子的name看得出是誰的,goal看得出是做什麼的。agent 要把箱子取名為<人>:<工作>,其餘寫在goal,只停自己開的箱子(留著sandbox_start回的 id)或名字寫明是自己的,絕對不要照時間大量停箱子,要停隊友的箱子先問。
誰看得到什麼:
- 帳號沒有成員或角色之分。最多可以在「帳號」裡連結一個 GitHub、一個 Google、一個 Apple 登入,每一個都以完整權限登入同一個帳號,所以只連結你自己的。
- 登入這個帳號的人在 app 裡看得到箱子卡片、「使用」按鈕、接手與 agent 的
sandbox_say訊息(也能留話給 agent)。 - 沒登入的隊友,從自己的 agent(
sandbox_start、sandbox_status或sandbox_list)拿到webUrl與每個services[].url。這些網址誰拿到都能用。app 裡「AI 停手了」的箱子(對話關掉了,或 1 小時沒用,又沒有卡片在等人)也一樣從 agent 找:sandbox_list帶每個箱子的agent.state,left或idle就是沒人在做的;用name或id叫自己的 agent 接手(接手別的對話留下的箱子)。 takeoverUrl打開的是 app 裡的箱子頁,要用這個帳號登入。sandbox_takeover產生的連結(…/box/<id>?t=<token>,30 分鐘有效)不用登入就能打開那一次接手;它會推播到帳號的手機 app,工具的進度訊息裡也帶著它。
範例系統
| 元件 | 跑在哪 | 呼叫端用的位址 |
|---|---|---|
web,Next.js 前端,port 3000 |
ECS 服務 web,cluster acme-dev |
https://dev.acme.example(公開的 load balancer) |
api,port 8080 |
ECS 服務 api |
api.acme.internal:8080(Cloud Map 的 DNS 名稱) |
billing,port 8080 |
ECS 服務 billing |
billing.acme.internal:8080 |
admin,後台 web app,port 8080 |
內部 load balancer 後面的 ECS 服務 admin |
internal-acme-dev-admin-1234567890.ap-northeast-1.elb.amazonaws.com:80 |
| Postgres | Aurora(RDS) | acme-dev.cluster-c1a2b3d4e5f6.ap-northeast-1.rds.amazonaws.com:5432 |
| Redis | ElastiCache | acme-dev.x1y2z3.ng.0001.apne1.cache.amazonaws.com:6379 |
ledger,port 9000 |
辦公室裡的一台伺服器 | ledger.office.lan:9000 |
| ledger 的資料庫 | 辦公室 | 10.20.0.15:5432 |
| Sentry | sentry.io,或自架在辦公室 | DSN https://<key>@o123.ingest.sentry.io/456,或 https://<key>@sentry.office.lan/7 |
agent 改了 api,後來 billing 也改了,接著又改了 admin。其他都留在 dev 上。
1. 一個環境、兩條連線
一個環境可以有任意多條連線。一個網路開一條,這裡是 aws 與 office,都在同一個環境 dev 底下:
- 每條連線有自己的 token、自己的連接器(要備援就同時跑好幾份)與自己的位址清單,最多 100 個
host:port。 - 箱子裡的程式連某個位址時,會走「列了這個確切
host:port」的那條連線。每個位址請列在網路連得到它的那條連線上;兩條連線列了同一個位址時,用名稱排序在前的那條。 - 一個箱子從所有連線合計最多拿 200 個位址。位址是確切的主機名或 IPv4 加上 port,只轉 TCP。
- 名字由連接器在它自己的網路裡解析,所以 Route 53 私有 zone、Cloud Map 名稱與辦公室的 DNS 都跟你的服務用起來一樣。
agent 建立 dev(POST /v1/environments),再建兩條連線 aws 與 office(POST /v1/environments/dev/connections,各一次)。token 各自只在回應的 runCommand 裡出現一次。
連線 aws:VPC 裡的 Fargate task
把連接器跑成一個 ECS 服務,放在你服務的子網與 security group。token 放在 SSM:
aws ssm put-parameter --name /parallelsandbox/connector-token --type SecureString --value 'psbx_conn_...'
aws logs create-log-group --log-group-name /ecs/parallelsandbox-connector
aws ecs register-task-definition --family parallelsandbox-connector \
--requires-compatibilities FARGATE --network-mode awsvpc --cpu 256 --memory 512 \
--execution-role-arn arn:aws:iam::<account>:role/ecsTaskExecutionRole \
--container-definitions '[{"name":"connector","image":"public.ecr.aws/b2n6a1j1/connector:latest","essential":true,
"secrets":[{"name":"PSBX_CONNECTOR_TOKEN","valueFrom":"arn:aws:ssm:ap-northeast-1:<account>:parameter/parallelsandbox/connector-token"}],
"logConfiguration":{"logDriver":"awslogs","options":{"awslogs-group":"/ecs/parallelsandbox-connector","awslogs-region":"ap-northeast-1","awslogs-stream-prefix":"connector"}}}]'
aws ecs create-service --cluster acme-dev --service-name parallelsandbox-connector \
--task-definition parallelsandbox-connector --desired-count 1 --launch-type FARGATE \
--network-configuration 'awsvpcConfiguration={subnets=[subnet-aaa,subnet-bbb],securityGroups=[sg-services],assignPublicIp=DISABLED}'
- execution role 要能對那個參數做
ssm:GetParameters;SecureString 用的是客戶自管的 KMS 金鑰時,還要能對那把金鑰做kms:Decrypt(預設的aws/ssm金鑰不用另外加)。 - 子網要有路出去網際網路(私有子網用 NAT gateway):task 要從
public.ecr.aws拉 image,並連api.parallelsandbox.com:443。沒有任何東西會連進來。 - 連接器連得到的每個目標,都要在目標自己的 port 上接受連接器的 security group:RDS(5432)、ElastiCache(6379)、內部 load balancer 的 listener port(80 或 443)、ECS task 本身的容器 port(
api.acme.internal這種 Cloud Map 名字解析到 task 的 IP,所以這些連線直接連到 task),以及你列出的其他東西。連接器用你服務的 security group 跑,就涵蓋已經接受那個 group 的目標;其餘的再個別允許。 - 如果你的服務是透過 ECS Service Connect 而不是 DNS 找彼此,那些名字只在同一個 Service Connect namespace 的 task 裡解析得到;連接器服務也要在同一個 namespace 開啟 Service Connect。
aws 的位址,一行一個,照服務設定裡的寫法:
acme-dev.cluster-c1a2b3d4e5f6.ap-northeast-1.rds.amazonaws.com:5432
acme-dev.x1y2z3.ng.0001.apne1.cache.amazonaws.com:6379
api.acme.internal:8080
billing.acme.internal:8080
internal-acme-dev-admin-1234567890.ap-northeast-1.elb.amazonaws.com:80
可能會改的服務,它們的內網名字也要列上,像這裡的 api.acme.internal:8080:沒改 api 的箱子經連接器連到 dev 上的那一份;在 sandbox_start 宣告了 api.acme.internal 的箱子會接走這個名字,它就從那個箱子的 reachable 消失。
port 80 上的內部 load balancer 跟其他位址一樣列上去就好。環境的每個主機在箱子裡都有自己的位址,boxd 也不在位址本身的 port 上聽,所以 host:80 不會跟 boxd 自己的 port 80 撞,你的程式也可以聽跟這些位址同一個 port 號。
匯入服務設定(見下)之後,GET /v1/environments/dev/suggested-endpoints 會回設定裡找到的位址:每個值裡的 host:port、每個網址(沒寫 port 的用 scheme 的預設:postgres 5432、mysql 3306、redis 6379、mongodb 27017、amqp 5672、http 80、https 443),以及配得上 *_PORT 的 *_HOST 變數;主機是私有 IPv4(10.0.0.0/8、172.16.0.0/12、192.168.0.0/16),或結尾是 .internal、.local、.lan、.corp、.svc、.cluster.local、.rds.amazonaws.com、.cache.amazonaws.com、.es.amazonaws.com、.mq.amazonaws.com、.docdb.amazonaws.com、.redshift.amazonaws.com 的才列。其他的,例如內部 load balancer 的 *.elb.amazonaws.com 名字或用你們自己網域的私有 zone,要自己加。agent 用 PUT /v1/environments/dev/connections/<id>/endpoints 一次設好整份清單。要查位址:
aws rds describe-db-clusters --query 'DBClusters[].[Endpoint,ReaderEndpoint,Port]' --output text
aws rds describe-db-instances --query 'DBInstances[].[Endpoint.Address,Endpoint.Port]' --output text
aws elasticache describe-replication-groups --query 'ReplicationGroups[].[ReplicationGroupId,NodeGroups[0].PrimaryEndpoint.Address,NodeGroups[0].PrimaryEndpoint.Port]' --output text
aws elbv2 describe-load-balancers --query 'LoadBalancers[?Scheme==`internal`].[LoadBalancerName,DNSName,LoadBalancerArn]' --output text
aws elbv2 describe-listeners --load-balancer-arn <arn> --query 'Listeners[].[Port,Protocol]' --output text
執行時才拿到更多位址的 client,會去連伺服器給它的那些位址:Redis Cluster 的節點 IP、Kafka 公告的 broker、MongoDB replica set 的成員。那些 host:port 也要一一列上,否則在箱子裡連不到。
連線 office:辦公室裡的一台主機
在辦公室裡一台連得到 ledger、它的資料庫與 Sentry 的 Linux 機器上:
docker run -d --name parallelsandbox-connector --restart=always \
-e PSBX_CONNECTOR_TOKEN=psbx_conn_... \
-e PSBX_ALLOW=ledger.office.lan:9000,10.20.0.15:5432,sentry.office.lan:443 \
public.ecr.aws/b2n6a1j1/connector:latest
office 的位址:
ledger.office.lan:9000
10.20.0.15:5432
sentry.office.lan:443
PSBX_ALLOW 選填,在你這邊再限一次同樣的清單。主機解析得到 *.office.lan、容器裡卻解析不到的話,啟動連接器時加 --dns <辦公室 DNS 伺服器> 或 --network host。
服務設定
- 從 AWS 匯入(
POST /v1/environments/dev/services/import,帶{"region":"ap-northeast-1","cluster":"acme-dev","service":"api"},一個服務呼叫一次):api、billing、web與admin。這要先有從 AWS 匯入服務設定的唯讀角色,由管 AWS 帳號的人建一次。一般的environment值原樣帶入,SSM 與 Secrets Manager 的引用會解開;S3 上的environmentFiles讀不到,回在sourceRef.skipped。每個服務在箱子裡變成/work/.sbx/env/<服務>.env與.sh。 - 服務有一部分設定放在 S3 的
environmentFiles檔案時,把那個檔案的內容用另一個名字上傳,例如api-files(PUT /v1/environments/dev/services/api-files,帶{"dotenv": "…"}),啟動服務時兩個檔案都帶,這個放前面,讓 task definition 自己的值蓋過它,跟在 ECS 上一樣:docker run --env-file /work/.sbx/env/api-files.env --env-file /work/.sbx/env/api.env ...(或. api-files.sh && . api.sh)。不要用匯入的那個名字上傳:上傳會取代那個服務的設定。 ledger的設定也照這樣上傳(PUT /v1/environments/dev/services/ledger),如果之後會在箱子裡跑它。externalBaseUrl沒需要就留空(見公開的 dev 端點與 externalBaseUrl)。
搬進箱子的服務要的 AWS 角色
在 ECS 上,api 與 billing 的 AWS 權限(S3、SQS 等)來自它們的 task role,不在設定裡,箱子也沒有這個 role。改成讓環境帶一個角色,照箱子裡的 AWS 權限:agent 從 GET /v1/environments/dev 拿到 CloudFormation 連結(awsBoxRoleQuickCreateUrl),管 AWS 帳號的人用它建立角色,給它搬進來的服務的 task role 原本有的權限(箱子要拉 dev 的 image 的話再加 ECR 的拉取),agent 再用 PUT /v1/environments/dev 帶 {"awsRoleArn", "awsRegion"} 設上。之後開的箱子會像 ECS task 一樣拿到臨時憑證;容器用 --env-file /work/.sbx/env/<服務>.env 拿到。
agent 檢查結果:
sandbox_environments {}
dev 的 reachable 應該列出全部八個位址,connections[] 裡 aws 與 office 都是 online: true,services 底下有 api、billing、web、admin、ledger 與它們的 envFile。
connectorOnline: true 只代表至少一條連線在線上,不代表兩條都在。看 connections[] 才知道每一條:online、sessions、lastSeenAt、connectorVersion,以及 reachable(走這條連線的位址)。有連線斷掉時,sandbox_start 的 next 會點名,例如 These connections have no connector online right now, so their addresses will refuse connections until their connector runs again: office (ledger.office.lan:9000, 10.20.0.15:5432, sentry.office.lan:443).。最後的確認仍然是在跑著的箱子裡用真正的用戶端(curl、pg_isready、psql 與 redis-cli 箱子裡都有)連它的一個位址,再看 sandbox_status → health.privateEndpoints[] 裡那個位址。只做 TCP 連線證明不了什麼:boxd 會先接起連線,才去問連接器。office 的連接器斷線時,它的位址會有 failed 次數,lastError 是 HTTP 503: the connector "office" of this environment is offline; start it in your network (docker run … parallelsandbox connector) and retry,用戶端看到的是連線被重置。
2. 改過的服務用 dev 上的名字跑
名字在箱子裡怎麼運作:
services裡的名字從箱子一開起來,在箱子裡就有自己的位址,箱子的 DNS 用它回答這個名字,程式與容器都一樣。它若也是環境的位址,這個箱子的reachable就不列它,所以它指到你在箱子裡的那一份,而不是連接器。沒宣告的一律照樣經連接器連到 dev。- 只有箱子裡的程式看得到這個變化。dev 上的服務、它們的佇列與排程工作,照樣呼叫 dev 上的
api:你的網路上沒有任何東西能連進箱子。要測經過api的整條呼叫鏈,就把需要的呼叫端也放進箱子跑(在箱子裡跑 dev 上沒改的呼叫端)。 services裡的port是呼叫端連的 port,name:port連到你的程式聽的targetPort,沒給就跟port一樣。呼叫端照用它在 dev 上的位址,程式可以聽別的 port:兩個改過的服務在 dev 上都聽 8080 的話,還是api.acme.internal:8080與billing.acme.internal:8080,各有各的targetPort(見同一個 port 的第二個改過的服務)。- 程式要聽
0.0.0.0的targetPort。只聽127.0.0.1的程式從它的網址連得到,用名字連不到。 - 箱子裡的 port 80 與 9095 屬於 boxd(80 是場景入口,9095 是它的 API),任何程式或容器都聽不了。呼叫端用 port 80 連的服務(例如內部 load balancer 後面的服務),在箱子裡照樣用它的名字與 port 80:宣告時加上
targetPort,也就是你的程式聽的 port(見內部 load balancer 後面的改過的服務)。沒給targetPort的話sandbox_start回 400。其他 port 都可以用,443 等特權 port 也行:命令以 root 執行,容器照常發布 port。 - 要接走環境的位址,就在
sandbox_start宣告那個名字,或之後在sandbox_status→health.features有take-over的箱子上用sandbox_wire補:名字保留它的位址,經連接器開著的連線會被關掉,從此連到箱子裡的那份。比較舊的箱子上,之後才補、又是環境位址的名字,照樣經連接器連出去。 - 沒宣告也沒列的東西一律連不到,各有各的樣子(在箱子上實測):
- 宣告過的名字或環境的主機,用別的 port 連,會留在箱子裡:箱子裡在
0.0.0.0聽那個 port 的程式會回應,沒有的話連線當場被拒絕。它不會跑到你的網路或別的箱子。 - 既沒宣告、也不是環境位址的主機名,會去查公開的 DNS,不是你的。只有你私有 DNS 認得的名字(Cloud Map、Route 53 私有託管區域、辦公室的 DNS)解析不到(
Could not resolve host)。公開 DNS 解析成私有 IP 的名字,例如 RDS endpoint 或internal-…elb.amazonaws.com,解析得到,連線則像沒列出的私有 IP 一樣逾時;要連就把它列上去。公開的名字直接連上網際網路。 - 沒列出的私有 IP 位址沒有回應:連線會逾時,
private-endpoints.log也不會寫任何東西。列出的 IP 位址用沒列出的 port 連也一樣,例如10.20.0.15:22。 - port 80 不一樣。列在 port 80 的環境位址(例如內部 load balancer)與宣告在 port 80 的 link,都跟其他名字一樣能用:每個名字都有自己的位址,碰不到箱子自己的 port 80。只有箱子裡的服務在 port 80 要
targetPort。沒宣告、也沒列在 80 的名字(宣告在別的 port 的服務或 link,或只列在別的 port 的環境主機)用 80 連,會連到 boxd 的場景入口,它回 502nothing listens on <name>:80 in this box: that name is declared on another port. Dial the port it is declared on, or declare it on port 80 with targetPort, the port your process listens on,程式與容器都一樣。localhost:80仍是場景入口,連到第一個宣告的服務。2026-09-24 20:30 UTC 之前開的箱子,這種名字用 80 連還是會連到第一個宣告的服務。port 9095 是 boxd 的 API,會回404 page not found。
- 宣告過的名字或環境的主機,用別的 port 連,會留在箱子裡:箱子裡在
開箱子
sandbox_start {
"name": "ana:api 退款改版",
"goal": "讓會員中心的退款按鈕把退款記進帳本;在瀏覽器裡用 dev 的資料從頭到尾退款成功就算完成",
"environment": "dev",
"size": 2,
"services": [
{ "name": "web", "port": 3000, "web": true },
{ "name": "api.acme.internal", "port": 8080 }
]
}
web: true讓web有一個人打得開的網址。它的網址就是回傳的webUrl,也就是第一個 web 服務的網址(這裡剛好也是sceneUrl,因為web宣告在第一個)。人用自己的手機或電腦打開它試這次的修改;不論web排在清單的第幾個,app 的「使用」按鈕打開的都是這個網址。api沒標web,沒有網址,箱子外面連不到它。api.acme.internal就是web與其他服務呼叫api時用的名字,所以箱子裡的web不用改任何設定就連到箱子裡的api。size: 2(4 vCPU、16 GB):這個箱子要 build 並跑好幾個服務的 image,之後還會更多。大小 1 適合一個網頁前端,或一兩個 Node、Python、Go 服務。name開頭寫這個箱子是誰的,隊友的 agent 在sandbox_list裡就分得出來。goal說這個箱子要做什麼、做到怎樣算完成:人在箱子的卡片上看到它,隊友的 agent 或這個對話關掉後的新對話,會在sandbox_status連同已經做過的步驟一起讀到它。- 箱子裡的程式,以及任何 Docker 網路(含 compose)上的容器,都用同樣的方式連到
api.acme.internal與環境的位址。 - 回傳的
environment.reachable不再列api.acme.internal,RDS、Redis、billing.acme.internal、內部 load balancer、ledger.office.lan、10.20.0.15與sentry.office.lan都還在。
把工作目錄送進去,兩個服務都用 dev 的設定啟動:
sandbox_sync { "id": "<id>", "localPath": "/home/dev/acme/platform", "dest": "platform" }
sandbox_exec { "id": "<id>", "cmd": "docker build -t api ./api && docker run -d --name api --env-file /work/.sbx/env/api.env -e SENTRY_ENVIRONMENT=psbx -e SENTRY_RELEASE=a1b2c3d -e SBX_BOX_ID -p 8080:8080 api", "cwd": "platform", "timeoutSec": 900, "note": "用 dev 的設定 build 並啟動改過的 api" }
sandbox_exec { "id": "<id>", "cmd": "docker build -t web ./web && docker run -d --name web --env-file /work/.sbx/env/web.env -p 3000:3000 web", "cwd": "platform", "timeoutSec": 900, "note": "用 dev 的設定 build 並啟動 web 前端" }
sandbox_exec { "id": "<id>", "cmd": "curl -fsS http://api.acme.internal:8080/health && curl -s -o /dev/null -w '%{http_code}\\n' http://localhost:3000/", "note": "確認 api 與 web 有回應" }
--env-file 後面的 -e 會蓋過檔案裡的值,所以不用改檔案就能讓某個值跟 dev 不同。
誰連到誰:
| 呼叫端 | 連的位址 | 實際連到 |
|---|---|---|
箱子裡的 web |
api.acme.internal:8080 |
箱子裡的 api |
箱子裡的 api |
RDS、Redis、billing.acme.internal:8080、內部 load balancer |
AWS 上的 dev,經 aws |
箱子裡的 api |
ledger.office.lan:9000、10.20.0.15:5432、sentry.office.lan:443 |
辦公室,經 office |
箱子裡的 api |
o123.ingest.sentry.io:443 與任何公開位址 |
直接上網際網路 |
| 人的瀏覽器 | webUrl |
箱子裡的 web,port 3000 |
dev 上的 web、billing |
api.acme.internal:8080 |
dev 上的 api,沒改過的那一份 |
人打開的頁面
經 webUrl 或箱子其他網址載入的頁面,跑在人的瀏覽器裡,在箱子外面。只有打到箱子網址的請求會進箱子,而且每個網址只到一個服務:sceneUrl 到第一個宣告的服務,services[].url 到它自己的服務。前端的瀏覽器程式如果呼叫絕對網址(NEXT_PUBLIC_API_URL=https://dev.acme.example/api),那些請求會到 dev,不會到箱子裡的 api。瀏覽器程式要跨 origin 呼叫箱子裡的 api,得 api 標了 web、頁面拿得到它的網址(網址組不出來),而且 CORS 允許頁面的 origin。比較簡單的做法是同源 proxy:讓瀏覽器呼叫前端自己 origin 上的一個路徑,由 web 的伺服器在箱子裡轉給 http://api.acme.internal:8080。路徑要跟 dev 一模一樣地轉:dev 的 load balancer 把 /api/... 原封不動交給 api 的話(路徑規則通常如此),目的地就保留前綴;只有 dev 上有東西拿掉它時才拿掉。
api要的是/api/orders(保留前綴):Next.jsrewrites(){ source: '/api/:path*', destination: 'http://api.acme.internal:8080/api/:path*' };Viteserver: { proxy: { '/api': 'http://api.acme.internal:8080' } }。api要的是/orders(拿掉前綴):Next.jsrewrites(){ source: '/api/:path*', destination: 'http://api.acme.internal:8080/:path*' };Viteserver: { proxy: { '/api': { target: 'http://api.acme.internal:8080', rewrite: (p) => p.replace(/^\/api/, '') } } }。
在箱子裡用這個設定 build web。如果 dev 是在 load balancer 用路徑規則把 /api 送到 API,而不是在 web 裡 rewrite,箱子裡的 web 前面沒有那個 load balancer:要在箱子的 build 加上 rewrite,否則頁面打的 /api/... 會打到 web 自己而失敗。箱子裡的瀏覽器(Playwright、螢幕上的 Chromium)每個宣告的名字都解析得到,不需要這些。
Next.js 在 next build 時就把兩樣東西定下來:NEXT_PUBLIC_* 的值會寫進瀏覽器的程式,next.config.js 的 rewrites() 會進到 build 的產出。所以箱子 build web 之前兩樣都要就位。先是 /api 的 rewrite,寫在箱子那份 web/next.config.js 裡(dev 已經有這個檔的話,跟它其他設定放在一起):
module.exports = {
async rewrites() {
return [{ source: '/api/:path*', destination: 'http://api.acme.internal:8080/api/:path*' }];
},
};
再來是帶著設定 build:. /work/.sbx/env/web.sh 會把所有設定 export 到箱子的 shell,docker build 用 --build-arg 點名的變數就從那裡取值:
cd /work/platform && . /work/.sbx/env/web.sh && export NEXT_PUBLIC_API_URL=/api
docker build $(sed -n 's/^\(NEXT_PUBLIC_[A-Za-z0-9_]*\)=.*/--build-arg \1/p' /work/.sbx/env/web.env) -t web ./web
Dockerfile 要逐一宣告(在跑 next build 的那個 stage 寫 ARG NEXT_PUBLIC_API_URL);沒宣告的 build 參數 Docker 會略過並給警告。export 那一行就是在箱子裡改值的地方,這裡改成同源的路徑;不在 web.env 裡的 NEXT_PUBLIC_* 名字要自己加 --build-arg NAME。之後改了其中任何一樣都要重新 build,只重啟容器吃不到。也可以不經 Docker,直接在箱子上 build:cd web && . /work/.sbx/env/web.sh && npm ci && npx next build。
箱子的網址前面沒有任何登入:網址裡的 key 是唯一的保護。拿到 webUrl 的人就能像那個人一樣用 web,也包括它透過箱子連得到的一切:這裡是箱子裡的 api,以及它背後 dev 的資料庫與服務。只分享給可以用 dev 的人。聊天 app(Slack、Teams、LINE 之類)會去抓貼進對話的網址來做預覽:那一次抓取跟其他請求一樣,預覽服務拿得到頁面,這個請求算箱子的使用,要 HTML 的抓取也會像載入頁面一樣喚醒凍結的箱子。箱子停掉後,這些網址就失效。箱子送到人瀏覽器的東西,頁面、檔案與 API 回應都一樣,算它往外的流量(點數)。
那個人不必替箱子保持醒著。他的請求在最後一次工具呼叫或喚醒後 2 小時內都讓箱子醒著;超過這段時間還在用頁面,可能會在使用中遇到箱子凍結。箱子凍結了,例如他吃完午餐回來,只要重新整理:頁面會顯示「Waking this box…」,大約 10 秒內自己重新整理成 app,這次喚醒也算一次使用,所以又有 2 小時。只有載入頁面會喚醒它,開著的頁面自己發的請求不會,所以隔了很久沒反應的前端要重新整理一次。帳號沒點數時,頁面會說箱子暫停了。停掉的箱子不會回來;新的箱子有新的網址。
經箱子任何一個網址進來的請求,到程式時都是 Host: 127.0.0.1:<targetPort>;公開的 host 在 X-Forwarded-Host(<id>-<key>.box.parallelsandbox.com 或 <id>-<key>-<targetPort>.box.parallelsandbox.com),scheme 在 X-Forwarded-Proto(https),用戶端位址在 X-Forwarded-For。只接受已知 host 的 dev server 會放行,相對路徑的轉址照常可用。用 Host 組絕對網址、轉址或 cookie 網域的程式,要改成信任這些 forwarded 標頭(Express 是 app.set('trust proxy', true))。箱子裡用名字呼叫服務的程式,送的是它自己的 Host,例如 api.acme.internal:8080。
在箱子網址上登入(SSO、OAuth)
如果 web 要透過你們的身分提供者(IdP)登入,IdP 必須接受箱子的網址當 redirect URI,但每個箱子的網址都有自己的隨機 host,沒辦法事先登記。可行的做法有兩種:
- 讓箱子裡的那份用團隊在本機用的登入方式:預先建好的測試使用者,或只在 dev 用的登入模式,用服務的設定或
--env-file之後再蓋過一個值來設。 - 在箱子跑著的期間登記它的確切網址。如果 IdP 的管理 API 能改 dev client 允許的 redirect URI,agent 就在
sandbox_start之後把webUrl或services[].url拿到的網址加進去,sandbox_stop之前拿掉。例如:Amazon Cognito 的aws cognito-idp update-user-pool-client --callback-urls …(沒給的設定一律回到預設值,所以要連 client 現有的設定一起給)、Auth0 Management API 的PATCH /api/v2/clients/{id}帶callbacks、Keycloak 管理 API 的PUT /admin/realms/{realm}/clients/{id}帶redirectUris。
絕對不要登記 https://*.box.parallelsandbox.com/* 這種萬用字元。每個帳號的箱子都在這個網域底下,任何一個 ParallelSandbox 使用者都能讓你使用者的授權碼送到他的箱子。登入也不能取代網址裡的 key:箱子前面唯一的保護仍然是那個 key。
同一個 port 的第二個改過的服務
billing 也改了,而且也聽 8080,而箱子裡的 8080 已經是 api 在用。名字與 port 照舊,給它另一個 targetPort:
sandbox_start {
"name": "ana:api 與 billing 退款改版",
"goal": "一起改版 api 與 billing 的退款;在瀏覽器裡退的款被改過的 billing 記下來就算完成",
"environment": "dev",
"services": [
{ "name": "web", "port": 3000, "web": true },
{ "name": "api.acme.internal", "port": 8080 },
{ "name": "billing.acme.internal", "port": 8080, "targetPort": 8081 }
]
}
sandbox_sync { "id": "<id>", "localPath": "/home/dev/acme/platform", "dest": "platform" }
sandbox_exec { "id": "<id>", "cmd": "docker build -t billing ./billing && docker run -d --name billing --env-file /work/.sbx/env/billing.env -p 8081:8080 billing", "cwd": "platform", "timeoutSec": 900, "note": "build 並啟動改過的 billing,發布在 8081" }
sandbox_exec { "id": "<id>", "cmd": "docker build -t api ./api && docker run -d --name api --env-file /work/.sbx/env/api.env -p 8080:8080 api", "cwd": "platform", "timeoutSec": 900, "note": "build 並啟動改過的 api" }
sandbox_exec { "id": "<id>", "cmd": "curl -fsS http://billing.acme.internal:8080/health && curl -fsS http://api.acme.internal:8080/health", "note": "確認兩個服務用 dev 的名字都有回應" }
每個名字在箱子裡有自己的位址,所以 billing.acme.internal:8080 連到發布在 8081 的 billing 容器,api.acme.internal:8080 照樣連到 api。呼叫端的設定都不用改:api 跟在 dev 上一樣呼叫 http://billing.acme.internal:8080。billing.acme.internal 是環境的位址,所以一定要在 sandbox_start 宣告才蓋得過去。
在箱子裡跑 dev 上沒改的呼叫端
在 dev 上只有 api 會呼叫 billing,而 dev 上的 api 照樣呼叫 dev 上的 billing。agent 只改了 billing 的話,箱子裡那一份沒有人呼叫,要等呼叫端也在箱子裡跑才會被用到。把沒改的 api 用 dev 上的名字,跟改過的 billing 一起放進箱子:
sandbox_start {
"name": "ana:billing 退款規則",
"goal": "改 billing 的退款規則;箱子裡跑的、沒改過的 dev 版 api 從改過的 billing 拿到新的退款金額就算完成",
"environment": "dev",
"services": [
{ "name": "api.acme.internal", "port": 8080 },
{ "name": "billing.acme.internal", "port": 8080, "targetPort": 8081 }
]
}
改過的 billing 照上一節啟動。拿到 dev 上那份 api 有三種做法:
從原始碼 build,用 dev 正在跑的那個 commit 與 dev 的設定:在箱子裡那份程式碼切到那個 commit,再
docker build -t api ./api && docker run -d --name api --env-file /work/.sbx/env/api.env -p 8080:8080 api。從你們的 ECR 拉 dev 正在跑的 image,前提是環境指定的 AWS 角色可以從那個 repository 拉(箱子裡的 AWS 權限):
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin <account>.dkr.ecr.ap-northeast-1.amazonaws.com docker run -d --name api --env-file /work/.sbx/env/api.env -p 8080:8080 <account>.dkr.ecr.ap-northeast-1.amazonaws.com/api:<dev 跑的 tag>箱子是 x86_64(amd64)。dev 把
api跑在 Graviton 上(task definition 裡runtimePlatform.cpuArchitecture: ARM64)、image 又沒有 amd64 版本的話,docker run會失敗:exec format error。這時照第一種方式從原始碼 build,或讓 dev 的 pipeline push 多架構的 image(docker buildx build --platform linux/amd64,linux/arm64)。箱子裡沒有裝模擬(工具)。角色要有
ecr:GetAuthorizationToken,以及對那個 repository 的ecr:BatchCheckLayerAvailability、ecr:GetDownloadUrlForLayer、ecr:BatchGetImage(AWS 管理的 policyAmazonEC2ContainerRegistryReadOnly四個都有)。沒有的話登入會失敗:AccessDeniedException … is not authorized to perform: ecr:GetAuthorizationToken。dev 跑的 image 寫在服務目前的 task definition:aws ecs describe-services --cluster acme-dev --services api --query 'services[0].taskDefinition',再aws ecs describe-task-definition --task-definition <arn> --query 'taskDefinition.containerDefinitions[].image',前提是角色可以讀 ECS;不行的話就問人。api的某個 build 已經發布成版本的話(sandbox_versions { "service": "api" }),直接宣告它,讓箱子幫你跑:{ "name": "api.acme.internal", "port": 8080, "version": "<標籤>" }(跑發布好的版本)。
接著在箱子裡從頭跑一遍:curl http://api.acme.internal:8080/... 會到箱子裡的 api,它再呼叫箱子裡改過的 billing。如果呼叫端已經在你的另一個箱子裡跑,就不用再放一份:從那個箱子用 link 把名字指到這個箱子,sandbox_wire { "id": "<跑 api 的箱子>", "service": "billing.acme.internal", "port": 8080, "fromBox": "<這個箱子>" }(連到別的箱子)。
寫入會進 dev 的資料庫
箱子裡改過的服務用的是 dev 的設定,所以連的是 dev 真正的資料庫:它的 migration 與每一筆寫入都會落在那裡,dev 上所有人都受影響。改動帶 migration,或測試會寫入、刪除資料時,就給箱子一個自己的資料庫:
測試用:
psbx-testdb up <名字>在箱子裡開一個乾淨的 Postgres 16(--mysql:MySQL 8.0;資料在記憶體裡,箱子停掉就沒了),用隨機的 port,等到能連線才回,並把/work/.sbx/testdb/<名字>.env寫成export行:DATABASE_URL、TEST_DATABASE_URL、TEST_POSTGRES_URI(Postgres)、PG*變數(所以psql直接能用)與 Laravel 的DB_*;MySQL 則是MYSQL_*取代PG*。它每顆印一行摘要與要跑的. /work/.sbx/testdb/<名字>.env,不印連線字串;psbx-testdb url <名字>才印,含密碼。它聽在箱子的127.0.0.1:直接跑在箱子上的程式連得到,容器要加--network host才連得到。其他用法(psbx-testdb --help):- 測試讀的是別的變數名時,什麼都讀不到,整批 SKIP,看起來卻像通過(全部
SKIP,或 0.0x 秒就ok):--as 變數名(可重複)把連線字串也寫到那個名字,例如psbx-testdb up --as BILLING_TEST_DSN。 - 給好幾個名字就一次開好幾顆(
psbx-testdb up a b c)。已經開著的名字照舊沿用,資料也在;psbx-testdb reset <名字>把那個資料庫 drop 再 create,down <名字>連同.env收掉。 - 資料放在記憶體(tmpfs),上限是箱子記憶體的四分之一,夾在 2 到 4 GB 之間。
--size 8g調高上限;--disk改把資料放在/work/.sbx/testdb/<名字>/data,不佔記憶體、也沒有上限。資料區寫滿時 Postgres 會 PANIC 或容器直接結束:這時url與env會印出它的docker logs,down之後再up --size 8g或--disk重開。 psbx-testdb gotest ./...跑go test,讓每個套件各有一顆全新的資料庫(經go test -exec),跑完收掉,不會讓套件在共用的資料庫裡互相清表;它也收--as、--size、--disk與--mysql,--keep跑完留著資料庫可以查,--之後的都交給go test。psbx-testdb list列出每一顆:名字、引擎、port、狀態、用量與上限、建立時間與 owner(--owner設定;預設是執行up時的目錄)。up會提醒開了超過 6 小時還沒收的資料庫。
- 測試讀的是別的變數名時,什麼都讀不到,整批 SKIP,看起來卻像通過(全部
跑在容器裡的服務:在旁邊開一個資料庫容器,在
--env-file後面蓋掉設定,再對它跑 migration:docker network create acme docker run -d --name billing-db --network acme -e POSTGRES_PASSWORD=dev postgres:16-alpine docker run -d --name billing --network acme --env-file /work/.sbx/env/billing.env -e DATABASE_URL=postgres://postgres:dev@billing-db:5432/postgres -p 8081:8080 billing
dev 設定的其他副作用
照 dev 設定跑的那份,會做 dev 那份做的每件事,不只是寫資料庫。箱子裡的 billing 可能會:
- 消費 dev 的佇列,把該給 dev 那份
billing的工作拿走; - 再跑一次排程工作,例如月結帳單或提醒信;
- 跟服務商登記 webhook,或收到本來要給 dev 的 webhook;
- 透過設定裡的服務商寄出真的 email 或簡訊;
- 經金流服務商扣款或退款。
在箱子那份:
- 用服務自己的開關關掉消費者與排程,在
--env-file之後設,例如-e WORKERS_ENABLED=false -e SCHEDULER_ENABLED=false(用billing實際讀的名字); - 金流、email、簡訊服務商都用測試模式的 key,存成 ParallelSandbox 的 secret,用
-e STRIPE_SECRET_KEY=$STRIPE_TEST_SECRET_KEY帶進去; - 箱子是用已發布的版本跑
billing時,把同樣的開關放進它的env:{ "name": "billing.acme.internal", "port": 8080, "version": "refund-a1b2c3d", "env": { "WORKERS_ENABLED": "false", "SCHEDULER_ENABLED": "false" } }(第 4 節); - 不希望任何箱子那份去消費的、在你網路裡的 broker(RabbitMQ、Kafka、Redis 佇列),就不要列在連線上:沒列的箱子連不到。SQS 是用環境的 AWS 角色經網際網路連的,所以要在服務裡關掉它的消費者,或在那個角色裡拿掉
sqs:ReceiveMessage。
內部 load balancer 後面的改過的服務
接著 agent 改了 admin。在 dev 上,它的呼叫端(這裡是 web 的伺服器端)經內部 load balancer internal-acme-dev-admin-1234567890.ap-northeast-1.elb.amazonaws.com:80 連到它,它的容器聽 8080。在箱子裡,它用 load balancer 的名字跑在 port 80,再用 targetPort 指出你的程式實際聽的 port:
sandbox_start {
"name": "ana:admin 稽核匯出",
"goal": "在 admin 加上稽核紀錄匯出;web 的管理頁經 load balancer 的名字下載到匯出檔就算完成",
"environment": "dev",
"services": [
{ "name": "web", "port": 3000, "web": true },
{ "name": "internal-acme-dev-admin-1234567890.ap-northeast-1.elb.amazonaws.com", "port": 80, "targetPort": 8082, "web": true }
]
}
sandbox_sync { "id": "<id>", "localPath": "/home/dev/acme/platform", "dest": "platform" }
sandbox_exec { "id": "<id>", "cmd": "docker build -t admin ./admin && docker run -d --name admin --env-file /work/.sbx/env/admin.env -p 8082:8080 admin", "cwd": "platform", "timeoutSec": 900, "note": "build 並啟動改過的 admin,發布在 8082" }
sandbox_exec { "id": "<id>", "cmd": "docker build -t web ./web && docker run -d --name web --env-file /work/.sbx/env/web.env -p 3000:3000 web", "cwd": "platform", "timeoutSec": 900, "note": "用 dev 的設定 build 並啟動 web 前端" }
sandbox_exec { "id": "<id>", "cmd": "curl -fsS http://internal-acme-dev-admin-1234567890.ap-northeast-1.elb.amazonaws.com/health", "note": "確認 admin 用 load balancer 的名字在 port 80 有回應" }
- 箱子裡的呼叫端照樣連
http://internal-acme-dev-admin-1234567890.ap-northeast-1.elb.amazonaws.com/,也就是 port 80,設定都不用改,連到的是發布在 8082 的admin容器。port 80 本身還是 boxd 的:沒給targetPort的話sandbox_start回 400(port 80 in the box is boxd's scene entry; add targetPort, the port your process listens on — callers keep dialing <name>:80)。 - 這個名字是環境的位址(列在
aws上),所以在sandbox_start宣告就會接走它:它從environment.reachable消失,箱子裡連這個名字的每條連線都到箱子,不再到 load balancer。這裡的 load balancer 只服務admin;你的 load balancer 若還轉給好幾個服務,呼叫端需要的那些也要放進箱子跑。 - 箱子裡沒有 load balancer:箱子裡的呼叫端用 load balancer 的名字直接連到
admin的容器,請求原樣送到。load balancer 在 dev 上做的事在這裡都不會發生:驗證動作(ALB 的 OIDC 或 Cognito)、host 與路徑規則、轉址(例如 HTTP 轉 HTTPS),以及它加上的 header(X-Forwarded-For、X-Forwarded-Proto、X-Forwarded-Port,驗證後的x-amzn-oidc-*)。經admin的箱子網址進來的請求帶的是箱子自己加的X-Forwarded-For、X-Forwarded-Proto與X-Forwarded-Host(人打開的頁面),沒有X-Forwarded-Port也沒有x-amzn-oidc-*。箱子裡的小 proxy(容器裡的 nginx 或 Caddy,宣告在 load balancer 的名字、port 80 上,有自己的targetPort,再轉給 8082 的admin)可以補上路徑規則、轉址與X-Forwarded-*header。登入它補不了:x-amzn-oidc-data是 ALB 用自己的金鑰簽的 token,照 AWS 的要求驗簽章的程式,會拒絕 proxy 自己做出來的任何東西。admin前面是會做驗證的 ALB 時,改用程式本身的 dev 或測試登入模式跑箱子裡這份(本機帳號、測試用的身分提供者、設定裡已經有的驗證開關),用--env-file後面的-e設定,已發布的版本則放進env。 - 兩個都標
web: true,就各有自己的網址。webUrl是web的(第一個 web 服務);services[]裡admin那一項的url是https://<id>-<key>-8082.box.parallelsandbox.com這種形式,8082 是它的targetPort。人兩個都能打開;網址照回傳的原樣使用。
公開的 dev 端點與 externalBaseUrl
箱子直接連得上網際網路:https://dev.acme.example 與其他公開的 dev 端點不用任何設定,程式與容器都一樣。
externalBaseUrl 是給「你宣告了、但沒在箱子裡跑」的名字用的。name:port 的每個 HTTP 請求會轉到同一個 base URL,路徑保留、Host 換掉。例如 repo 的 compose 檔裡有服務呼叫 gateway:8000,而它在 dev 上是 https://dev-gw.acme.example:宣告 { "name": "gateway", "port": 8000 },並給 "externalBaseUrl": "https://dev-gw.acme.example"。呼叫端用 port 80 連的名字也一樣轉,但跟所有 port 80 的服務一樣要給 targetPort:{ "name": "gateway", "port": 80, "targetPort": 8000 }。一個箱子只有一個 externalBaseUrl,只轉 HTTP。
有 externalBaseUrl 時(自己給的或環境設的),宣告的每個服務一開始都是 external,包括 web 與 api.acme.internal。要在箱子裡跑的,就在它的 targetPort 起程式,再把名字切過去;boxd 不佔那個 port,先後順序不拘:
sandbox_wire { "id": "<id>", "service": "api.acme.internal", "mode": "box" }
sandbox_wire { "id": "<id>", "service": "web", "mode": "box" }
切換只改轉送規則:已經連著的連線照舊,共用同一個 targetPort 的名字一起切。服務是 external 的期間,箱子上的程式連 localhost:<port> 也會到轉發器,但 boxd 自己的 80 與 9095 除外。
直接連 IP 的呼叫端
有些呼叫端不用名字:billing 可能從 Cloud Map API(DiscoverInstances)或 Redis 裡的名冊拿到 api task 的 IP,直接連 10.0.12.34:8080。這種要在 sandbox_start 用私有範圍加 port 宣告,範圍越窄越好,因為範圍內所有位址的那個 port 都會被接走:
"services": [{ "name": "api.acme.internal", "port": 8080 }, { "name": "10.0.0.0/20", "port": 8080 }]
之後箱子裡(含容器)往 10.0.0.0/20 任何位址的 8080 的連線,都會到箱子裡這個服務的 targetPort,這裡是 8080(服務是 external 時則到通往 externalBaseUrl 的轉發器);落在範圍內、同 port 的環境位址不再經連接器。範圍必須在 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16 或 100.64.0.0/10 之內,而且不能事後再加。用 Cloud Map DNS 名稱的呼叫端要宣告的是名字,不是範圍。
3. Sentry
沿用團隊的 Sentry;不需要 ParallelSandbox 的 log 服務。
DSN:匯入的設定通常就帶著
SENTRY_DSN(在 task definition 的 environment,或它引用的 secret 裡),所以它在/work/.sbx/env/api.env,用--env-file就進了容器。設定裡沒有的話,存成 ParallelSandbox 的 secret 再帶進去:PUT /v1/secrets/SENTRY_DSN,開箱時"secrets": ["SENTRY_DSN"](或之後sandbox_secrets { "id": "<id>", "names": ["SENTRY_DSN"] }),再docker run -e SENTRY_DSN ...。sentry.io:箱子經網際網路直接連得到,不用設定。
自架在辦公室的 Sentry:SDK 把事件送到 DSN 裡的 host 與 port,這裡是
sentry.office.lan:443。把這個host:port列在office連線上,如上。TLS 原封不動穿過去,所以 SDK 要跟在 dev 上一樣信任伺服器的憑證;憑證是你們自己的 CA 簽的話,容器裡也要有那個 CA,跟你 dev 上的容器一樣。瀏覽器的錯誤要送到只在辦公室裡的 Sentry:瀏覽器 SDK 直接從那個人的手機或筆電送事件,連不到
sentry.office.lan。改用 SDK 的tunnel選項(在Sentry.init設tunnel: '/sentry-tunnel'):頁面會把每個事件送到web自己網域上的這個路徑,再由web的伺服器經箱子的office連線轉過去。轉送端照 Sentry 文件的做法:讀請求內容的第一行(envelope header),取出它的dsn,確認 host 是sentry.office.lan、專案是你們的,再把整個內容 POST 到https://sentry.office.lan/api/<專案 id>/envelope/。@sentry/nextjs有個會自動做這件事的tunnelRoute選項,但 Sentry 文件說它不支援自架的 Sentry,所以這個路由要自己寫。第二個瀏覽器 appadmin也照做,在它自己的 origin 上有自己的路由:它的頁面經它的網址只連得到admin自己的伺服器,所以admin轉送自己的事件。標出來自箱子的事件。Sentry 的伺服器端 SDK(Python、Node、Go、Java、.NET、PHP)在程式沒有自己傳值給
Sentry.init時,會從環境變數讀SENTRY_ENVIRONMENT與SENTRY_RELEASE,所以-e SENTRY_ENVIRONMENT=psbx -e SENTRY_RELEASE=<commit>(已發布的版本則是把同樣的名字放進它的env)不用改程式:dev 的儀表板與依 dev 環境篩的警示不受影響,release 也告訴你箱子跑的是哪個 build。要分辨是哪個箱子,就帶-e SBX_BOX_ID,在程式初始化 Sentry 的地方把它加成 tag,例如 JavaScript 的initialScope: { tags: { psbx_box: process.env.SBX_BOX_ID } },或 Python 的sentry_sdk.set_tag("psbx_box", os.environ.get("SBX_BOX_ID"))。瀏覽器的 bundle 在 build 時從Sentry.init拿environment與release,在箱子裡 build 時設定。讀錯誤:
logs_search、logs_errors、logs_tail只讀 ParallelSandbox 自己的 log 服務,不讀 Sentry。用 Sentry 自己的存取方式,配一把唯讀的 auth token,放在 agent 的機器上,或 agent 要從箱子裡查時存成 ParallelSandbox 的 secret(SENTRY_AUTH_TOKEN):Sentry 的 API,例如curl -s -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" "https://sentry.io/api/0/organizations/<org>/issues/?project=<project id>&environment=psbx&statsPeriod=24h&query=psbx_box:<box id>%20release:<commit>",列出那一個箱子用那個 build 產生的 issue(自架的話是同一個路徑、換成你 Sentry 的 host,箱子經office連得到)、sentry-cli,或 agent 有的話用 Sentry 的 MCP server。經箱子網址打開的瀏覽器頁面,Sentry 記下的每個網址(頁面網址、breadcrumb、stack frame)都帶著箱子的 key。照
@parallelsandbox/log的做法在beforeSend把它拿掉,並從同一個 host 取箱子 id 當 tag:const m = /^([a-z0-9]{6,32})-([a-z0-9]{8,32})(?:-[0-9]{1,5})?\.box\.parallelsandbox\.com$/.exec(location.hostname); const scrub = (event) => (m ? JSON.parse(JSON.stringify(event).split(`${m[1]}-${m[2]}`).join(m[1])) : event); Sentry.init({ dsn: '<瀏覽器用的 DSN>', environment: 'psbx', release: '<commit>', initialScope: m ? { tags: { psbx_box: m[1] } } : undefined, beforeSend: scrub, beforeSendTransaction: scrub, });<id>-<key>只有英數與減號,在序列化後的事件裡取代它不會弄壞 JSON:web的https://<id>-<key>.box.parallelsandbox.com/cart(它第一個宣告,url就是sceneUrl)會記成https://<id>.box.parallelsandbox.com/cart,admin的https://<id>-<key>-8082.box.parallelsandbox.com/記成https://<id>-8082.box.parallelsandbox.com/。psbx_box這個 tag 跟服務端送的是同一個箱子 id,一次查詢就能兩邊都找到。在箱子裡 build 的瀏覽器程式的 source map,讓 Sentry 對箱子的 release 顯示原始碼。這是 Sentry 的標準用法,跟 ParallelSandbox 無關:在箱子上 build
web並產生瀏覽器的 source map(Next.js 用productionBrowserSourceMaps: true),注入 debug ID,再用Sentry.init的同一個 release 上傳。把可以上傳 source map 的 auth token 存成 secretSENTRY_AUTH_TOKEN,開箱時帶上("secrets": ["SENTRY_AUTH_TOKEN"]),或之後用sandbox_secrets送進去:cd /work/platform/web && . /work/.sbx/env/web.sh && npm ci && npx next build export SENTRY_ORG=<組織 slug> SENTRY_PROJECT=<瀏覽器專案 slug> # 自架的話再加 SENTRY_URL=https://sentry.office.lan/ npx @sentry/cli sourcemaps inject .next/static npx @sentry/cli sourcemaps upload --release a1b2c3d .next/static注入要在 build 出來的檔案開始提供之前做,之後再用這次的 build 啟動
web。上傳到 sentry.io 走網際網路;自架的 Sentry 經office連,那裡列了sentry.office.lan:443。在 Sentry 旁邊再加
@parallelsandbox/log,適合瀏覽器頁面:agent 想讀它們的 console 輸出(不只錯誤)、用logs_*依箱子篩、又不想拿 Sentry token 的時候。它只在瀏覽器跑,不能取代服務端的 Sentry。見 Log SDK。
4. 從另一個箱子用改過的 billing
改過的 billing 跑在箱子 1(第 2 節)。另一個箱子(箱子 2,同一個 agent 開的或隊友開的)不用重 build 就能用它,有兩種做法:
- 建議:在箱子 2 跑 billing 發布好的版本。 箱子 1 的
billingimage 一 build 好就發布(見下面),箱子 2 一開始就在billing.acme.internal宣告version,再用env標上 Sentry 的標記、關掉它的 worker。箱子 2 就跑自己的一份,不管箱子 1 還在不在;有新的 build 時,一個sandbox_wire … version就換過去。 - link 到箱子 1:箱子 2 需要箱子 1 那份活著的
billing時才用(它到目前的資料、接著的 debugger)。只在箱子 1 還在跑時能用。箱子 1 快要不在時,箱子 2 可以就地把這個名字換成已發布的版本,網址不變(見下面);2026-09-25 12:40 UTC 這個功能上線前開的箱子 2 則要開新的箱子 2,網址也會換。
箱子 1 還在跑:用 link 連過去
sandbox_start {
"name": "ben:web 接退款改版的 billing",
"goal": "接箱子 1 裡 ana 改過的 billing 做 web 的退款畫面;在瀏覽器裡退的款符合新的退款規則就算完成",
"environment": "dev",
"services": [
{ "name": "web", "port": 3000, "web": true },
{ "name": "api.acme.internal", "port": 8080 },
{ "name": "billing.acme.internal", "port": 8080, "fromBox": "<箱子 1>" }
]
}
<箱子 1>是箱子 1 的 id。不是自己開箱子 1 的 agent(例如隊友的),用sandbox_list照箱子的name、goal與services找到它。- 箱子 2 裡的
billing.acme.internal:8080會連到箱子 1 裡跑的billing,箱子 2 的程式與容器都一樣。沒給fromPort,所以用箱子 1 的billing.acme.internal的targetPort,也就是 8081。 billing.acme.internal也是環境的位址。link 蓋過它,所以箱子 2 連到的是箱子 1 那一份,不是 dev 的,這個名字也從箱子 2 的environment.reachable消失。- 箱子 2 自己跑
web與api,做法同第 2 節;它們照樣呼叫billing.acme.internal:8080。 - 箱子 2 有連線開著的期間,箱子 1 不會睡著;箱子 1 凍結的話,下一條連線會把它解凍。箱子 1 停掉之後,箱子 2 連過去會以 410 失敗(
… start a new box and re-point the link with sandbox_wire),對箱子 1 呼叫sandbox_stop時也會在linkedFrom列出箱子 2。要把名字指到別的箱子:sandbox_wire { "id": "<箱子 2>", "service": "billing.acme.internal", "port": 8080, "fromBox": "<新的箱子>" }。 - 箱子 1 快要不在時,就地把箱子 2 換成 billing 發布好的版本:
sandbox_wire { "id": "<箱子 2>", "service": "billing.acme.internal", "port": 8080, "version": "refund-a1b2c3d", "env": { "SENTRY_ENVIRONMENT": "psbx", "WORKERS_ENABLED": "false" } }。在sandbox_status→health.features有take-over的箱子上(2026-09-25 12:40 UTC 起開的箱子都有),版本會接手這個名字:位址不變,箱子 2 開著連到箱子 1 的連線會被關掉、它的程式重連到這個版本,紀錄不再是 link,箱子 1 的linkedFrom拿掉箱子 2,箱子 2 的網址不變。比較舊的箱子 2 會回 409(<name> is a link in this box, and box <id> runs boxd <version>, which cannot turn a link into a published version. Start a new box that declares the version, or wire the link again with fromBox or fromPort):照上面把 link 重新指到有在跑 billing 的箱子,或開一個新的箱子 2,宣告這個版本(下一節)。 - 箱子 2 自己凍結後又解凍時,它在凍結前開著、連到箱子 1(以及 dev)的連線會被關掉;它的程式要重新連線。
- 箱子 2 凍結或停掉之後,ParallelSandbox 會在大約一分鐘內關掉它連到箱子 1 的連線,箱子 1 之後就照自己的閒置時間凍結。箱子 2 醒著又連著的期間,箱子 1 會保持醒著。
- link 的流量經過 ParallelSandbox,算送出那一邊箱子的往外流量:箱子 2 送的請求算箱子 2 的,箱子 1 回的算箱子 1 的。
sandbox_status會列出箱子 2 的links[]與箱子 1 的linkedFrom[]。細節在連到別的箱子。
箱子 1 停掉之後:跑發布好的版本
link 要箱子 1 還在跑。想在箱子 1 停掉之後還留著這份 build,就先從箱子 1 發布它的 image:
sandbox_status { "id": "<箱子 1>" }
registry.imagePrefix 是確切的前綴,<registry>/parallelsandbox/tenant-<帳號>:。帳號的每個箱子都已經登入那個 registry,push 與 pull 都行,跑著的期間會一直保持登入。一個帳號只有一個 repository,所以 tag 要帶服務名:
sandbox_exec { "id": "<箱子 1>", "cmd": "docker tag billing '<imagePrefix>billing-a1b2c3d' && docker push '<imagePrefix>billing-a1b2c3d'", "timeoutSec": 900, "note": "把 billing 的 build push 給其他箱子用" }
sandbox_publish_version { "service": "billing", "label": "refund-a1b2c3d", "image": "<imagePrefix>billing-a1b2c3d", "gitSha": "a1b2c3d", "note": "退款規則" }
image 必須以 imagePrefix 開頭(否則 400),而且不會檢查存不存在,所以要先 push;label 在同一個服務內不能重複(重複回 409)。push 是箱子往外的流量,會計費。registry 沒有過期規則:image 一直留到帳號刪除為止。同一個 tag 再 push 會換成新的 image,箱子的 registry 登入只能 push 與 pull、不能刪,DELETE /v1/versions/{id} 只刪版本紀錄。
在箱子 2 用 billing 在 dev 上的名字宣告這個版本,不用 link;箱子會自己拉 image、把它跑起來:
sandbox_start {
"name": "ben:web 接退款改版的 billing",
"goal": "接 billing 已發布的退款版做 web 的退款畫面;在瀏覽器裡退的款符合新的退款規則就算完成",
"environment": "dev",
"services": [
{ "name": "web", "port": 3000, "web": true },
{ "name": "api.acme.internal", "port": 8080 },
{ "name": "billing.acme.internal", "port": 8080, "version": "refund-a1b2c3d",
"env": { "SENTRY_ENVIRONMENT": "psbx", "SENTRY_RELEASE": "a1b2c3d", "WORKERS_ENABLED": "false", "SCHEDULER_ENABLED": "false" } }
]
}
sandbox_status { "id": "<箱子 2>" }
sandbox_start立刻回。箱子 2 ready 之後會拉 image,用容器名psbx-svc-billing-acme-internal把它跑起來。呼叫sandbox_status,等到 billing 的services[].run.state變成running;failed會附錯誤與 log 的最後幾行。- 像這裡一樣在
sandbox_start宣告版本,每個箱子都可以。billing.acme.internal是環境的位址:在跑著的箱子上,有take-over功能時sandbox_wire帶version也會接走它;比較舊的箱子上,版本會起來,但名字照樣連到連接器。 - 要讓箱子 2 換成 billing 更新的 build,就用新標籤發布它,再呼叫
sandbox_wire { "id": "<箱子 2>", "service": "billing.acme.internal", "port": 8080, "version": "<新標籤>" }。開箱時用version宣告的名字可以這樣換,是不是環境的位址都一樣:容器會被換掉,run從starting重新開始,呼叫端照樣連billing.acme.internal:8080。 - 箱子 2 裡的 8080 已經是
api在用,所以容器發布在 20000,也就是從 20000 往上第一個沒用的 port;呼叫端照樣連billing.acme.internal:8080,箱子 2 的程式與容器都一樣。容器裡聽的是 image EXPOSE 的 port,沒有 EXPOSE 的話就是 8080。 - 這個版本是以服務
billing發布的,所以容器會拿到 billing 在 dev 的設定/work/.sbx/env/billing.env,接著是這裡給的env(同名的以env為準),加上SBX_BOX_ID。它送到 Sentry 的事件會標成psbx並帶 release(第 3 節),worker 與排程也保持關著(dev 設定的其他副作用)。env的值會出現在sandbox_status,所以不要放秘密。 refund-a1b2c3d是標籤。別的服務也有標成refund-a1b2c3d的版本時,名字的第一段billing會挑到這一個;不行的話就改給sandbox_versions回的versionId。
是箱子 2 宣告了 billing.acme.internal,它的呼叫端才會連到它跑的這顆 image。REST 用 GET /v1/versions 與 DELETE /v1/versions/{id} 管理版本。規則在跑發布好的版本。
做不到的事:
- link 到別的帳號的箱子,或用位址、範圍做 link:link 只連同一個帳號的箱子,而且用主機名。
- 箱子 1 停掉之後還連得到它:把 link 重新指到跑著的箱子,或跑發布好的版本。
- 版本就只是 image:
/work、容器的狀態與資料庫的內容都不會帶過去。
5. 排錯
- 箱子 1 不凍結:有別的箱子經 link 連著它(箱子 1 的 health 會顯示
servedLinks)。那個箱子凍結或停掉後,這些連線會在大約一分鐘內關掉;箱子 1 的sandbox_status會在linkedFrom列出連著它的箱子。 sandbox_environments→connections[]:哪一條連線斷了、從什麼時候(lastSeenAt)、它的連接器版本,以及它帶的位址。任何一條在線上connectorOnline就是true,所以光看它看不出office斷了。最後的確認是用真正的用戶端連它的一個位址(前面的檢查方法)。sandbox_status的health.privateEndpoints[]:每個位址的host、port、localIp與localPort(boxd 在箱子裡替它開的入口,不在位址本身的 port 上)、active、opened、failed與lastError。/work/.sbx/logs/private-endpoints.log:每次接不通一行,附原因。403:這個位址已經不在環境的任何連線上,加回去。箱子開起來之後才加的位址,那個箱子不認得,要開新的箱子。503:列了這個位址的那條連線,它的連接器離線,訊息裡會寫出是哪一條連線:辦公室主機上看docker logs parallelsandbox-connector,AWS 上看 log group/ecs/parallelsandbox-connector。箱子裡的用戶端看到的是連線先被接起、然後被重置。502:連接器在線上但連不到目標:檢查連接器到那個位址之間的 DNS、路由或 security group。- 宣告過的名字還是連到 dev:它是環境的位址,卻是在箱子跑起來之後用
sandbox_wire加的。改在sandbox_start宣告。 - 連線逾時、log 裡什麼都沒有:那個位址是沒有任何連線列出的私有 IP。
Could not resolve host:那個名字既沒宣告、也不是環境的位址。當場被拒絕:宣告過的名字或環境的主機用了沒人宣告的 port,而箱子裡也沒有程式聽那個 port。 - link 失敗:
sandbox_status.links[].lastError與private-endpoints.log會寫原因。410:另一個箱子已經停了,用sandbox_wire把 link 重新指過去。409:它還在開機,或跑的是 link 上線前的映像。502nothing listens on port …:另一個箱子裡的程式沒有在fromPort上聽。 aws ecr get-login-password回AccessDeniedException:環境的 AWS 角色沒有從 ECR 拉 image 的權限(在箱子裡跑 dev 上沒改的呼叫端)。- 箱子剛解凍就出現連線錯誤:boxd 會關掉凍結前就開著、連到環境位址與 link 的連線(解凍後約 15 秒內),讓程式拿到錯誤,而不是握著一條永遠不會回應的連線。資料庫連線池通常會自己重連;長時間連著的用戶端(訊息消費者、WebSocket 或 gRPC 串流)要自己重試。
- 502
nothing listens on <name>:80 in this box: that name is declared on another port. …:呼叫端用 port 80 連那個名字,但名字宣告(或列)在別的 port。改連它宣告的 port,或把名字宣告在 port 80 並給targetPort。2026-09-24 20:30 UTC 之前開的箱子,同樣的請求會連到第一個宣告的服務。 - 箱子裡的服務用名字連不到:要聽
0.0.0.0的targetPort,容器要把那個 port 發布出來(-p <targetPort>:<容器裡的 port>)。sandbox_status.wiring.services[]列出每個名字的port、targetPort、address與mode;有externalBaseUrl時,確認它是box。 sandbox_start回port 80 in the box is boxd's scene entry; add targetPort, the port your process listens on — callers keep dialing <name>:80:那個服務加上targetPort。sandbox_wire說箱子的 boxd 不支援targetport:這個箱子是targetPort上線前開的,開一個新箱子。- 箱子的網址回 404:key 不對,或
-<port>後面沒有web服務。網址照sandbox_status回的原樣使用。 - 人的瀏覽器看到的是 dev 的資料,不是箱子的:前端呼叫的是絕對的 API 網址(人打開的頁面)。
這套設定做不到的事
- 從你的網路連進箱子。dev 上的服務呼叫不到箱子裡的那一份;只有箱子裡的程式看得到它。
- link 到別的帳號的箱子,或在一個箱子停掉之後還用它的服務:link 只連同一個帳號、還在跑的箱子,另一個箱子停掉後就以 410 失敗。
- 連線上的位址用萬用字元或範圍比對:每個
host:port都要確切列出,每條連線最多 100 個,每個箱子最多 200 個。 - 把不同的宣告名字送到不同的外部網址:一個箱子只有一個
externalBaseUrl,而且只轉 HTTP。 - 在箱子裡聽 port 80 或 9095:boxd 佔著。呼叫端還是可以用 port 80 連一個名字,連到程式聽的
targetPort。 - 從箱子外面打開沒標
web、也不是第一個宣告的服務。 - 用
logs_*讀 Sentry:這些工具只讀 ParallelSandbox 自己的 log 服務。