SSM Session Managerを利用してローカルからVPC内のプライベートAPIを利用する - カドコミBFF開発における活用法

はじめに

こんにちは、しののめ(佐々木)です。
現在カドコミアプリで新規機能開発をしているのですが、ローカルの開発環境からVPC内、さらにはVPC Peering先のプライベートなAPIにアクセスしたいという要望が出てきました。

本記事では、SSM Session Managerのポートフォワーディングを使って低コストかつシンプルに解決することができたので紹介します。
また、構築時にハマったVPC Peeringのセキュリティグループの落とし穴についても触れていきます。

この記事はAIによる文章校正を利用しております。

背景と課題

現在カドコミアプリのBFFで新規機能開発をしています。
開発中のBFFアプリケーションはVPCのプライベートサブネット内にあるECS上で稼働しております。
このBFFはVPC Peering先のプライベートバックエンドAPIを呼び出す構成になっています。
ステージング環境では開発を素早く行うためにローカルからもこのバックエンドAPIを叩けるように一時的にセキュアなエンドポイントを作成しておりました。
ですが今回の機能開発専用の環境(以降Stg1と呼びます)ではローカルから叩けるバックエンドAPIを用意しておりませんでした。
コードを修正するたびにGitHub ActionsでECSへ新たにイメージをデプロイし直す必要があります。
新規イメージ作成を含むためデプロイに5分以上かかり、開発スピードが落ちてしまうという課題がありました。

[ステージング環境]
ECS (BFF) ──VPC Peering──> バックエンドAPI (別VPC)  

ローカルPC ─特別に露出させたエンドポイント─> バックエンドAPI (別VPC)  

[Stg1環境]
ECS (BFF) ──VPC Peering──> バックエンドAPI (別VPC)  

ローカルPC ──(到達不可)──> バックエンドAPI (別VPC)  

そのためローカルから安全にバックエンドAPIを利用できないかと考えたところ、下記の選択肢が上がりました。

選択肢の比較

1. AWS Client VPN

VPCにVPNエンドポイントを作成し、OpenVPN互換クライアントで接続する方式です。

  • VPC内のあらゆるリソースにL3レベルでアクセス可能
  • ただしサーバー/クライアント証明書の生成・管理が必要
  • Client VPNエンドポイントのコストが月$72〜(サブネット関連付け1つあたり$0.10/時間 + 接続時間あたり$0.05/時間)と高い
  • Terraformモジュールの新規作成が必要

チーム全体でVPC内リソースに恒常的にアクセスしたい場合には有力ですが、開発時に特定のAPIに接続したいだけの用途にはオーバースペックです。

2. プロキシサーバー(Squid / NGINX等)

VPC内のEC2にフォワードプロキシ(Squid等)やリバースプロキシ(NGINX等)を立て、ローカルからプロキシ経由でアクセスする方式です。

  • HTTP/HTTPSレベルでの制御が可能(URLベースのアクセス制御、ログ取得など)
  • 複数のバックエンドを1つのプロキシで束ねられる
  • ただしプロキシサーバー自体の構築・運用・セキュリティ管理が必要
  • プロキシへの到達手段が別途必要(パブリックに公開するなら認証の仕組みが必須、SSM経由ならSSMポートフォワーディングと大差ない)

接続先が多数あり、アクセス制御やログ取得の要件がある場合に向きますが、単一APIへの接続には複雑すぎます。

3. SSM Session Manager ポートフォワーディング(今回採用)

VPC内に小さなEC2を踏み台として置き、SSM Session Manager経由でポートフォワーディングする方式です。

  • VPNインフラ不要、証明書管理不要、プロキシソフトウェアの管理も不要
  • コストは月$3程度(t4g.nanoの場合)
  • IAMベースの認証で、既存のAWS認証情報をそのまま利用可能
  • セキュリティグループのインバウンドルール追加が不要

特定のエンドポイントへの接続が目的であれば、最もシンプルかつ低コストです。

比較表

項目 AWS Client VPN プロキシサーバー SSM ポートフォワーディング
月額コスト $72〜 $3〜 + 運用コスト $3程度
証明書管理 必要 不要(プロキシ認証は別途) 不要
接続範囲 VPC全体(L3) プロキシ設定次第 指定ポートのみ(L4)
認証方式 証明書 + α プロキシ認証 IAM
運用負荷 中(証明書更新等) 高(ソフトウェア管理)
導入の手間 中〜大
向いているケース VPC全体への常時接続 多数の接続先 + アクセス制御 特定エンドポイントへの接続

