團隊設定:多個服務、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 的場景入口,它回 502 nothing 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。

開箱子

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.js rewrites() { source: '/api/:path*', destination: 'http://api.acme.internal:8080/api/:path*' };Vite server: { proxy: { '/api': 'http://api.acme.internal:8080' } }。
  • api 要的是 /orders(拿掉前綴):Next.js rewrites() { source: '/api/:path*', destination: 'http://api.acme.internal:8080/:path*' };Vite server: { 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 管理的 policy AmazonEC2ContainerRegistryReadOnly 四個都有)。沒有的話登入會失敗: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 小時還沒收的資料庫。
  • 跑在容器裡的服務:在旁邊開一個資料庫容器,在 --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,所以這個路由要自己寫。第二個瀏覽器 app admin 也照做,在它自己的 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 存成 secret SENTRY_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 的 billing image 一 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,網址也會換。
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 上線前的映像。502 nothing 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 服務。