AWS EC2 の22番ポートを閉じつつセキュアにリモートコマンドを実行する #SSM
はじめに
EC2インスタンスに対して何か操作をしたい時に SSH
を用いてログインして作業をすることはよくあると思います。
SSH はインスタンスのシェルに直接入って操作が可能なので、直感的な操作が可能である一方で、
SSHキー の扱い等には気を配る必要があります。
そこで、AWS Systems Manager (SSM) の機能を用いることで、AWSの認証情報を使いつつセキュアにリモートコマンドを実行することができます。
また、接続先をインスタンスIDで指定するため、Elastic IP を振っていないインスタンスを再起動して Public IP が変わってしまったとしてもコマンドの接続先を変更する必要がありません。
日中しか使わない踏み台サーバーのIPを固定するために Elastic IP を振っていたり、夜間も休日も起動しっぱなしにしていませんか?
もしくは、CICDからリモートコマンドを実行するためにSSHキーをリポジトリにアップしていませんか?SSM で全部解決できます。
SSM について
SSM はAWSが提供する サーバー管理サービス です。
AWS上のEC2インスタンスやオンプレミスのサーバー等のあらゆるサーバーを一元的に管理するための各種機能を提供します。
SSM でリモートコマンドを実行するための機能
SSM でリモートコマンド実行するためには以下の2つの方法があります。
どちらの方法でも適切な権限設定とホストサーバーに SSM Agent をインストールする必要があります。
EC2の場合は大抵のAMIでプリインストールされています。
機能 | 特徴 |
---|---|
Run Command | 非対話式で複数行のリモートコマンドを一括実行できる 複数インスタンスに対して同時にコマンドを実行することもできる CICD等からコマンドを実行したい場合はこれを使う |
Session Manager | 対話式でSSHのようにコマンドを実行できる 利用にはクライアントPCにプラグインをインストールする必要がある SSHの代替として利用したい場合や、踏み台サーバーにアクセスする場合はこれを使う |
EC2の場合はどちらも無料で利用出来ます。
オンプレの場合は追加のプランが必要になったりします。
-> オンプレミスインスタンス管理を参照
SSM のリモートコマンド実行の仕組み
雑なフロー図
SSM におけるリモートコマンドはEC2インスタンス内の SSM Agent
が SSM に対して定期的にポーリングすることで実現されています。
このポーリングは443番ポートのアウトバウントのみで行われるため、インバウンドルールを最小限にすることができます。
-> エンドポイントへの接続の部分を参照
また、AWS CLIによる認証を行うため、実行権限のあるユーザーしかリモートコマンドを実行できないような仕組みになっています。
SSH と比較した際の SSM の利点
SSH と 比較した際の SSM を使う利点としては、主に以下の3つがあります。
- AWS の認証情報を使って認証とアクセスを行うためSSHキーを管理する必要がなくなり、セキュリテイリスクが低減する
- 22番ポートへのインバウンドを閉鎖できるためセキュリティリスクが低減する
- Public IP を考慮しなくてよくなるため、場合によってはコスト削減につながる
Elastic IPはインスタンスの起動状態に関わらずIPあたり月3.6$かかるようになりました。
特に社内ツールや踏み台サーバー等の利用者がいない時間帯は停止しても問題ないインスタンスで、再起動するたびにPublic IPが振り直されるのを防ぐためにElastic IPを使っていたり、そもそも再起動させないように運用しているインスタンスがある場合はコスト削減が期待できます。
考慮すべきこと
SSM は SSH と違って AWS が提供するサービスであるため、場合によってはサービス自体に問題が発生する可能性もあります。
そのため緊急時のアクセス手段として、管理者等がSSHキーを保持しておいて、いざとなれば SSH もできるようにしておくことも大事になります。
普段は22番ポートは閉鎖しておいて緊急時のみ解放する
構築方法
ユースケース別に構築方法を解説します。
ユースケース1: 非対話式にローカルからEC2にリモートコマンドを実行したい場合
ユースケース2: SSHの代替として対話式のシェルでローカルからEC2にアクセスしたい場合
ユースケース3: Github Actions からEC2にリモートコマンドを実行したい場合
作業環境
- Debian Bookworm (Windows 上で Devcontainer を実行)
- AWS CLI v2.28.17
- EC2 (Amazon Linux 2023)
- Terraform (IaC)
Terraform
を用いてインフラ構築を行なっているため、具体的なコードを記載しますので参考にしてください。
ユースケース1: 非対話式にローカルからリモートコマンドを実行して結果を確認する
Run Command
を使います。
ざっくりとした手順は以下です。
- VPCを作成し、セキュリティグループのアウトバウンドルールで443番ポートを許可する
- 上記のVPCに SSM Agent がプリインストールされているAMIを指定してEC2インスタンスを起動
- EC2用のIAMロール (インスタンスプロファイルロール) を作成してインスタンスに付与
- インスタンスプロファイルロールに
AmazonSSMManagedInstanceCore
ポリシーを付与 -
aws cli
で認証する - クライアントから
aws ssm send-command
を実行してリモートコマンドを発行 - クライアントから
aws ssm get-command-invocation
で結果を確認 - (Option) 22番ポートのインバウンドを無効にして検証する
Terraform での構築例
VPC と EC2 はそれぞれモジュール化しています。
1. VPCを作成し、セキュリティグループのアウトバウンドルールで443番ポートを許可する
Terraform での VPC モジュール例
VPC のモジュール定義は以下
以下、抜粋
variable "name" {
description = "名前"
type = string
}
variable "allow_ssh" {
description = "SSHを許可するか"
type = bool
}
# 以下を作成します。
# VPC, Subnet, Internet Gateway, Default Route Table, Default Network ACL, Default Security Group
# VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
tags = {
"Name" = "${var.name}-vpc"
}
}
# Subnet
resource "aws_subnet" "public_1a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "ap-northeast-1a"
map_public_ip_on_launch = true # パブリックIPを自動割り当て
tags = {
Name = "${var.name}-subnet-public-1a"
}
}
# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.name}-igw"
}
}
# Default Route Table
resource "aws_default_route_table" "public" {
default_route_table_id = aws_vpc.main.default_route_table_id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${var.name}-rtb-public"
}
}
resource "aws_route_table_association" "public_1a" {
subnet_id = aws_subnet.public_1a.id
route_table_id = aws_default_route_table.public.id
}
# Default Network ACL
resource "aws_default_network_acl" "main" {
default_network_acl_id = aws_vpc.main.default_network_acl_id
subnet_ids = [aws_subnet.public_1a.id]
ingress {
protocol = -1
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
egress {
protocol = -1
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
tags = {
Name = "${var.name}-acl"
}
}
# Default Security Group
resource "aws_default_security_group" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.name}-sg"
}
}
## インターネット -> VPC: SSH 許可
resource "aws_vpc_security_group_ingress_rule" "ingress_ssh" {
count = var.allow_ssh ? 1 : 0 # true の場合のみ作成
security_group_id = aws_default_security_group.main.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "tcp"
from_port = 22
to_port = 22
}
## インターネット -> VPC: HTTP 許可
resource "aws_vpc_security_group_ingress_rule" "ingress_http" {
security_group_id = aws_default_security_group.main.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "tcp"
from_port = 80
to_port = 80
}
## VPC -> インターネット: すべて許可
resource "aws_vpc_security_group_egress_rule" "egress_all" {
security_group_id = aws_default_security_group.main.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1" # 全てのポート
}
2. 上記のVPCに SSM Agent がプリインストールされているAMIを指定してEC2インスタンスを起動
大体のAMIには SSM Agent がプリインストールされています。
詳細はこちら
参考までに私は Amazon Linux 2023 amd64 (id: ami-07faa35bbd2230d90)
を使用しました。
Terraform での EC2 モジュール例
EC2 のモジュール定義は以下
以下、抜粋
variable "name" {
description = "名前"
type = string
}
variable "ami_id" {
description = "AMI ID"
type = string
}
variable "instance_type" {
description = "インスタンスタイプ"
type = string
}
variable "subnet_id" {
description = "サブネットID"
type = string
}
variable "security_group_id" {
description = "セキュリティグループID"
type = string
}
variable "enable_ssh" {
description = "SSHを有効にするか"
type = bool
}
variable "enable_ssm" {
description = "SSMを有効にするか"
type = bool
}
# EC2の信頼ポリシー
data "aws_iam_policy_document" "ec2" {
version = "2012-10-17"
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
# SSH Key: enable_ssh が true の場合のみ作成
resource "tls_private_key" "main" {
algorithm = "RSA"
rsa_bits = 4096
}
resource "aws_key_pair" "main" {
count = var.enable_ssh ? 1 : 0
key_name = "${var.name}-key"
public_key = tls_private_key.main.public_key_openssh
}
# インスタンスプロファイル用のロール
resource "aws_iam_role" "main" {
name = "${var.name}-ec2_Role"
assume_role_policy = data.aws_iam_policy_document.ec2.json
}
# SSM 許可ポリシー: enable_ssm が true の場合のみアタッチ
resource "aws_iam_role_policy_attachment" "main" {
count = var.enable_ssm ? 1 : 0
role = aws_iam_role.main.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
# インスタンスプロファイル
resource "aws_iam_instance_profile" "main" {
name = "${var.name}-ec2_InstanceProfile"
role = aws_iam_role.main.name
}
# EC2
resource "aws_instance" "main" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = [var.security_group_id]
key_name = var.enable_ssh ? aws_key_pair.main[0].key_name : null # enable_ssh が true の場合のみ設定
iam_instance_profile = aws_iam_instance_profile.main.name
tags = {
Name = "${var.name}-ec2"
}
}
# SSH Keyをローカルに保存: enable_ssh が true の場合のみ保存
resource "local_file" "ssh_key" {
count = var.enable_ssh ? 1 : 0
filename = "${path.cwd}/.ssh/${var.name}-key.pem"
content = tls_private_key.main.private_key_pem
file_permission = "0400"
depends_on = [aws_key_pair.main]
}
# IPアドレスをローカルに保存: enable_ssh が true の場合のみ保存
resource "local_file" "ip_address" {
count = var.enable_ssh ? 1 : 0
filename = "${path.cwd}/.ssh/${var.name}-ip.txt"
content = aws_instance.main.public_ip
depends_on = [aws_instance.main]
}
3. EC2用のIAMロール (インスタンスプロファイルロール) を作成してインスタンスに付与
EC2インスタンスがAWSのリソースにアクセスする際の権限等を インスタンスプロファイル を使って設定します。
EC2からこのロールを使用するにはEC2自体にロールをアタッチする必要があり、またロール側でも以下のような信頼ポリシーを設定する必要があります。
より詳細なコードは手順2 -> main.tf
-> インスタンスプロファイルの箇所を参照
AmazonSSMManagedInstanceCore
ポリシーを付与
4. インスタンスプロファイルロールに EC2インスタンスが SSM にアクセスしてリモートコマンドの送受信をするのに必要な権限セットが AmazonSSMManagedInstanceCore
ポリシーです。
-> 詳細はこちら
詳細なコードは手順2 -> main.tf
-> インスタンスプロファイルの箇所を参照
aws cli
で認証する
5. 私は SSO を用いた認証を行っているので以下コマンドでログインします。
aws sso login
aws ssm send-command
を実行してリモートコマンドを発行
6. クライアントから 適切な instance-id
を入力して実行してください。
aws ssm send-command \
--instance-ids "i-1234567890abcdef" \
--document-name "AWS-RunShellScript" \
--parameters 'commands=[
"echo Hello",
"echo World !"
]'
成功したら CommandId
をコピーしておきます。
aws ssm get-command-invocation
で結果を確認
7. クライアントから 適切な instance-id
と command-id
を入力して実行してください。
aws ssm get-command-invocation \
--instance-id "i-1234567890abcdef" \
--command-id "12345678-90ab-cdef-ghij-klmnopqrstuv"
成功したら StandardOutputContent
に Hello World !
と表示されるはずです。
実行できない場合
AWSコンソールで AWS Systems Manager
-> フリートマネージャー
に進んでいただくと SSM でリモートコマンドが実行可能なインスタンス一覧が表示されます。
ここに表示されなければこちらの必須条件のうちの何が満たされていない可能性があるのでもう一度確認してみて下さい。
フリートマネージャーの画面例
ユースケース2: SSHの代替として対話式のシェルをローカルから実行する
Session Manager
を使います。
また、前提条件としてユースケース1の環境構築が完了している必要があります。
というのも Session Manager
を使うには Run Command
の環境に追加の手順が必要だからです。
以下、ざっくり手順
-
Run Command
の実行に必要な環境の構築 - Session Manager Plugin のインストール
-
aws ssm start-session
コマンドで接続
Run Command
の実行に必要な環境の構築
1. ユースケース1を参照してください。
2. Session Manager Plugin のインストール
こちらを参考に、OSに合ったインストール方法を参照して実施してください。
私は Devcontainer なので以下のように対応しています。
aws ssm start-session
コマンドで接続
aws cli
でログイン後に以下コマンドで対話式のシェルを起動できます。
aws ssm start-session --target i-1234567890abcdef
ユースケース3: Github Actions からリモートコマンドを実行する
Run Command
を使います。
そのため、ユースケース1の環境構築が完了している必要があります。
また、Github Actions から AWS へのアクセスには OpenID Connect (OIDC)
を使います。
以下、ざっくり手順
-
Run Command
の実行に必要な環境の構築 -
OpenID Connet Provider (IdP)
の作成 - IdP のアクセス制御を行うためのIAMロールの作成
- SSM への最小権限ポリシーを作成してIAMロールにアタッチ
- 作成したIAMロールのARNや対象のインスタンスのID等をCICD上から参照できるように
Environments
を作成する - ワークフロー定義を作成
- AWSへ認証 (Assume Roleで指定したロールの権限を引き継ぐ)
-
aws ssm send-command
でリモートコマンドを実行 -
aws ssm wait command-executed
でリモートコマンドの完了まで待機 -
aws ssm get-command-invocation
でリモートコマンドの実行結果を表示
Terraform での構築例
OIDC に必要な IDプロバイダ と プロバイダに割り当てるロール と ロールに割り当てる最小権限ポリシー をまとめて一つのモジュールに定義しています。
また、Github Actions で利用する Environments
の生成も Terraform から実施しています。
Run Command
の実行に必要な環境の構築
1. ユースケース1を参照してください。
OpenID Connet Provider (IdP)
の作成
2. Terraform での OIDC モジュール例
OIDC 関連のモジュール定義は以下
抜粋
variable "allowed_github_repositories" {
description = "許可するGithubリポジトリの一覧"
type = list(string)
}
variable "allow_ssm" {
description = "SSMを許可するか"
type = bool
default = false
}
# IDプロバイダ
resource "aws_iam_openid_connect_provider" "main" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
}
# IDプロバイダに割り当てるロール: AssumeRoleの許可設定を行う
module "iam_oidc_role" {
source = "../role"
name = "GithubActionsOIDCRole"
iam_oidc_provider_arn = aws_iam_openid_connect_provider.main.arn
allowed_github_repositories = var.allowed_github_repositories
}
# SSMへの最小権限のポリシー
module "iam_oidc_policy_ssm" {
count = var.allow_ssm ? 1 : 0
source = "../policy/ssm"
name = "GithubActionsOIDCRole_SSMPolicy"
}
# ポリシーをロールにアタッチ
resource "aws_iam_role_policy_attachment" "ssm" {
count = var.allow_ssm ? 1 : 0
role = module.iam_oidc_role.name
policy_arn = module.iam_oidc_policy_ssm[0].arn
}
OIDC を解説しだすと終わらなくなってしまうので、技術的な詳細は公式ドキュメントを参照ください。
3. IdP のアクセス制御を行うためのIAMロールの作成
Terraform での OIDC IAMロール モジュール例
OIDC IAM ロール のモジュール定義は以下
抜粋
variable "name" {
type = string
description = "IAMロールの名前"
}
variable "iam_oidc_provider_arn" {
type = string
description = "OIDCプロバイダーのARN"
}
variable "allowed_github_repositories" {
type = list(string)
description = "許可するリポジトリの一覧"
}
# リポジトリの許可設定等を定義したカスタム信頼ポリシー
data "aws_iam_policy_document" "assume_role" {
# Github Actionsからの信頼ポリシー
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
# フェデレーション認証(外部の認証プロバイダー)を使用
principals {
type = "Federated"
identifiers = [var.iam_oidc_provider_arn]
}
# aud を見て aws 向けに発行されたトークンであることを確認
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
# sub を見て許可されているリポジトリであることを確認
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = var.allowed_github_repositories
}
}
}
# カスタム信頼ポリシーを設定してロールを作成
# MEMO: カスタム信頼ポリシーはロールに直接定義されるため作成されない
resource "aws_iam_role" "main" {
name = var.name
assume_role_policy = data.aws_iam_policy_document.assume_role.json
}
data.tf
が肝です。
IAMロール の作成時に誰がそのロールにアクセス可能か (信頼ポリシー) を設定します。
ここでは、Github Actions の OIDC から IAMロール への Assume Role
を許可するように設定しています。
また condition
ブロックでより厳密に条件を指定しています。
ここで作成したロールのARNはCICD上で参照するのでどこかに保存しておきましょう。
-> 手順5 で対応します。
4. SSM への最小権限ポリシーを作成してIAMロールにアタッチ
Terraform での OIDC IAMロール モジュール例
OIDC からSSMするための最小権限ポリシー のモジュール定義は以下
抜粋
variable "name" {
type = string
description = "ポリシーの名前"
}
# SSMへの最小権限ポリシー
data "aws_iam_policy_document" "main" {
statement {
effect = "Allow"
actions = [
"ssm:SendCommand",
"ssm:GetCommandInvocation"
]
resources = ["*"]
}
}
resource "aws_iam_policy" "main" {
name = var.name
policy = data.aws_iam_policy_document.main.json
}
ここでの肝も data.tf
です。
aws ssm send-command
の実行には ssm:SendCommand
aws ssm get-command-invocation
には ssm:GetCommandInvocation
の権限がそれぞれ必要になります。
Environments
を作成する
5. 作成したIAMロールのARNや対象のインスタンスのID等をCICD上から参照できるように Terraform での Github Environments モジュール例
モジュール定義は以下
抜粋
variable "name" {
description = "名前"
type = string
}
variable "secrets" {
description = "作成するシークレットのマップ"
type = map(string)
}
variable "variables" {
description = "作成するの変数のマップ"
type = map(string)
}
data "github_repository" "main" {
full_name = "kazeusagi/terraform-ssm"
}
# Environmentの作成
resource "github_repository_environment" "main" {
repository = data.github_repository.main.name
environment = var.name
}
# Secretの作成
resource "github_actions_environment_secret" "main" {
for_each = var.secrets
repository = data.github_repository.main.name
environment = github_repository_environment.main.environment
secret_name = each.key
plaintext_value = each.value
}
# Variableの作成
resource "github_actions_environment_variable" "main" {
for_each = var.variables
repository = data.github_repository.main.name
environment = github_repository_environment.main.environment
variable_name = each.key
value = each.value
}
CICD上からリモートコマンドを実行するには以下の変数が必要になります。
変数名 | 内容 | Secret |
---|---|---|
AWS_REGION | ログインするリージョン | どっちでも |
ASSUME_ROLE_ARN | ログインして委任するIAMロールのARN | 〇 |
INSTANCE_ID | リモートコマンド実行先のインスタンスID | どっちでも |
6. ワークフロー定義を作成
ワークフロー定義ファイルの例
# 再利用可能なワークフロー: SSM
name: SSM
on:
workflow_call:
inputs:
environment:
type: string
required: true
description: 対象の環境(dev, stg, prod)
env:
AWS_REGION: ${{ vars.AWS_REGION }}
ASSUME_ROLE_ARN: ${{ secrets.ASSUME_ROLE_ARN }}
INSTANCE_ID: ${{ secrets.INSTANCE_ID }}
jobs:
plan:
name: SSM
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
permissions:
id-token: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup AWS
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ env.ASSUME_ROLE_ARN }}
- name: SSM Send Command
id: send_command
run: |
COMMAND_ID=$(aws ssm send-command \
--instance-ids ${{ env.INSTANCE_ID }} \
--document-name "AWS-RunShellScript" \
--parameters 'commands=[
"echo Hello",
"echo From",
"echo Github Actions !"
]' \
--output text \
--query 'Command.CommandId'
)
echo "command_id=$COMMAND_ID" >> $GITHUB_OUTPUT
- name: SSM Wait Command
run: |
aws ssm wait command-executed \
--instance-id ${{ env.INSTANCE_ID }} \
--command-id ${{ steps.send_command.outputs.command_id }}
- name: SSM Get Command Result
run: |
aws ssm get-command-invocation \
--instance-id ${{ env.INSTANCE_ID }} \
--command-id ${{ steps.send_command.outputs.command_id }} \
--output json \
--query '{
status: Status,
output: StandardOutputContent,
error: StandardErrorContent
}'
name: SSM | Dev
on:
push:
branches:
- develop
jobs:
plan:
name: SSM workflow call
uses: ./.github/workflows/ssm.yml
permissions:
id-token: write
contents: read
with:
environment: dev
secrets: inherit
AWS認証の際に role-to-assume
に委任したいロールのARNを設定することで、
Github Actions 上からそのロールとして実際にAWSのリソースにアクセスできます。
そのため、キー情報等を一切含めることなくリモートコマンドを実行可能です🎉
成功すると以下のように結果が出力されます。
出力の例
最後に
ここまで読んでいただきありがとうございます。
SSM はとてもいい機能なので皆さんに知ってもらいたいという気持ちからつい熱が入ってしまい果てしなく長くなってしまいました。。
また、大分めちゃくちゃな書き方をしてしまったので、不明点やご指摘点等ありましたらコメントいただけたらと思います!
Discussion