こしあん
2025-01-25

VPCゲートウェイエンドポイントでS3への通信をオフロードする


29{icon} {views}

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の中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内

技術書コーナー

北海道の駅巡りコーナー


Add a Comment

メールアドレスが公開されることはありません。 が付いている欄は必須項目です