Blog

Amazon S3 Files を Lambda からマウントするTerraform で S3 Files を Lambda から使用してみました

岡本 渉 技術ブログ

私が住んでいる新潟でも桜が咲いたと思ったら、もうだいぶ散りかけています。この時期は花粉症持ちには外出が辛いので、ここ数年花見で一杯というわけには行かないのが辛いところですね。

さて、AWS から新しいサービス Amazon S3 Files が公開されました。
S3 Files を使うと、S3 バケットを NFS プロトコル経由でファイルシステムとしてマウントできます。対応するコンピューティングは EC2・Lambda・ECS・EKS の4つです。

通常の S3 API を使う場合は PUT/GET のたびにコードを書く必要がありますが、S3 Files を使うと Lambda 上のコードから /mnt/... にある普通のファイルとして読み書きできます。この記事では Terraform でリソースを構築し、Lambda 関数からファイルを書き出すところまでの手順をまとめます。

S3 Files の仕組み

S3 Files は内部的に Amazon EFS の基盤を使いつつ、データは S3 に保存し続けます。ファイルシステムへの書き込みは自動で S3 バケットに同期されるため、「ファイル操作をすれば S3 に入る」という感覚で使えます。

パフォーマンス面では、128KB 未満の小さなファイルは高速ストレージ層にキャッシュされます。128KB 以上のファイルは S3 から直接ストリーミングされる設計で、Lambda の場合はメモリを 512MB 以上にする必要があります。アクセスのないデータはデフォルト 30 日でキャッシュから落ちますが、S3 上のデータは残ります。

S3 バケットはバージョニングが必須

S3 Files はファイルシステムと S3 の双方向同期にバージョン ID を利用しています。そのため、バッキングとなる S3 バケットはバージョニングを有効にしていないとファイルシステムを作成できません。暗号化も SSE-S3(AES256)または SSE-KMS のどちらかが必要です。
resource "aws_s3_bucket" "demo" {
  bucket        = local.bucket_name
  force_destroy = true
}

resource "aws_s3_bucket_versioning" "demo" {
  bucket = aws_s3_bucket.demo.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "demo" {
  bucket = aws_s3_bucket.demo.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}
ファイルシステムは、バージョニングと暗号化の両方が設定されたあとに作成します。depends_on で順序を明示しておくのが安全です。
resource "aws_s3files_file_system" "demo" {
  bucket   = aws_s3_bucket.demo.arn
  role_arn = aws_iam_role.s3files_sync.arn

  depends_on = [
    aws_s3_bucket_versioning.demo,
    aws_s3_bucket_server_side_encryption_configuration.demo,
  ]
}

IAM に与える権限

S3 Files では用途の異なる2種類の IAM ロールが必要です。

ファイルシステムの同期ロール

S3 Files が S3 バケットを読み書きするためのロールです。信頼ポリシーのプリンシパルは elasticfilesystem.amazonaws.com で、aws:SourceAccount と aws:SourceArn の条件を付けることで意図しないリソースからの AssumeRole を防ぎます(S3 Files は EFS 基盤のため、プリンシパルは EFS のサービス名になっています)。
data "aws_iam_policy_document" "s3files_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["elasticfilesystem.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "aws:SourceAccount"
      values   = [data.aws_caller_identity.current.account_id]
    }
    condition {
      test     = "ArnLike"
      variable = "aws:SourceArn"
      values   = [
        "arn:aws:s3files:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:file-system/*"
      ]
    }
  }
}
このロールにアタッチするポリシーには、S3 バケットへの List と Object CRUD に加えて、S3 Files が内部で使う EventBridge ルールの管理権限が必要です。EventBridge の書き込み権限は DO-NOT-DELETE-S3-Files* というルール名プレフィックスと ManagedBy 条件で絞っています。
data "aws_iam_policy_document" "s3files_sync" {
  statement {
    sid     = "S3BucketPermissions"
    actions = ["s3:ListBucket", "s3:ListBucketVersions"]
    resources = [aws_s3_bucket.demo.arn]
    condition {
      test     = "StringEquals"
      variable = "aws:ResourceAccount"
      values   = [data.aws_caller_identity.current.account_id]
    }
  }

  statement {
    sid = "S3ObjectPermissions"
    actions = [
      "s3:AbortMultipartUpload", "s3:DeleteObject*",
      "s3:GetObject*", "s3:List*", "s3:PutObject*",
    ]
    resources = ["${aws_s3_bucket.demo.arn}/*"]
    condition {
      test     = "StringEquals"
      variable = "aws:ResourceAccount"
      values   = [data.aws_caller_identity.current.account_id]
    }
  }

  statement {
    sid = "EventBridgeManage"
    actions = [
      "events:DeleteRule", "events:DisableRule", "events:EnableRule",
      "events:PutRule", "events:PutTargets", "events:RemoveTargets",
    ]
    resources = ["arn:aws:events:*:*:rule/DO-NOT-DELETE-S3-Files*"]
    condition {
      test     = "StringEquals"
      variable = "events:ManagedBy"
      values   = ["elasticfilesystem.amazonaws.com"]
    }
  }
}

Lambda の実行ロール

Lambda がマウントするために必要な権限は s3files:ClientMount と s3files:ClientWrite の2つです。読み取り専用にする場合は ClientWrite を外します。VPC 内で動かすために AWSLambdaVPCAccessExecutionRole マネージドポリシーのアタッチも必要です。
data "aws_iam_policy_document" "lambda_s3files" {
  statement {
    sid       = "S3FilesMount"
    actions   = ["s3files:ClientMount", "s3files:ClientWrite"]
    resources = ["*"]
  }
}

