Header image

クラッソーネの開発者がエンジニアリングに関することもそうでないことも綴っています!

AWS SAM × Ruby で効率よくLambdaを開発するための5つのTips

AWS SAM × Ruby で効率よくLambdaを開発するための5つのTips

こんにちは。最近エレクトリックアップライトベースの練習をしている小木です。

クラッソーネでは、社内の各種ワークフローを自動化することで、より効率的に業務を行えるよう取り組んでいます。

これまでは Make というノーコードツール を利用して Slack 通知や Notion からのデータ取得、Google スプレッドシートへの書き込みなどを自動化していました。

Make 利用に関する過去のブログ記事 ↓

しかし、Make のシナリオ(Make で定義したワークフロー)が増えたり、人の退職や時間の経過によって仕様がわからなくなったりと、メンテナンスコストがかさんでしまいました。

そこで、Make で実装された各種ワークフローを AWS Lambda に置き換えていくプロジェクトが始動しました。

Lambda の実装には AWS SAM(Serverless Application Model) を使用しています。

AWS SAM は、ローカル環境で Lambda ファンクションを実行できたり、そのまま AWS 環境にデプロイできたりと、とても便利です。

ただ、「よし、これからガンガン開発していくぞ!」というテンションで使うと、少し面倒に感じる部分もありました。

具体的には、

  • sam local invoke する前に sam build しないとコードの変更が反映されない(これは lambda ファンクションの実装に Ruby を使っているので面倒くささを感じるのかもしれない)
  • sam local invoke が遅い(Docker のコンテナ上で実行されるので仕方のない面もある・・・)

ということで、今回はオレの考えた Lambda ファンクション開発の Tipsをいくつか紹介します。紹介するコード例は Ruby で書いていますが、言語に依存しない考え方なので応用できるはずです。

1. lambda_handler と ビジネスロジック(処理の本体) を分離する

sam init コマンドでテンプレートから Lambda ファンクションを作成すると、以下のようなコードが生成されると思います。

tree -L 2
.
├── README.md
├── business_logic
│   ├── Gemfile
│   ├── Gemfile.lock
│   └── app.rb
└── template.yaml

app.rb

def lambda_handler(event:, context:)
  {
    statusCode: 200,
    body: {}
  }.to_json
end

ここで、 lambda_handler 内にいきなりビジネスロジックを実装しないほうがいいです。デバッグやテストがしづらいからですね。

ビジネスロジックはクラス化して lambda_handler と分離します。

app.rb

# このクラスにビジネスロジックを実装する
class BusinessLogic
  def execute
  end
end

def lambda_handler(event:, context:)
	business_logic = BusinessLogic.new
	result = business_logic.execute

  {
    statusCode: 200,
    body: {
      result:
    }.to_json
  }
end

2. ビジネスロジッククラスを単独で実行できるようにする

1 でビジネスロジックをクラスに分けました。これを Lambda 経由でなくローカルで実行できるようにします。

app.rb

class BusinessLogic
  def execute
  end
end

# このファイル(app.rb)が直接実行されたときだけ処理を実行
if __FILE__ == $0
  business_logic = BusinessLogic.new
	result = business_logic.execute

	puts result
end

if __FILE__ == $0 はそのファイルが直接実行されたときだけブロック内のコードが実行される、という条件文です(他の言語にも同様のテクニックはあるんじゃないかなぁと)

こうすることで以下のように SAM CLI を使わずにプロセスのコードの動作検証ができる、というわけです。

cd business_logic
bundle exec ruby app.rb

このように、

  1. ビジネスロジックを Lambda を経由せずに実装、ユニットテスト
  2. sam local invoke で Lambda 環境上でのテスト
  3. デプロイ
  4. AWS Management Console 上、またはステージング環境に Lambda ファンクションを結合してテスト

のように開発サイクルを回すのが効率良いのではと思っています。

3. 環境変数で実行環境を分ける

開発用だったり、問題が起きた時のデバッグ用だったりに使うため、デプロイする Lambda ファンクションは本番用と検証用の少なくとも 2 つ用意するのが良いと思います。

