AWS NAT Gateway 你也太貴!用 Terraform 做一個自己的 NAT

程式技術
sharkHead

小弟我的部落格原本是架在 AWS 的 Lightsail 服務上,Lightsail 簡單好用又便宜,缺點是 OS 的 image 更新的有點慢。

Lightsail 上面 Ubuntu 22.04 的 image 在前陣子終於推出了,但 2023 都快過一半了…

在工作上轉換跑道之後,為了更熟悉 AWS 常見的服務,決定把部落格從 Lightsail 搬移到 EC2。

在 EC2 上就需要好好規劃網路架構,我個人比較想要實作有公有子網路 (public subnet) 與私有子網路 (private subnet) 的設計。將部落格放在公有子網路底下,使其可以對外開放。資料庫則放在私有子網路底下,讓外部無法直接連入,相對公有子網路會更安全。詳細可以參考 AWS 的 Example

但私有子網路除了外部無法直接連入,也無法直接對外連線。如果要與外面連線就必須透過 NAT (網路位址轉譯)。AWS 剛好也有 NAT Gateway 的服務,可以只直接拿來用,只不過 …

我把這個網路架構告訴公司裡面的前輩,想請教前輩有沒有問題。

前輩:「這個設計很常見,沒什麼問題。」

我:😁😁😁

前輩:「不過你知道 NAT Gateway 一個月就算沒有流量也要將近 30 美金嗎?」

我:😐😐😐

前輩:「有流量還要另外算費用喔。」

我:😱😱😱

前輩:「哈哈哈。其實你可以自己用 iptables 建立一個 SNAT,機器就算只開 nano 等級,也能夠大概承受每秒幾萬左右的流量。我給你我之前在用的 Terraform 範例吧。」

我:感謝前輩 😭😭😭

用 EC2 做一個自己的 NAT Gateway

前輩給我的 Terraform 檔案真的異常的簡單。就是開一個機器,並在這台機器上使用 iptables 實作 NAT 轉發。至於什麼是 iptables?可以看這部影片的介紹。

接下來開始看看前輩給我的 Terraform 檔案。首先在 data source 設定 AZ (availability zone),確保開的機器與子網路會在同一個區域 。

# data.tf

data "aws_availability_zones" "available" {
  state = "available"
}

建立 VPC (Virtual Private Cloud) 與其子網路。

# vpc.tf

# 建立一個 VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true
}

# 建立一個公有子網路
resource "aws_subnet" "public" {
  vpc_id = aws_vpc.main.id
  # 子網路可用區域需要與實例的可用區域相同
  availability_zone = data.aws_availability_zones.available.names[0]
  cidr_block        = "10.0.0.0/24"

  tags = {
    Name = "public subnet"
  }
}

# 建立一個私有子網路
resource "aws_subnet" "private" {
  vpc_id = aws_vpc.main.id
  availability_zone = data.aws_availability_zones.available.names[0]
  cidr_block        = "10.0.1.0/24"

  tags = {
    Name = "private subnet"
  }
}

設定公有子網路的路由表 (route table),將對外流量導向網際網路閘道器 (internet gateway),這樣公有子網路底下的機器才能對外連線。

路由表預設都會有一個 10.0.0.0/16 → local 的規則,因此如果兩台機器在同一個 VPC 但不同的 subnet 底下,它們依然能夠互通。

# vpc.tf

# 設置網際網路閘道器,以允許 VPC 中的實例與外部網路之間的通訊
resource "aws_internet_gateway" "public" {
  vpc_id = aws_vpc.main.id
}

# 設置公有子網路的路由表,使網路封包能夠有效地流入和流出
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.public.id
  }

  route {
    ipv6_cidr_block = "::/0"
    gateway_id      = aws_internet_gateway.public.id
  }

  tags = {
    Name = "public subnet route table"
  }
}

# 將這個路由表與公有子網路關聯起來
resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

建立一個 EC2,並使用 user data 在上面設定 iptables,將其變成 SNAT。

這邊要注意 source_dest_check 一定要設定成 false,否則流量不會通。

# ec2.tf

