こんにちは。
メディアサービス開発部バックエンド開発グループのフサギコ(髙﨑)です。
Ruby on Railsによるバックエンドの実装運用と、AWSによるサービスインフラの設計構築を中心とした、いわゆるテックリードのような立ち位置で働いています。
CircleCIでもOpenID ConnectのIDプロバイダ機能がリリースされた
私が書いた一つ前の記事では、GitHub ActionsとAWS IAMをOpenID Connectを使って連携させ、TypeScriptで記述したアプリケーション(Next.js, Serverless Framework)のデプロイや、TerrafromのリポジトリにPRを出した際の自動planを永続的アクセスキー不要で行っているとお話ししました。
一方で、TypeScriptやTerraform以外のアプリケーション、例えばRuby on RailsやPlay Framework(Scala)で書かれたアプリケーションはCircleCIでCI/CDを行っていたため、まだCircleCIのcontextにIAMユーザのアクセスキーを格納する方法でデプロイを行っていました。
そこに先日、CircleCIでもOpenID ConnectのIDプロバイダ機能がリリースされたとの告知がありました!
#OIDC リリースされました!
— CircleCI Japan (@CircleCIJapan) 2022年3月26日
(OpenID Connect Tokens)
Twitterでも「1月にでるはずだったよね」という期待をずいぶん頂き、ご関心をひしひしと感じてしました。
- Changelog: https://t.co/oqM0zbE2OF
- Doc: https://t.co/fOYKoKfTNd
それをうけて早速、CircleCIでもOpenID Connectを通じてAWSの一時アクセスキーを都度発行して操作する仕組みをTerraformで構築しましたので、本記事ではその手順についてお話します。
CircleCIのOpenID Connect連携をTerraformで構築する
この節では、AWSをTerraformで管理できるようになっていること、CircleCI configの書き方をある程度理解していることは前提として、OpenID ConnectによってCircleCIがAWS APIを操作できる状態をTerraformで構築する手順について述べます。
OpenID Connect Providerを作成する
下記のようにして、OpenID Connect Provider設定をAWS IAMに作成します。
locals { circle_ci_org_id = "00000000-1111-2222-3333-4444444444444" circle_ci_project_id = "55555555-6666-7777-8888-9999999999999" } data "http" "circle_ci_openid_configuration" { url = "https://oidc.circleci.com/org/${local.circle_ci_org_id}/.well-known/openid-configuration" } data "tls_certificate" "circle_ci" { url = jsondecode(data.http.circle_ci_openid_configuration.body).jwks_uri } resource "aws_iam_openid_connect_provider" "circle_ci_oidc_provier" { url = "https://oidc.circleci.com/org/${local.circle_ci_org_id}" client_id_list = [local.circle_ci_org_id] thumbprint_list = [data.tls_certificate.circle_ci.certificates[0].sha1_fingerprint] }
local変数として定義しているcircle_ci_org_idはOrganization SettingsのContexts画面にて確認できるOrganization ID、circle_ci_project_idはProject SettingsのOverview画面で確認できるProject IDです。 contextを作成していないとOrganization IDは表示されませんので、適切な名前で作成しましょう。本記事の手順でも後ほど使います。
GitHub Actionsのときは固定値だったaws_iam_openid_connect_providerのclient_id_listがcircle_ci_org_idになっていますが、Terraformのaws_iam_openid_connect_providerリソースのドキュメントによれば、client_id_listにはOIDCのaud claimを指定するとあるため、CircleCIの場合はOIDCについてのドキュメントより、CIrcleCIのOrganization IDを指定すればよいことがわかります。
またGitHub Actionsの記事と内容が重複しますが、OpenID ConnectでAWS IAMへ一時アクセスキーの発行をリクエストする際には、OpenID ConnectのIDプロバイダ、すなわちここではCircleCIが署名したJWTをtokenとして添付します。 ここで作成しているOpenID Connect Provider設定は、そのJWTをAWS IAMが検証する際に用いるものです。
上の二つのデータソースでCircleCIがJWTの署名に用いる鍵の公開鍵を取得し、そのフィンガープリントをOpenID Connect Providerに設定しています。
何らかの理由によってCircleCIがJWTの署名に用いる鍵が変わると、このaws_iam_openid_connect_provider.github_actions_oidc_provier
に差分が出ることになります。
OpenID ConnectはJWTの署名とその検証によって成り立つ仕組みのため、公開鍵のフィンガープリントをしっかり確認するのはセキュリティの根幹です。もし確認を怠ると、予期しない署名者を誤って信頼してしまう可能性があるため、これはしっかりやっておく必要があります。
2022/3/30時点でのフィンガープリントは9e99a48a9960b14926bb7f3b02e22da2b0ab7280
でした。
IAMロールを作成する
下記のようにして、CircleCIのジョブが借り受けるIAMロールを作成します。
また、CircleCIのジョブで使いますので、aws_iam_role.example_roleのarnをAWS_ROLE_ARNという名前でCircleCI contextの環境変数に格納しておいてください。
resource "aws_iam_role" "example_role" { name = "circle_ci_open_id_connect_example_role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRoleWithWebIdentity" Effect = "Allow" Principal = { Federated = [aws_iam_openid_connect_provider.circle_ci_oidc_provier.arn] } Condition = { StringLike = { "oidc.circleci.com/org/${local.circle_ci_org_id}:sub" = [ "org/${local.circle_ci_org_id}/project/${local.circle_ci_project_id}/user/*", ] } } }] }) } resource "aws_iam_role_policy" "example_role_policy" { name = "example_role_policy" role = aws_iam_role.example_role.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:GetCallerIdentity" Effect = "Allow" Resource = "*" }] }) }
GitHub ActionsとはOpenID ConnectのJWTの内容が違いますから、当然assume_role_policyのCondition節の条件もそれに合わせて変える必要があります。
CircleCIのOIDCについてのドキュメントによれば、subは"org/<organization-id>/project/<project-id>/user/<user-id>"
との構成になっているので、上記のStringLikeでCircleCIのプロジェクト、すなわちGitHubにおけるリポジトリ単位までは制限できることになります。
一方で、gitのrefなどはIDトークンに含まれていませんので、「本番環境にデプロイできるIAMロールはmainブランチから起動されたジョブしかassumeできない」といったような制限の仕方は今のところできなさそうです。そのあたりは今後に期待ですね。
CircleCIのconfigを作成する
下記のようにして、CircleCIのconfigと、CircleCIのOpenID Connect IDトークンから一時アクセスキーを発行するシェルスクリプトを記述します。 シェルスクリプトはpermissionを744にして.circleciディレクトリの中にassume-role-with-oidc.shという名前でcommitしました。
version: 2.1 executors: minimal: docker: - image: cimg/base:stable resource_class: small orbs: aws-cli: circleci/aws-cli@2.1.0 jobs: assume_role_with_oidc: executor: minimal steps: - checkout - aws-cli/install - run: | ./.circleci/assume-role-with-oidc.sh ${AWS_ROLE_ARN} circle_ci_open_id_connect_example ${CIRCLE_OIDC_TOKEN} - run: | source ./.circleci/use-temporary-aws-access-key.sh aws sts get-caller-identity workflows: version: 2 test_oidc: jobs: - assume_role_with_oidc: context: oidc-example
#!/usr/bin/env bash set -euo pipefail DURATION_SECONDS=$((60*15)) aws_sts_credentials=`aws sts assume-role-with-web-identity \ --role-arn $1 \ --role-session-name $2 \ --web-identity-token $3 \ --duration-seconds ${DURATION_SECONDS} \ --query "Credentials" \ --output "json"` echo AWS_ACCESS_KEY_ID="$(echo $aws_sts_credentials | jq -r '.AccessKeyId')" cat <<EOT > "$(dirname $0)/use-temporary-aws-access-key.sh" export AWS_ACCESS_KEY_ID="$(echo $aws_sts_credentials | jq -r '.AccessKeyId')" export AWS_SECRET_ACCESS_KEY="$(echo $aws_sts_credentials | jq -r '.SecretAccessKey')" export AWS_SESSION_TOKEN="$(echo $aws_sts_credentials | jq -r '.SessionToken')" EOT
参考にさせていただいた記事のシェルスクリプトとの差分は、
- xオプションを外し、その代わりにアクセスキーIDだけechoするようにした
- role session nameも外から渡せるようにした
あたりです。
さて、前述のTerraformをapplyしたうえでCircleCI configをGitHubにpushすると、CircleCIは指定したIAMロールの権限を持った有効期限付きのアクセスキーを借り受け、aws cliを通じてstsのget-caller-identityコマンドを実行します。その模様が下記のスクリーンショットです。
aws sts get-caller-identity
コマンドの出力のArnのassumed-roleの次に指定したIAMロールのロール名である circle_ci_open_id_connect_example_role
が、その次にrole-session-nameで指定したcircle_ci_open_id_connect_example
が入っていることから、OpenID Connectを通じて借り受けたアクセスキーを使っていることが確認できます。
このようにして、CircleCIでもOpenID Connectを通じて借り受けたアクセスキーを使ってAWSのAPIを操作できるようになりました。
aws-ecr Orbを使ってECRへpushする
これで永続的アクセスキーが不要になりました、めでたしめでたし…となればよかったのですが、そうなるまでにはもう一つハードルがありました。
本記事の冒頭でCircleCIでCI/CDしているとお話ししましたRuby on RailsやPlay Framework(Scala)で書かれたアプリケーションは、いずれもDockerイメージをECRにpushしてデプロイしています。
その際には、DockerイメージのbuildからECRへのpushを一括して行ってくれるCircleCI公式のaws-ecr Orbを利用しています。そのaws-ecr Orbが提供しているaws-ecr/build-and-push-image
ジョブを用いるとECRへのログインからDockerイメージのbuild, pushまで一気通貫で行ってくれるので便利でしたが、そのジョブの内部で使用しているaws-ecr/ecr-loginコマンドがECRへdockerログインを行う際、従来の永続的アクセスキーを受け取ること前提の書き方になっている1ため、OIDCを通じて発行した一時アクセスキーではECRへのログインに失敗する状態になってしまいました。
そのため、下記のようにジョブとしては自前で定義し、途中のECRへのログインをするstepは直接書き、Dockerイメージのbuildとpushはaws-ecr Orbのbuild-imageコマンドとpush-imageコマンドを利用する、という形に落ち着きました。
- run: name: login to ECR command: | source ./.circleci/use-temporal-aws-access-key.sh aws ecr get-login-password | docker login --username AWS --password-stdin $AWS_ECR_ACCOUNT_URL - aws-ecr/build-image: account-url: AWS_ECR_ACCOUNT_URL repo: repo_name tag: << parameters.image_tag >> extra-build-args: << parameters.extra-build-args >> - aws-ecr/push-image: account-url: AWS_ECR_ACCOUNT_URL repo: repo_name tag: << parameters.image_tag >>
このあたりはOpenID Connectがリリース直後すぎてOrbの対応が追い付いていないだけだと思いますので、先々のOrbのアップデートで対応されることに期待したいと思います。
まとめ
本記事では前記事のGitHub Actionsに続いてCircleCIでもOpenID Connectを通じてAWSのAPIをセキュアに操作する仕組みをTerraformで構築する手順と、aws-ecr Orbの7系を用いてAWC ECRへDockerイメージをbuild, pushしようとした際に期待通り動作しなかったこと、その暫定的対処についてお話しました。
これからCI/CDを整備しようというかたの参考になれば幸いです。
ブックウォーカーでは物理・電子・Web連載問わず漫画や本が好き、あるいはテストやデプロイなどの定型業務は自動化に任せ、よりサービスに注力できる状態を作り上げることに興味があるWebアプリケーションエンジニアを募集しています。
興味がありましたらぜひ、採用情報ページからご応募ください。
参考文献
- CircleCIがOpenID ConnectをサポートしたのでAWSと連携させてJWTを使用したAssumeRoleを試してみた | DevelopersIO
- aws_iam_openid_connect_providerのthumbprint_listの計算方法 - Qiita
-
aws-ecr Orbは最新の8系が先日リリースされており、今回試せたのは7系latestの7.3.0ですが、8系でも同じ書かれ方をしているため、同様にまだ未対応だと思われます↩