プライベートサブネットにあるRDSへ手元から接続するために、パブリックサブネットに踏み台サーバーを置いてSSHで接続している環境は多いのではないでしょうか。この方法では、SSH鍵の配布と管理、22番ポートの開放、接続元IPアドレスの管理といった手間とリスクがついて回ります。
この記事では、AWS Systems ManagerのSession Managerを使い、SSH鍵もインバウンドの開放も使わずにRDSへ接続する仕組みをTerraformで作ります。
SSH踏み台の問題
- SSH鍵の管理:鍵の配布、退職者の鍵の削除、漏えい時の差し替えが必要になる
- ポートの開放:22番ポートをインターネットに向けて開ける必要がある。接続元IPで絞っても、在宅勤務などでIPが変わるたびに更新が要る
- 誰が接続したか追いにくい:共有の鍵を使うと、操作した人を特定できない
Session Managerの仕組み
Session Managerでは、接続の向きが逆になります。
- 中継用のEC2インスタンスで動くSSMエージェントが、内側からSystems Managerに接続する
- 利用者は、AWS CLIでSystems Managerにセッションの開始を依頼する
- Systems Managerが、利用者と中継用インスタンスの通信を橋渡しする
- 中継用インスタンスが、RDSへの通信を転送する(ポートフォワーディング)
中継用インスタンスは引き続き必要ですが、置き場所はプライベートサブネットで、パブリックIPもインバウンドのルールも不要です。**認証はIAMで行うので、SSH鍵は存在しません。**誰がいつ接続したかは、CloudTrailに記録されます。
中継用インスタンスのIAMロール
SSMエージェントがSystems Managerと通信するための権限を付けます。AWS管理ポリシーのAmazonSSMManagedInstanceCoreで足ります。
resource "aws_iam_role" "ssm_relay" {
name = "ssm-relay-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "ssm_core" {
role = aws_iam_role.ssm_relay.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "ssm_relay" {
name = "ssm-relay-profile"
role = aws_iam_role.ssm_relay.name
}
中継用インスタンスとセキュリティグループ
variable "vpc_id" {
type = string
}
variable "private_subnet_id" {
type = string
}
variable "rds_security_group_id" {
type = string
}
data "aws_ssm_parameter" "al2023" {
name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64"
}
resource "aws_security_group" "ssm_relay" {
name = "ssm-relay-sg"
vpc_id = var.vpc_id
egress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [var.rds_security_group_id]
}
}
resource "aws_vpc_security_group_ingress_rule" "rds_from_relay" {
security_group_id = var.rds_security_group_id
referenced_security_group_id = aws_security_group.ssm_relay.id
from_port = 5432
to_port = 5432
ip_protocol = "tcp"
}
resource "aws_instance" "ssm_relay" {
ami = data.aws_ssm_parameter.al2023.value
instance_type = "t4g.nano"
subnet_id = var.private_subnet_id
vpc_security_group_ids = [aws_security_group.ssm_relay.id]
iam_instance_profile = aws_iam_instance_profile.ssm_relay.name
associate_public_ip_address = false
metadata_options {
http_tokens = "required"
}
tags = {
Name = "ssm-relay"
}
}
ポイントは次のとおりです。
- インバウンドのルールがない:中継用インスタンスのセキュリティグループには、ingressを1つも書いていない。SSMエージェントは内側から外へ接続するので、受け付ける通信がない
- アウトバウンドは2つだけ:Systems Managerとの通信(443番)と、RDSへの通信(5432番)
- AMI:Amazon Linux 2023にはSSMエージェントが最初から入っている。SSMパラメータから最新のAMI IDを取得している
- http_tokens = "required":インスタンスメタデータへのアクセスをIMDSv2に限定する。認証情報の窃取を防ぐための基本設定
RDS側のセキュリティグループには、中継用インスタンスからの5432番を許可するルールを追加しています。
Systems Managerへの経路
SSMエージェントは、Systems ManagerのAPIにHTTPSで接続します。プライベートサブネットからの経路は次のどちらかです。
- NATゲートウェイがある場合:そのまま通信できる。追加の設定は不要
- NATゲートウェイがない場合:ssm、ssmmessages、ec2messagesの3つのインターフェース型VPCエンドポイントが必要
data "aws_region" "current" {}
resource "aws_vpc_endpoint" "ssm" {
for_each = toset(["ssm", "ssmmessages", "ec2messages"])
vpc_id = var.vpc_id
service_name = "com.amazonaws.${data.aws_region.current.name}.${each.value}"
vpc_endpoint_type = "Interface"
subnet_ids = [var.private_subnet_id]
security_group_ids = [aws_security_group.vpc_endpoint.id]
private_dns_enabled = true
}
aws_security_group.vpc_endpointは、VPC内からの443番を許可するセキュリティグループです。インターフェース型エンドポイントには時間料金がかかるため、NATゲートウェイがある環境では作らなくてもかまいません。
接続する利用者のIAM権限
利用者には、中継用インスタンスへのポートフォワーディングだけを許可します。
data "aws_caller_identity" "current" {}
resource "aws_iam_policy" "rds_port_forward" {
name = "rds-port-forward"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "ssm:StartSession"
Resource = [
aws_instance.ssm_relay.arn,
"arn:aws:ssm:${data.aws_region.current.name}::document/AWS-StartPortForwardingSessionToRemoteHost"
]
},
{
Effect = "Allow"
Action = ["ssm:TerminateSession", "ssm:ResumeSession"]
Resource = "arn:aws:ssm:*:${data.aws_caller_identity.current.account_id}:session/$${aws:userid}-*"
}
]
})
}
- StartSessionの対象を絞る:中継用インスタンスと、ポートフォワーディング用のドキュメントだけを許可する。これで、シェルのセッションは開けない
- 自分のセッションだけ終了できる:TerminateSessionとResumeSessionは、自分が開始したセッションに限定している
$${aws:userid} の $$ は、TerraformにIAMポリシー変数として ${aws:userid} をそのまま出力させるためのエスケープです。
RDSに接続する
利用者の手元には、AWS CLIとSession Managerプラグインが必要です。次のコマンドで、手元の15432番ポートをRDSの5432番につなぎます。
aws ssm start-session \
--target i-0123456789abcdef0 \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["mydb.xxxxxxxx.ap-northeast-1.rds.amazonaws.com"],"portNumber":["5432"],"localPortNumber":["15432"]}'
セッションを開いたまま、別のターミナルから手元のポートに接続します。
psql -h localhost -p 15432 -U app_user -d appdb
DBクライアントのGUIツールでも、接続先をlocalhost:15432にするだけで使えます。
記録として残るもの
ポートフォワーディングのセッションで残る記録は次のとおりです。
- CloudTrail:誰が、いつ、どのインスタンスにセッションを開始・終了したか
- Session Managerのセッション履歴:セッションの開始・終了時刻と利用者
一方、**ポートフォワーディングで流れたSQLの中身は、Session Managerのログには残りません。**シェルのセッションとは異なり、通信をそのまま転送しているためです。誰がどのSQLを実行したかまで記録したい場合は、RDS側で監査ログ(PostgreSQLならpgAuditなど)を有効にします。
まとめ
- Session Managerを使えば、SSH鍵もインバウンドの開放もなしにRDSへ接続できる
- 中継用インスタンスはプライベートサブネットに置き、インバウンドのルールは持たせない
- 利用者のIAM権限は、中継用インスタンスへのポートフォワーディングだけに絞る
- 接続の記録はCloudTrailに残るが、SQLの中身は残らない。必要ならRDS側で監査ログを取る
鍵とポートの管理から解放されるだけでなく、「誰が接続したか」をIAMの単位で追えるようになります。踏み台を新しく作るなら、最初からSession Managerで構成することをおすすめします。