mybook

最初の Lambda 関数 — Hello World から始める

「まず動かしてみよう」

ダイチは翌日の昼休み、自分のAWS開発アカウントを開いた。理論は分かった。次は手を動かす番だ。最初のLambda関数として、シンプルな「Hello World」を作り、デプロイし、実行してみる。

開発環境のセットアップ

まずは必要なツールを揃える。

# AWS CLI のインストール確認
aws --version
# aws-cli/2.x.x Python/3.x.x ...
 
# AWS SAM CLI のインストール(Mac)
brew tap aws/tap
brew install aws-sam-cli
sam --version
 
# 認証情報の設定
aws configure
# AWS Access Key ID: ...
# AWS Secret Access Key: ...
# Default region name: ap-northeast-1
# Default output format: json

INFO

AWS SAM(Serverless Application Model)は、Lambda関数のローカル開発・テスト・デプロイを簡単にするフレームワーク。CloudFormationの拡張として動作し、template.yaml 1ファイルでインフラを定義できる。

SAMプロジェクトの作成

# SAM アプリケーションの初期化
sam init \
  --runtime ruby3.2 \
  --name serverless-ec-api \
  --app-template hello-world
 
cd serverless-ec-api
tree .
# .
# ├── README.md
# ├── events/
# │   └── event.json
# ├── hello_world/
# │   ├── Gemfile
# │   └── app.rb
# └── template.yaml

生成されたファイルを見ていこう。

template.yaml — インフラ定義

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
 
Globals:
  Function:
    Timeout: 30
    MemorySize: 256
    Runtime: ruby3.2
    Environment:
      Variables:
        RACK_ENV: production
 
Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get

app.rb — Lambda ハンドラ

# frozen_string_literal: true
 
require 'json'
 
def lambda_handler(event:, context:)
  # API Gateway からのリクエスト情報
  http_method = event['httpMethod']
  path = event['path']
  query_params = event['queryStringParameters'] || {}
 
  name = query_params['name'] || 'World'
 
  puts "Received #{http_method} request to #{path}"
  puts "Context: function_name=#{context.function_name}, " \
       "remaining_time=#{context.get_remaining_time_in_millis}ms"
 
  {
    statusCode: 200,
    headers: {
      'Content-Type' => 'application/json',
      'X-Custom-Header' => 'Lambda-Response'
    },
    body: JSON.generate({
      message: "Hello, #{name}!",
      timestamp: Time.now.iso8601,
      function: context.function_name
    })
  }
rescue StandardError => e
  puts "Error: #{e.message}"
  puts e.backtrace.join("\n")
 
  {
    statusCode: 500,
    body: JSON.generate({ error: 'Internal Server Error' })
  }
end

ローカルでのテスト

SAMの最大の利点の一つは、Lambdaをローカルで実行できること。本番デプロイ前に動作確認ができる。

# ローカルで Lambda 関数を直接呼び出す
sam local invoke HelloWorldFunction --event events/event.json
 
# events/event.json の中身
cat events/event.json
{
  "httpMethod": "GET",
  "path": "/hello",
  "queryStringParameters": {
    "name": "Daichi"
  },
  "headers": {
    "Content-Type": "application/json"
  },
  "body": null
}
# 実行結果
# START RequestId: xxx
# Received GET request to /hello
# Context: function_name=HelloWorldFunction, remaining_time=29998ms
# END RequestId: xxx
# REPORT RequestId: xxx Duration: 45.23 ms Billed Duration: 46 ms Memory Size: 256 MB Max Memory Used: 48 MB
# {"statusCode":200,"headers":{...},"body":"{\"message\":\"Hello, Daichi!\",\"timestamp\":\"2024-01-15T12:00:00+09:00\",\"function\":\"HelloWorldFunction\"}"}
# ローカルでAPIサーバーを起動
sam local start-api --port 3001
 
# 別ターミナルから
curl "http://localhost:3001/hello?name=Daichi"
# {"message":"Hello, Daichi!","timestamp":"...","function":"HelloWorldFunction"}

INFO

sam local はDockerを使ってLambdaの実行環境をエミュレートする。本番と同じRubyランタイムで動作確認できる。Dockerが必要なので事前にインストールしておくこと。

AWS へのデプロイ

ローカルで動作確認ができたら、本番にデプロイする。

# ビルド(依存Gemのインストール等)
sam build
 
# 初回デプロイ(ガイド付き)
sam deploy --guided
 
# Configuring SAM deploy
# =========================================
# Stack Name [sam-app]: serverless-ec-api
# AWS Region [us-east-1]: ap-northeast-1
# Confirm changes before deploy [y/N]: y
# Allow SAM CLI IAM role creation [Y/n]: Y
# Save arguments to configuration file [Y/n]: Y
# SAM configuration file [samconfig.toml]: [Enter]
 
# Deploying with following values
# ===============================
# Stack name                   : serverless-ec-api
# Region                       : ap-northeast-1
# Confirm changeset            : True
# ...
 
# CloudFormation stack changeset
# ---------------------------------
# Operation  LogicalResourceId      Type
# ---------  ---------------------- ----------------
# + Add      HelloWorldFunction     AWS::Lambda::Function
# + Add      HelloWorldFunctionRole AWS::IAM::Role
# + Add      ServerlessRestApi      AWS::ApiGateway::RestApi

デプロイが完了すると、API GatewayのエンドポイントURLが表示される。

# Outputs
# -------
# Key: HelloWorldApi
# Value: https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/hello
 
