まだ朝晩は涼しいですが、新潟もだいぶ暑くなってきました。梅雨入り前のこの時期は、外に出るたびに夏が近づいてくるのを感じます。仕事終わりに軽めのビールを一杯やるには良い季節ですね。
さて、近頃の私はというと WordPress サイトの構築のために、自社で管理している GitLab から AWS 上の EC2 に対してソースコードを rsync でデプロイする仕組みを作りました。
使用した技術は OIDC, AWS STS, EC2 Instance Connect, SSM Session Manager となります。
やりたいこと
やりたかったことは以下の通りです。
- GitLab CI に長期の AWS アクセスキーや SSH 秘密鍵を置かない
- EC2 の 22 番ポートを開けない
- 踏み台サーバーを用意しない
- 既存の運用に近い形で rsync デプロイしたい
そこで今回は、GitLab CI の OIDC トークンで AWS IAM ロールを引き受け、SSM Session Manager 経由の SSH で EC2 に接続し、最後は rsync でファイルを配信する構成にしました。EC2 はプライベートサブネットに置いたままで、インバウンドのポートは一切開けていません。
この構成にすると、認証情報の管理が楽になります。
- IAM アクセスキーを発行しないため、アクセスキーのローテーションが不要になる
- SSH 鍵も短い有効期限のものを都度使用するため、GitLab で秘密鍵を管理しなくてよい
- EC2 はプライベートサブネットにあり、22 番ポートも開放していないため、外部から直接アクセスできない
なお、以下の説明ではドメイン名、AWS アカウント ID、インスタンス ID などは仮のものとして書いています。
全体の構成
大まかな流れは次の通りです。
- GitLab CI が id_tokens で OIDC ID トークンを発行する
- AWS STS の AssumeRoleWithWebIdentity でデプロイ用 IAM ロールを引き受ける
- EC2 Instance Connect で一時 SSH 公開鍵を EC2 に注入する
- SSM Session Manager の AWS-StartSSHSession を SSH の ProxyCommand として使う
- その SSH 経由で rsync する
SSM Session Manager だけでも EC2 へは到達できますが、rsync は SSH を使います。
そこで、接続経路は SSM に閉じつつ、認証に使う SSH 公開鍵は EC2 Instance Connect でジョブごとに一時注入する形にしています。
この公開鍵も注入から 60 秒だけ有効なので、ジョブが終われば何も残りません。
Terraform 側の定義
OIDC プロバイダーと IAM ロールは Terraform で管理しています。
まず、セルフマネージド GitLab を IAM の OIDC プロバイダーとして登録します。
data "tls_certificate" "gitlab" {
url = var.gitlab_url
}
resource "aws_iam_openid_connect_provider" "gitlab" {
url = var.gitlab_url
client_id_list = [var.gitlab_oidc_audience]
thumbprint_list = [data.tls_certificate.gitlab.certificates[0].sha1_fingerprint]
}
デプロイ用ロールの信頼ポリシーでは、aud と sub を見ています。
locals {
gitlab_oidc_hostpath = replace(var.gitlab_url, "https://", "")
}
data "aws_iam_policy_document" "gitlab_deploy_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.gitlab.arn]
}
condition {
test = "StringEquals"
variable = "${local.gitlab_oidc_hostpath}:aud"
values = [var.gitlab_oidc_audience]
}
condition {
test = "StringLike"
variable = "${local.gitlab_oidc_hostpath}:sub"
values = [
"project_path:${var.gitlab_project_path}:ref_type:branch:ref:${var.gitlab_ref}"
]
}
}
}
対象リポジトリとブランチは terraform.tfvars で指定します。
gitlab_url = "https://gitlab.example.com"
gitlab_oidc_audience = "https://gitlab.example.com"
gitlab_project_path = "example-group/example-project"
gitlab_ref = "main"
これで、GitLab のプロジェクト example-group/example-project の main ブランチから起動したパイプラインだけが権限を限定したIAMロールを引き受けられます。
デプロイ用IAMロールに付ける権限は、EC2 と SSM の参照、対象インスタンスへの AWS-StartSSHSession の開始、自分が開始した SSM セッションの操作、EC2 Instance Connect の公開鍵注入に絞りました。
EC2 Instance Connect の部分は、OS ユーザーも条件で縛っています。
statement {
sid = "SendTemporarySshKey"
effect = "Allow"
actions = ["ec2-instance-connect:SendSSHPublicKey"]
resources = ["arn:aws:ec2:${var.region}:${data.aws_caller_identity.current.account_id}:instance/${aws_instance.ec2.id}"]
condition {
test = "StringEquals"
variable = "ec2:osuser"
values = [var.deploy_user]
}
}
ここで ec2:osuser を使うのがポイントです。
アクション名は ec2-instance-connect:SendSSHPublicKey ですが、条件キーのプレフィックスは ec2-instance-connect ではありません。
詳しくは後述の「ハマったところ」で触れます。
GitLab CI 側の設定
GitLab CI 側では id_tokens を使って OIDC ID トークンを発行します。
本番のデプロイジョブはこんな感じです。
deploy-prod:
stage: deploy
image:
name: amazon/aws-cli:2.17.0 # latest ではなくバージョン固定
entrypoint: [""]
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
id_tokens:
AWS_ID_TOKEN:
aud: https://gitlab.example.com
before_script:
# デプロイに必要なツールを追加(Runner のアーキテクチャに合わせる)
- yum install -y unzip openssh-clients rsync
- |
curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/linux_arm64/session-manager-plugin.rpm" -o "session-manager-plugin.rpm" && \
yum install -y session-manager-plugin.rpm && \
rm -f session-manager-plugin.rpm
# OIDC: 静的キーなしでロールを引き受ける
- export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/web_identity_token
- echo "$AWS_ID_TOKEN" > "$AWS_WEB_IDENTITY_TOKEN_FILE"
- export AWS_ROLE_ARN="${DEPLOY_ROLE_ARN}"
- aws sts get-caller-identity # 失敗すれば早期に落ちる
AWS CLI v2 では、環境変数で AWS_WEB_IDENTITY_TOKEN_FILE(OIDC トークンの置き場所)と AWS_ROLE_ARN(引き受けたいロール)を渡しておけば、あとは CLI が勝手にロールへの引き受け処理をやってくれます。
befor_script で aws sts get-caller-identity を入れているのは、OIDC まわりに問題があったら早い段階でジョブが落ちるようにするためです。
aud は Terraform 側の gitlab_oidc_audience と完全に一致させます。https:// の有無も含めて別物として扱われるので、ここは明示しておきましょう。
OIDC経由でIAMロールを引き受けたら、CI ジョブ内で SSH 鍵を作り、公開鍵を EC2 Instance Connect で注入します。
ssh-keygen -t ed25519 -N '' -f /tmp/deploy_key -q
aws ec2-instance-connect send-ssh-public-key \
--instance-id "${DEPLOY_INSTANCE_ID}" \
--instance-os-user "${DEPLOY_USER}" \
--ssh-public-key "file:///tmp/deploy_key.pub"
SSH の設定では、SSM Session Manager を ProxyCommand に指定します。
接続先はホスト名ではなく EC2 のインスタンス ID です。
Host i-xxxxxxxxxxxxxxxxx
User ec2-user
IdentityFile /tmp/deploy_key
IdentitiesOnly yes
ConnectTimeout 60
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
ProxyCommand sh -c "aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters portNumber=%p --region ${AWS_REGION}"
あとは通常の SSH 接続と同じように rsync できます。
rsync -amz --delete \
-e "ssh -F /tmp/ssh_config" \
./wp-content/themes/ \
"${DEPLOY_INSTANCE_ID}:${DEPLOY_PATH}/wp-content/themes/"
デプロイ後に wp-cli で翻訳ファイルを更新するといった処理も、同じ SSH 設定をそのまま使って実行できます。
ssh -F /tmp/ssh_config "$PROD_DEPLOY_INSTANCE_ID" "/usr/local/bin/wp --path=${PROD_DEPLOY_PATH} language core update"
EC2 側には SSM Agent と、インスタンスロールに AmazonSSMManagedInstanceCore 相当の権限が必要です。
ハマったところ
ID トークンが空になる
実際に GitLab CI/CD でまわしたところ、aws sts get-caller-identity でエラーが発生しました。
Invalid length for parameter WebIdentityToken, value: 1
エラーメッセージにもあるとおり、トークンファイルの中身が改行 1 文字だけで ID トークンが空でした。
id_tokens の変数名、マージ後の CI 設定、同名の CI/CD 変数、Runner バージョンを確認しても問題はありません。
原因は GitLab サーバー側 ci_jwt_signing_key の欠落でした。
GitLab は CI の ID トークンを内部の署名鍵で署名して発行します。
この鍵がないと、id_tokens を書いてもトークンが発行されません。
確認は GitLab サーバー上で行いました。
sudo gitlab-rails runner "puts Gitlab::CurrentSettings.ci_jwt_signing_key.present? ? 'present' : 'MISSING'"
結果は MISSING で、GitLab サーバに署名鍵が無いことが判明しました。
今回は次のように鍵を生成して保存しています。
sudo gitlab-rails runner "
key = OpenSSL::PKey::RSA.new(2048).to_pem
Gitlab::CurrentSettings.current_application_settings.update!(ci_jwt_signing_key: key)
puts 'saved'
"
sudo gitlab-ctl restart puma
sudo gitlab-ctl restart sidekiq
古い情報だと Rails.application.secrets.ci_jwt_signing_key を見る例もありますが、新しめの GitLab ではその参照経路が使えない場合があります。
今回は Gitlab::CurrentSettings で確認しました。
この鍵は通常、インストールやアップグレードで用意されるものです。
過去に gitlab-secrets.json を伴わないバックアップからリストアした影響があったのではないかと思われます。
復旧後は gitlab-secrets.json を含めてバックアップを取り直しておきましょう。
aud は完全一致させる
OIDC の aud でもハマりました。
合わせる必要があるのは次の 3 つです。
- GitLab の ID トークンに入る aud
- IAM OIDC プロバイダーの ClientIDList
- IAM ロール信頼ポリシーの :aud 条件
検証時だけ payload の aud を確認してみます。トークン本体は機密なのでログに残さないようにします。
PAYLOAD=$(echo "$AWS_ID_TOKEN" | cut -d. -f2)
echo "${PAYLOAD}===" \
| tr '_-' '/+' \
| base64 -d 2>/dev/null \
| tr ',' '\n' \
| grep -i aud || true
https://gitlab.example.com と gitlab.example.com は別物です。GitLab CI の id_tokens、Terraform の client_id_list、IAM 信頼ポリシーを同じ文字列に揃えて解決しました。
EC2 Instance Connect の条件キー
なんとか OIDC federation が通ったので、EC2 Instance Connect の公開鍵を注入しようとしたら AccessDenied になりました。
is not authorized to perform: ec2-instance-connect:SendSSHPublicKey
IAM ポリシーには ec2-instance-connect:SendSSHPublicKey を書いていて、EC2 の ARN もリージョンも合っているように見えます。
ここで simulate-principal-policy を使い、確認することにしました。
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::000000000000:role/example-gitlab-deploy \
--action-names ec2-instance-connect:SendSSHPublicKey \
--resource-arns arn:aws:ec2:ap-northeast-1:000000000000:instance/i-xxxxxxxx \
--context-entries 'ContextKeyName=ec2:osuser,ContextKeyValues=ec2-user,ContextKeyType=string' \
--query 'EvaluationResults[0]'
原因は条件キーでした。
SendSSHPublicKey で OS ユーザーを縛る条件キーは ec2:osuser とすべきでしたが、 ec2-instance-connect:osuser と書いており、マッチしませんでした。
アクションのプレフィックスと条件キーのプレフィックスが違うので、ここは公式ドキュメントを見ないと間違えやすいところでした。
OpenSSH の accept-new
最後に SSH 設定で次のエラーが出ました。
/tmp/ssh_config line 6: unsupported option "accept-new".
今回は接続経路を SSM に閉じ、IAM と一時鍵で制御する前提だったため、次の設定にしています。
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
通常 SSH 接続時にホスト鍵検証を無効化するのは避けるべきです。
今回は、EC2 インスタンス ID を接続先にし、SSM Session Manager を経由して、IAM で操作主体を絞っていることを前提にしているため、無効化しても問題ないという判断です。
やってみてよかったこと
この構成にして、デプロイまわりの運用はかなり整理できました。
- GitLab CI に長期 AWS アクセスキーを保存しなくてよい
- EC2 の SSH ポートを公開しなくてよい
- 踏み台サーバーを持たなくてよい
- IAM 信頼ポリシーでリポジトリとブランチを制限できる
- ファイル配信は従来通り rsync で扱える
特に、OIDC で「どのリポジトリのどのブランチから来たジョブか」を IAM 側で見られるのは扱いやすいです。静的キーを CI/CD 変数に置く構成に比べると、漏えい時の影響範囲も抑えやすくなります。
一方で、セルフマネージド GitLab の OIDC は GitLab サーバー側の状態にも依存します。今回の ci_jwt_signing_key のように、CI YAML だけ見ていても分からない原因がありました。
また、IAM でハマったときは、勘で直すより simulate-principal-policy を使ったほうが早いです。どの条件がマッチしていないかを機械的に見られるので、今回のような条件キーの取り違えにも気づきやすくなります。
まとめ
GitLab CI から EC2 へデプロイするだけなら、方法はいくつもあります。
ただ、長期アクセスキーを置かず、SSH ポートも開けず、踏み台も増やさずに rsync したい場合は、OIDC、SSM Session Manager、EC2 Instance Connect の組み合わせが現実的でした。
部品ごとはよくある仕組みですが、実際に組み合わせると aud の完全一致、GitLab の署名鍵、EC2 Instance Connect の条件キーなど、地味にハマるポイントがあります。
一度形にしてしまえば、あとは Terraform と GitLab CI の設定として管理できます。
静的キーを減らしながら、既存のデプロイ手順を大きく変えずに済んだのは良かったです。
現場からは以上です。
Blog