resource "aws_iam_role_policy_attachment" "lambda_vpc" {
  role       = aws_iam_role.lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
}

Lambda でマウントしてファイルを書き出す

マウントターゲットとアクセスポイント

Lambda からマウントするには、Lambda が動くサブネットと同じ AZ ごとにマウントターゲットを作る必要があります。for_each でサブネットをループします。
resource "aws_s3files_mount_target" "demo" {
  for_each = aws_subnet.lambda

  file_system_id  = aws_s3files_file_system.demo.id
  subnet_id       = each.value.id
  security_groups = [aws_security_group.mount_target.id]
  ip_address_type = "IPV4_ONLY"
}
アクセスポイントはマウント時のルートディレクトリと POSIX ユーザーを定義します。ここでは UID/GID を 1000、ルートディレクトリを /lambda に設定しています。Lambda が /mnt/s3files に書いたファイルは、S3 では lambda/ プレフィックス以下に保存されます。

resource "aws_s3files_access_point" "lambda" {
  file_system_id = aws_s3files_file_system.demo.id

  posix_user {
    uid = 1000
    gid = 1000
  }

  root_directory {
    path = "/lambda"
    creation_permissions {
      owner_uid   = 1000
      owner_gid   = 1000
      permissions = "755"
    }
  }
}

Lambda 関数の設定

file_system_config ブロックでアクセスポイントの ARN とマウントパスを指定します。マウントパスは /mnt/ 始まりである必要があります。

resource "aws_lambda_function" "demo" {
  function_name = "${var.name_prefix}-writer"
  role          = aws_iam_role.lambda.arn
  handler       = "app.handler"
  runtime       = "nodejs24.x"
  timeout       = 30
  memory_size   = 512

  vpc_config {
    subnet_ids         = [for subnet in aws_subnet.lambda : subnet.id]
    security_group_ids = [aws_security_group.lambda.id]
  }

  file_system_config {
    arn              = aws_s3files_access_point.lambda.arn
    local_mount_path = "/mnt/s3files"
  }

  depends_on = [
    aws_s3files_mount_target.demo,
    aws_iam_role_policy.lambda_s3files,
    aws_iam_role_policy_attachment.lambda_vpc,
  ]
}

Lambda 関数コード(Node.js)

マウント後は通常のファイル操作でそのまま書き込めます。
import { mkdir, readdir, writeFile } from "node:fs/promises";
import path from "node:path";

export const handler = async () => {
  const mountPath = process.env.DEMO_MOUNT_PATH ?? "/mnt/s3files";
  const targetDir = path.join(mountPath, "demo");

  await mkdir(targetDir, { recursive: true });

  const timestamp = new Date().toISOString()
    .replace(/[-:]/g, "")
    .replace(/\.\d{3}Z$/, "Z");
  const filename = `created-by-lambda-${timestamp}.txt`;

  await writeFile(
    path.join(targetDir, filename),
    `This file was created through an Amazon S3 Files mount.\ntimestamp=${timestamp}\n`,
    "utf8"
  );

  const files = (await readdir(targetDir, { withFileTypes: true }))
    .filter(e => e.isFile())
    .map(e => e.name);

  return {
    statusCode: 200,
    body: JSON.stringify({
      writtenFile: path.join(targetDir, filename),
      expectedS3Key: `${process.env.DEMO_PREFIX}/${filename}`,
      visibleFilesInMount: files,
      syncNote: "S3 への反映は最大 60 秒程度かかります",
    }),
  };
};
ファイルシステムへの書き込みは S3 に自動同期されますが、S3 API から見えるようになるまで最大 60 秒程度かかります。Lambda の呼び出し直後に aws s3 ls をしてもまだ表示されないことがあるのはそのためです。

Lambda は VPC に置く必要がある

S3 Files のマウントは VPC 内の NFS(TCP 2049)で行われます。Lambda をデフォルトの VPC なし設定で動かすとマウントターゲットに到達できないため、VPC への接続が必須です。
Lambda とマウントターゲット間で NFS を許可するセキュリティグループが必要になります。
# Lambda → マウントターゲットへの送信を許可
resource "aws_vpc_security_group_egress_rule" "lambda_to_mount_target_nfs" {
  security_group_id            = aws_security_group.lambda.id
  referenced_security_group_id = aws_security_group.mount_target.id
  ip_protocol                  = "tcp"
  from_port                    = 2049
  to_port                      = 2049
  description                  = "Allow Lambda to reach S3 Files mount targets over NFS."
}

# マウントターゲット側で Lambda からの受信を許可
resource "aws_vpc_security_group_ingress_rule" "mount_target_from_lambda_nfs" {
  security_group_id            = aws_security_group.mount_target.id
  referenced_security_group_id = aws_security_group.lambda.id
  ip_protocol                  = "tcp"
  from_port                    = 2049
  to_port                      = 2049
  description                  = "Allow Lambda to mount S3 Files over NFS."
}
注意が必要なのは AZ の対応です。Lambda が複数の AZ にまたがる場合、対応するマウントターゲットのない AZ にサブネットがあるとマウントに失敗します。Lambda を配置する AZ すべてにマウントターゲットを用意してください。

まとめ

S3 Files を使えば、S3 バケットをファイルシステムとして扱えます。Lambda から使う場合の要点をまとめると、S3 バケットのバージョニング有効化、同期用ロールと Lambda 実行ロールの2つの IAM ロール、VPC への配置と各 AZ へのマウントターゲット準備、そして file_system_config への access point ARN 指定です。ファイルシステムへの書き込みは S3 に自動同期されますが、最大 60 秒程度のラグがある点は設計時に考慮しておくとよいでしょう。

コード全体は GitHub で公開していますので、こちらもご参照ください。
現場からは、以上です。