S3Filesを初めて使ってみたのでブログに残します。
S3Filesとは
2026年4月ごろに登場したS3をファイルシステムとしてマウントするための機能です。
今までS3をEC2などからマウントして使いたい場合はMountpoint for Amazon S3というものを使っていたと思うのですが、こちらはマウントした後にアクセスするとS3のAPIに変換されてバケット内のオブジェクトを取得するような動きになっているのでEFSなんかと比べるとパフォーマンスは低下します。
S3Filesの場合、ファイルやディレクトリを操作すると、メタデータなどが高性能ストレージというところにキャッシュされるらしく素早く操作することが可能になっているようです。(裏側はEFS基盤みたいです)
試してみた
構成
ざっくりこんな感じで設定してみます。
ECSはApacheのコンテナを一つ用意してドキュメントルートでマウントしてみます。
S3にはindex.htmlを配置してブラウザからアクセスしたときにWebサイトが表示できるか確認します。

設定
設定は以下のTerraformを使用します。
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.58.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
description = "AWSリージョン"
type = string
default = "ap-northeast-1"
}
variable "prefix" {
description = "リソース名のプレフィックス"
type = string
default = "s3files-test"
}
variable "s3_bucket_name" {
description = "S3 Files用バケット名"
type = string
}
# -------------------------------------------------------------------
# Data Sources
# -------------------------------------------------------------------
data "aws_caller_identity" "current" {}
# -------------------------------------------------------------------
# VPC
# -------------------------------------------------------------------
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "${var.prefix}-vpc" }
}
resource "aws_subnet" "public_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = { Name = "${var.prefix}-public-a" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.prefix}-igw" }
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = { Name = "${var.prefix}-public-rt" }
}
resource "aws_route_table_association" "public_a" {
subnet_id = aws_subnet.public_a.id
route_table_id = aws_route_table.public.id
}
# -------------------------------------------------------------------
# S3バケット
# -------------------------------------------------------------------
resource "aws_s3_bucket" "main" {
bucket = var.s3_bucket_name
tags = { Name = var.s3_bucket_name }
}
resource "aws_s3_bucket_public_access_block" "main" {
bucket = aws_s3_bucket.main.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "main" {
bucket = aws_s3_bucket.main.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "main" {
bucket = aws_s3_bucket.main.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# 動作確認用サンプルファイル
resource "aws_s3_object" "sample" {
bucket = aws_s3_bucket.main.id
key = "index.html"
content = "<html><body><h1>Hello from S3 Files!</h1></body></html>"
content_type = "text/html"
depends_on = [aws_s3_bucket_versioning.main]
}
# -------------------------------------------------------------------
# IAM: S3 Files ファイルシステム用ロール
# -------------------------------------------------------------------
resource "aws_iam_role" "s3files_filesystem" {
name = "${var.prefix}-s3files-fs-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowS3FilesAssumeRole"
Effect = "Allow"
Principal = { Service = "elasticfilesystem.amazonaws.com" }
Action = "sts:AssumeRole"
Condition = {
StringEquals = {
"aws:SourceAccount" = data.aws_caller_identity.current.account_id
}
ArnLike = {
"aws:SourceArn" = "arn:aws:s3files:${var.aws_region}:${data.aws_caller_identity.current.account_id}:file-system/*"
}
}
}]
})
tags = { Name = "${var.prefix}-s3files-fs-role" }
}
resource "aws_iam_role_policy" "s3files_filesystem" {
name = "${var.prefix}-s3files-fs-policy"
role = aws_iam_role.s3files_filesystem.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "S3BucketPermissions"
Effect = "Allow"
Action = ["s3:ListBucket", "s3:ListBucketVersions"]
Resource = aws_s3_bucket.main.arn
Condition = {
StringEquals = { "aws:ResourceAccount" = data.aws_caller_identity.current.account_id }
}
},
{
Sid = "S3ObjectPermissions"
Effect = "Allow"
Action = ["s3:AbortMultipartUpload", "s3:DeleteObject*", "s3:GetObject*", "s3:List*", "s3:PutObject*"]
Resource = "${aws_s3_bucket.main.arn}/*"
Condition = {
StringEquals = { "aws:ResourceAccount" = data.aws_caller_identity.current.account_id }
}
},
{
Sid = "EventBridgeManage"
Effect = "Allow"
Action = ["events:DeleteRule", "events:DisableRule", "events:EnableRule", "events:PutRule", "events:PutTargets", "events:RemoveTargets"]
Resource = ["arn:aws:events:*:*:rule/DO-NOT-DELETE-S3-Files*"]
Condition = { StringEquals = { "events:ManagedBy" = "elasticfilesystem.amazonaws.com" } }
},
{
Sid = "EventBridgeRead"
Effect = "Allow"
Action = ["events:DescribeRule", "events:ListRuleNamesByTarget", "events:ListRules", "events:ListTargetsByRule"]
Resource = ["arn:aws:events:*:*:rule/*"]
}
]
})
}
# -------------------------------------------------------------------
# IAM: ECS タスク実行ロール
# -------------------------------------------------------------------
resource "aws_iam_role" "ecs_task_execution" {
name = "${var.prefix}-ecs-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
tags = { Name = "${var.prefix}-ecs-execution-role" }
}
resource "aws_iam_role_policy_attachment" "ecs_task_execution" {
role = aws_iam_role.ecs_task_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
# -------------------------------------------------------------------
# IAM: ECS タスクロール
# -------------------------------------------------------------------
resource "aws_iam_role" "ecs_task" {
name = "${var.prefix}-ecs-task-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
tags = { Name = "${var.prefix}-ecs-task-role" }
}
resource "aws_iam_role_policy_attachment" "ecs_task_s3files" {
role = aws_iam_role.ecs_task.name
policy_arn = "arn:aws:iam::aws:policy/AmazonS3FilesClientFullAccess"
}
# ECS Exec に必要な SSM / ログ権限
resource "aws_iam_role_policy" "ecs_task_exec_command" {
name = "${var.prefix}-ecs-task-exec-command"
role = aws_iam_role.ecs_task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ECSExec"
Effect = "Allow"
Action = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
]
Resource = "*"
},
{
Sid = "ECSExecLogs"
Effect = "Allow"
Action = [
"logs:CreateLogStream",
"logs:DescribeLogGroups",
"logs:DescribeLogStreams",
"logs:PutLogEvents"
]
Resource = "*"
}
]
})
}
resource "aws_iam_role_policy" "ecs_task_s3_direct" {
name = "${var.prefix}-ecs-task-s3-direct"
role = aws_iam_role.ecs_task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "S3ObjectReadAccess"
Effect = "Allow"
Action = ["s3:GetObject", "s3:GetObjectVersion"]
Resource = "${aws_s3_bucket.main.arn}/*"
},
{
Sid = "S3BucketListAccess"
Effect = "Allow"
Action = "s3:ListBucket"
Resource = aws_s3_bucket.main.arn
}
]
})
}
# -------------------------------------------------------------------
# セキュリティグループ
# -------------------------------------------------------------------
resource "aws_security_group" "s3files_mount_target" {
name = "${var.prefix}-s3files-mt-sg"
description = "S3 Files mount target security group"
vpc_id = aws_vpc.main.id
tags = { Name = "${var.prefix}-s3files-mt-sg" }
}
resource "aws_security_group" "ecs_task" {
name = "${var.prefix}-ecs-task-sg"
description = "ECS task security group for Apache + S3 Files"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "S3 Files mount target (TCP 2049)"
from_port = 2049
to_port = 2049
protocol = "tcp"
security_groups = [aws_security_group.s3files_mount_target.id]
}
egress {
description = "HTTPS outbound for ECR / CloudWatch / S3 API"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${var.prefix}-ecs-task-sg" }
}
resource "aws_security_group_rule" "mt_inbound_from_ecs" {
type = "ingress"
description = "NFS from ECS task"
from_port = 2049
to_port = 2049
protocol = "tcp"
security_group_id = aws_security_group.s3files_mount_target.id
source_security_group_id = aws_security_group.ecs_task.id
}
# -------------------------------------------------------------------
# S3 Files
# -------------------------------------------------------------------
resource "aws_s3files_file_system" "main" {
bucket = aws_s3_bucket.main.arn
role_arn = aws_iam_role.s3files_filesystem.arn
depends_on = [
aws_s3_bucket_versioning.main,
aws_iam_role_policy.s3files_filesystem,
]
tags = { Name = "${var.prefix}-s3files-fs" }
}
resource "aws_s3files_mount_target" "public_a" {
file_system_id = aws_s3files_file_system.main.id
subnet_id = aws_subnet.public_a.id
security_groups = [aws_security_group.s3files_mount_target.id]
}
# -------------------------------------------------------------------
# ECS
# -------------------------------------------------------------------
resource "aws_cloudwatch_log_group" "ecs" {
name = "/ecs/${var.prefix}"
retention_in_days = 7
tags = { Name = "${var.prefix}-logs" }
}
resource "aws_ecs_cluster" "main" {
name = "${var.prefix}-cluster"
setting {
name = "containerInsights"
value = "disabled"
}
tags = { Name = "${var.prefix}-cluster" }
}
resource "aws_ecs_task_definition" "apache" {
family = "${var.prefix}-task"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_task_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
volume {
name = "s3files-volume"
s3files_volume_configuration {
file_system_arn = aws_s3files_file_system.main.arn
}
}
container_definitions = jsonencode([
{
name = "apache"
image = "httpd:2.4"
essential = true
portMappings = [{
containerPort = 80
protocol = "tcp"
}]
mountPoints = [{
sourceVolume = "s3files-volume"
containerPath = "/usr/local/apache2/htdocs"
readOnly = false
}]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.ecs.name
"awslogs-region" = var.aws_region
"awslogs-stream-prefix" = "apache"
}
}
}
])
tags = { Name = "${var.prefix}-task" }
}
resource "aws_ecs_service" "apache" {
name = "${var.prefix}-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.apache.arn
desired_count = 1
launch_type = "FARGATE"
network_configuration {
subnets = [aws_subnet.public_a.id]
security_groups = [aws_security_group.ecs_task.id]
assign_public_ip = true
}
force_new_deployment = true
enable_execute_command = true
depends_on = [
aws_s3files_mount_target.public_a,
]
tags = { Name = "${var.prefix}-service" }
}
上記のコードを使用すると、今回使用するECS、S3周りが全て作成されます。
デプロイ
以下のコマンドを実行してデプロイします。
terraform init
terraform plan # planは実行してもしなくても大丈夫です
terraofmr apply
applyを実行するとS3バケット名を聞かれるので任意のバケット名を入力してデプロイしてください。
コードの説明
今回メインになるのは以下の部分化と思います。
ここら辺の説明は全て以下のドキュメントに記載されていたので、こちらを読んでから作成すれば詰まる部分はなかったです。
S3filseのマウントポイント
この部分でS3とECSの間にあるマウントポイントを作成しています。
マウントポイントではS3バケットにアクセスするためのIAMポリシーが必要となります。
また、ファイル同期のためにEventBridgeを使うのでその権限も与えています。
# -------------------------------------------------------------------
# IAM: S3 Files ファイルシステム用ロール
# -------------------------------------------------------------------
resource "aws_iam_role" "s3files_filesystem" {
name = "${var.prefix}-s3files-fs-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowS3FilesAssumeRole"
Effect = "Allow"
Principal = { Service = "elasticfilesystem.amazonaws.com" }
Action = "sts:AssumeRole"
Condition = {
StringEquals = {
"aws:SourceAccount" = data.aws_caller_identity.current.account_id
}
ArnLike = {
"aws:SourceArn" = "arn:aws:s3files:${var.aws_region}:${data.aws_caller_identity.current.account_id}:file-system/*"
}
}
}]
})
tags = { Name = "${var.prefix}-s3files-fs-role" }
}
resource "aws_iam_role_policy" "s3files_filesystem" {
name = "${var.prefix}-s3files-fs-policy"
role = aws_iam_role.s3files_filesystem.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "S3BucketPermissions"
Effect = "Allow"
Action = ["s3:ListBucket", "s3:ListBucketVersions"]
Resource = aws_s3_bucket.main.arn
Condition = {
StringEquals = { "aws:ResourceAccount" = data.aws_caller_identity.current.account_id }
}
},
{
Sid = "S3ObjectPermissions"
Effect = "Allow"
Action = ["s3:AbortMultipartUpload", "s3:DeleteObject*", "s3:GetObject*", "s3:List*", "s3:PutObject*"]
Resource = "${aws_s3_bucket.main.arn}/*"
Condition = {
StringEquals = { "aws:ResourceAccount" = data.aws_caller_identity.current.account_id }
}
},
{
Sid = "EventBridgeManage"
Effect = "Allow"
Action = ["events:DeleteRule", "events:DisableRule", "events:EnableRule", "events:PutRule", "events:PutTargets", "events:RemoveTargets"]
Resource = ["arn:aws:events:*:*:rule/DO-NOT-DELETE-S3-Files*"]
Condition = { StringEquals = { "events:ManagedBy" = "elasticfilesystem.amazonaws.com" } }
},
{
Sid = "EventBridgeRead"
Effect = "Allow"
Action = ["events:DescribeRule", "events:ListRuleNamesByTarget", "events:ListRules", "events:ListTargetsByRule"]
Resource = ["arn:aws:events:*:*:rule/*"]
}
]
})
}
...中略...
# -------------------------------------------------------------------
# S3 Files
# -------------------------------------------------------------------
resource "aws_s3files_file_system" "main" {
bucket = aws_s3_bucket.main.arn
role_arn = aws_iam_role.s3files_filesystem.arn
depends_on = [
aws_s3_bucket_versioning.main,
aws_iam_role_policy.s3files_filesystem,
]
tags = { Name = "${var.prefix}-s3files-fs" }
}
resource "aws_s3files_mount_target" "public_a" {
file_system_id = aws_s3files_file_system.main.id
subnet_id = aws_subnet.public_a.id
security_groups = [aws_security_group.s3files_mount_target.id]
}
ECSの設定
ECS側ではタスク定義でマウントポイントを参照する設定を入れています。
また、S3を直接参照する権限も必要になるため、タスクロールにS3アクセス用のIAMも付与しています。
resource "aws_iam_role" "ecs_task" {
name = "${var.prefix}-ecs-task-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
tags = { Name = "${var.prefix}-ecs-task-role" }
}
resource "aws_iam_role_policy_attachment" "ecs_task_s3files" {
role = aws_iam_role.ecs_task.name
policy_arn = "arn:aws:iam::aws:policy/AmazonS3FilesClientFullAccess"
}
# ECS Exec に必要な SSM / ログ権限
resource "aws_iam_role_policy" "ecs_task_exec_command" {
name = "${var.prefix}-ecs-task-exec-command"
role = aws_iam_role.ecs_task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ECSExec"
Effect = "Allow"
Action = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
]
Resource = "*"
},
{
Sid = "ECSExecLogs"
Effect = "Allow"
Action = [
"logs:CreateLogStream",
"logs:DescribeLogGroups",
"logs:DescribeLogStreams",
"logs:PutLogEvents"
]
Resource = "*"
}
]
})
}
resource "aws_iam_role_policy" "ecs_task_s3_direct" {
name = "${var.prefix}-ecs-task-s3-direct"
role = aws_iam_role.ecs_task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "S3ObjectReadAccess"
Effect = "Allow"
Action = ["s3:GetObject", "s3:GetObjectVersion"]
Resource = "${aws_s3_bucket.main.arn}/*"
},
{
Sid = "S3BucketListAccess"
Effect = "Allow"
Action = "s3:ListBucket"
Resource = aws_s3_bucket.main.arn
}
]
})
}
...中略...
# -------------------------------------------------------------------
# ECS
# -------------------------------------------------------------------
resource "aws_cloudwatch_log_group" "ecs" {
name = "/ecs/${var.prefix}"
retention_in_days = 7
tags = { Name = "${var.prefix}-logs" }
}
resource "aws_ecs_cluster" "main" {
name = "${var.prefix}-cluster"
setting {
name = "containerInsights"
value = "disabled"
}
tags = { Name = "${var.prefix}-cluster" }
}
resource "aws_ecs_task_definition" "apache" {
family = "${var.prefix}-task"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_task_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
volume {
name = "s3files-volume"
s3files_volume_configuration {
file_system_arn = aws_s3files_file_system.main.arn
}
}
container_definitions = jsonencode([
{
name = "apache"
image = "httpd:2.4"
essential = true
portMappings = [{
containerPort = 80
protocol = "tcp"
}]
mountPoints = [{
sourceVolume = "s3files-volume"
containerPath = "/usr/local/apache2/htdocs"
readOnly = false
}]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.ecs.name
"awslogs-region" = var.aws_region
"awslogs-stream-prefix" = "apache"
}
}
}
])
tags = { Name = "${var.prefix}-task" }
}
セキュリティグループ
S3Filesの使用するセキュリティグループでは2049番ポートを使用するのでそちらをインバウンドルールで許可しています。
resource "aws_security_group" "s3files_mount_target" {
name = "${var.prefix}-s3files-mt-sg"
description = "S3 Files mount target security group"
vpc_id = aws_vpc.main.id
tags = { Name = "${var.prefix}-s3files-mt-sg" }
}
resource "aws_security_group" "ecs_task" {
name = "${var.prefix}-ecs-task-sg"
description = "ECS task security group for Apache + S3 Files"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "S3 Files mount target (TCP 2049)"
from_port = 2049
to_port = 2049
protocol = "tcp"
security_groups = [aws_security_group.s3files_mount_target.id]
}
egress {
description = "HTTPS outbound for ECR / CloudWatch / S3 API"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${var.prefix}-ecs-task-sg" }
}
resource "aws_security_group_rule" "mt_inbound_from_ecs" {
type = "ingress"
description = "NFS from ECS task"
from_port = 2049
to_port = 2049
protocol = "tcp"
security_group_id = aws_security_group.s3files_mount_target.id
source_security_group_id = aws_security_group.ecs_task.id
}
ファイルの読み書きをしてみる
デプロイが完了したらブラウザからアクセスして画面が表示されるか確認してみます。
ECSタスクの画面からパブリックIPアドレスを確認してブラウザからアクセスしてみてください。

以下のような画面が表示されれば成功です。
初回アクセス時はやはり少し時間がかかるようです。

書き込みはECSタスクにECS Execで接続して確認してみます。
接続後に以下のコマンドでindex.htmlに書き込みます。
echo "<html><body><h1>hoge test</h1></body></html>" > /usr/local/apache2/htdocs/index.html
再度ブラウザからアクセスしてみると表示が変わっていることが確認できます。

さいごに
初回アクセス時はキャッシュ層にデータが無いので表示に少し時間がかかるのがわかりました。
アプリケーションでNFSしか使えないけどS3のデータが使いたい用途であれば十分な内容なのかなと思います。