
Epic Games の Lore を NAT なしのプライベートサブネットで動かすのに必要な VPC エンドポイントを実測した
はじめに
Epic Games のバージョン管理システム Lore は、サーバー (loreserver) をセルフホストできます。以前の記事 で loreserver を ECS Fargate と S3、DynamoDB でデプロイしました。ただしその構成はデフォルト VPC のパブリックサブネットに置いたものでした。本記事では、これを NAT ゲートウェイなしのプライベートサブネットに置き換え、必要な VPC エンドポイントの最小セットを確認します。
Lore とは
Lore とは、Epic Games が 2026 年 6 月に公開したオープンソースのバージョン管理システムです。コードと大容量バイナリが混在するプロジェクト、特にゲームやエンタメ制作を主な対象としています。サーバーをセルフホストでき、ストレージのバックエンドに AWS の S3 と DynamoDB を選べます。
検証環境
- リージョン: ap-northeast-1 (東京)
- Terraform: 1.15.2
- Docker (buildx): イメージのビルドに使用
- loreserver: 0.8.5-nightly
- 認証: aws-vault の profile を使用
対象読者
- Lore を自チームでセルフホストしたいクラウドエンジニア
- システムをインターネットから隔離したプライベートサブネットで運用したい方
- Fargate をプライベートサブネットで動かすときの VPC エンドポイント構成を知りたい方
参考
背景: なぜ VPC エンドポイントを調べるのか
NAT ゲートウェイを置けば話は早いのですが、あえて VPC エンドポイントを調べます。理由は 2 つあり、どちらも Lore の性質と運用の現場に根ざしています。
1 点目に、セキュリティ要件への考慮です。ゲームスタジオはソースやアセットという価値の高い知的財産を扱うため、基盤をインターネットから隔離する運用が現場で広く求められます。NAT ゲートウェイは必要な AWS サービスだけでなくインターネット全体への通信経路を開くため、この要件と相反します。VPC エンドポイントなら、到達先を必要な AWS サービスだけに限定し、通信を AWS の内部網に閉じられます。
2 点目に、費用です。Lore は大容量バイナリを扱うバージョン管理です。アセットの push や clone で S3 を GB 単位で流します。NAT ゲートウェイはデータ処理に 0.062 USD/GB かかるため、TB 級のアセット流通では無視できない額になります。一方、S3 と DynamoDB のゲートウェイ型エンドポイントはデータ処理課金が無料です。大容量データを扱う Lore ほど、大量のデータを無料のゲートウェイ型に載せる意味が大きくなります。
そこで本記事では、インターネットへの経路を持たせずに loreserver を Fargate で動かすとき、必要な VPC エンドポイントは最小で何か探ります。1 つ欠けるとどこで何が壊れるか観察します。
構成
検証環境は、NAT ゲートウェイもインターネットゲートウェイへの経路も持たないプライベートサブネットです。
全体としては次のようになります。
VPC エンドポイントには 2 種類あります。インターフェース型はサブネットに ENI を置き、443 番ポートで対象サービスへ中継します。時間課金とデータ処理課金がかかります。ゲートウェイ型はルートテーブルに経路を足すだけで、S3 と DynamoDB の 2 サービスにだけ使え、課金は無料です。
候補としたエンドポイントは、インターフェース型が ecr.api/ecr.dkr/logs/sts の 4 つ、ゲートウェイ型が s3/dynamodb の 2 つです。sts は要否が不明なため候補に含めて実測します。
検証方法: 起動可否だけで判定する
検証は、クライアントを接続せず、Fargate タスクが起動するかどうかだけで判定します。クライアントの push を伴わないこの方法が成立するのは、loreserver が起動時に S3 と DynamoDB へ存在確認のアクセスを行うためです。 エンドポイントが欠けると、この起動時のアクセスで失敗します。
これはソースコードで確認できます。稼働バージョンと一致するコミット 28728f2e (0.8.5-nightly) で、AWS プラグインの初期化は次のようになっています。S3 クライアントを組み立てるときに ensure_bucket が呼ばれ、その build() で S3 のバケット存在確認 (HeadBucket) を発行します。続いて DynamoDB クライアントで ensure_table を呼び、テーブル存在確認 (DescribeTable) を発行します。
.s3_with_path_style(plugin_config.s3_force_path_style)
.ensure_bucket(&plugin_config.s3_bucket)
.build()
.await
.map_err(|e| {
PluginError::from(PluginInitError {
plugin_name: plugin_name.to_string(),
message: format!("Failed to create S3 client: {e}"),
})
})?;
// Build DynamoDB client
let dynamodb_client_builder = Box::pin(
AwsClientBuilder::builder()
// ...
.dynamodb()
.ensure_table(&plugin_config.dynamodb_fragments_table)
.ensure_table(&plugin_config.dynamodb_metadata_table);
let dynamodb_client =
Box::pin(dynamodb_client_builder.build())
.await
.map_err(|e| {
PluginError::from(PluginInitError {
plugin_name: plugin_name.to_string(),
message: format!("Failed to create DynamoDB client: {e}"),
})
})?;
S3 の存在確認が DynamoDB より先に来ている点が、後の検証結果とマッチします。これらは初回リクエスト時の遅延処理ではなく、起動時の同期処理です。失敗するとサーバーは起動できません。
具体的な検証手順は次の通りです。
- 候補エンドポイントを全てそろえた状態を基準にする
- そこから 1 つだけ外して Fargate タスクを起動し、
aws ecs describe-tasksの停止理由 (stoppedReason) と CloudWatch のログを記録 - 元に戻す
- 1 つずつ独立に外すことで、各エンドポイントの必要性を切り分ける
検証結果
ベースライン
候補エンドポイントを全てそろえてタスクを起動すると、インターネット経路がなくてもタスクは起動し、ログは次の行まで到達しました。
Server is up, waiting for shutdown signal
ログには、AWS のイミュータブルストア (S3 と DynamoDB) 、ミュータブルストア、ロックストアを順に生成する行が並びます。インターネット経路がなくても、必要なエンドポイントがそろえば loreserver は動きます。
sts エンドポイントの検証
sts エンドポイントを外しても、タスクは問題なく起動しました。 ログにも STS 関連のエラーは出ません。ECS のタスクロールの認証情報は、タスクメタデータエンドポイントから取得され、STS を直接呼ばないためです。したがって sts エンドポイントは不要です。
logs エンドポイントの検証
logs エンドポイントを外すと、タスクは起動に失敗し、停止理由は次のようになりました。
ResourceInitializationError: failed to validate logger args: The task cannot find
the Amazon CloudWatch log group defined in the task definition. There is a connection
issue between the task and Amazon CloudWatch. Check your network configuration.
この失敗は awslogs ドライバの初期化で起きるため、コンテナが起動する前に止まります。そのため loreserver のログは残りません。原因は CloudWatch では追えず、ECS の停止理由でしか分かりません。VPC エンドポイントの過不足を検証するときは、まず logs を確保しないと、他の失敗の原因も追えなくなります。
なお、停止理由の文言は log group が見つからないと読めますが、実体は CloudWatch への到達不能です。 ロググループ自体は存在しています。文言をそのまま信じず、ロググループの実在を aws logs describe-log-groups で切り分けるとよいでしょう。
ecr.api と ecr.dkr の検証
イメージの取得に関わる ECR のエンドポイントは 2 つあり、外したときの失敗が異なります。
ecr.api を外すと、認証トークンの取得で失敗します。
ResourceInitializationError: unable to pull secrets or registry auth: ...
operation error ECR: GetAuthorizationToken ...
Post "https://api.ecr.ap-northeast-1.amazonaws.com/": dial tcp 3.112.x.x:443: i/o timeout
ecr.dkr を外すと、イメージのマニフェスト解決で失敗します。
CannotPullContainerError: pull image manifest has been retried 7 time(s):
failed to resolve ref .../lorevpce-server:v1: ...
Head "https://<アカウント ID>.dkr.ecr.ap-northeast-1.amazonaws.com/v2/.../manifests/v1":
dial tcp 54.250.x.x:443: i/o timeout
ecr.api は認証トークンの取得 (GetAuthorizationToken) 、ecr.dkr はマニフェストの解決に使われます。役割が分かれているため、両方が必要です。プライベート DNS がないとレジストリ名が公開 IP に解決され、経路がなくタイムアウトしている様子も読み取れます。
s3 エンドポイントの検証
s3 のゲートウェイ型エンドポイントを外すと、イメージの取得で失敗します。ただし ecr.dkr のときとは失敗の位置が違います。
CannotPullContainerError: ... failed to copy: httpReadSeeker: failed open: ...
dial tcp 52.219.x.x:443: i/o timeout
failed to copy はイメージレイヤの取得です。接続先の 52.219.x は S3 の IP レンジです。ECR のイメージレイヤは S3 に保管されるため、マニフェストの解決 (ecr.dkr) は通っても、レイヤの取得 (S3) で止まります。
s3 が必須である理由は、実はもう 1 つあります。前述のソースの通り、loreserver は起動時に S3 の HeadBucket を叩きます。今回の実測では、s3 を外すとイメージの取得段階で先に落ちるため、この起動時の HeadBucket 失敗そのものは観測できません。ただしソース上、イメージの取得とアプリの起動時アクセスの両方で S3 が必要です。
dynamodb エンドポイントの検証
dynamodb のゲートウェイ型エンドポイントを外すと、これまでと違い、コンテナは起動します。イメージの取得もログ出力も通るためです。そのうえで loreserver が起動時に DynamoDB を叩いて失敗し、終了コード 1 で落ちました。ログには次が出ています。
"span.name":"DynamoDbImpl::table_exists" ... "elapsed_ms":"18240" ...
error: ... ConnectorError { kind: Timeout, ... }
Failed to create DynamoDB client: AWS SDK error: ... Timeout
この Failed to create DynamoDB client は、先ほど引用したソースの aws.rs の失敗メッセージと同じです。ランタイムのログとソースが一致しています。ここが、クライアントを接続せずタスクの起動可否だけでエンドポイントの要否を判定できる根拠です。loreserver が起動時に DynamoDB を叩くため、エンドポイントが無いとこの段階で落ちます。
最小セットで起動する
最後の確認として、sts だけを外し、ecr.api/ecr.dkr/logs/s3/dynamodb の 5 つを残した状態でタスクを起動しました。イミュータブル/ミュータブル/ロックストアの生成がすべて成功し、Server is up に到達しました。この 5 つがあれば sts なしで起動する、と対照確認できました。
結論: 必要な VPC エンドポイントの最小セット
結論は次の通りです。
| エンドポイント | 型 | 課金 | 必要か | 用途 |
|---|---|---|---|---|
| ecr.api | インターフェース型 | 時間 + データ処理 | 必須 | イメージ取得時の認証トークン取得 |
| ecr.dkr | インターフェース型 | 時間 + データ処理 | 必須 | イメージのマニフェスト解決 |
| logs | インターフェース型 | 時間 + データ処理 | 必須 | awslogs ドライバによるログ送信 |
| s3 | ゲートウェイ型 | 無料 | 必須 | ECR レイヤ取得と起動時の HeadBucket、Lore のデータ |
| dynamodb | ゲートウェイ型 | 無料 | 必須 | Lore の fragments/metadata/mutable/lock |
| sts | インターフェース型 | 時間 + データ処理 | 不要 | 認証はタスクメタデータ経由で完結 |
Lore 固有の依存は s3 と dynamodb です。Fargate 一般の依存は ecr.api/ecr.dkr/logs と、ECR レイヤ取得を兼ねる s3 です。
コストと NAT ゲートウェイとの比較
最小セットのうち、インターフェース型は ecr.api/ecr.dkr/logs の 3 つです。ap-northeast-1 では 1 エンドポイント 1 アベイラビリティゾーンあたり 0.014 USD/hour で、データ処理が 0.01 USD/GB です。ゲートウェイ型の s3 と dynamodb は無料です。
比較のため NAT ゲートウェイを見ると、0.062 USD/hour に加えデータ処理が 0.062 USD/GB です。24 時間 365 日の常時稼働なら、インターフェース型 3 つで約 30 USD/月、NAT ゲートウェイ 1 つで約 45 USD/月です。時間課金だけを見れば大きな差ではありません。
差が出るのはデータ処理です。 Lore は大容量バイナリを S3 で流します。この S3 の通信を、NAT ゲートウェイなら 0.062 USD/GB で処理するのに対し、S3 のゲートウェイ型エンドポイントなら無料で流せます。アセットを日常的に push する現場ほど、この差が影響してきます。VPC エンドポイントを選ぶ動機は、インターネットからの隔離というセキュリティ面と、大量のデータを無料で流せる費用面の両方にあります。
まとめ
loreserver を NAT ゲートウェイなしのプライベートサブネットで Fargate 稼働させるとき、必要な VPC エンドポイントは ecr.api/ecr.dkr/logs のインターフェース型 3 つと、s3/dynamodb のゲートウェイ型 2 つの、あわせて 5 つでした。sts は不要です。
検証で分かった実務上の勘どころとしては、logs エンドポイントの順序の罠です。logs が無いとログ自体を送れないため、他の失敗の原因まで追えなくなります。エンドポイントを絞り込むときは、まず logs を確保してから他を検証するとよいでしょう。
付録: 実装の全文
本記事で使った Terraform とイメージ定義です。local.toml の s3_bucket は自分のアカウント ID に合わせて置き換えてください。ストレージ (S3 バケットと DynamoDB 4 テーブル) とタスクロール (DescribeTable を含む) は 以前の記事 と同じであるため、ここでは差分となるネットワークまわりを中心に載せます。
ネットワーク (network.tf)
# 検証専用 VPC。インターフェース型エンドポイントのプライベート DNS を使うため
# DNS サポートとホスト名を有効にする。
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "${var.name_prefix}-vpc" }
}
# プライベートサブネット 1 つ。インターネットゲートウェイも NAT も持たせない。
resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id
cidr_block = var.subnet_cidr
availability_zone = var.az
map_public_ip_on_launch = false
tags = { Name = "${var.name_prefix}-private" }
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.name_prefix}-private" }
}
resource "aws_route_table_association" "private" {
subnet_id = aws_subnet.private.id
route_table_id = aws_route_table.private.id
}
# タスク用セキュリティグループ。分離はルーティングで担保するため egress は全許可でよい。
resource "aws_security_group" "task" {
name = "${var.name_prefix}-task"
description = "loreserver task; egress via endpoints only (no internet route)"
vpc_id = aws_vpc.main.id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# インターフェース型エンドポイント用セキュリティグループ。
resource "aws_security_group" "endpoint" {
name = "${var.name_prefix}-endpoint"
description = "Allow 443 from task SG to interface endpoints"
vpc_id = aws_vpc.main.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
security_groups = [aws_security_group.task.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# インターフェース型エンドポイント (トグルで個別に付け外し)
resource "aws_vpc_endpoint" "ecr_api" {
count = var.enable_ecr_api ? 1 : 0
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.ecr.api"
vpc_endpoint_type = "Interface"
subnet_ids = [aws_subnet.private.id]
security_group_ids = [aws_security_group.endpoint.id]
private_dns_enabled = true
}
resource "aws_vpc_endpoint" "ecr_dkr" {
count = var.enable_ecr_dkr ? 1 : 0
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.ecr.dkr"
vpc_endpoint_type = "Interface"
subnet_ids = [aws_subnet.private.id]
security_group_ids = [aws_security_group.endpoint.id]
private_dns_enabled = true
}
resource "aws_vpc_endpoint" "logs" {
count = var.enable_logs ? 1 : 0
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.logs"
vpc_endpoint_type = "Interface"
subnet_ids = [aws_subnet.private.id]
security_group_ids = [aws_security_group.endpoint.id]
private_dns_enabled = true
}
resource "aws_vpc_endpoint" "sts" {
count = var.enable_sts ? 1 : 0
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.sts"
vpc_endpoint_type = "Interface"
subnet_ids = [aws_subnet.private.id]
security_group_ids = [aws_security_group.endpoint.id]
private_dns_enabled = true
}
# ゲートウェイ型エンドポイント (無料、ルートテーブルへ関連付け)
resource "aws_vpc_endpoint" "s3" {
count = var.enable_s3 ? 1 : 0
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = [aws_route_table.private.id]
}
resource "aws_vpc_endpoint" "dynamodb" {
count = var.enable_dynamodb ? 1 : 0
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.dynamodb"
vpc_endpoint_type = "Gateway"
route_table_ids = [aws_route_table.private.id]
}
変数 (variables.tf)
variable "region" {
type = string
default = "ap-northeast-1"
}
variable "az" {
type = string
default = "ap-northeast-1a"
}
variable "name_prefix" {
type = string
default = "lorevpce"
}
variable "vpc_cidr" {
type = string
default = "10.20.0.0/16"
}
variable "subnet_cidr" {
type = string
default = "10.20.1.0/24"
}
variable "image_tag" {
type = string
default = "v1"
}
variable "task_cpu" {
type = number
default = 512
}
variable "task_memory" {
type = number
default = 1024
}
# VPC エンドポイントの個別トグル。ベースラインは全て true。
# 段階的な欠落検証では、確認したいエンドポイントだけ -var で false にする。
variable "enable_ecr_api" {
type = bool
default = true
}
variable "enable_ecr_dkr" {
type = bool
default = true
}
variable "enable_logs" {
type = bool
default = true
}
variable "enable_sts" {
type = bool
default = true
}
variable "enable_s3" {
type = bool
default = true
}
variable "enable_dynamodb" {
type = bool
default = true
}
ECS (ecs.tf)
クライアントを接続しないため、NLB もターゲットグループも紐付けません。プライベートサブネットに配置し、パブリック IP を割り当てません。
resource "aws_ecs_service" "lore" {
name = "${var.name_prefix}-server"
cluster = aws_ecs_cluster.lore.id
task_definition = aws_ecs_task_definition.lore.arn
desired_count = 1
launch_type = "FARGATE"
network_configuration {
subnets = [aws_subnet.private.id]
security_groups = [aws_security_group.task.id]
assign_public_ip = false
}
}
タスク定義は前回の記事と同じで、X86_64 の Fargate、ECR のオーバーレイイメージ、awslogs のログ設定です。タスクロールには S3 の権限に加え、起動時の存在確認に使う dynamodb:DescribeTable を含めます。
コンテナイメージ (Dockerfile と local.toml)
公式のビルド済みイメージに、AWS バックエンドを指す local.toml を焼き込みます。
FROM lore-server:base-amd64
COPY local.toml /etc/lore/config/local.toml
[immutable_store]
mode = "aws"
[mutable_store]
mode = "aws"
[lock_store]
mode = "aws"
[plugins.aws.immutable_store]
s3_bucket = "lorevpce-store-<アカウント ID>"
s3_region = "ap-northeast-1"
s3_force_path_style = false
dynamodb_fragments_table = "lorevpce-fragments"
dynamodb_metadata_table = "lorevpce-metadata"
dynamodb_region = "ap-northeast-1"
timeout_millis = 120000
[plugins.aws.mutable_store]
dynamodb_table = "lorevpce-mutable"
dynamodb_region = "ap-northeast-1"
timeout_millis = 120000
[plugins.aws.lock_store]
dynamodb_table = "lorevpce-locks"
dynamodb_region = "ap-northeast-1"
timeout_millis = 120000




