はじめに
なぜかモバイル用のVPNを構築する必要が出てきたので、AWSでTerraformを使いながら構築します。
主にモバイル端末から新規固定IPでアクセスするための方法です。PCのVPNと同じネットワークに接続したいのであれば別の方法が必要です。
- この記事は人の手で書かれています
- AI(Gemini)に校正をお願いし人手で修正しています
- コードはAIで書き、実際にデプロイした上で動作確認したものを掲載しています
- 図表はAI生成です
- まとめは100%AI生成です
この記事で紹介しているコードは下記リポジトリで公開しています。
設計ポイント
- 徹底的なコストカット
- コストを優先とし、可用性は二の次とします
- 冗長性を考慮しない設計で、AZ障害が発生したら使用できなくなります
- NATをNAT GatewayではなくNAT Instanceにすればもっと安くできる可能性があります。今回はEC2が許可されない場合があることを踏まえNAT Gatewayを使っています。
- 検証時だけ使うとか、営業時間内でのみ起動させることを仮定します。24時間営業の話ではありません。
- コストを優先とし、可用性は二の次とします
- モバイル端末からアクセス可能な設定
- MFAはモバイルで使えないらしい?ので、モバイルで使用可能な認証方法を使用します
- 通信量は多くなく、コストとして無視できると仮定します
参考
を大いに参考にしています。
AWSドキュメント
- https://aws.amazon.com/jp/vpc/pricing/
- https://aws.amazon.com/jp/vpn/pricing/
- https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/nat-gateways-regional.html
- https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/VPC_NAT_Instance.html
リソースとコスト
以下のAWSリソースを作成していきます。
コストは東京リージョン(ap-northeast-1)での算出です。
Regional NATは1AZのみ使用している値段です。
| 名前 | 役割 | コスト |
|---|---|---|
| VPC | VPN接続用のネットワーク | -- |
| Private Subnet | VPN接続用のサブネット | -- |
| Route Table | サブネットからNATのルート定義 | -- |
| Internet Gateway | インターネットへ出るIGW | -- |
| Regional NAT Gateway | Private Subnetからインターネットへ出るためのNAT | $0.062/h |
| Public IP | NAT用のPublic IP | $0.005/h |
| Client VPN Endpoint | VPNの本体 | $0.15/h |
また、このほかに通信量に対する従量課金があります。詳しくはAWSドキュメントを参照してください。
1GBあたりの通信量がかかります。
Regional NAT Gatewayは手動モードで運用し、意図しないPublic IPの使用を防いでいます。
自動モードもありますが、IPアドレスの固定化をする際には手動モードが好ましいです。
コスト削減
上記に挙げたリソースのうち、設置しているだけでコストがかかるのはNAT, VPN, Public IPの3点です。
VPNはVPNそのものではなく、サブネットに紐づけられているとコストが発生します。
外に出るIPを固定したいモチベーションがあるので、Public IPについてはコストカットできません。
月720時間換算で、月あたり$3.6になります。
NAT, VPNについては使用していない時間についてリソースを削除することでコストを削減します。
1日あたりの稼働時間ごとに算出した月額費用は以下の通りです。Public IPは前述の通りリソース削除をしない前提なので24時間のみです。
| サービス | 24h/d | 8h/d | 12h/d | 16h/d |
|---|---|---|---|---|
| Regional NAT Gateway | 44.64 | 14.88 | 22.32 | 29.76 |
| Public IP | 3.60 | - | - | - |
| Client VPN Endpoint | 108.00 | 36.00 | 54.00 | 72.00 |
また、使うときだけ起動させ、それ以外の時間はリソース削除し課金されないようにする案も考えられます。
NAT稼働時のコストを下げるために、NAT GatewayではなくNAT Instanceを使用する方法もあります。
EC2インスタンスを用意しNATの働きをさせる方法で、Spotインスタンスを活用できればコストをNAT Gatewayよりも大幅に下げることができます。
フロー
構成されるリソースのつながりは以下の図のようになります。
Regional NAT Gatewayを使用している場合は、従来のNAT Gateway(Zonal NAT Gateway)を使用している場合と異なり、Public Subnetを用意する必要はありません。
Regional NAT GatewayからのルーティングはNAT作成時に自動で作成されます。
今回の用途ではZonal NAT Gatewayでも問題なく動くと思われます。
Terraform
VPC周り
VPC周りは以下のように設定しました。
- aws_nat_gatewayで
availability_mode = "regional"を設定し、availability_zone_addressを設定することで手動モードに設定 - aws_eipでPublic IPを作成し保持
- NATとRoute Tableに
local.stoppable_resource_countへの参照を追加し、条件によってNATの有無を切り替えられるように設定- これにより、使っていない場合にNATを削除することをやりやすくしています
resource "aws_vpc" "main" {
cidr_block = local.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = local.vpc_name
}
}
# Internet Gateway
# Required for Regional NAT Gateway to route traffic to the internet
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${local.vpc_name}-igw"
}
}
# Private Subnet (Single AZ)
# Client VPN Endpoint will be associated with this subnet
resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(local.vpc_cidr, 8, 1)
availability_zone = local.availability_zone
map_public_ip_on_launch = false
tags = {
Name = "${local.vpc_name}-private-1a"
Zone = local.availability_zone
}
}
# Elastic IP for Regional NAT Gateway
# This IP will be used for outbound internet traffic
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "${local.vpc_name}-nat-eip-1a"
}
}
# Regional NAT Gateway (Manual mode)
# No subnet_id required - operates at VPC level
# Manual mode: Fixed Elastic IP, no auto-expansion to new AZs
resource "aws_nat_gateway" "regional" {
count = local.stoppable_resource_count
vpc_id = aws_vpc.main.id
availability_mode = "regional"
connectivity_type = "public"
# Manual mode: Specify Elastic IP for each AZ
# This disables auto-expansion - must manually add new AZs if needed
availability_zone_address {
availability_zone = local.availability_zone
allocation_ids = [aws_eip.nat.id]
}
tags = {
Name = "${local.vpc_name}-regional-nat"
}
depends_on = [aws_internet_gateway.main]
}
# Route Table for Private Subnet
# Routes all internet traffic through Regional NAT Gateway
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
dynamic "route" {
for_each = local.stoppable_resource_count > 0 ? toset([0]) : []
content {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.regional[0].id
}
}
tags = {
Name = "${local.vpc_name}-private-rt"
}
}
# Route Table Association
# Associates the private subnet with the route table
resource "aws_route_table_association" "private" {
subnet_id = aws_subnet.private.id
route_table_id = aws_route_table.private.id
}
VPN周り
VPN周りは以下のように設定しました。
- TCPで443ポートを使用しています。UDPを使った方がパフォーマンスが出るとのことですが、自分が構築した際にはネットワークの都合上使用できなかったためTCPにしています
- 変更する場合は
client_vpn_sgのSGのingressをudpに、aws_ec2_client_vpn_endpointのtransport_protocolをudpに変更してください
- 変更する場合は
- mTLSを認証に使用しています。あらかじめ接続するユーザーにのみ証明書を埋め込んだ接続設定を渡す運用です
- そのため
aws_ec2_client_vpn_authorization_ruleですべてのユーザーの接続を許可しています -
aws_ec2_client_vpn_endpointのauthentication_optionsでtype = "certificate-authentication"とすることで証明書による認証が使えるようになります
- そのため
- インターネットに出るIPを固定化するためのVPNなので、
aws_ec2_client_vpn_endpointのsplit_tunnel = false,aws_ec2_client_vpn_authorization_ruleやaws_ec2_client_vpn_routeで使われるCIDRを0.0.0.0/0にしています -
aws_ec2_client_vpn_routeのリソースが生成され、サブネットとの紐付けがされていると課金されます-
local.stoppable_resource_countへの参照を追加し、条件によってリソースの有無を切り替えます
-
# Security Group for Client VPN Endpoint
module "client_vpn_sg" {
source = "terraform-aws-modules/security-group/aws"
version = "~> 5.0"
name = "${local.vpc_name}-sg"
description = "Security group for Client VPN endpoint"
vpc_id = aws_vpc.main.id
ingress_with_cidr_blocks = [
{
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = local.vpn_client_cidr
description = "Allow VPN client connections on TCP 443"
}
]
egress_with_cidr_blocks = [
{
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = "0.0.0.0/0"
description = "Allow all outbound traffic"
}
]
}
# AWS Client VPN Endpoint
# Main VPN endpoint that clients connect to
resource "aws_ec2_client_vpn_endpoint" "main" {
description = "Client VPN for ${local.vpc_name}"
server_certificate_arn = aws_acm_certificate.server.arn
client_cidr_block = local.vpn_client_cidr
vpc_id = aws_vpc.main.id
security_group_ids = [module.client_vpn_sg.security_group_id]
dns_servers = [local.vpc_dns_server]
self_service_portal = "disabled"
# Authentication using mutual TLS (certificate-based)
authentication_options {
type = "certificate-authentication"
root_certificate_chain_arn = aws_acm_certificate.client.arn
}
# Connection settings
connection_log_options {
enabled = true
cloudwatch_log_group = aws_cloudwatch_log_group.client_vpn.name
}
# Full tunnel mode - route all client traffic through VPN
split_tunnel = false
# Network protocol settings
transport_protocol = "tcp"
vpn_port = 443
tags = {
Name = local.vpn_name
}
}
# Network Association - Associate VPN endpoint with private subnet
# This is where VPN clients will be logically connected
resource "aws_ec2_client_vpn_network_association" "main" {
count = local.stoppable_resource_count
client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main.id
subnet_id = aws_subnet.private.id
}
# Authorization Rule - Allow access to the Internet (0.0.0.0/0)
# This enables VPN clients to access internet resources
resource "aws_ec2_client_vpn_authorization_rule" "internet" {
client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main.id
target_network_cidr = "0.0.0.0/0"
authorize_all_groups = true
description = "Allow access to the Internet"
}
# Route - Route internet traffic through the private subnet to NAT Gateway
# This enables full tunnel mode where all client traffic goes through VPN
resource "aws_ec2_client_vpn_route" "internet" {
count = local.stoppable_resource_count
client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main.id
destination_cidr_block = "0.0.0.0/0"
target_vpc_subnet_id = aws_ec2_client_vpn_network_association.main[0].subnet_id
description = "Route internet traffic through NAT Gateway"
}
resource "aws_cloudwatch_log_group" "client_vpn" {
name = local.log_group_name
retention_in_days = local.log_retention_days
tags = {
Name = local.log_group_name
}
}
証明書周り
ここではVPNで使用するための証明書を作成します。
簡単に済ませるため、Terraformで作成します。
このとき、stateファイルに作成した証明書の秘密鍵が埋め込まれるので取り扱いに注意してください。
要件があり許容できない場合には、手元で作成した上でACMへの取り込みを手動で行い、そのリソースをaws_ec2_client_vpn_endpointなどで参照するようにしてください。
ca
# CA (Certificate Authority) Private Key
# Used to sign server and client certificates
resource "tls_private_key" "ca" {
algorithm = "RSA"
rsa_bits = 2048
}
# CA Self-Signed Certificate
# Root certificate for the VPN infrastructure
resource "tls_self_signed_cert" "ca" {
private_key_pem = tls_private_key.ca.private_key_pem
subject {
common_name = local.ca_cn
organization = local.organization
}
validity_period_hours = 87600 # 10 years
allowed_uses = [
"cert_signing",
"key_encipherment",
"digital_signature",
]
is_ca_certificate = true
}
サーバー証明書
aws_ec2_client_vpn_endpoint の server_certificate_arn で指定するACMの中身になります。
aws_acm_certificate のリソースを作成することで、証明書をACMにインポートできます。
tls_cert_request の common_name に設定した名前がACMのコンソールに表示されます。
# Server Private Key
resource "tls_private_key" "server" {
algorithm = "RSA"
rsa_bits = 2048
}
# Server Certificate Signing Request
resource "tls_cert_request" "server" {
private_key_pem = tls_private_key.server.private_key_pem
subject {
common_name = local.server_cn
organization = local.organization
}
}
# Server Certificate (signed by CA)
resource "tls_locally_signed_cert" "server" {
cert_request_pem = tls_cert_request.server.cert_request_pem
ca_private_key_pem = tls_private_key.ca.private_key_pem
ca_cert_pem = tls_self_signed_cert.ca.cert_pem
validity_period_hours = 87600 # 10 years
allowed_uses = [
"key_encipherment",
"digital_signature",
"server_auth",
]
}
# Import Server Certificate to ACM
# Required for Client VPN Endpoint authentication
resource "aws_acm_certificate" "server" {
private_key = tls_private_key.server.private_key_pem
certificate_body = tls_locally_signed_cert.server.cert_pem
certificate_chain = tls_self_signed_cert.ca.cert_pem
tags = {
Name = "${local.vpc_name}-server-cert"
}
lifecycle {
create_before_destroy = true
}
}
クライアント証明書
ユーザーごとに証明書を作成します。
ACMでPrivate CAを立ててそちらで管理する方法もありますが、非常に高額なので手元で作成します。
aws_acm_certificate で、クライアント証明書の発行に使用しているCAの証明書を登録します。
個々のユーザーの証明書の登録は必要ありません。
# Client Private Keys - One per user
resource "tls_private_key" "client" {
for_each = toset(local.vpn_users)
algorithm = "RSA"
rsa_bits = 2048
}
# Client Certificate Signing Requests - One per user
resource "tls_cert_request" "client" {
for_each = toset(local.vpn_users)
private_key_pem = tls_private_key.client[each.key].private_key_pem
subject {
common_name = each.key
organization = local.organization
}
}
# Client Certificates (signed by CA) - One per user
resource "tls_locally_signed_cert" "client" {
for_each = toset(local.vpn_users)
cert_request_pem = tls_cert_request.client[each.key].cert_request_pem
ca_private_key_pem = tls_private_key.ca.private_key_pem
ca_cert_pem = tls_self_signed_cert.ca.cert_pem
validity_period_hours = 87600 # 10 years
allowed_uses = [
"key_encipherment",
"digital_signature",
"client_auth",
]
}
# Import Client CA Certificate to ACM
# Used to authenticate client connections
# Note: Client VPN uses the same CA certificate for client authentication
resource "aws_acm_certificate" "client" {
private_key = tls_private_key.ca.private_key_pem
certificate_body = tls_self_signed_cert.ca.cert_pem
certificate_chain = tls_self_signed_cert.ca.cert_pem
tags = {
Name = "${local.vpc_name}-client-ca-cert"
}
lifecycle {
create_before_destroy = true
}
}
接続
設定ファイルの作成
接続に使用するためのovpnを作成します。
この作業はTerraformだけだと難しいので、スクリプトで実行します。
AWSリージョンは ap-northeast-1 で実行しています。
下記のアウトプットが設定されているとします。
このOutputをCLIで読み取って、設定ファイルを作成します。
# Client VPN Endpoint ID
# Required for exporting client configuration
output "client_vpn_endpoint_id" {
description = "ID of the Client VPN endpoint"
value = aws_ec2_client_vpn_endpoint.main.id
}
# Client Certificates - Map of username to certificate PEM
output "client_certificates" {
description = "Map of client certificates (username -> certificate PEM)"
value = {
for user in local.vpn_users :
user => tls_locally_signed_cert.client[user].cert_pem
}
sensitive = true
}
# Client Private Keys - Map of username to private key PEM
output "client_private_keys" {
description = "Map of client private keys (username -> private key PEM)"
value = {
for user in local.vpn_users :
user => tls_private_key.client[user].private_key_pem
}
sensitive = true
}
まず、ベースとなるVPN構成ファイルをCLIで作成します。
VPN_USER="USER" # 証明書を出力するユーザーを指定してください
OUTPUT_FILE="vpn-${VPN_USER}.ovpn"
ENDPOINT_ID=$(terraform output -raw client_vpn_endpoint_id)
aws ec2 export-client-vpn-client-configuration \
--client-vpn-endpoint-id "$ENDPOINT_ID" \
--output text \
>$OUTPUT_FILE
下記のようなファイルが生成されます。
client
dev tun
proto tcp
remote cvpn-endpoint-???.prod.clientvpn.ap-northeast-1.amazonaws.com 443
remote-random-hostname
resolv-retry infinite
nobind
remote-cert-tls server
cipher AES-256-GCM
verb 3
<ca>
-----BEGIN CERTIFICATE-----
(証明書)
-----END CERTIFICATE-----
</ca>
reneg-sec 0
verify-x509-name (tls_cert_request.serverのcommon name) name
ここにユーザーごとの証明書を加えます。 <cert> と、 <key> のブロックを追加していきます。
cat <<'EOF' >>$OUTPUT_FILE
<cert>
EOF
terraform output -json client_certificates | jq -r ".\"$VPN_USER\"" >>$OUTPUT_FILE
cat <<'EOF' >>$OUTPUT_FILE
</cert>
<key>
EOF
terraform output -json client_private_keys | jq -r ".\"$VPN_USER\"" >>$OUTPUT_FILE
cat <<'EOF' >>$OUTPUT_FILE
</key>
EOF
完成系は
client
dev tun
proto tcp
remote cvpn-endpoint-???.prod.clientvpn.ap-northeast-1.amazonaws.com 443
remote-random-hostname
resolv-retry infinite
nobind
remote-cert-tls server
cipher AES-256-GCM
verb 3
<ca>
-----BEGIN CERTIFICATE-----
(証明書)
-----END CERTIFICATE-----
</ca>
reneg-sec 0
verify-x509-name (tls_cert_request.serverのcommon name) name
<cert>
-----BEGIN CERTIFICATE-----
(証明書の中身)
-----END CERTIFICATE-----
</cert>
<key>
-----BEGIN RSA PRIVATE KEY-----
(秘密鍵の中身)
-----END RSA PRIVATE KEY-----
</key>
のようになります。
このファイルを接続するデバイスに転送し、OpenVPNアプリに読み込ませるとVPN接続ができます。
ユーザーの削除
今回クライアント証明書を使った認証をしているため、ユーザーを削除し接続できなくするためにはCRLを設定する必要があります。
Terraformのtls providerではCRLを作成することは現時点で対応していないため、opensslコマンドやeasy-rsaといったツールを使用して作成してください。
GitHubリポジトリの、 scripts/generate-crl.sh のスクリプトにopensslコマンドを使うAIで生成したコードを記載しました。
このコードで試したところ問題なく失効させたユーザーで接続できなくなり、失効していないユーザーは問題なく接続できています。
作成後は aws ec2 import-client-vpn-client-certificate-revocation-list のコマンドでVPNに設定を反映させられます。
設定後、失効させられた証明書での接続はできなくなります。手元の環境では延々と再接続を試行する挙動でした。
失効させていないユーザーが接続できることもしっかり確認しましょう。
証明書の設定が間違っていたり、有効期限が切れていたりすると接続できるはずのユーザーも失効したユーザーと同じような挙動になってしまいます。
運用
VPNを起動する際には以下のTerraformを流し、NATなどを構築します。
terraform apply -var="mode=ON"
stoppable_resource_count経由でリソースの数が設定され、VPNのサブネットとの紐付け、NAT、ルートテーブルの設定が構築されます。
おおよそ15分程度かかります。
VPNを終了する際には以下のTerraformを流し、NATなどを削除します。
terraform apply -var="mode=OFF"
NATにアタッチしているIPアドレス自体は削除していないので、外に出るためのIPが変わることはありません。
今回はcountを使ってリソースの削除をしています。
この方法はapplyコマンドだけで運用できる反面、削除するための全てのリソースにcountの指定が必要です。
また削除されることのあるリソースに依存している場合にもcountの指定が必要です。
destoryのコマンドであれば依存関係にあるリソースも含め全て削除できます。
ただapply以外の運用が必要になるのでproviderなどの自動アップデートでのplanと相性が悪いかもしれません。
まとめ
この記事では、モバイル端末から固定IPでアクセスするためのAWS Client VPN環境を、Terraformを使って低コストで構築・運用する方法をご紹介しました。
今回の構成のポイントを振り返ります。
- 割り切ったコスト削減: 可用性を下げたシングルAZ構成とRegional NAT Gatewayを採用し、Terraformの変数切り替えで「使う時だけリソースを作成する」運用を実現しました
- 固定IPの維持: インターネットへの出口となるEIPだけは常時保持し、維持費(月額数ドル)を払いながらも全体のランニングコストを最小化しています
- 構築と設定の自動化: 手作業だと面倒な証明書の作成・ACMへの登録から、クライアント用の
.ovpnファイルの生成までをコードとスクリプトで完結させました
エンタープライズ向けの堅牢な構成ではありませんが、「検証環境としての一時利用」や「特定の時間帯だけ、モバイルから固定IPでアクセスしたい」といった要件に対しては、非常にコストパフォーマンスの高いアプローチです。
AWS環境での固定IP運用や、Client VPNのコストに悩んでいる方の参考になれば幸いです。