deploy/terraform/ を説明します。対象読者は、これを適用するSREまたはプラットフォームエンジニアであり、Flow作成者ではありません。flow がどのようにデプロイされ、ロールバックされるか(Pack digestをfrontに公開すること)を探している場合、それはここでいう「deploy」とは別の、無関係な意味です。代わりに デプロイとロールバック を参照してください。
ここでは、
deploy/terraform/*.tf、deploy/docker/*、docs/11-production-deployment-aws.md に照らして検証された、現時点のモジュールを説明します。Terraformモジュールは terraform validate に合う形で、レビュー可能に作られています。実際に適用するには、本物のAWS認証情報、Route53ゾーンを持つ登録済みドメイン、そしてECRにすでにpush済みのコンテナイメージ(service_image、browser_image、web_image)が必要です。以下のインフラが存在した後に、それらのイメージが実際にどのようにビルドされ、ロールアウトされるかは CI/CDパイプライン を参照してください。アーキテクチャ概観
このデプロイメントは、4つのECS Fargateサービス、4つのサブネット階層、1つのRDS Postgresインスタンス、1つのRedis replication groupで構成されます。4つのECS Fargateサービス
api・web・worker・browser(3つではありません)4つのサブネット階層
public・app・data・browser1つのRDS Postgres
ラッパーストアとエンジンのruns/journal/plane storeの両方を支える
1つのRedis replication group
後述するマルチレプリカのギャップを埋める
WrapperStore / Kms / BrowserCapability / Store インターフェイスの背後にあるバックエンドを選び、配線するだけです。
Network — AZごとの4つのサブネット階層
network.tf は、VPC(var.vpc_cidr、デフォルト 10.42.0.0/16)を、cidrsubnet(vpc_cidr, 4, i) によってAZごとに4つの連続した /20 階層へ切り分けます。AZ数は var.az_count(2または3)です。
| 階層 | ホスト | Egress | Ingress |
|---|---|---|---|
public | ALB、NAT gateways | Internet Gateway | Internet (443/80) |
app | jinbatrail-api、jinbatrail-web、worker ECS tasks | NAT(VPC内のRDS/Redis/EFS、Secrets Manager、KMS、SQS、Anthropic API) | ALBのみ (8080, 3000) |
data | RDS、ElastiCache、EFS mount targets | なし | app security groupのみ (5432, 6379, 2049) |
browser | chromium ECS task | NAT、public 443/80/53へのallow-list | app security groupのみ、CDP :9222 |
aws_network_acl.browser)で行います。このNACLは、広いインターネット許可より小さいルール番号で、すべてのRFC1918範囲とlink-local IMDSブロック(169.254.0.0/16)を deny します。そのため、記録済みflowが 169.254.169.254(instance metadata)や内部サービスCIDRへ移動しようとしても、SGのegressルールが何を許していても物理的に到達できません。狭く高い番号のNACL allowは、app サブネットからのCDP戻り経路のephemeral ports(stateless NACLでは明示的に必要)と、VPC内ICMP(Path-MTU Discovery。CDP WebSocketの大きなフレームがcross-AZ hopで黙ってblack-holeしないようにするため)にだけ存在します。
Data tier
RDS Postgres、Multi-AZ、RDS管理のmaster password
1つのaws_db_instance(Postgres 16、gp3)が、1つの接続文字列の背後で 両方 のストアを支えます。ラッパーストア(JT_DATABASE_URL ?? DATABASE_URL — orgs、API keys、Automations)と、エンジンのruns/journal/plane store(BROWSERFLOW_DATABASE_URL ?? DATABASE_URL)です。どちらも同じ DATABASE_URL に解決されるため、1つのRDSエンドポイントで3つすべてを満たせます。
manage_master_user_password = true— master credentialはRDS自身が所有し、AWSが約7日ごとに自動ローテーションするSecrets Manager secretに入ります。Secrets ManagerにDATABASE_URLsecretはありません。理由は後述の entrypointが起動時にDATABASE_URLを組み立てる理由 を参照してください。multi_az = var.environment == "production"— Multi-AZは本番のみです。staging/devはフットプリントとコストを最小にするためsingle-AZで動きます。- Storageとmaster-user secretはどちらも、このモジュール自身のKMS CMK(
aws_kms_key.envelope— 後述の KMS CMK を参照)で暗号化されます。 backup_retention_period = 14、deletion_protection、performance_insights_enabledはenvironment == "production"によって有効化されます(Performance Insightsはburstableなdb.t4g.micro/t3クラスではサポートされておらず、staging.tfvarsが使うのはまさにそれです)。- parameter groupレベルの
rds.force_ssl = 1により、転送中のTLSが必須になります。組み立てられるDATABASE_URLはsslmode=no-verifyを持ちます(TLSは有効、ただしRDSのCA chainは検証しません。require/verify-fullではなくno-verifyにする理由はentrypointのセクションを参照してください)。
ElastiCache Redis
1つのaws_elasticache_replication_group(Redis 7.1、TLS + 同じCMKによるat-rest encryption、automatic failover付きMulti-AZ)が、同じ JT_REDIS_URL から読み取られる3つの用途を同時に支えます。
- 分散rate limiter(
createRedisRateLimiter)。これにより/v1/runのquotaはプロセス単位ではなくfleet全体で強制されます。 - live-view/authoringのpub-sub relay。あるレプリカで生成されたCDP frameを、別のレプリカに接続しているviewerへfan outできます。
- 後述のEventBridge tickと並ぶscheduler-firing coordination。
EFS — content-addressed registry
aws_efs_file_system.registry は、すべての api/worker taskから /data/registry(BROWSERFLOW_DATA_DIR)にmountされます。access pointはscopedな /registry rootとPOSIX identity uid=gid=1000 に固定されており、taskはscoped pathをmountし、filesystem rootはmountしません。Packとflow digestはcontent-addressedで、到着時に検証されるため、全taskにまたがる同時readerは構造上安全です。publishはまれで、冪等です。
Compute — 4つのECS Fargateサービス
compute.tf は1つのcluster上で 4つ のサービスを実行します。execution role(image pull、logs、secret injection)は共有し、task roleは分割します(最小権限、階層ごと)。
| サービス | イメージ | ポート | 役割 | ALB配下か |
|---|---|---|---|---|
jinbatrail-api | var.service_image | 8080 | Honoコントロールプレーン: keys、Automations、/v1/run、live-view/authoring、MCP。JT_RUN_DISPATCH=sync のときはP1/P2 in-process executorも兼ねます。 | はい — api.* |
jinbatrail-web | var.web_image | 3000 | Next.js SSR run inspector + canvas⇄YAML editor。APIへのsame-origin proxy。secrets、EFS、DBはありません。 | はい — app.*(本番のみ、その前段にCloudFront) |
worker | var.service_image、commandを server-worker.ts にoverride | — | SQS run-dispatch queueをpollし、APIのin-process pathと 同じ plane.rider.run を呼び出します。ALBはありません。queue depthでautoscaleします。 | いいえ |
chromium (browser tier) | var.browser_image | 9222 (CDP) | self-hosted Chromium。api/worker からCDP経由で到達します。分離された browser subnet/SGに置かれます。Cloud Map経由で browser.<name>.internal:9222 として発見できます。 | いいえ — internalのみ |
api と worker は同じimageと同じ common_environment/common_secrets 配線を共有します。worker の違いは、container command(["bun","run","packages/service/src/server-worker.ts"])とload balancerを持たないことだけです。chromium taskのIAM role(browser_task)は意図的に ほぼ空 です。信頼できない記録済みflowを実行するため、container escapeが完全に成功してもAWS APIは呼び出せません。api/worker の共有task roleは、1つのCMKに対する kms:Encrypt/Decrypt/GenerateDataKey/DescribeKey、dispatch queue + DLQに対する sqs:*、そしてこのstack自身のsecretに対する secretsmanager:GetSecretValue だけに絞られています。
api deploymentはtaskを1つずつrollし(deployment_minimum_healthy_percent = 100、maximum_percent = 200)、deployまたはscale-inで古いtaskがkillされる前に、in-flightな同期Runをdrainするため120秒(deregistration_delay)を与えます。
Edge — ALB (api.*) + CloudFront (app.*)
1つのALBが、単一のHTTPS (443) listener上のhost-based listener ruleで両方のhostnameをfrontします。HTTP (80) はそこへredirectされます。
app.<domain>→webtarget group(priority 10)— Next.js SSR app。api.<domain>→apitarget group(priority 20)— ただし意図されたpublic surfaceだけです:/v1/*、/api/*、/health、/ready、/internal/tick。priority 30の2つ目のruleが、hosted MCP endpoint用に/mcp、/mcp/*を追加します。これは 別の ruleです。ALBのpath_patternconditionは最大5つの値までで、最初のruleがすでにその上限に達しているためです。api.<domain>上のその他すべて → 固定の403 {"error":"forbidden"}response。 これはlistenerのdefault_actionです。composed engineのコントロールプレーン(/runs、/flows、/deploys、/secrets、/schedules、/registry)は、その層では設計上unauthenticatedだからです。wrapper自身のapp-level gateはdefense in depthであって、唯一の防衛線ではありません。ALBでdeny-by-defaultにすることで、app-layer gateが将来regressしてもギャップを閉じられます。
alb_idle_timeout >= 300—variables.tfのhard validationです。serviceはSSE/WS connectionを255秒(server.tsBunidleTimeout)開いたままにするため、ALB timeoutがそれより短いとlive-view streamがsession途中で切れます。- Stickiness on(
lb_cookie、3600秒)をapitarget groupで有効にします。これにより、特定のauthoring/live-view sessionは、そのbrowser pageを実際に所有しているレプリカへ着地し続けます。
AWSManagedRulesCommonRuleSet + 2000-req/5-min-per-IPのrate-based rule)がALBの前段に置かれます。これは粗い外側のguardであり、本当のquotaではありません。本当のquotaは上記のper-org Redis limiterです。
app.<domain> は本番でのみCloudFrontを通ります(local.enable_cdn = var.environment == "production")。staging/devでは、app.* はALBへ直接aliasされ、ALBが同じ jinbatrail-web ECS serviceへhost-routeします。これにより、CDNを必要としない環境で、CloudFrontの約40分のprovisioning timeと空bucketのstatic pathを避けられます。CloudFrontが有効な場合は、Origin Access Controlによりprivate S3 bucketからbuild済みNext.js static assetsをserveします(CloudFrontだけがbucket readerです)。その前段にはCloudFront専用scopeのWAFがあります。AWSの要件により、これはdeploymentのhome regionに関係なく us-east-1 に置く必要があるため、aws.us_east_1 provider aliasを使います。
KMS CMK — secret envelope + at-rest key
1つのcustomer-managed key(aws_kms_key.envelope、年次auto-rotation)が二重の役割を持ちます。
- Secret envelope master key。
var.use_kms_envelope = true(デフォルト)のとき、serviceはJINBA_KMS_KEY_ARNを設定して動きます。これはJINBA_MASTER_KEYbridgeより優先されます。master keyはKMSから出ず、すべてのsecret unwrapは独立したCloudTrail監査対象のkms:Decryptcallになります。 - At-rest encryption。RDS、Redis、EFS、SQS queues、S3 web bucket、CloudWatch log groupを暗号化します。監査されるkeyは1つなので、rotationはserviceごとのre-encrypt sweepではなく、単一のCMK operationになります。
kms:ViaService でscopeされたencrypt/decryptを付与します。task-role accessは別途、compute.tf 自身のIAM policyで付与されます。これは、CMKとそれを使うroleの間の循環依存を避けるためです。
use_kms_envelope = false(staging.tfvars が実行する設定)は、JINBA_MASTER_KEY bridgeへfallbackします。これはSecrets Manager内にある、本物の生成済み64-hex keyで、すべてのtaskで同一です。restartをまたいで残り、レプリカ間で共有されますが、task environmentを通過します。bridgeとしては許容できますが、本番で推奨されるpathではありません。このモジュールはKMS CMKと JINBA_MASTER_KEY secretの両方を無条件で生成するため、この変数を切り替えるのはconfig changeであり、新しいresourceの再applyではありません。SQS run-dispatch — FIFOではなくstandard queue
queue.tf はFIFO queueではなく、standard SQS queue(aws_sqs_queue.dispatch)とDLQをprovisionします。POST /v1/run(async mode、JT_RUN_DISPATCH=queue)は {org, flow, digest, inputs} をenqueueして run_id を返します。worker serviceはそれをlong-pollし(receive_wait_time_seconds = 20)、APIのsynchronous pathが使うものと同一の plane.rider.run を呼び出します。Standard SQSはat-least-onceであり、exactly-onceでも厳密な順序付きでもありません。ここではそれで問題ありません。エンジンのJournalが手順ごとの冪等性キーを持つため、再配信されたmessageは二重実行ではなくRunを 再開 します。FIFOのより厳格なordering/dedup保証は、この粒度では不要です。visibility_timeout_seconds = 360 は、AWSがworkerが死んだとみなして再配信するまで、workerがmessageを保持できる時間の上限です。messageがDLQに入るまでの maxReceiveCount = 5 で、DLQにはpaging SNS topicへ配線された独自のdepth alarmがあります。worker serviceの desired_count は、step-scaling CloudWatch alarmsを通じて ApproximateNumberOfMessagesVisible に基づいてautoscaleします(backlogが5を超えたら素早くscale outし、queueが3回連続の5分間空になったらゆっくりscale in)。API request rateとは完全に切り離されています。
EventBridge → signed /internal/tick
serviceはscheduleをPostgresに 保存 しますが、自分ではfireしません。単純に全レプリカでcronerを動かすと、それぞれのcronが N 回fireしてしまいます。scheduler.tf はleader electionを作る代わりに、timerをAWSに所有させることでこれを解決します。
従来型のEventBridge ruleが毎分fireする
schedule_expression = "rate(1 minute)" の aws_cloudwatch_event_rule です。これはresolutionであり、scheduleがfireするgranularityではありません。これは通常のEventBridge Rule + API Destinationであり、別サービスの “Amazon EventBridge Scheduler”(aws_scheduler_schedule)ではありません。署名付きでAPI DestinationへPOSTする
API_KEY auth付きの aws_cloudwatch_event_connection が、aws_cloudwatch_event_api_destination の各invocationに、JT_INTERNAL_TICK_SECRET(secretとして api taskへinjectされる同じ生成値)を含む X-Jinba-Tick-Secret headerを付与します。invocation_endpoint は https://api.<domain>/internal/tick です。/internal/tick からの5xx)は120秒以内に最大3回retryされ、その後、失敗したRunと 同じrun-dispatch DLQ に入ります(EventBridge targetの dead_letter_config)。1つのalarmが両方のfailure classをカバーします。/internal/tick がALB層でinternet-reachableである(api_allow listener ruleのpath listに入っている)のは、network isolationではなくHMAC verificationがそのtrust boundaryだからです。
entrypointが起動時にDATABASE_URLを組み立てる理由
deploy/docker/entrypoint.service.sh(containerの ENTRYPOINT で、imageの CMD をwrapするもの)は、ある1つの相互作用のためだけに存在します。manage_master_user_password = true は、AWSがRDS master passwordを約7日ごとに、その内容をTerraformが管理しない専用のSecrets Manager secretへ自動ローテーションすることを意味します。
以前の設計では、terraform apply 時に DATABASE_URL を1回組み立て、static secretに入れていました。そのsecretは次のrotationでstaleになります。新しいECS taskはPostgres auth(28P01)に失敗し、rotation windowをまたいだdeployでは毎回 aws ecs wait services-stable がtimeoutしていました。修正では、組み立てを apply time から task start time へ移しました。
- Terraformは、rotationをまたいでも変わらないpieceだけをplainな非secret environmentとしてinjectします。
JT_DB_HOST(aws_db_instance.main.address、hostのみで.endpointではない)、JT_DB_PORT、JT_DB_USER、JT_DB_NAME、JT_DB_SSLMODE=no-verifyです。 - 現在の rotating passwordは、RDS-managed secretから直接
secretsreferenceとしてinjectします。:password::JSON-key selector("${local.rds_master_secret_arn}:password::")を使います。ECSはtask startごとにこれを解決するため、新しくrollされたtaskは常に、その瞬間にliveなpasswordを受け取ります。 - containerの最初のprocessとして実行されるentrypointが、これらのpieceからconnection stringを組み立て、実際の
CMD(または後述のmigration taskのようなECScommandoverride)をexecする前にDATABASE_URLをexportします。
bun -e 経由の encodeURIComponent)。RDS-managed passwordには + & / % などの文字が含まれる場合があり、そのままだとconnection stringが壊れるためです。次に、sslmode=no-verify(require や verify-full ではない)は意図的です。rds.force_ssl=1 は転送中TLSを引き続き強制しますが、RDS CAがcontainerのtrust storeに入っておらず、node-postgres >=8.13 はそうでなければ SELF_SIGNED_CERT_IN_CHAIN でconnectionを拒否するため、no-verify はCA-chain verificationをskipします。
DATABASE_URL がすでに設定されている場合は変更しないため、まったく同じimageがdev(DATABASE_URL を直接設定する環境)とこのECS topologyの両方で無修正で動きます。one-off migration task(migrate.tf)は同じimage、role、env/secrets配線を再利用します。違いは command が packages/service/src/migrate.ts にoverrideされることだけなので、connection stringは同じ方法で解決されます。
モジュールの適用
remote-state backendをaccountごとに1回bootstrapする
main moduleのstateは、暗号化されversioningされたS3 bucketとDynamoDB lock tableに置かれます。ただし、そのbucket/table自体を、それが保持するstateの中に置くことはできません。別の
bootstrap/ module(local state)がそれらを作成します。環境ごとに、そのbackendに対してmain moduleをinitする
dev/staging/production)は独自のstate keyを持つため、staging applyが production のresourceに触れることはありません。必須inputを指定してplan/applyする
domain_name にはdefaultがありません。domainが欠けていれば、placeholderをshipするのではなく plan が明確に失敗します。anthropic_api_key はauthoring agentのkeyを1回seedします(その後はconsole内でrotateしてください。Terraformの lifecycle.ignore_changes により、次回applyでconsole rotationが上書きされることはありません)。service_image、browser_image、web_image)はすでにECRに存在している必要があります。このモジュールはECR repoもimage buildもprovisionしません。それは CI/CDパイプライン の仕事です。alarmを人へ配線する
すべてのCloudWatch alarm(ALB 5xx、RDS CPU/storage/connections、DLQ depth、unhealthy API targets)は、configurationに関係なく1つのSNS topicへpublishします。ただし、
-var 'alarm_email=oncall@example.com' を設定して(AWSから送られるsubscription emailをconfirmして)いない限り、またはSlack/PagerDuty/OpsGenie integrationを alarm_sns_topic_arn outputへsubscribeしていない限り、誰にもpageしません。staging.tfvars は、最小フットプリントのための実際に適用されたreferenceです。db.t4g.micro / cache.t4g.micro、単一の共有NAT gateway、KMS envelopeではなく JINBA_MASTER_KEY bridge、そして api_desired_count = 1 を使います。
api_desired_count を1より大きくしないこと
serviceにはこのための実際のcode-level readiness gate(packages/service/src/readiness.ts)があり、本番でのhard boot-failureとしても、ALBがhealth-checkする GET /ready routeとしても評価されます。このgateは、replicaCount > 1 かつ 4つのsingle-process gapのいずれかが閉じていないときにtripします。4つのgapとは、共有rate limiter、重複しないscheduler firer、sticky/relayed live-view、共有engine storeです。このTerraformモジュールは、それらのgapに必要なpiece(Redis replication group、per-replica cronerの代わりのEventBridge tick、sticky ALB sessions、そして両store用のRDS Postgres)をprovisionします。またcomposition rootは、Redis、JT_INTERNAL_TICK_SECRET、engine DATABASE_URL がすべて構成されているときにgapが閉じていると扱います。このdeploymentでは、それらはデフォルトで構成されています。
ただし、それはこのモジュールが現時点で水平スケールして安全であることと 同じではありません。コード上で確認できる具体的な理由が2つあります。
- gateが確認するreplica countは自己申告であり、ここでは何もそれを報告していません。
replicaCountはJT_REPLICA_COUNTから来ます。これはデフォルトで1であり、environment variables reference の自身のdocumentationにある通り、“informational … not used to configure anything.” です。compute.tfのcommon_environmentは、どこでもvar.api_desired_countからJT_REPLICA_COUNTを設定していません。具体的には、このモジュールで今api_desired_countを2に上げても、/readygateは2レプリカで動いていることを認識しません。1を読み続け、greenのままです。意図されたhard-fail protectionは、それが観測すべきTerraform knobに配線されていません。 - Live-view session ownershipは、gap closureでは消えない物理的制約です。 active authoring sessionのCDP pageは、あるtaskのprocessによって開かれ、移動できません。Redis pub-sub relayはすでにcaptureされた frames を他のレプリカへfan outしますが、2つ目のレプリカは、自分が開いていないpageを引き継いで control することはできません。sticky ALB sessionsがこれを成立させており、gap closureだけで成立しているのではありません。そしてstickiness pinningはbest-effort cookieであって、すべてのfailure modeでのhard guaranteeではありません(task recycleがsession途中で起きれば、そのsessionのbrowserはやはりdropします)。
api replicaを2つ以上必要とする場合、それは terraform apply -var api_desired_count=2 ではなくengineering taskとして扱ってください。gateが実際に見られるように JT_REPLICA_COUNT を本当のdesired countへ配線し、Redis/scheduler/engine-DBがtarget environmentで本当に到達可能であることを確認し、sticky-session failoverをload下で検証してから本番で信頼してください。worker と browser にはこの制約はありません。worker のqueue-depth autoscaler(queue.tf)とbrowser poolは、最初から複数taskで動くように設計されています。
Observability
observability.tf は、プロダクトがすでに持つrun inspectorの上に、運用レイヤーを追加します。
- 1つのSNS topic。下記のすべてのalarmがそこへpublishします(
alarm_sns_topic_arn)。任意のemail subscriptionはvar.alarm_emailでgateされます。subscriberがいなくてもalarmは常に存在しdashboardを動かすため、このモジュールのapplyが黙って誰かにpageし始めることはありません。 - ALB: ELB-generatedおよびtarget-generatedの5xx counts、
apitarget groupのunhealthy-host count(これはboot/readiness gateがinfra signalとして現れたものです。/healthに失敗するtaskはhealthy targetになりません)、そしてlive-view WS/SSE relayのconnection dropのproxyとしての高いTargetConnectionErrorCount。 - RDS: CPU、free storage、connection-count thresholds。それぞれ単純な
var.*_alarm_thresholdで、instance sizeごとにtuneできます。 - SQS: queue-backlog high/low(worker autoscaling policiesを駆動)とDLQ-not-empty(Run、またはscheduler tickがすべてのredeliveryを使い切った状態)。
- CloudWatch dashboard(
${name}-ops)。4つのwidgetを持ちます: ALB requests/5xx、target health/latency、RDS CPU/connections/storage、queue/DLQ depth。 - Tracing は変数でgateされます(
enable_tracing、デフォルトfalse— opt inするまでコストゼロ)。X-Ray write IAM policyを付与し、OTEL_*envをadvertiseするだけです。実際にspanをexportするには、task上のADOT/OTel collector sidecarがまだ必要ですが、このモジュールはそれを追加しません。
関連
CI/CDパイプライン
このモジュールが実行するcontainer imageが実際にどのようにビルドされ、本物のPostgresに対してgateされ、4つのECS serviceへrollされるか。
環境変数
このモジュールがinjectするすべての
JT_* / JINBA_* / BROWSERFLOW_* variable。defaultと本番要件も含みます。コントロールプレーン
このインフラが実際に実行するもの。2つ目のエンジンになることなく、flowをscheduleしobserveするcoordinatorです。
シークレット
このモジュールのKMS CMKが支えるsecrets brokerが、run timeにworker上で
secret.* referenceを解決する方法。