🤖

Lambdaを使ってリポジトリ内のファイルを取得しGitHub Issueを作成する

に公開

はじめに

こんにちは、M-Yamashitaです。

業務でAWS Lambda(以降、Lambda)を使うことになったので、お試しとしてLambdaを使ってリポジトリ内のファイルを取得しGitHub Issueを作成することをやってみることにしました。業務で活かせることを考え、GitHub上の個人で作成した組織を使用し、その組織にあるリポジトリからマニフェストを取得することを考えます。

なお今回の記事は、いわゆるやってみた記事となるため、作成手順に加えて時折発生したエラーとその原因・解決策も載せています。そのため脱線が多い内容となりますのでご注意ください。

前提

使用バージョン

ローカル環境のMacOS: Sequoia 15.3.2
Ruby: 3.3
Terraform: 1.9.5

本記事の前に実施済みなこと

  • ローカル環境へのTerraformのインストール
  • GitHubとAWSとのOIDC連携

構成図

今回作成しようとする構成図は次のとおりです。

AWS上でLambda、Amazon ECR(以降、ECR)、AWS Secrets Manager(以降、Secrets Manager)を使い、GitHubのリポジトリ内にあるマニフェストファイルからnamespace一覧を取得できる構成となります。またLambdaはRubyのコンテナを使用し、GitHub Appsを使用してリポジトリにアクセスするようにします。
なお、AWSの各サービスの構築に関してはTerraformを使用します。さらにCI/CD環境を構築し、GitHub Actionsを使用してECRにイメージをプッシュし、Lambdaをデプロイするようにします。

システムの構築

GitHub Appsの作成および対象の組織へのインストール

まずはGitHub Appsを作ります。GitHubの公式ドキュメントやZennの記事をもとに、組織向けのGitHub Appsを作成します。

https://docs.github.com/ja/apps/creating-github-apps/registering-a-github-app/registering-a-github-app

https://zenn.dev/tmknom/articles/github-apps-token

基本的には公式ドキュメントに沿って必要項目を埋めておきます。Expire user authorization tokensについてはGitHubがチェックしたままを推奨しているのでそのままにしておきます。またPermissions項目については、今回はRepositoryからのコンテンツ取得、およびissueを作成するので、Repository permissionsを開き、以下2つの項目の権限を変更します。

  • Contents: Read-only
  • Issues : Read and write

実際に設定した画面はこちらです。

これで保存すると、次のような画面が表示されます。

この画面にて、Client IDの値があるので控えておきます。また、この画面下部にあるGenerate a private keyを押下し、private keyを生成しておきます。これも後ほど必要になりますので、忘れずに控えておきます。

左サイドメニューのInstall Appを選択すると、次のような画面が表示されますので、GitHub Appsを対象組織にインストールすることでGitHub Appsの設定は完了となります。

GitHub AppsのクレデンシャルをSecrets Managerに登録する

Secrets Managerを開き、シークレットを新しく保存します。シークレットタイプについては、今回GitHub Appsの秘匿情報を追加するためその他のシークレットのタイプを選択します。
キー/値のペア項目では、先ほどのClient IDprivate keyの値をそれぞれGITHUB_APPS_CLIENT_IDGITHUB_APPS_PRIVATE_KEYとして登録します。

Client IDprivate keyを設定後、Secrets Manager上でのシークレットの名前を指定します。ここではgithub_apps_secretsと指定しました。出来上がったシークレットは以下画像のようになります。

Lambda上のRubyでの実装およびテスト環境の整備

Rubyでの実装

次にLambdaで実行するRubyのコードを準備していきます。コードで実現したいことは以下のとおりです。

  1. GitHub AppsのClient IDprivate keyをSecretsManagerから取得する
  2. octokitとjwtのgemを使い、GitHub Appsを使ったoctokitのクライアントを作成する
  3. 2のクライアントを使い、GitHubのリポジトリからマニフェストファイルを取得する
  4. マニフェストファイルからnamespaceを取得し、2のクライアントを使ってissueとして投稿する

AWSの公式ドキュメントにて、以下のようなサンプルコードが用意されていますのでそれを元に作成していきます。

module LambdaFunctions
  class Handler
    def self.process(event:,context:)
      "Hello!"
    end
  end
end

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/ruby-handler.html

また先ほどの公式ドキュメントにはてベストプラクティスも記載してあります。それによると、次のようなコアロジックの分離での実装をすすめられているので、実装ではそこの部分も考慮した形としました。

  • Lambda ハンドラーをコアロジックから分離します。
def lambda_handler(event:, context:)
    foo = event['foo']
    bar = event['bar']

    result = my_lambda_function(foo:, bar:)

end

def my_lambda_function(foo:, bar:)
    // MyLambdaFunction logic here

end

この実装を進めるうえでのポイントの1つとして、gemのインストールをどこで実施するかという点があります。方法としては2つあり、1つはLambdaを実行した際にgemをリアルタイムでインストールする方法、もう1つはコードをコンテナ化しておき、Gemfileにgemを定義してビルド時にインストールしておく方法があります。
当初コンテナ化するほどでもないかなと思い、リアルタイムでgemをインストールする方法を試してみました。ですがgemの依存ライブラリがNative extensionのgemとなっており、そのインストールが毎回エラーとなってしまっていました。このエラーに対してこれといった解決策が見つからず長い間つまづいてしまっており、これ以上の時間を投資しても解決できるか不明な状態でした。
そのためリアルタイムでのインストールはいったん諦めて、コンテナ化する方向に舵を切りました。この時のdockerfileとしては以下の通りです。

FROM ruby:3.3.0-slim-bookworm AS build