今回は「特定のバックエンドAPIにローカルから接続したい」という要件だったため、SSM Session Managerを採用しました。

SSM Session Managerとは

AWS Systems Manager Session Managerは、EC2インスタンスやオンプレミスサーバーに対して、SSHやポート開放なしでセキュアに接続できるAWSのマネージドサービスです。

通常のSSH接続と異なり、以下の特徴があります。

  • IAMによるアクセス制御
    • SSHキーの配布・管理が不要
    • IAMポリシーで誰がどのインスタンスに接続できるかを制御できる
  • インバウンドポートの開放が不要
    • SSM AgentがAWS APIに対してアウトバウンド(HTTPS/443)で通信するため、セキュリティグループにインバウンドルールを追加する必要がない
  • セッションの監査・ログ記録
    • CloudWatch LogsやS3にセッションログを保存でき、誰がいつ接続したかを追跡できる

Session Managerにはポートフォワーディング機能があり、ローカルPCの特定ポートをリモートホストのポートに転送できます。
今回はこの機能を使って、踏み台EC2経由でVPC Peering先のバックエンドAPIにアクセスします。

構成

最終的な構成は以下の通りです。

ローカルPC (localhost:8443)
  ↕ SSM Session Manager (HTTPS/443経由、AWS API)
踏み台EC2 (プライベートサブネット / t4g.nano)
  ↕ VPC Peering
バックエンドAPI (別VPC / port 443)

先に上げたSSM Session Managerの特徴のNATゲートウェイ経由でSSMサービスへのアウトバウンド通信が可能となっているため、踏み台EC2はプライベートサブネットに置くことができます。

Terraformによる構築

踏み台EC2に必要なリソースは3つだけです。

AMIの取得

SSMパラメータストア経由で最新のAmazon Linux 2023 AMI IDを取得します。 data "aws_ami" + filterよりもシンプルに書けます。

data "aws_ssm_parameter" "amazon_linux_2023" {
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64"
}

ARM64(Graviton)を指定することで、t4g系インスタンスが使えてコストを抑えられます。

IAM Role + Instance Profile

SSM Session Managerに必要な最小限のポリシーのみアタッチします。

