🐥

RustでLambdaを書いてsam local start-apiでLocalStackのDynamoDBを使う

に公開

はじめに

Rustの門を叩いてからインプットしているだけでアプリケーションを作ってなかったので、とりあえずなんかやってみようということでAPI Gateway + Lambdaの構成でAPIを作ることに。
サーバレスは運用コストが低かったりと結構好きなんですが、動作確認がデプロイしてからじゃないとできないし面倒だなーなんて思っていました。
そんなときにsam local start-apiでローカル環境でAPI Gateway + Lambdaを実行できるということを知りさっそくやってみようと思います。

技術スタック

  • Rust
  • Amazon API Gateway
  • Amazon DynamoDB
  • AWS Lambda
  • LocalStack

構成図

かなりアバウトな図ですが、イメージはこんな感じです。

サンプル

1から手順を紹介しますが、急いでいる方のために完成したサンプルプロジェクトを公開します。
必要に応じてこちらをご利用ください。

https://github.com/tsuruya-jp/rust-samlocalapi-localstack

Rustのプロジェクトを作る

まずはsamでローカル環境を動かせるようにRustのプロジェクトを作成します。

  1. プロジェクト作成

    cargo new project
    # or
    cargo init .
    
  2. template.ymlを作成

    サンプルは/のエンドポイントでGETとPOSTのAPIを作成します。

    template.yml
    AWSTemplateFormatVersion: '2010-09-09'
    Transform: AWS::Serverless-2016-10-31
    Description: Rust Lambda Function with LocalStack
    
    Globals:
      Function:
        Runtime: provided.al2023
        Architectures:
          - x86_64
        Tracing: Active
        CodeUri: ./
        Handler: bootstrap
        Timeout: 30
        MemorySize: 128
        Environment:
          Variables:
            RUST_BACKTRACE: 1
    
    Resources:
      GetFunction:
        Type: AWS::Serverless::Function
        Metadata:
          BuildMethod: rust-cargolambda
          BuildProperties:
            Binary: get_function
        Properties:
          Events:
            GetRoot:
              Type: Api
              Properties:
                Path: /
                Method: GET
    
      PostFunction:
        Type: AWS::Serverless::Function
        Metadata:
          BuildMethod: rust-cargolambda
          BuildProperties:
            Binary: post_function
        Properties:
          Events:
            PostRoot:
              Type: Api
              Properties:
                Path: /
                Method: POST
    
  3. cargo.toml修正

    最下部に追加する

    cargo.toml
    [[bin]]
    name = "get_function"
    path = "src/get_function.rs"
    
    [[bin]]
    name = "post_function"
    path = "src/post_function.rs"
    
  4. 必要なクレートを追加する

    cargo add aws_config
    cargo add serde
    cargo add tokio --features macros
    cargo add tracing --features log
    cargo add tracing-subscriber --no-default-features --features fmt
    cargo add aws_lambda_events
    cargo add lambda_runtime
    
  5. Lambdaハンドラーを作成する

    src/配下にcargo.tomlに記載したパス通りのファイルを作る

    ./
    ├── Cargo.lock
    ├── Cargo.toml
    ├── src
    │   ├── get_function.rs
    │   └── post_function.rs
    └── template.yml
    
  6. 実装する

    とりあえずリクエストを受け取ってレスポンスを返すだけの処理を書く。post_function.rsも同じ実装で良い。

    get_function.rs
    use aws_lambda_events::{apigw::{ApiGatewayProxyRequest, ApiGatewayProxyResponse}, http::HeaderMap};
    use lambda_runtime::{Error, LambdaEvent, run, service_fn};
    
    pub async fn handler(
        _event: LambdaEvent<ApiGatewayProxyRequest>,
    ) -> Result<ApiGatewayProxyResponse, Error> {
    
        let mut headers = HeaderMap::new();
        headers.insert("content-type", "text/html".parse().unwrap());
    
        let resp = ApiGatewayProxyResponse {
            status_code: 200,
            multi_value_headers: headers.clone(),
            is_base64_encoded: false,
            body: Some("Hello AWS Lambda HTTP request".into()),
            headers,
        };
        Ok(resp)
    }
    
    #[tokio::main]
    async fn main() -> Result<(), Error> {
        run(service_fn(handler)).await
    }
    
  7. ローカルでAPIを動かす

    1. コマンドを実行してAPIサーバを起動する

      sam build
      sam local start-api
      
    2. リクエストを送信する

      curl --location 'http://127.0.0.1:3000/'
      
    3. レスポンスが返される

      Hello AWS Lambda HTTP request
      