RUN apt-get update && \
  apt-get install -y --no-install-recommends \
  build-essential \
  libssl-dev \
  zlib1g-dev \
  libreadline-dev \
  libxml2-dev \
  libxslt1-dev \
  libffi-dev && \
  apt-get clean && \
  rm -rf /var/lib/apt/lists/*

RUN gem install bundler:2.5.3

WORKDIR /app
# Copy Gemfile and Gemfile.lock
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local path 'vendor/bundle'
RUN bundle install

FROM public.ecr.aws/lambda/ruby:3.3

COPY --from=build /app/vendor/bundle vendor/bundle

# Copy function code
COPY lib/*.rb ${LAMBDA_TASK_ROOT}/

# Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile)
CMD [ "lambda_function.LambdaFunction::Handler.process" ]

このdockerfileではマルチステージビルドを採用し、ビルド後のイメージを軽量化するようにしました。この軽量化の目的として、不要なファイルやライブラリを含めないことによるセキュリティリスクの低減や、後述するECRにアップロードした際の課金額を減らす狙いがあります。

これで実装は完了です。

テスト環境の整備

コードが出来上がったのでテスト環境を整えます。今回はコード量が少なくやりたいこともシンプルだったのでコードを先に作った後にテスト環境を整えることにしました。

今回のテスト環境の整備においてテストフレームワークには手慣れたRSpecを採用しました。またテスト実施にあたってテスト用のprivate keyが必要と思われたので、octokitリポジトリからテスト用のprivate keyを取得しました。
https://github.com/octokit/octokit.rb/blob/main/spec/fixtures/fake_integration.private-key.pem
加えて、Secrets Managerからの取得をテストできるようにLocalStackを導入します。LocalStackの導入が初めてだったこともありいろいろとつまづいてしまったので、導入手順も含めて記載します。

LocalStackの導入

LocalStackの構築に関しては、以下公式ドキュメントとZennの記事を参考に構築します。
https://docs.localstack.cloud/getting-started/installation/
https://zenn.dev/negibouze/articles/0b0d115cacf6cb

構築にはCLIやdocker compposeを使った方法があります。後述するCI環境でRSpecを使ったテストを実行する際、docker composeでLocalStackを起動しておく方法がシンプルで分かりやすいと考えたので、 docker composeの使用を選択しました。公式ドキュメントにcomposeファイルのテンプレートがあるので、これを元にSERVICES=secretsmanagerを指定します。作成したcomposeファイルは次のとおりです。

compose.yaml
services:
  localstack:
    container_name: "${LOCALSTACK_DOCKER_NAME:-localstack-main}"
    image: localstack/localstack
    ports:
      - "127.0.0.1:4566:4566"            # LocalStack Gateway
    environment:
      - SERVICES=secretsmanager
      - DEBUG=${DEBUG:-0}
    volumes:
      - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"

次に、LocalStack上のsecretsmanagerに値が登録されるようにします。この値の登録に関して調べたところ、LocalStackにはライフサイクルのフェーズがあるようです。
https://docs.localstack.cloud/references/init-hooks/

LocalStack has four well-known lifecycle phases or stages:

  • BOOT: the container is running but the LocalStack runtime has not been started
  • START: the Python process is running and the LocalStack runtime is starting
  • READY: LocalStack is ready to serve requests
  • SHUTDOWN: LocalStack is shutting down
    You can hook into each of these lifecycle phases using custom shell or Python scripts. Each lifecycle phase has its own directory in /etc/localstack/init. You can mount individual files, stage directories, or the entire init directory from your host into the container.
 /etc
 └── localstack
     └── init
         ├── boot.d           <-- executed in the container before localstack starts
         ├── ready.d          <-- executed when localstack becomes ready
         ├── shutdown.d       <-- executed when localstack shuts down
         └── start.d          <-- executed when localstack starts up

この仕様を見ると、各フェーズにおいてそのディレクトリに配置したファイルが実行されるようです。そのため、secretsmanagerへの登録処理を記載したinit.shとシークレット情報を持ったsecrets.jsonを作成し、etc/localstack/init/boot.dディレクトリに配置すると、LocalStackの起動時にシークレットが登録されるようになります。値の登録処理ではawslocalコマンドを使用します。これはLocalStackのCLIコマンドであり、awsコマンドのラッパーとなります。そのためawslocalコマンドの使用方法はawsコマンドに似た使用方法となります。

https://github.com/localstack/awscli-local
https://docs.aws.amazon.com/cli/latest/reference/secretsmanager/create-secret.html

AWSの公式ドキュメントを見てみるとシークレットの登録例が掲載されています。この処理を元に、init.shの中身を以下のようにします。
https://docs.aws.amazon.com/cli/latest/reference/secretsmanager/create-secret.html#examples

init.sh
#!/bin/bash

awslocal secretsmanager create-secret \
    --name github_apps_secrets\
    --description "LocalStack Secret" \
    --secret-string file:///etc/localstack/init/ready.d/secrets.json

次にsecrets.jsonを作ります。secrets.jsonはコード管理することもあり本番用のシークレット情報は載せられません。そのためテスト用の値をセットしておきます。

secrets.json
{
  "GITHUB_APPS_PRIVATE_KEY": "private_key",
  "GITHUB_APPS_CLIENT_ID": "client_id"
}

また、init.shやsecrets.jsonをLocalStackの起動前に配置しておく必要があるので、LocalStackをコンテナ化します。

Dockerfile
FROM localstack/localstack
COPY --chown=localstack ./init.sh /etc/localstack/init/ready.d/init.sh
COPY --chown=localstack ./secrets.json /etc/localstack/init/ready.d/secrets.json
RUN apt-get update && apt-get install -y jq
RUN chmod u+x /etc/localstack/init/ready.d/init.sh

補足としてjqを入れたのはデバッグ用のためです。またLocalStackをコンテナ化したので先ほどのcomposeファイルも修正しておきます。

docker composeを使ってコンテナを起動後シークレットが登録されているかどうか確認します。この確認においてはコンテナ外からのアクセスでチェックします。この理由としては後述するCI環境において、RSpecの実行環境はコンテナではないため、コンテナ外からコンテナのLocalStackにアクセスした際に問題なく値を取れるようにしておくためです。

確認する際のコマンドには、ローカル環境にインストールしていたawsコマンドを使います。なおawsコマンド実行において、~/.aws/config~/.aws/credentialsに以下のようなdefault設定があったので、profileの引数指定は不要でした。

~/.aws/config
[default]
region = ap-northeast-1
~/.aws/credentials
[default]
aws_access_key_id = xxx
aws_secret_access_key = xxx

この前提でaws secretsmanager list-secretsを実行します。endpointの引数にはLocalStackのURL、regionの引数にはLocalStackのデフォルトリージョンであるus-east-1を指定します。すると以下のようなレスポンスが返ってきて、シークレットが登録されていることが確認できました。

❯ aws secretsmanager list-secrets --endpoint-url=http://localhost:4566 --region us-east-1
{
    "SecretList": [
        {
            "ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:github_apps_secrets-FsVdXB",
            "Name": "github_apps_secrets",
            "Description": "LocalStack Secret",
            "LastChangedDate": "2025-04-01T22:53:02.256303+09:00",
            "LastAccessedDate": "2025-04-01T09:00:00+09:00",
            "SecretVersionsToStages": {
                "3d8e19ce-887b-4e3c-9bea-9bacf32278c1": [
                    "AWSCURRENT"
                ]
            },
            "CreatedDate": "2025-04-01T22:53:02.256303+09:00"
        }
    ]
}

念の為、aws secretsmanager get-secret-valueを使って--secret-idの引数にシークレット名を指定し、シークレットの値を取得してみます。すると以下のようにシークレットの値が取得できました。

❯ aws secretsmanager get-secret-value --secret-id github_apps_secrets --endpoint-url=http://localhost:4566 --region us-east-1
{
    "ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:github_apps_secrets-FsVdXB",
    "Name": "github_apps_secrets",
    "VersionId": "3d8e19ce-887b-4e3c-9bea-9bacf32278c1",
    "SecretString": "{\n  \"GITHUB_APPS_PRIVATE_KEY\": \"private_key\",\n  \"GITHUB_APPS_CLIENT_ID\": \"client_id\"\n}",
    "VersionStages": [
        "AWSCURRENT"
    ],
    "CreatedDate": "2025-04-01T22:53:02+09:00"
}

これでLocalStackの導入は完了です。

CI環境の整備

コードとテスト環境が整ったので次にCI環境を整えます。CI環境には手慣れたGitHub Actionsを使用します。CIの流れとしてはLocalStackを起動しその後にテストを実行する形にしました。LocalStackの起動にはdocker composeを使用しているため、RSpecの実行時にLocalStackが起動している必要があります。そのためdocker composeでの起動後に、LocalStackの起動完了に必要な時間を見積もったsleepコマンドを入れ、その後にRSpecを実行するようにしました。
コード例は以下のようになります。

rspec.yaml
name: RSpec

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - name: Set up Ruby
      uses: ruby/setup-ruby@v1
      with:
        bundler-cache: true
    - name: Run tests
      run: |
        docker compose -f compose.yaml up -d
        sleep 10
        bundle exec rspec
        docker compose -f compose.yaml down

ワークフローはできたものの、CI実行時のエラーと改善点がそれぞれ1つずつ見つかりました。

CI実行時のエラー: Aws::InstanceProfileCredentials::TokenRetrivalError

ワークフローでのRSpec実行時に以下のエラーが発生しました。

Error retrieving instance profile credentials: Aws::InstanceProfileCredentials::TokenRetrivalError
・・・略・・・
Aws::Errors::MissingCredentialsError:
unable to sign request without credentials set
・・・略・・・

上記エラーが発生したときの対象のコードは以下の通りです。

  params = { region: 'ap-northeast-1' }

  # if localstack's endpoint is set, use it and set region to us-east-1 for localstack
  endpoint = ENV.fetch('AWS_SECRET_MANGER_ENDPOINT', nil)
  if endpoint
    params[:endpoint] = endpoint
    params[:region] = 'us-east-1'
  end

  @client = Aws::SecretsManager::Client.new(params)

原因と解決策

エラーの最初の部分だけ見るとわかりませんが、よく読んでみるとcredentialsが見つからないというエラーのようです。一応それ以外にも問題がないか調べるためGitHub Actionsのコードにデバッグコードを仕込んでみましたが、LocalStack用のendpointの環境変数、接続コード自体は問題なさそうでした。

credentialsの読み込みについてはAWS SDK for Rubyのgemが管理しているようでした。そのREADMEを読んでみると次のような記載があります。

  • The shared credentials ini file at ~/.aws/credentials. The location used can be changed with the AWS_CREDENTIALS_FILE ENV variable.
    • Unless ENV['AWS_SDK_CONFIG_OPT_OUT'] is set, the shared configuration ini file at ~/.aws/config will also be parsed for credentials.

https://github.com/aws/aws-sdk-ruby/tree/version-3?tab=readme-ov-file#configuration

これを今の状況に当てはめて考えると、ローカル環境上ではすでに~/.aws/config~/.aws/credentialsが存在しているので、コード上でAws::SecretsManager::Client.new(params)を実行した際、newの引数にcredentialsがなくてもデフォルトでローカルの設定を読み込んでくれます。
一方、GitHub Actions上では~/.aws/config~/.aws/credentialsは存在しないため、credentialsの引数を指定しないとエラーが発生してしまうようでした。
そのためLocalStackを使うケースでは、以下のようにAws::SecretsManager::Clientの初期化時にcredentialsの引数も与える形にします。

  params = { region: 'ap-northeast-1' }

  # if localstack's endpoint is set, use it and set region to us-east-1 for localstack
  endpoint = ENV.fetch('AWS_SECRET_MANGER_ENDPOINT', nil)
  if endpoint
    params[:endpoint] = endpoint
    params[:region] = 'us-east-1'
    params[:credentials] = Aws::Credentials.new('dummy', 'dummy') # これを追加
  end

  @client = Aws::SecretsManager::Client.new(params)

この修正によりCI実行時のエラーを解決できました。

改善ポイント: docker composeのヘルスチェック追加

RSpec実行前にsleepを入れていますが、これはLocalStackの起動に必要な時間を見積もっているだけなので、LocalStackが起動完了したかどうかは確認できていません。そのため、docker composeによる起動が想定以上に時間がかかってsleepの時間をオーバーしてしまい、LocalStack起動完了前にRSpecが実行されて失敗することも考えられます。加えてsleepを使う方法はスマートではないと感じました。

これを改善すべく何か良い方法がないかと調べてみたところ、docker composeにはヘルスチェック機能が存在するようです。またそのヘルスチェックが通るまで待つ--waitオプションもあるようです。

https://qiita.com/hichika/items/9b96634d471246359e66#ヘルスチェックの設定

公式ドキュメントにも記載があります。

  • ヘルスチェックの書き方:

https://docs.docker.com/reference/compose-file/services/#healthcheck

  • ヘルスチェックの各項目について

https://docs.docker.com/reference/dockerfile/#healthcheck

  • waitオプション

https://docs.docker.com/reference/cli/docker/compose/up/

このヘルスチェックを使うにあたり、LocalStackにおけるヘルスチェックのエンドポイントを調べたところ、公式ドキュメントに記載がありました。

The API path for the LocalStack internal resources is /_localstack. Several endpoints are available under this path. For instance, /_localstack/health checks the available and running AWS services in LocalStack while /_localstack/diagnose (enable with the DEBUG=1 configuration variable), reports extensive and sensitive data from the LocalStack instance.
https://docs.localstack.cloud/references/internal-endpoints/

この文章を読むと、LocalStackのヘルスチェックのエンドポイントは/_localstack/healthとなるようです。そのため、http://localhost:4566/_localstack/healthで確認ができそうでした。試しにcurlで叩いて、jqでレスポンスを整形すると以下のようなレスポンスが返ってきました。

curl -s http://localhost:4566/_localstack/health | jq                                          
{
  "services": {
    "acm": "disabled",
    "apigateway": "disabled",
    "cloudformation": "disabled",
    "cloudwatch": "disabled",
    "config": "disabled",
    "dynamodb": "disabled",
    "dynamodbstreams": "disabled",
    "ec2": "disabled",
    "es": "disabled",
    "events": "disabled",
    "firehose": "disabled",
    "iam": "disabled",
    "kinesis": "disabled",
    "kms": "available",
    "lambda": "available",
    "logs": "disabled",
    "opensearch": "disabled",
    "redshift": "disabled",
    "resource-groups": "disabled",
    "resourcegroupstaggingapi": "disabled",
    "route53": "disabled",
    "route53resolver": "disabled",
    "s3": "available",
    "s3control": "disabled",
    "scheduler": "disabled",
    "secretsmanager": "running",
    "ses": "disabled",
    "sns": "disabled",
    "sqs": "disabled",
    "ssm": "disabled",
    "stepfunctions": "disabled",
    "sts": "available",
    "support": "disabled",
    "swf": "disabled",
    "transcribe": "disabled"
  },
  "edition": "community",
  "version": "4.2.1.dev7"
}

このレスポンスを見ると、ヘルスチェックのエンドポイントはLocalStackで使用可能なサービスの一覧とそのステータスを返してくれるようです。今回使用するsecretsmanagerの状態は"secretsmanager": "running",となっていました。
そのため、runningになっていることをヘルスチェックの条件に追加したcomposeファイルに修正します。

compose.yaml
version: "3.6"

services:
  localstack:
    container_name: "${LOCALSTACK_DOCKER_NAME:-localstack-main}"
    build: ./localstack
    ports:
      - "127.0.0.1:4566:4566"
    environment:
      - SERVICES=secretsmanager
      - DEBUG=${DEBUG:-0}
    healthcheck: # このhealthcheck項目を追加
      test: [ "CMD-SHELL", "curl -s http://localhost:4566/_localstack/health | jq '.services.secretsmanager' | grep running" ]
      interval: 1s
      timeout: 30s
      retries: 10
      start_period: 1s
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock"

このようにすることで、LocalStackの起動後にヘルスチェックが実行され、secretsmanagerの状態がrunningになるまで待機してくれるようになります。
これでsleepコマンドを外すことができます。スマートにしたワークフローは以下の通りです。

rspec.yaml
name: RSpec

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - name: Set up Ruby
      uses: ruby/setup-ruby@v1
      with:
        bundler-cache: true
    - name: Run tests
      run: |
        docker compose -f compose.yaml up -d --wait
        bundle exec rspec
        docker compose -f compose.yaml down

これでCI環境の整備は完了です。次はTerraformでのAWS上の各サービス構築を行います。

Terraformの初期設定およびIAMユーザーやECRの作成

ここではTerraformで初期設定した後にIAMユーザー、ECRをそれぞれ構築します。

初期設定: S3をbackendにする

初期設定に関する記事がありましたので、それに沿ってTerraform用のIAMユーザー(lambda-terraformer)を作成し、アクセスキーとシークレットキーを取得します。なお、IAMユーザーのポリシーにはAmazonS3FullAccessを付与しています。
https://qiita.com/sinshutu/items/7d3cc7438871c50ea63c

aws configureを実行し、先ほどのキーを入力します。

❯ aws configure --profile lambda-terraformer
AWS Access Key ID [None]: xxx
AWS Secret Access Key [None]: xxx
Default region name [None]: ap-northeast-1
Default output format [None]: 

プロファイルが正常に追加されたことを確認します。

❯ aws s3 ls --profile lambda-terraformer

エラーが出ないので問題ないようです。
次にTerraformのbackendをS3に設定します。先ほどの記事を参考に作成したコードは次のとおりです。

main.tf
provider "aws" {
  region  = "ap-northeast-1"
  profile = "lambda-terraformer"
}

terraform {
  required_version = "~> 1.9.5"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "5.91.0"
    }
  }
  backend "s3" {
    bucket  = "sample-rb-lambda-tfstate"
    region  = "ap-northeast-1"
    profile = "lambda-terraformer"
    key     = "production.tfstate"
    encrypt = true
  }
}

resource "aws_s3_bucket" "sample_rb_lambda_bucket" {
  bucket = "sample-rb-lambda-tfstate"
}

このコードを使ってterraform initを実行するとbucket作成とbackend設定を同時に行ってしまいエラーが発生します。

❯ terraform init
Initializing the backend...
╷
│ Error: Failed to get existing workspaces: S3 bucket "sample-rb-lambda-tfstate" does not exist.
│ 
│ The referenced S3 bucket must have been previously created. If the S3 bucket
│ was created within the last minute, please wait for a minute or two and try
│ again.
│ 
│ Error: operation error S3: ListObjectsV2, https response error StatusCode: 404, RequestID: YJ3AEJS6FNMM5SHS, HostID: 0KcfwnKlVjIoExcXtAizkxK0czzYaIPeibZT6IrRXe2vFBMt1zC5NU8QMMElAlsAjoTh1tXt2NY=, NoSuchBucket: 
│ 

調べてみると同様のエラーに出会っている人がいるようでした。
https://qiita.com/ryo-sato/items/2bc7438077c567c178e5

解決策として、一旦ackend設定をコメントアウトしてterraform applyまで完了させ、コメントアウトを外し再度terraform initを実行すると良いようです。
そのためまずはbackend設定をコメントアウトしterraform applyまで実行します。

❯ terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "5.91.0"...
- Installing hashicorp/aws v5.91.0...
- Installed hashicorp/aws v5.91.0 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
❯ terraform apply

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the
following symbols:
  + create

## 以下省略

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

backend設定のコメントアウトを元に戻し、改めてterraform initを実行します。ローカル上にあるterraform.tfstateをコピーしますか?と聞かれるのでyesを選択します。

❯ terraform init 
Initializing the backend...
Do you want to copy existing state to the new backend?
  Pre-existing state was found while migrating the previous "local" backend to the
  newly configured "s3" backend. No existing state was found in the newly
  configured "s3" backend. Do you want to copy this state to the new "s3"
  backend? Enter "yes" to copy and "no" to start with an empty state.

  Enter a value: yes


Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v5.91.0

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

これで初期設定は完了です。

ECR作成

次はECRの作成です。ただし、いきなりECRを作成することはできないので、その前にECR作成用のIAMポリシーを作成します。

ECR作成用のIAMポリシーを作成

Terraform用のIAMユーザーlambda-terraformerに、次のようなECR用のインラインポリシーを追加します。

ecr-manage-policy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Statement1",
            "Effect": "Allow",
            "Action": [
                "ecr:CreateRepository",
                "ecr:DescribeRepositories",
                "ecr:DeleteLifecyclePolicy",
                "ecr:DeleteRepository",
                "ecr:GetLifecyclePolicy",
                "ecr:ListTagsForResource",
                "ecr:PutLifecyclePolicy"
            ],
            "Resource": "*"
        }
    ]
}

これでECRを作成する準備が整いました。

ECR作成

ECR用のポリシーがIAMユーザーにセットされたのでECRを作成します。今回のケースでは、Lambdaに使用するイメージはlatest運用としたいのでMUTABLEをセットしておきます。またイメージは最新の1つだけあれば良いので、untaggedとなった不要なイメージは早めに削除し課金を防ぐようにライフサイクルを設定します。
公式ドキュメントのaws_ecr_repositoryaws_ecr_lifecycle_policyのサンプルコードを見ると、実現したいことがほぼそのまま書いてあるようでした。そのため、そのサンプルコードをベースに作成します。作成したコードは次のとおりです。

resource "aws_ecr_repository" "sample_rb_lambda_ecr_repository" {
  name                 = "sample-rb-lambda"
  image_tag_mutability = "MUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }
}

resource "aws_ecr_lifecycle_policy" "sample_rb_lambda_ecr_lifecycle_policy" {
  repository = aws_ecr_repository.sample_rb_lambda_ecr_repository.name

  policy = <<EOF
{
    "rules": [
        {
            "rulePriority": 1,
            "description": "Expire images older than 1 days",
            "selection": {
                "tagStatus": "untagged",
                "countType": "sinceImagePushed",
                "countUnit": "days",
                "countNumber": 1
            },
            "action": {
                "type": "expire"
            }
        }
    ]
}
EOF
}

ECRへイメージをプッシュする環境の整備

前述の段階でECRの作成まで完了しました。次はLambda構築に移りたいところですが、ECRにイメージがない状態でLambdaを作成しても動作できないので意味がありません。
そのためECRにイメージをプッシュできる環境を作成します。この作成において、同じようなことをしている人がいたので、その方のコードを参考にさせていただきました。
https://zenn.dev/kou_pg_0131/articles/gh-actions-ecr-push-image

この記事をベースに以下3つを実施していきます。

  • Terraform用のIAMユーザーlambda-terraformerにIAM Roleを作成する権限を付与
  • GitHub ActionsがECRにプッシュするためのIAMロールの作成
  • GitHub ActionsでECRにプッシュするためのワークフロー作成

なお、AWS上でOIDCの設定はすでに完了している前提で進めます。

Terraform用のIAMユーザーにIAM Roleを作成する権限を付与

ECRにプッシュするためのIAM Roleを作成するためには、IAM Roleを作成する権限が必要です。そこでTerraform用のIAMユーザーlambda-terraformerに、インラインポリシーとしてiam-role-manage-policyを作成しました。付与した権限は以下の通りです。

iam-role-manage-policy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "iam:GetRole",
                "iam:UpdateAssumeRolePolicy",
                "iam:ListInstanceProfilesForRole",
                "iam:DeleteRolePolicy",
                "iam:ListAttachedRolePolicies",
                "iam:CreateRole",
                "iam:DeleteRole",
                "iam:PutRolePolicy",
                "iam:ListRolePolicies",
                "iam:GetRolePolicy"
            ],
            "Resource": "arn:aws:iam::<aws account id>:role/*"
        }
    ]
}

GitHub ActionsがECRにプッシュするためのIAM Roleの作成

GitHub ActionsがECRにプッシュするためのIAM Roleを作成します。このIAM Roleの信頼ポリシーには、ECRにプッシュできるリポジトリやブランチ名も指定することができるようでした。

https://dev.classmethod.jp/articles/allowing-assumerole-only-for-specific-repositories-and-branches-with-oidc-collaboration-between-github-actions-and-aws/

そのため、リポジトリ名はコードを置いているリポジトリ(M-Yamashita01/sample-rb-lambda)とし、ブランチ名に関してはmainブランチを指定しました。これにより、GitHub Actionsでmainブランチにpushした際のみECRにプッシュできるようになります。
作成したコードは次のとおりです。

resource "aws_iam_role" "github_actions_ecr_push_role" {
  name = "github-actions-ecr-push-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Effect = "Allow",
        Principal = {
          Federated = "arn:aws:iam::<aws account id>:oidc-provider/token.actions.githubusercontent.com"
        },
        Action = "sts:AssumeRole"
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:sub" : "repo:M-Yamashita01/sample-rb-lambda:ref:refs/heads/main"
          }
        }
      }
    ]
  })
}

resource "aws_iam_role_policy" "ecr_push_image_policy" {
  name   = "ecr-push-image-policy"
  role   = aws_iam_role.github_actions_ecr_push_role.name
  policy = data.aws_iam_policy_document.ecr_push_image_poclicy_document.json
}

data "aws_iam_policy_document" "ecr_push_image_poclicy_document" {
  # ECRログインに必要
  statement {
    effect    = "Allow"
    actions   = ["ecr:GetAuthorizationToken"]
    resources = ["*"]
  }

  # `docker push`に必要。resourcesにはECRリポジトリのARNを指定
  statement {
    effect = "Allow"
    actions = [
      "ecr:CompleteLayerUpload",
      "ecr:UploadLayerPart",
      "ecr:InitiateLayerUpload",
      "ecr:BatchCheckLayerAvailability",
      "ecr:PutImage",
    ]
    resources = ["arn:aws:ecr:ap-northeast-1:<aws account id>:repository/sample-rb-lambda"]
  }
}

GitHub ActionsでECRにプッシュするためのワークフロー作成

GitHub Actions用のIAM Roleが作成されたので、そのIAM Roleを使用したGitHub Actionsのワークフローを作成します。
ワークフローでの秘匿情報のハードコードを避けるため、先ほどのIAM RoleのARNはECR_PUSH_AWS_ROLE_ARNという名前でGitHubのシークレットに登録しておきます。またそれ以外のAWSアカウントやリージョン、リポジトリ名も同様に登録しておきます。
作成したGitHub Actionsのワークフローは次のとおりです。

name: Build and Push

on:
  push:
    branches: [ "main" ]

permissions:
  contents: read
  id-token: write

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-region: ${{ secrets.AWS_REGION }}
        role-to-assume: ${{ secrets.ECR_PUSH_AWS_ROLE_ARN }}

    - name: Log in to Amazon ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1

    - name: Build Docker image
      run: docker build -t ${{ secrets.ECR_REPOSITORY }}:latest .

    - name: Tag Docker image
      run: docker tag ${{ secrets.ECR_REPOSITORY }}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/${{ secrets.ECR_REPOSITORY }}:latest

    - name: Push Docker image to ECR
      run: docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/${{ secrets.ECR_REPOSITORY }}:latest

mainブランチに適当な修正コードをpushしてGitHub Actionsを実行することで、ECRにイメージがプッシュされました。

TerraformでLambdaを作成する

Lambda作成

aws_lambda_functionを参考に、LambdaをTerraformで作成します。このLambdaはコンテナを使って起動するため、package_typeにはImageを指定します。また、image_uriには前述でECRにプッシュしたイメージのURIを指定します。コンソール上で見ると以下のようなURIになっていると思います。

このURIをTerraform上のコードを使って表すと"${aws_ecr_repository.sample_rb_lambda_ecr_repository.repository_url}:latest"となります。そのため、LambdaのTerraformコードは次のようになります。

data "aws_iam_policy_document" "sample_rb_lambda_assume_role" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}

resource "aws_iam_role" "sample_rb_lambda_iam_role" {
  name               = "sample-rb-lambda-iam-role"
  assume_role_policy = data.aws_iam_policy_document.sample_rb_lambda_assume_role.json
}

resource "aws_lambda_function" "sample_rb_lambda_function" {
  function_name = "sample-rb-lambda"
  package_type  = "Image"
  image_uri     = "${aws_ecr_repository.sample_rb_lambda_ecr_repository.repository_url}:latest"
  role          = aws_iam_role.sample_rb_lambda_iam_role.arn
  timeout       = 60
}

LambdaをapplyするためのIAMインラインポリシーを作成

Terraform用のIAMユーザーlambda-terraformerにLambdaを作成するためには、IAMのインラインポリシーが必要です。一通り必要な項目を埋めてterraform applyを実行すると、PassRoleが付与されてないので実行できないというエラーが発生しました。

│ Error: creating Lambda Function (sample-rb-lambda): operation error Lambda: CreateFunction, https response error StatusCode: 403, RequestID: 610eb31c-ace0-468b-8407-2225d1a70f76, api error AccessDeniedException: User: arn:aws:iam::<aws account id>:user/lambda-terraformer is not authorized to perform: iam:PassRole on resource: arn:aws:iam::<aws account id>:role/sample-rb-lambda-iam-role because no identity-based policy allows the iam:PassRole action│

PassRoleについてよく分からなかったので、クラスメソッドさんの記事を参考にしました。
https://dev.classmethod.jp/articles/iam-role-passrole-assumerole/

理解が難しかったので何回か読み直し、まだ完璧ではないものの以下の結論を咀嚼して飲み込みました。

・IAM ロールはお面のようなもの
・PassRole は人間以外にもお面を与えること

このことを踏まえたうえで、PassRole権限の追加におけるベストな方法を検討しました。今回のケースではTerraform用のIAMユーザーlambda-terraformerにPassRoleの権限を追加し、Resourcesに指定されたロールを渡すことができるようになります。ただResourceを*にすると全てのIAM Roleを渡せることになってしまい、セキュリティ的に問題があります。そのためResourceにはTerraformコード上に書いたsample-rb-lambda-iam-roleのIAM RoleをPassRoleできるようにします。さらにこのPassRoleできるサービスをLambdaだけに絞ります。
この結果、Terraform用のIAMユーザーlambda-terraformerに追加するインラインポリシー(lambda-manage-policy)は以下のようになります。

lambda-manage-policy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "ecr:SetRepositoryPolicy",
                "ecr:DeleteRepositoryPolicy",
                "ecr:GetRepositoryPolicy"
            ],
            "Resource": "*"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": "iam:PassRole",
            "Resource": "arn:aws:iam::<aws account id>:role/sample-rb-lambda-iam-role",
            "Condition": {
                "StringEquals": {
                    "iam:PassedToService": "lambda.amazonaws.com"
                }
            }
        },
        {
            "Sid": "VisualEditor2",
            "Effect": "Allow",
            "Action": [
                "lambda:CreateFunction",
                "lambda:ListVersionsByFunction",
                "lambda:GetFunction",
                "lambda:UpdateFunctionConfiguration",
                "lambda:DeleteFunction"
            ],
            "Resource": "arn:aws:lambda:*:<aws account id>:function:*"
        }
    ]
}

この状態でterraform applyを実行すると成功し、Lambdaが作成されました。

CD環境の整備

AWS上にLambdaが出来上がったので、次はLambdaのデプロイを自動化するためにCD環境を整備します。Lambdaのデプロイに使うツールは色々とありますが、今回はlambrollを選択しました。

https://github.com/fujiwara/lambroll

lambrollを使った初期化

まずはlambrollを使うための初期化を行います。初期化については以下の記事を参考にしました。

https://dev.classmethod.jp/articles/lambroll-lambda-deployment-tool-fujiwara-ware/

関数名については、先ほど作ったLambdaの関数名を指定します。次のようにコマンドを実行すると、function.json.lambdaignoreが作成されます。

❯ lambroll init --function-name sample-rb-lambda
2025/03/15 18:15:54 [info] lambroll v1.2.1
2025/03/15 18:15:54 [info] function sample-rb-lambda found
2025/03/15 18:15:54 [info] creating .lambdaignore
2025/03/15 18:15:54 [info] creating function.json

作成されたfunction.jsonにて、Timeoutをデフォルトの3秒から60秒に変更しておきます。これはマネジメントコンソール上でLambdaをお試しで動かしたところ3秒以上かかることがわかったので、長めに設定しておきます。
出来上がったfunction.jsonは以下の通りです。

function.json
{
  "Architectures": [
    "x86_64"
  ],
  "Code": {
    "ImageUri": "<aws account id>.dkr.ecr.ap-northeast-1.amazonaws.com/sample-rb-lambda:latest"
  },
  "EphemeralStorage": {
    "Size": 512
  },
  "FunctionName": "sample-rb-lambda",
  "LoggingConfig": {
    "LogFormat": "Text",
    "LogGroup": "/aws/lambda/sample-rb-lambda"
  },
  "MemorySize": 128,
  "PackageType": "Image",
  "Role": "arn:aws:iam::<aws account id>:role/sample-rb-lambda-iam-role",
  "SnapStart": {
    "ApplyOn": "None"
  },
  "Timeout": 60,
  "TracingConfig": {
    "Mode": "PassThrough"
  }
}

これでlambrollを使った初期化は完了です。

lambrollを使うためのIAM Role整備

GitHub Actionsでlambrollを実行するために、lambroll用のIAM Roleを作る必要があります。ですが今回のケースでのlambroll実行に必要な権限がわかりませんでした。
そのため、次の2ステップで必要な権限を把握しIAM Roleを作成します。

  1. AWSマネジメントコンソール上でテスト用のIAMユーザーとIAM Roleを作り、ローカル上でlambrollを使いトライ&エラーしながら必要な権限を確認する
  2. 1のステップで把握した権限をもとにTerraformでlambroll用のIAM Roleを作成する

テスト用のIAMユーザーとIAM Roleの作成およびローカル上でlambrollを使ったトライ&エラー

まずはlambroll用のIAMユーザー(lambroll-for-lambda-deploy)を作ります。加えてそのIAMユーザーにインラインポリシーを追加します。インラインポリシーの権限がわからなかったので、Go未経験ではあるものの、もしかしたらlambrollのデプロイ用コードに何かヒントがありそうかと思いコードを眺めてみました。

https://github.com/fujiwara/lambroll/blob/v1/deploy.go

しばらく見ていると、AWSへの操作と思われるコードにおいて権限名と一致するようなコードがあることが分かりました。例としてはapp.lambda.UpdateFunctionConfigurationのようなコードです。これをもとに、以下のようなデプロイに必要な権限のポリシーを作成し、IAMユーザー(lambroll-for-lambda-deploy)に追加します。

{
    "Statement": [
        {
            "Action": [
                "lambda:UpdateFunctionConfiguration",
                "lambda:UpdateFunctionCode",
                "lambda:UpdateAlias",
                "lambda:GetFunction",
                "lambda:ListVersionsByFunction",
                "lambda:CreateAlias"
            ],
            "Effect": "Allow",
            "Resource": "*",
            "Sid": ""
        }
    ],
    "Version": "2012-10-17"
}

IAMユーザーへのポリシー追加完了後、ローカル上でlambroll deployを実行してみたところ以下のようなエラーが出ました。

❯ lambroll deploy --profile=lambroll-for-lambda-deploy
2025/03/21 00:04:20 [info] lambroll v1.2.1
2025/03/21 00:04:20 [info] loading Function from function.json
2025/03/21 00:04:20 [info] starting deploy function sample-rb-lambda
2025/03/21 00:04:21 [info] using docker image <aws account id>.dkr.ecr.ap-northeast-1.amazonaws.com/sample-rb-lambda:latest
2025/03/21 00:04:21 [info] updating function configuration 
2025/03/21 00:04:21 [info] updating function configuration ... 
2025/03/21 00:04:21 [info] State:Active LastUpdateStatus:Successful
2025/03/21 00:04:21 [error] FAILED. failed to update function configuration: failed to update function configuration: operation error Lambda: UpdateFunctionConfiguration, https response error StatusCode: 403, RequestID: 1ce05657-7e5e-4044-b80f-c5589fe1e669, api error AccessDeniedException: User: arn:aws:iam::<aws account id>:user/lambroll-for-lambda-deploy is not authorized to perform: iam:PassRole on resource: arn:aws:iam::<aws account id>:role/sample-rb-lambda-iam-role because no identity-based policy allows the iam:PassRole action

LambdaをTerraformで作成する時に出てきたPassRole権限が、今回も不足しているとのことです。そのため先ほどのIAMユーザー(lambroll-for-lambda-deploy)に以下のようなiam:PassRoleの権限を追加します。

{
    "Sid": "VisualEditor1",
    "Effect": "Allow",
    "Action": [
        "iam:PassRole"
    ],
    "Resource": "arn:aws:iam::<aws account id>:role/sample-rb-lambda-iam-role",
    "Condition": {
        "StringEquals": {
            "iam:PassedToService": "lambda.amazonaws.com"
        }
    }
}

この権限追加でlambrollを使ってLambdaをデプロイできるようになりました。

Terraformでlambroll用のIAM Roleを作成する

次にTerraformを使いIAM Roleを作成します。信頼ポリシーにはGitHub OIDCの設定が必要なので、以下GitHubのドキュメントを参考します。

https://docs.github.com/ja/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services#configuring-the-role-and-trust-policy

このドキュメントのロールと信頼ポリシーの構成項目にある"token.actions.githubusercontent.com:sub"の値をリポジトリ名とブランチ名に修正する必要があります。この信頼ポリシーと、前述したlambrollで必要な権限をもとに作成したIAM Roleのコードは以下のようになります。

# Deploy IAM Role for Lambda
resource "aws_iam_role" "sample_rb_lambda_iam_deploy_role" {
  name = "sample-rb-lambda-iam-deploy-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Effect = "Allow",
        Principal = {
          "Federated" = "arn:aws:iam::<aws account id>:oidc-provider/token.actions.githubusercontent.com"
        },
        Action = "sts:AssumeRoleWithWebIdentity",
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" : "sts.amazonaws.com",
            "token.actions.githubusercontent.com:sub" : "repo:M-Yamashita01/sample-rb-lambda:ref:refs/heads/main"
          }
        }
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "sample_rb_lambda_policy_attachment" {
  role       = aws_iam_role.sample_rb_lambda_iam_deploy_role.name
  policy_arn = aws_iam_policy.sample_rb_lambda_deploy_policy.arn
}

resource "aws_iam_policy" "sample_rb_lambda_deploy_policy" {
  name        = "sample-rb-lambda-deploy-policy"
  description = "Deploy Policy for sample-rb-lambda"
  policy      = data.aws_iam_policy_document.sample_rb_lambda_policy_document.json
}

data "aws_iam_policy_document" "sample_rb_lambda_policy_document" {
  statement {
    actions = [
      "lambda:UpdateFunctionConfiguration",
      "lambda:UpdateFunctionCode",
      "lambda:UpdateAlias",
      "lambda:GetFunction",
      "lambda:ListVersionsByFunction",
      "lambda:CreateAlias"
    ]
    resources = ["*"]
  }

  statement {
    actions   = ["iam:PassRole"]
    resources = [aws_iam_role.sample_rb_lambda_iam_role.arn]
    condition {
      test     = "StringEquals"
      variable = "iam:PassedToService"
      values   = ["lambda.amazonaws.com"]
    }
  }
}

このコードを使ってterraform applyしましたがエラーとなってしまいました。

│ Error: creating IAM Policy (sample-rb-lambda-deploy-policy): operation error IAM: CreatePolicy, https response error StatusCode: 403, RequestID: e50bac94-e02c-48f9-9f4b-1b741c5d9a24, api error AccessDenied: User: arn:aws:iam::<aws account id>:user/lambda-terraformer is not authorized to perform: iam:CreatePolicy on resource: policy sample-rb-lambda-deploy-policy because no identity-based policy allows the iam:CreatePolicy action

Terraform用のIAMユーザーlambda-terraformeriam:CreatePolicyの権限が必要とのことです。iam:CreatePolicyが必要ということはGetやDeleteなども必要になると思われます。そのため必要な権限を調べたあと、IAMユーザーlambda-terraformerにすでにアタッチされていたインラインポリシーiam-role-manage-policyに追加することにしました。修正後のiam-role-manege-policyは以下の通りです。

iam-role-manege-policy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "iam:GetRole",
                "iam:UpdateAssumeRolePolicy",
                "iam:ListInstanceProfilesForRole",
                "iam:DetachRolePolicy",
                "iam:DeleteRolePolicy",
                "iam:ListAttachedRolePolicies",
                "iam:CreateRole",
                "iam:DeleteRole",
                "iam:AttachRolePolicy",
                "iam:PutRolePolicy",
                "iam:ListRolePolicies",
                "iam:GetRolePolicy"
            ],
            "Resource": "arn:aws:iam::<aws account id>:role/*"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "iam:CreatePolicy",
                "iam:GetPolicyVersion",
                "iam:GetPolicy",
                "iam:ListPolicyVersions",
                "iam:DeletePolicy"
            ],
            "Resource": "arn:aws:iam::<aws account id>:policy/*"
        }
    ]
}

改めてterraform applyを実行すると成功しました。これでlambroll用のIAM Roleが作成済みとなります。

lambrollを使ったGitHub Actionsワークフロー作成

権限が整ったので、GitHub Actionsでのデプロイワークフローを作成します。この作成において注意点が2つあります。
1つはARNをGitHub Secretに登録してそれを参照するようにすることです。先ほど作成したlambroll用のデプロイIAM Role(sample-rb-lambda-iam-deploy-role)をsecretに設定しておきます。
2つ目はワークフローの実行タイミングです。今の時点で、コンテナをビルドしECRにプッシュするワークフローが存在します。そのためこのワークフローが完了したあとにlambrollでデプロイする必要があります。もしECRにプッシュするワークフローが完了する前にデプロイのワークフローを実行した場合、最新から1つ前のイメージでLambdaがデプロイされてしまいます。そのためGitHub Actionsの機能であるworkflow_runを活用し、指定のワークフローが終了次第このデプロイワークフローを実行できるようにしました。
https://docs.github.com/ja/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#workflow_run

作成したワークフローは以下の通りです。

.github/workflows/deploy.yaml
name: Deploy to AWS Lambda

on:
  workflow_run:
    workflows: ["Build and Push"]
    types: [completed]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-region: ${{ secrets.AWS_REGION }}
          role-to-assume: ${{ secrets.DEPLOY_LAMBDA_AWS_ROLE_ARN }}

      - name: Deploy to Lambda using Lambroll
        uses: fujiwara/lambroll@febaa8df8bc65284e4b196b8359cb065e82d63cb # v1

      - run: |
          lambroll deploy

このワークフローを使ってGitHub Actionsを実行すると、ECRにプッシュしたイメージを使ってLambdaがデプロイされました。

Lambda実行

Lambdaのデプロイが完了したので実行してみます。AWSマネジメントコンソール上でLambdaのテストタブを開き、適当なイベント名を指定し、イベント共有の設定はプライベートとし、Lambdaに渡すパラメータのJSONはデフォルトのままにして、テストボタンで実行します。

証明書読み取りエラー

Lambdaを実行すると、OpenSSL::PKey::RSAのインスタンス作成時に以下のようなエラーが発生しているようでした。

{
  "errorMessage": "Neither PUB key nor PRIV key",
  "errorType": "Function<OpenSSL::PKey::RSAError>",
・・・
}

エラーメッセージの通り、読み込んだ証明書が公開鍵、秘密鍵のどちらでもないというエラーです。このときのSecretManager上の秘密鍵は以下のようになっていました。

-----BEGIN RSA PRIVATE KEY-----\nMIIxxxxxxx=\n-----END RSA PRIVATE KEY-----

LocalStackを使って同じ秘密鍵を設定し、ローカルでテストしたところテストは全てパスしてしまいました。どうやらAWS上だけで起きる問題のようです。
調査を進めたところ、秘密鍵を読み込んだ際に改行コードが\\nになってしまう記事を見つけました。

https://qiita.com/syossan27/items/f67cc3fd24e90b2a819c

おそらく同様の問題が今回でも起きていそうと想定し、Lambda内で秘密鍵に対してgsub("\\n", "\n"))した後、OpenSSL::PKey::RSA.newにその秘密鍵を渡すようにしました。実際のコードは以下の通りです。

JWT.encode(payload, OpenSSL::PKey::RSA.new(@secrets_manager_client.private_key.gsub("\\n", "\n")), "RS256")

この修正で先ほどのエラーを解決できました。

contents読み出し後のsplitでエラー

証明書は正常に読み込めるようになったものの、octokitのgemを経由して取得したファイルのcontentsをsplitしようとした際、文字コードに関する以下のようなエラーが発生しました。

{
  "errorMessage": "invalid byte sequence in US-ASCII",
  "errorType": "Function<ArgumentError>",
・・・
}

このエラーメッセージを使って調べていたところ、次の記事と同じ現象が発生していると思われました。

public.ecr.aws/lambda/ruby:3.3 を使用した場合の実行結果は以下のようになりました。ひらがなを含む2行目で Invalid byte sequence が発生していることがわかります。

https://tech.repro.io/entry/2024/12/11/155730

実際にAWSドキュメントを見てみると、私の使用しているAWSのベースイメージはAmazon Linux 2023を使っているようでした。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/ruby-image.html#ruby-image-base

このイメージ上でlocaleと環境変数を出力して確認してみました。

bash-5.2# ruby -v
ruby 3.3.7 (2025-01-15 revision be31f993d7) [aarch64-linux]
bash-5.2#
bash-5.2# locale --all-locales
C
C.utf8
POSIX
bash-5.2#
bash-5.2# env
HOSTNAME=4da30dd148f0
PWD=/var/task
TZ=:/etc/localtime
LAMBDA_TASK_ROOT=/var/task
HOME=/root
LANG=en_US.UTF-8
LAMBDA_RUNTIME_DIR=/var/runtime
TERM=xterm
SHLVL=1
LD_LIBRARY_PATH=/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib
PATH=/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin
_=/usr/bin/env

この結果から、ロケールがCロケールしかないにも関わらず、LANGがen_US.UTF-8になっていることが分かります。そのため上記テックブログの記事と同じ結論となりますが、enロケールが使えず今回の文字コードの問題が出ていると思われます。
この問題の対処として記事内で言及されていたように、環境変数のLANGC.UTF-8に変更することで解決できるようです。具体的には、Dockerfileの中で以下のように環境変数を設定します。

FROM public.ecr.aws/lambda/ruby:3.3

ENV LANG=C.UTF-8
・・・

この指定により文字コードのエラーがなくなり、Lambdaが問題なく動作するようになりました。

動かした結果

このシステムを使用して、リポジトリ内のマニフェストを取得し、namespaceの一覧をGitHubのissueに出力した結果は次のとおりとなります。

元のマニフェストファイル
# xxx bbb triggered by M-Yamashita01.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: app
  namespace: argocd1
spec:
  destination:
    namespace: argocd
    server: https://kubernetes.default.svc
  project: default
  source:
    path: installs #子Applicationのmanifestがあるディレクトリを指定する
    repoURL: https://github.com/xxx/argocd-sample-app.git
    targetRevision: main
  syncPolicy:
    automated: {}
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: apps2
  namespace: argocd2
spec:
  destination:
    namespace: argocd
    server: https://kubernetes.default.svc
  project: default
  source:
    path: installs #子Applicationのmanifestがあるディレクトリを指定する
    repoURL: https://github.com/xxx/argocd-sample-app.git
    targetRevision: main
  syncPolicy:
    automated: {}
---
# xxx bbb triggered 
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: apps3
  namespace: argocd3
spec:
  destination:
    namespace: argocd
    server: https://kubernetes.default.svc
  project: default
  source:
    path: installs #子Applicationのmanifestがあるディレクトリを指定する
    repoURL: https://github.com/xxx/argocd-sample-app.git
    targetRevision: main
  syncPolicy:
    automated: {}
---
# xxx bbb triggered by M-Yamashita01.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: apps4
  namespace: argocd4
spec:
  destination:
    namespace: argocd
    server: https://kubernetes.default.svc
  project: default
  source:
    path: installs #子Applicationのmanifestがあるディレクトリを指定する
    repoURL: https://github.com/xxx/argocd-sample-app.git
    targetRevision: main
  syncPolicy:
    automated: {}

GitHub Issueに出力した結果は以下の通りです。namespace一覧が取得できていることが分かります。

おわりに

今回の記事では、Lambdaを使ってリポジトリのファイルにアクセスしてGitHub Issueに投稿するシステムの作成手順と結果について説明しました。
エラーとその解決のための脱線話や、CIやCDなどいろいろ盛り込んだシステムにしたのでかなりボリュームのある記事となりました。綺麗にスマートにシステムを作れたらそれも良い形とは思いますが、泥臭くエラーに向き合って脱線を繰り返す姿勢も良い形だと思っています。そうすることで自身の守備範囲を広めたり、あの時調べた内容が別の何かとつながったりすることもあります。

この記事が誰かのお役に立てれば幸いです。

Money Forward Developers

Discussion