
Epic Games の Lore サーバーを AWS の Fargate + S3 + DynamoDB に Terraform でデプロイしてみた
はじめに
Epic Games のバージョン管理システム Lore は、サーバー (loreserver) をセルフホストできます。ストレージのバックエンドとして、ローカルディスクのほかに AWS の S3 と DynamoDB を選べます。本記事では、この loreserver を AWS 上にデプロイし、ローカルの lore クライアントから実際に使えるところまでを確認します。
構成のねらいは、できるだけマネージドサービスに任せることです。イミュータブルなデータを S3、可変な状態を DynamoDB に置けば、ストレージはサーバーレスになります。残る loreserver の実行を ECS Fargate に任せれば、EC2 を持たずに済みます。
loreserver は S3 と DynamoDB のバックエンドで問題なく動き、Fargate に載せてローカルから push できました。Lore 固有の依存は S3 と DynamoDB だけで、あとは Fargate 一般の構成です。ただし事前に作るべき DynamoDB テーブルのスキーマと、タスクロールに必要な権限にコツがあります。
Lore とは
Lore とは、Epic Games が 2026 年 6 月に公開したオープンソースのバージョン管理システムです。コードと大容量バイナリが混在するプロジェクト、特にゲームやエンタメ制作を主な対象としています。サーバーをセルフホストでき、ストレージのバックエンドに AWS の S3 と DynamoDB を選べます。
検証環境
- リージョン: ap-northeast-1 (東京)
- Terraform: 1.15
- Docker (buildx): イメージのビルドに使用
- 認証: aws-vault の profile を使用
対象読者
- Lore を自チームでセルフホストしたいクラウドエンジニア
- ゲーム開発やエンタメ制作で Lore の採用を検討していて、運用イメージをつかみたい方
参考
- Lore 公式リポジトリ (GitHub)
- Lore 公式ドキュメント
- Lore System Design (S3 / DynamoDB バックエンドの説明)
- ローカルサーバーのデプロイ (公式 how-to)
構成
全体の構成は次の通りです。クライアントは Network Load Balancer 経由で Fargate 上の loreserver に接続し、loreserver が S3 と DynamoDB を読み書きします。
- イミュータブルストアは S3、ミュータブルストアとロックストアは DynamoDB に置きます。どちらもサーバーレスで、容量を気にせず従量課金で使えます。
- loreserver の実行は ECS Fargate にします。EC2 を持たず、タスクの常駐やスケールを AWS に任せます。
- クライアントとの通信は gRPC over TCP にします。Lore は QUIC over UDP も使えますが、TCP だけで clone や push を完結できるため、ロードバランサを単純にできます。
- ロードバランサは Network Load Balancer の TCP リスナーにします。
前提となる Lore の仕様
デプロイの前に、loreserver の AWS バックエンドについて押さえておくべき点があります。
loreserver は起動時に、設定された S3 バケットと DynamoDB テーブルの存在を確認します。存在しないとエラーで停止します。つまり、バケットとテーブルは事前に作っておく必要があります。自動作成はされません。
DynamoDB のテーブルは 4 つ必要で、それぞれキースキーマが決まっています。属性型は B がバイナリ、S が文字列です。
- fragments: パーティションキー
hash(B) 、ソートキーrepository_context(B) - metadata: パーティションキー
hash(B) - mutable: パーティションキー
repository_id(B) 、ソートキーkey(B) - locks: パーティションキー
hash(B) 、ソートキーrepositoryBranch(B) 、および GSI 3 つ
locks テーブルの GSI は次の 3 つで、いずれも Projection は ALL です。
- owner-repo-branch: パーティションキー
ownerId(S) 、ソートキーrepositoryBranch(B) - repo-branch: パーティションキー
repository(B) 、ソートキーbranch(B) - repo-branch-description: パーティションキー
repositoryBranch(B) 、ソートキーdescription(S)
これらのスキーマは Lore のソースコード (lore-aws/src/store) から確認したものです。
手順 1: ストレージを Terraform で作る
まず S3 バケットと DynamoDB の 4 テーブルを作ります。ここが Lore 固有の部分です。DynamoDB は課金を抑えるためオンデマンド (PAY_PER_REQUEST) にします。
locks テーブルは GSI が参照する属性も attribute として定義する必要があります。Terraform では次のように書きます (抜粋) 。
resource "aws_dynamodb_table" "locks" {
name = "lorec-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "hash"
range_key = "repositoryBranch"
attribute { name = "hash" type = "B" }
attribute { name = "repositoryBranch" type = "B" }
attribute { name = "repository" type = "B" }
attribute { name = "branch" type = "B" }
attribute { name = "ownerId" type = "S" }
attribute { name = "description" type = "S" }
global_secondary_index {
name = "owner-repo-branch"
hash_key = "ownerId"
range_key = "repositoryBranch"
projection_type = "ALL"
}
# repo-branch, repo-branch-description も同様に定義する
}
fragments と metadata と mutable のテーブルは、前述のスキーマの通りシンプルに定義します。
手順 2: IAM タスクロール
loreserver が S3 と DynamoDB にアクセスするためのタスクロールを作ります。注意すべき点として、CRUD の権限 (GetItem や PutItem など) だけでは足りません。loreserver は起動時にテーブルの存在を DescribeTable で確認するため、dynamodb:DescribeTable が必要です。これが抜けているとコンテナが起動直後にクラッシュします (詳しくは後述) 。
タスクロールに与える DynamoDB のアクションは次のようにします。
actions = [
"dynamodb:DescribeTable",
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Query",
"dynamodb:BatchGetItem",
"dynamodb:BatchWriteItem",
"dynamodb:ConditionCheckItem",
]
対象リソースは 4 テーブルの ARN と、locks テーブルの index/* です。S3 は対象バケットに対して GetObject や PutObject、ListBucket を与えます。
手順 3: コンテナイメージのビルドと ECR への push
loreserver のコンテナイメージは、公式リポジトリの Dockerfile からビルドします。
1 つ目の注意点は CPU アーキテクチャです。Fargate では linux/amd64 のイメージを使います。Lore の arm64 イメージは Graviton3 の SVE 命令を前提としており、Apple Silicon や Fargate ARM64 (Graviton2) では動きません。Apple Silicon 上で amd64 をエミュレーションビルドすると、Rust のコンパイルに数分から十数分かかりますが、完了はします。
2 つ目の注意点は設定の渡し方です。公式イメージは /etc/lore/config から設定を読みます。ここに、AWS バックエンドを指す local.toml を焼き込んだオーバーレイイメージを作ります。
FROM lore-server:base-amd64
COPY local.toml /etc/lore/config/local.toml
local.toml は次のようにします。実 AWS を使うので endpoint は指定せず、s3_force_path_style は false にします。バケット名とテーブル名は手順 1 で作ったものに合わせます。
[immutable_store]
mode = "aws"
[mutable_store]
mode = "aws"
[lock_store]
mode = "aws"
[plugins.aws.immutable_store]
s3_bucket = "lorec-store-<アカウント ID>"
s3_region = "ap-northeast-1"
s3_force_path_style = false
dynamodb_fragments_table = "lorec-fragments"
dynamodb_metadata_table = "lorec-metadata"
dynamodb_region = "ap-northeast-1"
[plugins.aws.mutable_store]
dynamodb_table = "lorec-mutable"
dynamodb_region = "ap-northeast-1"
[plugins.aws.lock_store]
dynamodb_table = "lorec-locks"
dynamodb_region = "ap-northeast-1"
ビルドしたイメージを ECR に push します。
手順 4: ネットワークと Fargate
Network Load Balancer を作り、TCP リスナー 41337 を Fargate サービスのターゲットグループに向けます。ターゲットグループのヘルスチェックは、loreserver の HTTP エンドポイント 41339 の /health_check に向けます。
セキュリティグループは 2 段にします。NLB 用のセキュリティグループで自分の IP からの 41337 だけを許可し、タスク用のセキュリティグループで NLB のセキュリティグループからのみ受けます。これで、公開しつつアクセス元を自分に限定できます。
Fargate のタスク定義では、アーキテクチャを X86_64 に指定し、イメージは ECR のもの、ポートは 41337 と 41339、タスクロールは手順 2 のもの、ログは awslogs にします。サービスの希望タスク数は 1 とし、NLB のターゲットグループに紐付けます。


手順 5: デプロイ
Terraform を apply し、S3 と DynamoDB、ECR、NLB、Fargate を作ります。イメージを ECR に push した後、Fargate のサービスがタスクを起動し、NLB のターゲットが healthy になれば準備完了です。

つまずき: タスクが起動直後にクラッシュする
デプロイして最初に当たったのがこれでした。Fargate のタスクが起動直後に終了し、繰り返し再起動します。ECS サービスの実行中タスク数は 0 のまま、NLB のターゲットは initial から進みません。
ECS のイベントには詳細が出ません。原因はコンテナのログにあります。CloudWatch Logs のロググループ /ecs/lorec-server を見ると、次のエラーが出ていました。
Plugin 'aws' initialization failed: Failed to create DynamoDB client:
AccessDeniedException: User: .../lorec-task/... is not authorized to
perform: dynamodb:DescribeTable on resource: .../table/lorec-fragments
because no identity-based policy allows the dynamodb:DescribeTable action
loreserver は起動時に DescribeTable でテーブルの存在を確認します。タスクロールに CRUD の権限しか与えていなかったため、この確認で権限エラーになりクラッシュしていました。手順 2 の通り dynamodb:DescribeTable を足すと、正常に起動しました。
Fargate のタスクがすぐ終了するときは、ECS のイベントではなくコンテナの CloudWatch Logs を見ます。loreserver の AWS バックエンドは、CRUD に加えて起動時の存在確認に DescribeTable を要します。

疎通確認
タスクが起動し、NLB のターゲットが healthy になったら、ローカルの lore クライアントから接続します。NLB の DNS 名に対して grpc:// で接続します。
lore repository create grpc://<NLB の DNS>:41337/awsdemo
lore stage asset.bin
lore commit --identity <名前> "v1 on fargate"
lore push
lore clone grpc://<NLB の DNS>:41337/awsdemo ./cloned
create から push、clone まですべて成功し、clone したファイルの内容が元と一致しました。ローカルの lore クライアントが、NLB 経由で Fargate 上の loreserver に到達し、往復できています。
データが実際に AWS に入ったかについても確認します。小さなファイル 1 つを含むリポジトリで、次のようになりました。

S3 バケット: 11 オブジェクト。キーは BLAKE3 ハッシュで、コンテンツアドレス方式で格納されている

DynamoDB: fragments 11 件、metadata 11 件、mutable 5 件、locks 0 件
これはローカル環境で確認したときと同じ件数で、Fargate 上の loreserver が実 S3 と DynamoDB を正しく使っていることの裏づけになります。
まとめ
loreserver を AWS の Fargate + S3 + DynamoDB に Terraform でデプロイし、ローカルのクライアントから push できるところまで確認しました。
やってみて分かったのは、Lore 固有の依存は S3 と DynamoDB だけだということです。あとは Fargate に載せるための一般的な構成です。ストレージがサーバーレスなので、loreserver の実行を Fargate にすれば、状態を持たない実行環境になり、運用が楽になります。
一方でコツもありました。DynamoDB のテーブルは決まったスキーマで事前に作る必要があること、タスクロールに dynamodb:DescribeTable が要ること、Fargate では amd64 イメージが必須なことです。 特に DescribeTable の権限不足は、起動直後のクラッシュという分かりにくい形で現れます。
本記事の構成は、アクセス元を自分の IP に限定した検証用のものです。認証を有効にする、プライベートサブネットに置く、といった本番向けの作り込みは別途必要となることに注意してください。
付録: 実装の全文
本記事で使った Terraform とイメージ定義の全文です。allowed_cidr は自分のグローバル IP に、local.toml の s3_bucket は自分のアカウント ID に合わせて置き換えてください。
Terraform 一式
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.40"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = {
Project = "blog-lore-article-c"
ManagedBy = "terraform"
}
}
}
data "aws_caller_identity" "current" {}
variable "region" {
description = "デプロイ先リージョン"
type = string
default = "ap-northeast-1"
}
variable "name_prefix" {
description = "リソース名の接頭辞"
type = string
default = "lorec"
}
variable "allowed_cidr" {
description = "NLB へのアクセスを許可するソース CIDR (自分のグローバル IP)"
type = string
# 実行時に -var で上書きするか、ここを自分の IP に書き換える
default = "203.0.113.10/32"
}
variable "image_tag" {
description = "loreserver コンテナイメージのタグ"
type = string
default = "v1"
}
variable "task_cpu" {
description = "Fargate タスクの CPU ユニット"
type = number
default = 512
}
variable "task_memory" {
description = "Fargate タスクのメモリ (MiB)"
type = number
default = 1024
}
# イミュータブルなデータ用の S3 バケット
resource "aws_s3_bucket" "store" {
bucket = "${var.name_prefix}-store-${data.aws_caller_identity.current.account_id}"
force_destroy = true
}
resource "aws_s3_bucket_public_access_block" "store" {
bucket = aws_s3_bucket.store.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# fragments テーブル
resource "aws_dynamodb_table" "fragments" {
name = "${var.name_prefix}-fragments"
billing_mode = "PAY_PER_REQUEST"
hash_key = "hash"
range_key = "repository_context"
attribute {
name = "hash"
type = "B"
}
attribute {
name = "repository_context"
type = "B"
}
}
# metadata テーブル
resource "aws_dynamodb_table" "metadata" {
name = "${var.name_prefix}-metadata"
billing_mode = "PAY_PER_REQUEST"
hash_key = "hash"
attribute {
name = "hash"
type = "B"
}
}
# mutable テーブル
resource "aws_dynamodb_table" "mutable" {
name = "${var.name_prefix}-mutable"
billing_mode = "PAY_PER_REQUEST"
hash_key = "repository_id"
range_key = "key"
attribute {
name = "repository_id"
type = "B"
}
attribute {
name = "key"
type = "B"
}
}
# locks テーブル (GSI 3 個)
resource "aws_dynamodb_table" "locks" {
name = "${var.name_prefix}-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "hash"
range_key = "repositoryBranch"
attribute {
name = "hash"
type = "B"
}
attribute {
name = "repositoryBranch"
type = "B"
}
attribute {
name = "repository"
type = "B"
}
attribute {
name = "branch"
type = "B"
}
attribute {
name = "ownerId"
type = "S"
}
attribute {
name = "description"
type = "S"
}
global_secondary_index {
name = "owner-repo-branch"
hash_key = "ownerId"
range_key = "repositoryBranch"
projection_type = "ALL"
}
global_secondary_index {
name = "repo-branch"
hash_key = "repository"
range_key = "branch"
projection_type = "ALL"
}
global_secondary_index {
name = "repo-branch-description"
hash_key = "repositoryBranch"
range_key = "description"
projection_type = "ALL"
}
}
data "aws_iam_policy_document" "ecs_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
# タスク実行ロール: ECR からの pull と CloudWatch Logs への書き込み
resource "aws_iam_role" "execution" {
name = "${var.name_prefix}-exec"
assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}
resource "aws_iam_role_policy_attachment" "execution" {
role = aws_iam_role.execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
# タスクロール: loreserver が S3 と DynamoDB にアクセスするための権限
resource "aws_iam_role" "task" {
name = "${var.name_prefix}-task"
assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}
data "aws_iam_policy_document" "task" {
statement {
sid = "S3"
actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"]
resources = [
aws_s3_bucket.store.arn,
"${aws_s3_bucket.store.arn}/*",
]
}
statement {
sid = "DynamoDB"
actions = [
"dynamodb:DescribeTable",
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Query",
"dynamodb:BatchGetItem",
"dynamodb:BatchWriteItem",
"dynamodb:ConditionCheckItem",
]
resources = [
aws_dynamodb_table.fragments.arn,
aws_dynamodb_table.metadata.arn,
aws_dynamodb_table.mutable.arn,
aws_dynamodb_table.locks.arn,
"${aws_dynamodb_table.locks.arn}/index/*",
]
}
}
resource "aws_iam_role_policy" "task" {
name = "${var.name_prefix}-task-policy"
role = aws_iam_role.task.id
policy = data.aws_iam_policy_document.task.json
}
data "aws_vpc" "default" {
default = true
}
data "aws_subnets" "default" {
filter {
name = "vpc-id"
values = [data.aws_vpc.default.id]
}
}
# NLB 用セキュリティグループ: 自分の IP からの 41337 / 41339 のみ許可
resource "aws_security_group" "nlb" {
name = "${var.name_prefix}-nlb"
description = "Allow lore ports from the operator IP only"
vpc_id = data.aws_vpc.default.id
ingress {
description = "gRPC/QUIC"
from_port = 41337
to_port = 41337
protocol = "tcp"
cidr_blocks = [var.allowed_cidr]
}
ingress {
description = "HTTP health"
from_port = 41339
to_port = 41339
protocol = "tcp"
cidr_blocks = [var.allowed_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# Fargate タスク用セキュリティグループ: NLB からのみ受ける
resource "aws_security_group" "task" {
name = "${var.name_prefix}-task"
description = "Allow lore ports from the NLB security group"
vpc_id = data.aws_vpc.default.id
ingress {
description = "gRPC/QUIC from NLB"
from_port = 41337
to_port = 41337
protocol = "tcp"
security_groups = [aws_security_group.nlb.id]
}
ingress {
description = "HTTP health from NLB"
from_port = 41339
to_port = 41339
protocol = "tcp"
security_groups = [aws_security_group.nlb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_lb" "nlb" {
name = "${var.name_prefix}-nlb"
internal = false
load_balancer_type = "network"
security_groups = [aws_security_group.nlb.id]
subnets = data.aws_subnets.default.ids
}
# gRPC/TCP のターゲットグループ (Fargate awsvpc なので target_type = ip)
resource "aws_lb_target_group" "grpc" {
name = "${var.name_prefix}-grpc"
port = 41337
protocol = "TCP"
target_type = "ip"
vpc_id = data.aws_vpc.default.id
health_check {
protocol = "HTTP"
port = "41339"
path = "/health_check"
interval = 30
}
}
resource "aws_lb_listener" "grpc" {
load_balancer_arn = aws_lb.nlb.arn
port = 41337
protocol = "TCP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.grpc.arn
}
}
resource "aws_ecr_repository" "lore" {
name = "${var.name_prefix}-server"
image_tag_mutability = "MUTABLE"
force_delete = true
}
resource "aws_cloudwatch_log_group" "lore" {
name = "/ecs/${var.name_prefix}-server"
retention_in_days = 7
}
resource "aws_ecs_cluster" "lore" {
name = "${var.name_prefix}-cluster"
}
resource "aws_ecs_task_definition" "lore" {
family = "${var.name_prefix}-server"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = var.task_cpu
memory = var.task_memory
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.task.arn
runtime_platform {
operating_system_family = "LINUX"
cpu_architecture = "X86_64"
}
container_definitions = jsonencode([
{
name = "loreserver"
image = "${aws_ecr_repository.lore.repository_url}:${var.image_tag}"
essential = true
portMappings = [
{ containerPort = 41337, protocol = "tcp" },
{ containerPort = 41339, protocol = "tcp" },
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.lore.name
"awslogs-region" = var.region
"awslogs-stream-prefix" = "loreserver"
}
}
}
])
}
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 = data.aws_subnets.default.ids
security_groups = [aws_security_group.task.id]
assign_public_ip = true
}
load_balancer {
target_group_arn = aws_lb_target_group.grpc.arn
container_name = "loreserver"
container_port = 41337
}
depends_on = [aws_lb_listener.grpc]
}
output "nlb_dns" {
description = "NLB の DNS 名。クライアントは grpc://<この値>:41337 で接続する"
value = aws_lb.nlb.dns_name
}
output "ecr_repository_url" {
description = "loreserver イメージの push 先"
value = aws_ecr_repository.lore.repository_url
}
output "s3_bucket" {
value = aws_s3_bucket.store.bucket
}
output "dynamodb_tables" {
value = {
fragments = aws_dynamodb_table.fragments.name
metadata = aws_dynamodb_table.metadata.name
mutable = aws_dynamodb_table.mutable.name
locks = aws_dynamodb_table.locks.name
}
}
output "ecs_cluster" {
value = aws_ecs_cluster.lore.name
}
output "ecs_service" {
value = aws_ecs_service.lore.name
}
コンテナイメージ (Dockerfile と local.toml)
公式リポジトリの lore-server/Dockerfile から lore-server:base-amd64 をビルドしておき、それをベースに local.toml を焼き込みます。
# 公式ビルド済みイメージ (lore-server:base-amd64) に、AWS バックエンド用の
# local.toml を焼き込むオーバーレイ。local.toml は /etc/lore/config に置かれ、
# LORE_ENV=docker の docker.toml より後に読まれて mode を aws に上書きする。
FROM lore-server:base-amd64
COPY local.toml /etc/lore/config/local.toml
s3_bucket は Terraform の出力 s3_bucket と同じ値にします。
[immutable_store]
mode = "aws"
[mutable_store]
mode = "aws"
[lock_store]
mode = "aws"
[plugins.aws.immutable_store]
s3_bucket = "lorec-store-<アカウント ID>"
s3_region = "ap-northeast-1"
s3_force_path_style = false
dynamodb_fragments_table = "lorec-fragments"
dynamodb_metadata_table = "lorec-metadata"
dynamodb_region = "ap-northeast-1"
timeout_millis = 120000
[plugins.aws.mutable_store]
dynamodb_table = "lorec-mutable"
dynamodb_region = "ap-northeast-1"
timeout_millis = 120000
[plugins.aws.lock_store]
dynamodb_table = "lorec-locks"
dynamodb_region = "ap-northeast-1"
timeout_millis = 120000