resource "aws_iam_role" "bastion" {
  name = "bastion_iam_role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "bastion_ssm" {
  role       = aws_iam_role.bastion.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "bastion" {
  name = "bastion_instance_profile"
  role = aws_iam_role.bastion.name
}

EC2 Instance

セキュリティグループは既存のBFF用SGを使用します(理由は後述の「ハマりポイント」で解説します)。

resource "aws_instance" "bastion" {
  ami                    = data.aws_ssm_parameter.amazon_linux_2023.value
  instance_type          = "t4g.nano"
  subnet_id              = module.network.subnet_private_ids[0]
  iam_instance_profile   = aws_iam_instance_profile.bastion.name
  vpc_security_group_ids = module.security_group.security_group_bff_ids

  root_block_device {
    volume_type = "gp3"
    volume_size = 8
  }

  tags = {
    Name = "bastion"
  }
}

terraform apply すると、4〜5リソースが作成されます。
既存のインフラへの変更は不要です。

接続手順

1. hostsファイルの設定

ポートフォワーディングはL4(TCP)レベルの転送なので、ローカルからはlocalhostに接続することになります。
しかしバックエンドAPIのTLS証明書は実際のドメイン名で発行されているため、https://localhost:8443 にアクセスすると証明書のドメイン検証エラーになります。

これを回避するために、/etc/hosts でドメインをlocalhostに向けます。
注: 以下に出てきている<instance-id><backend api domain>は環境に合わせて適宜置き換えてください。

sudo sh -c 'echo "127.0.0.1 <backend api domain>" >> /etc/hosts'

2. ポートフォワーディングの開始

aws ssm start-session \
  --target <instance-id> \
  --document-name AWS-StartPortForwardingSessionToRemoteHost \
  --parameters '{"host":["<backend api domain>"],"portNumber":["443"],"localPortNumber":["8443"]}' \
  --region ap-northeast-1

Waiting for connections... と表示されたら準備完了です。
このターミナルは開いたままにしておきます。

3. ローカルからのアクセス

別のターミナルから、HTTPSでアクセスします。

curl https://<backend api domain>:8443/

ローカルで動かしているアプリケーションの接続先URLも同様に設定します。

重要: https:// を指定すること。
ポートフォワーディングは生のTCPトンネルであり、TLSハンドシェイクはクライアントとバックエンドAPI間でend-to-endで行われます。
http:// で接続すると、HTTPS portにplain HTTPリクエストを送ることになり 400 Bad Request が返ります。

4. 作業終了後のクリーンアップ

hostsファイルに追加したエントリを削除します。
残したままだと通常のDNS解決が上書きされ続けます。

sudo sed -i '' '/<backend api domain>/d' /etc/hosts

ハマりポイント: VPC PeeringのセキュリティグループがクロスVPC SG参照だった

当初、踏み台EC2にはアウトバウンドのみを許可した専用のセキュリティグループを作成してアタッチしていました。
しかし構築後、踏み台からバックエンドAPIへの接続がタイムアウトするという問題が発生しました。

状況

  • 踏み台EC2からのDNS解決は成功。VPC Peering先のプライベートIPが正しく返る
  • しかしTCP接続がタイムアウト(curl exit code 28)
  • 一方、同じVPC内のECSタスク(BFF)からは問題なくバックエンドAPIに疎通できている

DNS解決は通るのにTCP接続ができません。
ルーティングの問題かと思いましたが、プライベートサブネットのルートテーブルにはVPC Peering先へのルートが正しく設定されています。
ECSタスクも同じプライベートサブネットにいるのに、ECSは通って踏み台は通らない。

原因はセキュリティグループでした。

原因: 同一リージョンVPC PeeringのクロスVPC SG参照

AWSでは、同一リージョンのVPC Peering接続に限り、Peering先VPCのセキュリティグループIDをインバウンドルールのソースとして参照できます。

[自VPC]                          [Peering先VPC]
sg-aaaa (BFF用SG)  ──Peering──>  sg-xxxx (バックエンドAPI用SG)
                                   インバウンドルール:
                                   - port 443 from sg-aaaa ← BFFのSGをIDで指定

Peering先のバックエンドAPI側では、CIDRブロック(10.2.0.0/16など)ではなく、自VPCのBFF用セキュリティグループIDを指定してインバウンドを許可していました。

この場合、BFFのSG(sg-aaaa)を持つECSタスクはPeering先に到達できますが、新しく作った踏み台用のSG(sg-bbbb)は許可リストに含まれていないため、接続がタイムアウトします。

解決策

踏み台専用のSGを新たに作るのではなく、BFFが使用している既存のセキュリティグループをそのまま踏み台EC2に設定しました。

resource "aws_instance" "bastion" {
  # ...
  vpc_security_group_ids = module.security_group.security_group_bff_ids
}

BFFのSGにはegressの全許可が含まれていたため、踏み台に必要なアウトバウンド通信も問題なく動作します。
Peering先のSGルールに合致するようになり、疎通が通りました。Peering先への変更依頼は不要です。

教訓

今回のケースに限った話ではありませんが、リクエストが通らない場合にはルーティングやネットワークACLだけでなく、セキュリティグループのルールも合わせて確認することが大切です。
また今回のようにセキュリティグループにはIDベースのルールもあるため、接続先の許可設定はCIDRベースなのかSG IDベースなのかを確認しましょう。
専用SGを別途作成してPeering先に追加許可を依頼する方法もありますが、踏み台のような用途であれば既存SGを流用することでシンプルに解決することができます。

まとめ

「VPC内の特定エンドポイントにローカルからアクセスしたい」というユースケースなら、SSM Session Managerのポートフォワーディングで十分対応でき、VPNやプロキシと比べてコストも導入の手間も大幅に削減できます。

新しいリソースを追加して通信がうまくいかない場合は、接続先のセキュリティグループのインバウンドルールを確認しましょう。

We are hiring

ドワンゴでは物理・電子・Web連載問わず漫画や本が好き、あるいは長年運用されてきたWebサービスを紐解き、より良い形に作り替えていくことに興味があるWebアプリケーションエンジニアを募集しています。

もし興味がありましたらぜひ、採用情報ページからご応募ください。
カジュアル面談のご相談もお気軽にどうぞ。