(本番用と検証用それぞれのデプロイには SAM CLI の --config-env オプションを利用します)

template.yaml

Parameters:
  AppEnv:
    Type: String
    Default: dev
    AllowedValues: [dev, stg, prd]
    Description: 環境名を指定してください。dev, stg, prdのいずれかを指定してください。

Resources:
  BusinessLogicFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub ${AppEnv}-BusinessLogicFunction
      Description: !Sub ${AppEnv}, BusinessLogicFunction
      CodeUri: business_logic/
      Handler: app.lambda_handler
      Runtime: ruby3.4
	    Environment:
	        Variables:
	          APP_ENV: !Ref AppEnv

Lambda の環境変数に実行環境を表す変数を定義します。ここでは AppEnv という名前で定義しています。

コード内では以下のように利用できます。

app.rb

class BusinessLogic
  def execute
    if app_env == 'stg'
      puts "Running in staging environment"
    elsif app_env == 'prd'
      puts "Running in production environment"
    else
      puts "Running in development environment"
    end
  end

  private

  def app_env
    ENV['APP_ENV'] || 'dev'
  end
end

実行環境を示す環境変数 APP_ENV によってコード内で処理を分岐することができるようになりました。これだけだと当たり前では?みたいなコードですが、APP_ENV を定義したのは次でやりたいことがあるからです。

4. ビジネスロジックで利用するシークレット値は Lambda の環境変数ではなく AWS Secrets Manager から取得する

これまでは AppEnv と同様に環境変数でシークレットを渡していました。これはこれで安全なのですが、シークレットの値が増えるとデプロイコマンドが煩雑になったりタイプミスしやすかったり、シークレットの値を毎回コピペしないといけなかったりと手間がかかっていました。

例えば外部の API を複数コールするような Lambda ファンクションの場合こんな感じでした。

sam deploy --config-env staging \
 --parameter-overrides AppEnv="stg" \
 ApiKey="" \
 NotionToken="" \
 NotionProjectDatabaseId="" \
 SlackBotToken="" \
 SlackSigningSecret="" \
 SlackListenChannelId=""

そこで、ビジネスロジック側で利用するシークレットは Secrets Manager に登録し、ビジネスロジッククラスの初期化時に環境変数としてロードするようにしました。

SecretsManager

stg/my_secret

{
  "API_KEY": "staging環境用のAPIキー",
  "NOTION_TOKEN": "staging環境用のトークン"
  //...
}
require 'aws-sdk-secretsmanager'

def get_secret(secret_name)
  client = Aws::SecretsManager::Client.new(region: 'ap-northeast-1')
  response = client.get_secret_value(secret_id: secret_name)
  JSON.parse(response.secret_string)
end

class BusinessLogic
	def initialize
		load_secrets
	end

	private

	def load_secrets
		secrets = get_secret("#{app_env}/my_secret")

	  # 環境変数にセットする
    secrets.each do |key, value|
      ENV[key] = value
    end
	end
end

外部 API のキーも本番用・検証用に分かれているので、先ほど設定した APP_ENV の値でそれぞれの環境用のシークレットを Secrets Manager から取得する感じです。

こうすることで、

  • Lambda ファンクションの挙動に関する設定は Lambda の環境変数
  • ビジネスロジックで利用する秘匿情報は Secrets Manager

と差別化でき、管理がしやすくなりました。

5. AWS プロファイルは SAM CLI のオプションで指定する

さて、上のサンプルコードで Aws::SecretsManager::Client を使っていますが、AWS にアクセスするために AWS プロファイルの設定が必要です。

Lambda 上でコードが実行されるときは AWS のプロファイル情報は自動的に割り当てられますが、

stg / prd 環境、開発環境でどのプロファイルが読み込まれるのかは意識しておく必要があります。

私は以前このような実装にしていました。