# 本番エンドポイントへのリクエスト
curl "https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/hello?name=Daichi"
# {"message":"Hello, Daichi!","timestamp":"2024-01-15T12:05:30+09:00","function":"serverless-ec-api-HelloWorldFunction-XXXX"}

CloudWatch Logsでログを確認する

Lambdaの実行ログはCloudWatch Logsに自動的に保存される。

# SAM CLIでログをリアルタイム確認
sam logs -n HelloWorldFunction --stack-name serverless-ec-api --tail
 
# 特定の時間範囲のログを取得
sam logs -n HelloWorldFunction \
  --stack-name serverless-ec-api \
  --start-time "2024-01-15T12:00:00" \
  --end-time "2024-01-15T13:00:00"

ログには以下のような情報が含まれる。

START RequestId: a1b2c3d4-e5f6-... Version: $LATEST
Received GET request to /hello
Context: function_name=serverless-ec-api-HelloWorldFunction-XXXX, remaining_time=29998ms
END RequestId: a1b2c3d4-e5f6-...
REPORT RequestId: a1b2c3d4-e5f6-... Duration: 45.23 ms Billed Duration: 46 ms Memory Size: 256 MB Max Memory Used: 48 MB

REPORT 行の Duration が実際の実行時間、Billed Duration が課金対象時間(1ms単位で切り上げ)だ。

実用的なRubyのLambda構造

Hello World を超えて、実際のビジネスロジックに使える構造を作ってみよう。

# app.rb — より実用的な構造
# frozen_string_literal: true
 
require 'json'
require 'logger'
 
# ロガーの設定(Lambda環境ではSTDOUTがCloudWatch Logsに転送される)
$logger = Logger.new($stdout)
$logger.level = ENV['LOG_LEVEL'] == 'DEBUG' ? Logger::DEBUG : Logger::INFO
 
# ハンドラ(Lambda のエントリーポイント)
def lambda_handler(event:, context:)
  $logger.info("Request received: #{event['httpMethod']} #{event['path']}")
 
  handler = resolve_handler(event)
  handler.call(event, context)
rescue StandardError => e
  $logger.error("Unhandled error: #{e.class} - #{e.message}")
  $logger.error(e.backtrace.first(5).join("\n"))
  error_response(500, 'Internal Server Error')
end
 
private
 
def resolve_handler(event)
  method = event['httpMethod']
  path = event['path']
 
  case [method, path]
  when ['GET', '/hello']  then method(:handle_hello)
  when ['GET', '/health'] then method(:handle_health)
  else method(:handle_not_found)
  end
end
 
def handle_hello(event, _context)
  name = (event['queryStringParameters'] || {})['name'] || 'World'
  success_response({ message: "Hello, #{name}!" })
end
 
def handle_health(_event, _context)
  success_response({ status: 'ok', timestamp: Time.now.iso8601 })
end
 
def handle_not_found(_event, _context)
  error_response(404, 'Not Found')
end
 
def success_response(body)
  json_response(200, body)
end
 
def error_response(status, message)
  json_response(status, { error: message })
end
 
def json_response(status, body)
  {
    statusCode: status,
    headers: { 'Content-Type' => 'application/json' },
    body: JSON.generate(body)
  }
end

デプロイのベストプラクティス

Loading diagram...
# RSpec によるユニットテスト
# spec/unit/test_handler.rb
 
require 'json'
require_relative '../../hello_world/app'
 
RSpec.describe '#lambda_handler' do
  let(:event) do
    {
      'httpMethod' => 'GET',
      'path' => '/hello',
      'queryStringParameters' => { 'name' => 'Daichi' }
    }
  end
 
  let(:context) do
    double('context',
      function_name: 'test-function',
      get_remaining_time_in_millis: 30000
    )
  end
 
  it 'returns 200 with greeting message' do
    result = lambda_handler(event: event, context: context)
 
    expect(result[:statusCode]).to eq(200)
    body = JSON.parse(result[:body])
    expect(body['message']).to eq('Hello, Daichi!')
  end
end
# テスト実行
cd hello_world
bundle exec rspec

WARNING

Lambda では $LOAD_PATH やグローバル変数がウォームスタート時に再利用される。データベース接続やHTTPクライアントなどの初期化はハンドラ関数の外(モジュールレベル)に置くと効率的だが、その分リソースが関数コンテナ間で共有されることを意識すること。

環境変数の管理

Railsの config/credentials.yml.enc に相当するのが、LambdaではAWS Secrets ManagerやSystems Manager Parameter Storeだ。

# template.yaml に環境変数を追加
Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      Environment:
        Variables:
          DATABASE_URL: !Sub '{{resolve:ssm:/myapp/production/database_url}}'
          LOG_LEVEL: INFO
# コード内での使用
database_url = ENV['DATABASE_URL']
log_level = ENV.fetch('LOG_LEVEL', 'INFO')

ダイチの感想

最初のLambda関数のデプロイを終え、ダイチはSlackに投稿した。

今日やったこと:
✅ SAMプロジェクト作成
✅ ローカルでHello World動作確認
✅ AWSへのデプロイ成功
✅ CloudWatch Logsでログ確認

感想:
デプロイが `sam deploy` 一発で済むのが驚き。
EC2の時はCapistranoで色々設定してたのに...

次: API Gateway + Lambda でREST APIを作る

サーバーの設定ファイルを書く必要もなく、sam deploy 一つで動く状態になった。ダイチは画像リサイズ処理の移行に向けて、確実に前進していた。