VPCゲートウェイエンドポイントでS3への通信をオフロードする
Posted On 2025-01-25
AWSのゲートウェイ型VPCエンドポイントを利用すれば、NATを通さずに無料でS3への通信を処理できる。Terraformでの設定例やテスト結果を通して、大容量ファイル送信時のコスト削減とプライベート環境の実現手順を解説する。
目次
はじめに
- VPCエンドポイントには、インターフェイス型とゲートウェイ型がある
- ゲートウェイ型は無料で利用でき、DynamoDBとS3の通信をオフロードできる
- よくある活用法は、「S3の通信をNATゲートウェイ通してしまうと高いので、VPCエンドポイントを通しましょう」という場合
- あるいは、プライベートサブネットをNATを使わずに本当にプライベートにしたいが、S3にアクセスするためにVPCエンドポイントをデプロイするという場合
概念
AWSのページから図を参照
Terraformでの実装
S3用のゲートウェイ型のVPCエンドポイントはこれだけで実装できる
# VPC Endpoint for S3
data "aws_region" "current" {}
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${data.aws_region.current.name}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = [
aws_route_table.private.id,
]
tags = {
Name = "${var.vpc_name}-s3-endpoint"
}
}
テスト
テスト用のEC2インスタンスをプライベートサブネットに作り、Session Mangaerを通じて接続する。Session Mangaer用に別のインターフェイス型のVPCエンドポイントが必要だが、面倒なのでNATインスタンスをデプロイしている。この設定なら本来はNATインスタンスはいらない。
EC2上で100MBのファイル×50を作る
for i in {1..50}; do
dd if=/dev/zero of=file${i}.bin bs=1M count=100
done
AWS CLIでS3にアップロードする。このときの帯域を見る
aws s3 sync . s3://<target_s3_bucket_name>
Session MangaerのEC2
AWS CLIでアップロードしているので「送信側」のトラフィックが大きく跳ね上がっている(1GB以上)
NATインスタンスのEC2
S3のアップロードのタイミングではほとんど通信は発生していない。最初に15MB程度の通信が発生しているが、おそらくVPCの初期化か、Session Mangaerの初期化のタイミングなので、S3のアップロードは関係ない。
したがって、VPCのゲートウェイ型エンドポイントでS3の通信をNATからオフロードできている。
ルートテーブルを見る
プライベートサブネットのルートテーブルにVPCエンドポイントが反映されている
送信先pl-...
というのがプレフィックスリスト。vpce-..
がVPCエンドポイント
全体のコード
main.tf
variable "vpc_name" {
type = string
description = "Name of VPC"
default = "isolated-vpc"
}
variable "vpc_cidr_block" {
type = string
description = "CIDR block of VPC"
default = "172.19.0.0/20"
}
variable "availability_zones" {
type = list(string)
description = "List of availability zones"
default = ["ap-northeast-1a", "ap-northeast-1c", "ap-northeast-1d"]
}
variable "s3_bucket_name" {
type = string
description = "Name of S3 bucket"
}
vpc.tf
# Define a VPC
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr_block
enable_dns_support = true
enable_dns_hostnames = true
assign_generated_ipv6_cidr_block = true
tags = {
Name = var.vpc_name
}
}
# Internet gateway
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.vpc_name}-igw"
}
}
# Public subnet
# (10.0.0.0:20 -> [10.0.0.0:24, 10.0.1.0:24, 10.0.2.0:24])
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 4, count.index)
ipv6_cidr_block = cidrsubnet(aws_vpc.main.ipv6_cidr_block, 8, count.index)
availability_zone = element(var.availability_zones, count.index)
map_public_ip_on_launch = false
assign_ipv6_address_on_creation = true
tags = {
Name = "${var.vpc_name}-public-${count.index + 1}"
}
}
# Private subnet
# (10.0.0.0:20 -> [10.0.4.0:23, 10.0.6.0:23, 10.0.8.0:23])
resource "aws_subnet" "private" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 3, 2 + count.index)
ipv6_cidr_block = cidrsubnet(aws_vpc.main.ipv6_cidr_block, 8, 4 + count.index)
availability_zone = element(var.availability_zones, count.index)
map_public_ip_on_launch = false
assign_ipv6_address_on_creation = true
tags = {
Name = "${var.vpc_name}-private-${count.index + 1}"
}
}
# Public route table
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
route {
ipv6_cidr_block = "::/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = {
Name = "${var.vpc_name}-public-route-table"
}
}
resource "aws_route_table_association" "public_assoc" {
count = length(aws_subnet.public)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# Private route table
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.vpc_name}-private-route-table"
}
}
resource "aws_route_table_association" "private_assoc" {
count = length(aws_subnet.private)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
# VPC Endpoint for S3
data "aws_region" "current" {}
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${data.aws_region.current.name}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = [
aws_route_table.private.id,
]
tags = {
Name = "${var.vpc_name}-s3-endpoint"
}
}
# NAT instance(本来はいらないが、Session Mangaerの検証用)
# fck-nat for cost savings (NAT instance)
resource "aws_eip" "nat_eip" {
domain = "vpc"
tags = {
Name = "${var.vpc_name}-nat-eip"
}
}
module "fck-nat" {
source = "RaJiska/fck-nat/aws"
name = "${var.vpc_name}-fck-nat"
vpc_id = aws_vpc.main.id
subnet_id = aws_subnet.public[0].id
eip_allocation_ids = [aws_eip.nat_eip.id] # Allocation ID of an existing EIP
instance_type = "t4g.nano"
update_route_tables = true
route_tables_ids = {
"private-route-table" = aws_route_table.private.id
}
}
ec2.tf
# S3 Bucket
resource "aws_s3_bucket" "s3" {
bucket = var.s3_bucket_name
force_destroy = true
}
# ─────────────────────────────────────────────────────────────
# EC2 用の IAM ロール・ポリシー (Session Manager 用)
# ─────────────────────────────────────────────────────────────
resource "aws_iam_role" "ec2_ssm_role" {
name = "ec2_ssm_role"
assume_role_policy = data.aws_iam_policy_document.ec2_assume_role_policy.json
}
data "aws_iam_policy_document" "ec2_assume_role_policy" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role_policy_attachment" "ec2_ssm_attach" {
role = aws_iam_role.ec2_ssm_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_role_policy_attachment" "s3_fullaccess_attach" {
role = aws_iam_role.ec2_ssm_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
}
# ─────────────────────────────────────────────────────────────
# Security Group (SSH を使わず Session Manager 接続想定なのでインバウンドは最小限)
# ─────────────────────────────────────────────────────────────
resource "aws_security_group" "ec2_sg" {
name = "ec2-sg"
vpc_id = aws_vpc.main.id
egress {
from_port = 0
to_port = 0
protocol = -1
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
}
# ─────────────────────────────────────────────────────────────
# IAM インスタンスプロファイル (EC2 → SSM Role 紐づけ)
# ─────────────────────────────────────────────────────────────
resource "aws_iam_instance_profile" "ec2_ssm_profile" {
name = "ec2-ssm-profile"
role = aws_iam_role.ec2_ssm_role.name
}
# ─────────────────────────────────────────────────────────────
# EC2 インスタンス定義 (Private Subnet に配置)
# ALB 経由で 80 番アクセス、Session Manager 経由で操作想定
# ─────────────────────────────────────────────────────────────
# AMI の指定。Amazon Linux 2023
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["137112412989"] # AmazonのAMI所有者ID
filter {
name = "name"
# Amazon Linux 2023 AMIの名前パターン。minimumを除外する
values = ["al2023-ami-2023*-kernel-*-x86_64"]
}
filter {
name = "architecture"
values = ["x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_instance" "ssm_ec2" {
ami = data.aws_ami.amazon_linux.image_id
instance_type = "t3.micro"
subnet_id = aws_subnet.private[0].id
security_groups = [aws_security_group.ec2_sg.id]
iam_instance_profile = aws_iam_instance_profile.ec2_ssm_profile.name
tags = {
Name = "ssm-ec2"
}
}
所感
- ゲートウェイ型エンドポイントは普通に簡単だった
- よくインターフェイス型のエンドポイントと混同して「VPCエンドポイントはみんな課金される」と勘違いする例があるので(自分も昔は区別つかなかった)注意
Shikoan's ML Blogの中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内
技術書コーナー
北海道の駅巡りコーナー