まずはこれでローカル環境でAPI Gateway + LambdaのAPIサーバを動かすことができました。

LocalStackの環境を作る

次はDockerでLocalStackDynamoDBを立ち上げます。

  1. docker-compose.ymlを作成する

    LocalStackはいろんなAWSのサービスをローカルで起動させることができますが、今回はDynamoDBのみで良いのでSERVICES=dynamodbとしておきます。

    docker-compose.yml
    services:
      localstack:
        image: localstack/localstack:latest
        ports:
        - "4566:4566"
        environment:
        - SERVICES=dynamodb
        - AWS_ACCESS_KEY_ID=dummy
        - AWS_SECRET_ACCESS_KEY=dummy
        - AWS_DEFAULT_REGION=ap-northeast-1
    
  2. LocalStackを起動する

    1. コンテナを起動する

      docker compose up -d
      
    2. LocalStackの起動状況を確認する

      curl -s localhost:4566/_localstack/health | jq .
      

      返却内容

      {
      "services": {
          "acm": "disabled",
          "apigateway": "disabled",
          "cloudformation": "disabled",
          "cloudwatch": "disabled",
          "config": "disabled",
          "dynamodb": "available",
          "dynamodbstreams": "available",
          "ec2": "disabled",
          "es": "disabled",
          "events": "disabled",
          "firehose": "disabled",
          "iam": "disabled",
          "kinesis": "available",
          "kms": "disabled",
          "lambda": "disabled",
          "logs": "disabled",
          "opensearch": "disabled",
          "redshift": "disabled",
          "resource-groups": "disabled",
          "resourcegroupstaggingapi": "disabled",
          "route53": "disabled",
          "route53resolver": "disabled",
          "s3": "disabled",
          "s3control": "disabled",
          "scheduler": "disabled",
          "secretsmanager": "disabled",
          "ses": "disabled",
          "sns": "disabled",
          "sqs": "disabled",
          "ssm": "disabled",
          "stepfunctions": "disabled",
          "sts": "disabled",
          "support": "disabled",
          "swf": "disabled",
          "transcribe": "disabled"
      },
      "edition": "community",
      "version": "4.8.2.dev13"
      }
      
    3. aws cliを実行してみる

      aws dynamodb list-tables
      
    4. 実行結果を確認する

      {
          "TableNames": []
      }
      

      このとき

      You must specify a region. You can also configure your region by running "aws configure".
      

      と怒られた場合、aws cliのセットアップが完了していないので、~/.aws/config~/.aws/credentialsを作成する。

      もしくは、aws configureコマンドでセットアップを行う。

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

DynamoDBにテーブルを作る

LocalStackは無料プランではデータが永続化されないため、ローカル環境ではコンテナを起動するたびに初期化データを投入する必要があります。
毎回手動でテーブルを作成するのは手間なので、初期化用のスクリプトを作成します。

スクリプトはコンテナ側の/etc/localstack/init/ready.dに配置することで、Dockerコンテナ起動時に自動で実行されます。

https://docs.localstack.cloud/aws/capabilities/config/initialization-hooks/

  1. スクリプトを作る

    scripts/init.sh
    #!/bin/bash
    
    key_name="user_id"
    table_name="users"
    
    aws --endpoint-url=http://localstack:4566 dynamodb create-table \
        --table-name=$table_name \
        --attribute-definitions AttributeName=$key_name,AttributeType=S \
        --key-schema AttributeName=$key_name,KeyType=HASH \
        --billing-mode PAY_PER_REQUEST
    
  2. docker-compose.ymlを修正する

    docker-compose.yml
    services:
      localstack:
        image: localstack/localstack:latest
        ports:
        - "4566:4566"
        environment:
        - SERVICES=dynamodb
        - AWS_ACCESS_KEY_ID=dummy
        - AWS_SECRET_ACCESS_KEY=dummy
        - AWS_DEFAULT_REGION=ap-northeast-1
    +   volumes:
    +   - "./scripts:/etc/localstack/init/ready.d"
    
  3. コンテナを再起動する(作り直す)

    docker compose up -d --force-recreate
    

    DynamoDBが作られているか確認する

    aws dynamodb list-tables
    

    作られていることが確認できる

    {
        "TableNames": [
            "users"
        ]
    }
    