client = if ENV['APP_ENV'] == 'dev'
							  credentials = Aws::Credentials.new(
							    ENV['AWS_ACCESS_KEY_ID'],
							    ENV['AWS_SECRET_ACCESS_KEY']
							  )
							  Aws::SecretsManager::Client.new(
								  region: 'ap-northeast-1',
								  credentials:
								)
							else
								Aws::SecretsManager::Client.new(
								  region: 'ap-northeast-1'
								)
							end

これだと、 APP_ENV が適切に設定されていないと AWS のリソースにアクセスできなかったり、最悪の場合違う AWS 環境のリソースを利用してしまったりと危うい実装でした。

AWS SDK も SAM CLI も ~/.aws/credentials に設定されているデフォルトプロファイルを暗黙的に読んでくれるので、コード中でキーをわざわざ設定する必要はありません。

アクセスキーではなく、プロファイルを指定しましょう!

別のプロファイルを利用したい場合は sam コマンドの --profile パラメータで利用するプロファイルを指定できます。

# どちらの方法でもok
AWS_PROFILE=project-a-user sam local invoke
sam local invoke --profile project-a-user

2 で紹介したビジネスロジッククラスのコードを単体で実行する場合にも環境変数として AWS_PROFILE を指定してあげればコード内で指定したプロファイルが利用されます。

AWS_PROFILE=project-a-user bundle exec ruby app.rb

【追記】

スミマセン、あとで色々検証したところ

client = Aws::SecretsManager::Client.new;

みたいに認証情報を aws sdk でデフォルトで読み込む値を利用するような実装では、

sam local invoke

で Lambda ファンクションを実行したときに認証情報が正しく読み込まれませんでした。

例えば以下のようなデバッグコードを入れて

logger.debug "=== Environment Variables ==="
logger.debug "APP_ENV: #{ENV['APP_ENV']}"
logger.debug "AWS_PROFILE: #{ENV['AWS_PROFILE']}"
logger.debug "AWS_ACCESS_KEY_ID: #{ENV['AWS_ACCESS_KEY_ID']}"

ローカルで実行してみると、

```ruby
AWS_PROFILE=sample-user sam local invoke

こんな出力になります。

D, [2025-05-30T05:25:18.276124 #19] DEBUG -- : === Environment Variables ===
D, [2025-05-30T05:25:18.276200 #19] DEBUG -- : APP_ENV: dev
D, [2025-05-30T05:25:18.276231 #19] DEBUG -- : AWS_PROFILE:
D, [2025-05-30T05:25:18.276255 #19] DEBUG -- : AWS_ACCESS_KEY_ID: "~/.aws/credentialsに登録されているsample-userのアクセスキー"

これちょっと不思議な挙動なんですけど、

Lambda 側の環境変数には AWS_PROFILE が渡っていないのに、ローカルの ~/.aws/credentials に定義しているプロファイルの AWS_ACCESS_KEY_ID はちゃんと渡っているんですよねー。謎。

(※ あえて書いていませんが、 AWS_SECRET_ACCESS_KEY も渡ってきています)

なんというか不思議な挙動です。

けっきょく、 sam local invoke で認証情報を利用するために以下のようなコードを書いています。

client = if ENV['APP_ENV'] == 'dev'
           Aws::SecretsManager::Client.new(
             region: 'ap-northeast-1',
             access_key_id: ENV['AWS_ACCESS_KEY_ID'],
             secret_access_key: ENV['AWS_SECRET_ACCESS_KEY']
           )
         else
           Aws::SecretsManager::Client.new
         end

dev というのはローカル(= sam local ) で利用するときアクセスキーを利用する分岐に入るために設定した環境変数です。

まとめ

ということで、AWS SAM を使って Lambda ファンクションを開発するときの個人的 Tips を紹介しました。

SAM CLI には sam sync コマンド(コードの変更を検知して差分を Lambda にデプロイしてくれる。 sam build / sam deploy を手動で実行する必要がなくなり、高速に開発を進めることができる)もありますが、デプロイに多少なりとも時間がかかるので私はほとんど使っていません。デプロイ済みの Lambda ファンクションをデバッグするのには便利かな。


静岡県浜松市のアプリケーションデベロッパー。ITコミュニティやシビックテックにも興味があります。