resource "aws_instance" "nat" {
  ami                         = data.aws_ami.amazon_linux_arm.id
  instance_type               = "t4g.nano"
  key_name                    = aws_key_pair.ssh.key_name
  # 設定可用區域,確保與子網路同一個可用區域
  availability_zone           = data.aws_availability_zones.available.names[0]
  # 將這個 NAT 設定在公有子網路底下,這樣 NAT 才能將封包對外傳送
  subnet_id                   = aws_subnet.public.id
  associate_public_ip_address = true
  security_groups             = [aws_security_group.nat.id]
  # 因為 NAT 會修改來源 IP,因此這裡必須設定為 false,流量才能連通
  source_dest_check           = false
  user_data_base64            = data.cloudinit_config.nat_setup.rendered
  user_data_replace_on_change = true

  root_block_device {
    volume_type           = "gp3"
    delete_on_termination = true
    encrypted             = true
  }

  credit_specification {
    cpu_credits = "standard"
  }

  tags = {
    Name = "nat instance"
  }
}

# 設定 nat 的 security group
resource "aws_security_group" "nat" {
  name   = "nat"
  vpc_id = aws_vpc.main.id

  ingress {
    description = "Allow NAT for all instances in vpc"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description      = "Allow NAT out-going anywhere"
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  tags = {
    Name = "nat security group"
  }
}

# 使用 user data 設定 iptables
data "cloudinit_config" "nat_setup" {
  gzip          = true
  base64_encode = true

  part {
    filename     = "01-update-package.sh"
    content_type = "text/x-shellscript"
    content      = file("scripts/update-package.sh")
  }

  # 重點在這個設定檔案,設定 iptables 讓這個實例變成 NAT
  part {
    filename     = "02-iptables-nat.sh"
    content_type = "text/x-shellscript"
    content      = file("scripts/iptables-nat.sh")
  }
}

最重要的部分就是用來設定 iptables 的 user data:iptables-nat.sh

#!/bin/bash

############################################################
# 步驟 1,部署 sysctl 設定檔
############################################################

# 新增一個 sysctl 的設定檔案
cat <<'EOF' >/etc/sysctl.d/30-ip_forward.conf
net.ipv4.ip_forward=1
net.ipv4.conf.eth0.send_redirects=0
net.ipv4.ip_forward_use_pmtu=1
EOF

# 重新載入 sysctl 的設定
sysctl --load /etc/sysctl.d/30-ip_forward.conf
sysctl -a | grep net.ipv4.ip_forward

############################################################
# 步驟 2,部署 NAT 服務工具
############################################################

# 取得該機器的網卡介面
interface_name=$(ip route show | grep 'default' | xargs | rev | cut -d ' ' -f 1 | rev)
[[ ! -d /opt/nat/ ]] && mkdir -p /opt/nat/

# 新增一個 scripts 檔案,
cat <<EOF >/opt/nat/ip_nat.sh
#!/bin/bash

# 使用 iptabless 指令啟用 IP NAT 向外路由
iptables -t nat -A POSTROUTING -o ${interface_name} -j MASQUERADE

# 顯示 NAT 路由規則資訊
iptables -t nat -L POSTROUTING
EOF

chmod +x /opt/nat/ip_nat.sh

############################################################
# 步驟 3,配置 SNAT 服務
############################################################

# 註冊一個新的 SNAT 服務,該服務會執行剛剛新增的 script 檔案
cat <<EOF >/etc/systemd/system/snat.service
[Unit]
Description = SNAT via ENI ${interface_name}

[Service]
ExecStart = /opt/nat/ip_nat.sh
Type = oneshot

[Install]
WantedBy = multi-user.target
EOF

# 步驟 4,安裝並啟用 SNAT 服務
systemctl enable snat
systemctl start snat

設定私有子網路的路由表,將私有子網路的流量全部導向至 NAT。

# vpc.tf

# 設定私有子網路的 route table,子網路底下所有對外連線都會導向到 NAT
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block           = "0.0.0.0/0"
    
    network_interface_id = aws_instance.nat.primary_network_interface_id
  }

  tags = {
    Name = "private route table"
  }
}

# 私有子網路與 route table 關聯起來
resource "aws_route_table_association" "private_route_table_association" {
  subnet_id      = aws_subnet.private.id
  route_table_id = aws_route_table.private.id
}

當私有子網路的路由表設定好。我們就可以把機器開在底下,機器可以對外連線,但外部無法直接連入。

resource "aws_instance" "database" {
  # ...

  subnet_id = aws_subnet.private.id

  # ...
}

參考資料

sharkHead
written by
sharkHead

後端打工仔,在下班後喜歡研究各種不同的技術。稍微擅長 PHP,並偶爾涉獵前端開發。個性就像動態語言般隨興,但渴望做事能像囉嗦的靜態語言那樣嚴謹。

0 則留言