Lambdaを改修する

先程作成したGETの実装にDynamoDBからデータを取得する実装をおこないます。

まず、DynamoDB SDKと JSONシリアライズ用のクレートを追加します。

cargo add aws_sdk_dynamodb
cargo add serde_json
get_function.rs
use aws_config::BehaviorVersion;
use aws_lambda_events::{apigw::{ApiGatewayProxyRequest, ApiGatewayProxyResponse}, encodings::Body, http::HeaderMap};
use aws_sdk_dynamodb::Client;
use lambda_runtime::{Error, LambdaEvent, run, service_fn};
use serde_json::json;

pub async fn handler(
    _event: LambdaEvent<ApiGatewayProxyRequest>,
) -> Result<ApiGatewayProxyResponse, Error> {

    let config = aws_config::defaults(BehaviorVersion::latest())
        .endpoint_url("http://localstack:4566")
        .load()
        .await;

    let client = Client::new(&config);

    let list_resp = client.list_tables().send().await;

    let mut headers = HeaderMap::new();
    headers.insert("content-type", "application/json".parse().unwrap());

    let resp = match list_resp {
        Ok(resp) => {
            tracing::info!("Found {} tables", resp.table_names().len());
            let tables: Vec<String> = resp.table_names().iter().map(|s| s.to_string()).collect();

            let body = json!({
                "tables": tables
            }).to_string();

            ApiGatewayProxyResponse {
                status_code: 200,
                multi_value_headers: headers.clone(),
                is_base64_encoded: false,
                body: Some(Body::Text(body)),
                headers,
            }
        }
        Err(err) => {
            tracing::error!("Failed to list tables: {err:?}");

            let error_body = json!({
                "error": "Failed to list tables",
                "message": err.to_string()
            }).to_string();

            ApiGatewayProxyResponse {
                status_code: 500,
                multi_value_headers: headers.clone(),
                is_base64_encoded: false,
                body: Some(Body::Text(error_body)),
                headers,
            }
        }
    };

    Ok(resp)
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    tracing_subscriber::fmt().json()
        .with_max_level(tracing::Level::INFO)
        .with_current_span(false)
        .with_ansi(false)
        .without_time()
        .with_target(false)
        .init();

    run(service_fn(handler)).await
}

ポイントは、DynamoDBクライアントのエンドポイントをhttp://localstack:4566にすることです。
http://127.0.0.1:4566http://localhost:4566の場合LocalStackに接続できません。

修正が完了したら再度sam buildを実行してください。

コンテナ間の通信ができるようにする

実装が完了しましたが、このままではLambdaの処理がLocalStackのコンテナにアクセスできずエラーになってしまいます。

理由は、sam local で実行するLambdaはdockerコンテナで実行されるためです。
コンテナ間の通信が必要になるためlocalhostでは通信が不可能となります。

対応方法

sam local start-apiコマンドに--docker-networkオプションをつけて実行することでコンテナ間の通信を可能にします。

  1. LocalStackコンテナが起動しているネットワークを確認する

    docker network ls
    

    デフォルトはディレクトリ名 + _defaultで命名されています。
    本プロジェクトは「rust-samlocalapi-localstack_default」という名前で作成しています。

    NETWORK ID     NAME                                              DRIVER    SCOPE
    b86fc161fc86   rust-samlocalapi-localstack_default               bridge    local
    
  2. 表示されたNETWORK IDをオプションに付ける

    sam local start-api --docker-network b86fc161fc86
    
  3. APIを実行する

    curl --location 'http://127.0.0.1:3000/'
    

    返却されるjson

    {"tables":["users"]}
    

次回

ほとんど完成なんですが、一旦ここまでにして、次の記事でDynamoDBのデータを取得&登録する実装を書こうと思います。

GETでデータ取得、POSTでデータ登録するようなAPIを作るのでお時間があれば見てみてください。

GitHubで編集を提案

Discussion