Lambdaのローカル開発環境とCI/CD構築(coverageも)

Lambdaをサクサク作りたい

最近実務でもプライベートでもLambdaを使う機会が多いのですが、毎回悩むのが開発環境とCI/CD。

ちょっとしたLambdaならブラウザコンソール上のエディタを使って作るとか、zipで固めるとかでいいんですけど、それなりに大きなLambda関数だとやっぱり

ローカルで開発&単体テスト&結合テスト --> GitにPush --> 自動テスト&デプロイ(ついでにcoverage計測)

てな流れを作りたい。

構成

ということで、今回は下記のような構成で作ってみたいと思います。

  1. ローカルでコーディング、テスト、動作確認
  2. GitにPush(masterブランチはdev環境、releaseブランチはrelease環境)
  3. 自動テスト、デプロイ、codecovにカバレッジ送信
  4. プルリクの場合、codecovからプルリクにカバレッジレポート自動POST

開発者がやることはローカルでコーディングしてテスト書いてGitにPushするだけ。あとは自動でテスト、カバレッジ計測、問題無ければデプロイまで実施してくれるようにしたいです。

今回作るAPIは下記の通りです。S3との通信ができれば他サービスとの連携も可能なので、これくらいシンプルで良いと思います。

Request(application/json): { "textBody": "ファイルに書き込みたい内容" }

Response(ok):{ "messasge": "Succeeded." }

Response(4xx/5xx):{ エラー内容 }

Description: textBody に指定した内容をS3バケットの text.txt に保存するAPI。

使用する技術・サービス

今回使用する技術・サービスのうち、主要なものを記載しておきます。

Table 1: 主要な技術・サービス一覧
技術・サービス名 概要
Serverless Framework AWSの各種サービスへのデプロイを自動化するためのフレームワーク。 serverless.yml に構成を記述することで、一発でデプロイできます。
serverless-offline ローカルでserverlessでデプロイする環境のうち、LambdaとAPI Gatewayを再現するプラグイン。
serverless-s3-local ローカルでserverlessでデプロイする環境のうち、S3を再現するプラグイン。serverless-offlineのプラグインという位置付けです。
serverless-rust serverless-offlineでRustで書いたLambdaを動作させるためのプラグイン。
TravisCI CIサービス。公開リポジトリなら無料で使えます。
Codecov カバレッジレポートサービス。公開リポジトリなら無料で使えます。

TravisCIとCodecovの登録方法については割愛するので、未登録の方は登録しておいてください(Githubでログインするだけ)。


ローカル開発環境とメイン処理

それでは始めにローカル開発環境の構築を行っていきます。S3との通信はとりあえず置いといて、APIにPOSTしたらバリデーションして返答を返すところまで。

プロジェクト作成

今回はserverless AWS Rust HTTP templateをベースにプロジェクトを作成します。

$ npx sls install \
  --url https://github.com/softprops/serverless-aws-rust-http \
  --name lambda-rust-sample
$ cd lambda-rust-sample
Code 1: プロジェクト作成コマンド

必要なnode modulesの追加

$ npm i serverless -g
$ npm i -D serverless-offline serverless-s3-local serverless-rust
Code 2: 必要なnode_modulesのインストール

なお、serverlessコマンドはglobal領域にインストールしておきます。

また、serverless-offlineのmasterブランチは本記事執筆時点(2020/11/15)でrustに対応しておらず、こちらのプルリクエストで対応されているので、そっちを使うようにします。

"serverless-offline": "EgorDm/serverless-offline.git#feature/rust-invoke",
Code 3: package.jsonの修正

serverless.ymlの修正

serverless.yml を修正して、下記の通り修正します。

service: rust-lambda-sample
provider:
  name: aws
  runtime: rust
  memorySize: 128
  region: ap-northeast-1
  stage: ${opt:stage, self:custom.defaultStage}
  logs:
    restApi:
      accessLogging: true
package:
  individually: true
plugins:
  - serverless-rust
  - serverless-offline
  - serverless-s3-local
functions:
  hello:
    handler: hello
    events:
      - http:
          path: '/'
          method: POST
          integration: lambda
          request:
            template:
              application/json: $input.json('$')
custom:
  s3:
    host: localhost
    directory: /tmp
    port: 8000
    vhostBuckets: false
Code 4: serverless.yml

少しポイントになる所を解説。

...
provider:
  name: aws
  runtime: rust
plugins:
  - serverless-rust
  - serverless-offline
  - serverless-s3-local
...
Code 5: [point1] plugins

ここで、プラグインとして先程追加したserverless-rust、offline、s3-localを使用することを示しています。また、serverless-rustのおかげでruntimeとしてrustを指定できます。

...
functions:
  hello:
    handler: hello
    events:
      - http:
          path: '/'
          method: POST
          integration: lambda
          request:
            template:
              application/json: $input.json('$')
...
Code 6: [point2] ingeration

integrationlambda を指定しています。何も指定しないと lambda-proxy となりますが、プロキシ統合だと勝手にAPI Gatewayでリクエストとレスポンスのマッピングな行われてしまい上手くいかなかったのでLambda統合にしました。

リクエストマッピングについては template にて設定しており、リクエストの body だけ取り出してAPI GatewayからLambdaにパスする流れになっています。

Cargo.tomlの修正

ひとまず必要な依存関係だけ定義しておきます。 anyhowsimple_logger はそれぞれエラーハンドリングとログパッケージですが、お好きなパッケージがありましたらそちらを使用してもOKです。

なお、 lambdalambda_http についてはcrates.ioに上がっているパッケージではなく、githubの最新ソースから取得するようにします。

[package]
name = "hello"
version = "0.1.0"
edition = "2018"

[dependencies]
tokio = { version = "0.2", features = ["macros"] }
lambda = { git = "https://github.com/awslabs/aws-lambda-rust-runtime/", branch = "master"}
lambda_http = { git = "https://github.com/awslabs/aws-lambda-rust-runtime/", branch = "master"}
serde_derive = "1.0.117"
serde = "1.0.117"
serde_json = "1.0.59"
simple_logger = "1.11.0"
log = "0.4.11"
anyhow = "1.0.34"
Code 7: Cargo.toml

Lambda本体の作成

LambdaのソースコードをRustで記述します。

use lambda::{handler_fn, Context};
use anyhow::{anyhow, Result};
use serde_derive::{Deserialize, Serialize};
use simple_logger::SimpleLogger;
use log::{LevelFilter, error};

#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct CustomEvent {
    text_body: Option<String>,
}

#[derive(Serialize, Debug, PartialEq)]
struct CustomOutput {
    message: String,
}

const MSG_EMPTY_TEXT_BODY: &str = "Empty text body.";
const MSG_TEXT_BODY_TOO_LONG: &str = "Text body is too long (max: 100)";

#[tokio::main]
async fn main() -> Result<()> {
    SimpleLogger::new().with_level(LevelFilter::Debug).init().unwrap();
    lambda::run(handler_fn(hello))
        .await
        // https://github.com/dtolnay/anyhow/issues/35
        .map_err(|err| anyhow!(err))?;
    Ok(())
}

async fn hello(event: CustomEvent, c: Context) -> Result<CustomOutput> {
    if let None = event.text_body {
        error!("Empty text body in request {}", c.request_id);
        return Err(anyhow!(get_err_msg(400, MSG_EMPTY_TEXT_BODY)));
    }
    let text = event.text_body.unwrap();
    if text.len() > 100 {
        error!("text body is too long (max: 100) in request {}", c.request_id);
        return Err(anyhow!(get_err_msg(400, MSG_TEXT_BODY_TOO_LONG)));
    }

    Ok(CustomOutput {
        message: format!("Succeeded.")
    })
}

fn get_err_msg(code: u16, msg: &str) -> String {
    format!("[{}] {}", code, msg)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn can_hello_handler_handle_valid_request() {
        let event = CustomEvent {
            text_body: Some("Firstname".to_string())
        };
        let expected = CustomOutput {
            message: "Succeeded.".to_string()
        };
        assert_eq!(
            hello(event, Context::default())
                .await
                .expect("expected Ok(_) value"),
            expected
        )
    }

    #[tokio::test]
    async fn can_hello_handler_handle_empty_text_body() {
        let event = CustomEvent {
            text_body: None
        };
        let result = hello(event, Context::default()).await;
        assert!(result.is_err());
        if let Err(error) = result {
            assert_eq!(
                error.to_string(),
                format!("[400] {}", MSG_EMPTY_TEXT_BODY)
            )
        } else {
            // result must be Err
            panic!()
        }
    }

    #[tokio::test]
    async fn can_hello_handler_handle_text_body_too_long() {
        let event = CustomEvent {
            text_body: Some("12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901".to_owned())
        };
        let result = hello(event, Context::default()).await;
        assert!(result.is_err());
        if let Err(error) = result {
            assert_eq!(
                error.to_string(),
                format!("[400] {}", MSG_TEXT_BODY_TOO_LONG)
            )
        } else {
            // result must be Err
            panic!()
        }
    }
}
Code 8: main.rs

CustomEvent がAPI Gatewayから受け取った内容(=ユーザのリクエストbody)に対応しており、 text_bodyOption にすることで何も指定しない場合に500で死なないようにしています。

CustomOutput はレスポンスの内容になります。

ややこしいことは lambda-rust-runtime がやってくれるので、こちらが書く内容としてはリクエストとレスポンスをマッピングする構造体を定義して、それを返却するだけです。現状のソースでは、リクエストに textBody が存在すれば Succeeded. が、存在しなかったり100文字以上だとエラーメッセージが返ってきます。

動作確認

それでは、ローカルで動作確認してみます。

プロジェクトルートで下記のコマンドを実行し、ローカル環境を走らせます。

$ npm i
$ sls offline start --stage local
...
POST | http://localhost:3000/local
Code 9: serverless開始コマンド

この状態で、POSTを飛ばしてみます。POSTした時点でRustのビルドがスタートするので、初回は結構時間がかかります。

$ curl -X POST -H "Content-Type: application/json" -d '{"textBody": "aaaaa"}' http://localhost:3000/local
{"body":"{\"message\":\"Succeeded.\"}"}
$ curl -X POST -H "Content-Type: application/json" -d '{"textBodyyyyy": "aaaaa"}' http://localhost:3000/local
{"body":"{\"errorType\":\"anyhow::Error\",\"errorMessage\":\"[400] Empty text body.\"}"}
Code 10: POSTしてみる

ローカルだとresponseがbodyに入っちゃってますが、実際にデプロイすると中身だけちゃんと返ってきます(ほんとは同じ挙動になってほしいけど多分serverless-offlineの仕様orバグ?)。

デプロイ確認

ここまでのソースで、手動でデプロイできるか確認しておきます。

serverlessのcredential設定を行います。

$ sls config credentials --stage dev --provider aws --key "${AWS_ACCESS_KEY_ID}" --secret "${AWS_SECRET_ACCESS_KEY}"
$ sls deploy --stage dev
Code 11: serverlessのcredential設定&デプロイ実行

デプロイ後、表示されたエンドポイントにPOSTしてみて、想定通りのレスポンスが返却されることを確認します。


S3通信処理の追加とカバレッジ収集

S3との通信処理実装

S3との通信に使用するcrateはrusotoです。

まず、依存関係を追加します。

rusoto_core = "0.45.0"
rusoto_s3 = "0.45.0"
rusoto_mock = "0.45.0"
Code 12: Cargo.tomlへの追加内容

今回使用するバケットの情報を serverless.yml に追加しておきます。

service: rust-lambda-test
provider:
...
  iamRoleStatements:
    - Effect: "Allow"
      Action:
        - "s3:*"
      Resource:
        Fn::Join:
          - ""
          - - "arn:aws:s3:::"
            - ${self:custom.bucketName.${self:provider.stage}}
            - "/*"
...
functions:
  hello:
...
    environment:
      BUCKET_NAME: ${self:custom.bucketName.${self:provider.stage}}
      LOCAL_FLAG: ${self:custom.localFlag.${self:provider.stage}}
resources:
  Resources:
    Bucket:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ${self:custom.bucketName.${self:provider.stage}}
custom:
...
  bucketName:
    local: zeroclock-lambda-rust-bucket-local
    dev: zeroclock-lambda-rust-bucket-dev
    release: zeroclock-lambda-rust-bucket-release
  localFlag:
    local: local
    dev: ''
    release: ''
Code 13: serverless.ymlへのバケット情報追加

S3Clientを取得する処理と、単体テストを追加します。すいません、ちょっと長いです・・・。

use std::env;
use rusoto_s3::{
    S3,
    S3Client,
    PutObjectRequest,
};
use rusoto_core::Region;
use rusoto_mock::{
    MockCredentialsProvider,
    MockRequestDispatcher,
    MockResponseReader,
    ReadMockResponse,
};

...
const MOCK_KEY: &str = "AWS_MOCK_FLAG";
const BUCKET_NAME_KEY: &str = "BUCKET_NAME";
const LOCAL_KEY: &str = "LOCAL_FLAG";

...
async fn hello(event: CustomEvent, c: Context) -> Result<CustomOutput> {
...
    let s3 = get_s3_client();
    let bucket_name = env::var(BUCKET_NAME_KEY)?;
    s3.put_object(PutObjectRequest {
        bucket: bucket_name.to_string(),
        key: "test.txt".to_string(),
        body: Some(text.into_bytes().into()),
        acl: Some("public-read".to_string()),
        ..Default::default()
    }).await?;

    Ok(CustomOutput {
        message: format!("Succeeded.")
    })
}

...
fn get_s3_client() -> S3Client {
    let s3 = match env::var(MOCK_KEY) {
        Ok(_) => {
            // Unit Test
            S3Client::new_with(
                MockRequestDispatcher::default().with_body(
                    &MockResponseReader::read_response("mock_data", "s3_test.json")
                ),
                MockCredentialsProvider,
                Default::default(),
            )
        },
        Err(_) => {
            if env::var(LOCAL_KEY).unwrap() != "" {
                // local
                return S3Client::new(Region::Custom {
                    name: "ap-northeast-1".to_owned(),
                    endpoint: "http://host.docker.internal:8000".to_owned(),
                })
            }
            // cloud
            return S3Client::new(Region::ApNortheast1)
        },
    };
    s3
}

#[cfg(test)]
mod tests {
    use super::*;

    fn setup() {
        env::set_var(MOCK_KEY, "1");
        env::set_var(BUCKET_NAME_KEY, "test-bucket");
    }

    #[test]
    fn can_get_local_s3_client() {
        env::set_var(LOCAL_KEY, "local");
        let _s3 = get_s3_client();
        assert!(true);
    }

    #[test]
    fn can_get_cloud_s3_client() {
        env::set_var(LOCAL_KEY, "");
        let _s3 = get_s3_client();
        assert!(true);
    }

    #[tokio::test]
    async fn can_hello_handler_handle_valid_request() {
        setup();
        let event = CustomEvent {
            text_body: Some("Firstname".to_string())
        };
        let expected = CustomOutput {
            message: "Succeeded.".to_string()
        };
        assert_eq!(
            hello(event, Context::default())
                .await
                .expect("expected Ok(_) value"),
            expected
        )
    }

    #[tokio::test]
    async fn can_hello_handler_handle_empty_text_body() {
        setup();
        let event = CustomEvent {
            text_body: None
        };
        let result = hello(event, Context::default()).await;
        assert!(result.is_err());
        if let Err(error) = result {
            assert_eq!(
                error.to_string(),
                format!("[400] {}", MSG_EMPTY_TEXT_BODY)
            )
        } else {
            // result must be Err
            panic!()
        }
    }

    #[tokio::test]
    async fn can_hello_handler_handle_text_body_too_long() {
        setup();
        let event = CustomEvent {
            text_body: Some("12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901".to_owned())
        };
        let result = hello(event, Context::default()).await;
        assert!(result.is_err());
        if let Err(error) = result {
            assert_eq!(
                error.to_string(),
                format!("[400] {}", MSG_TEXT_BODY_TOO_LONG)
            )
        } else {
            // result must be Err
            panic!()
        }
    }
}
Code 14: main.rs

単体テスト時はrusoto_mockを使用し、ローカル開発環境の場合はカスタムエンドポイントで生成しています。 host.docker.internal は、Dockerコンテナから見たホストマシンのIPアドレスです(serverless-offlineのrustプラグインの場合、内部的にdockerが起動しているため)。

なお、credentialsは環境変数を使用するのでコード内には出てきません。

rusoto_mockでS3Clientを生成する際、レスポンスのデータを記述したファイルが必要なので、今回は空データを準備しておき、テストを実行してみます。

$ mkdir mock_data
$ touch mock_data/s3_test.json
$ cargo test
    Finished test [unoptimized + debuginfo] target(s) in 5.07s
     Running target/debug/deps/hello-ed9968ea3f56ae48

running 5 tests
test tests::can_get_cloud_s3_client ... ok
test tests::can_get_local_s3_client ... ok
test tests::can_hello_handler_handle_text_body_too_long ... ok
test tests::can_hello_handler_handle_empty_text_body ... ok
test tests::can_hello_handler_handle_valid_request ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Code 15: テスト実行

カバレッジ収集

カバレッジ収集は下記のCLIツールを使用します。

Table 2: カバレッジ収集に使用するCLIツール
名称 概要
lcov カバレッジデータ自体は後述のgrcovで良いのですが、ローカルでカバレッジをHTMLに出力するために使用(genhtmlコマンド)。Macの場合は brew install lcov でOK。
grcov Rustのカバレッジ収集ツール。Mozillaが保守しているので安心。Cargoでインストール( cargo install grcov )。
rust-covfix 必須じゃないですけど、これが無いとなんかカバレッジが明らかに高かったり低かったり謎の現象に見舞われたので使用。Cargoでインストール( cargo install rust-covfix )。

若干面倒なので、スクリプトを書いてプロジェクトルートに置いておきます(codecovのところは今はスルーでOK)。

#!/usr/bin/env bash

set -eux

PROJ_NAME=$(cat Cargo.toml | grep -E "^name" | sed -E 's/name[[:space:]]=[[:space:]]"(.*)"/\1/g' | sed -E 's/-/_/g')
rm -rf target/debug/deps/${PROJ_NAME}-*

export CARGO_INCREMENTAL=0
export RUSTFLAGS="-Zprofile -Ccodegen-units=1 -Copt-level=0 -Clink-dead-code -Coverflow-checks=off -Zpanic_abort_tests -C panic=abort"

cargo +nightly build
cargo +nightly test

zip -0 ccov.zip `find . \( -name "${PROJ_NAME}*.gc*" -o -name "test-*.gc*" \) -print`
grcov ccov.zip -s . -t lcov --llvm --branch --ignore-not-existing --ignore "/*" --ignore "tests/*" -o lcov.info
rust-covfix -o lcov.info lcov.info

if [ $# = 0 ] || [ "$1" != "ontravis" ]; then
    genhtml -o report/ --show-details --highlight --ignore-errors source --legend lcov.info --branch-coverage
fi

if [ $# -gt 1 ] && [ "$2" = "sendcov" ]; then
    bash <(curl -s https://codecov.io/bash) -f lcov.info -t "${CODECOV_TOKEN}"
fi
Code 16: カバレッジ集計スクリプト(coverage.sh)

引数無しで、カバレッジを計測してレポートをHTML出力させます。

$ bash coverage.sh

成功したら、 report/index.html をブラウザで開くとカバレッジが見れます。

これで、カバレッジ計測までいけました。

ローカルでS3との連携テスト

単体テストも通ってカバレッジも取れるようになったので、S3連携処理込みでローカル動作確認してみます。

はじめに、serverless-s3-local用にAWS CLI用のcredentials設定が必要です。

$ vim ~/.aws/credentials
#以下を追加
[s3local]
aws_access_key_id=S3RVER
aws_secret_access_key=S3RVER
Code 17: AWS Credentials設定
# タブA
# AWS_PROFILEにs3-local用のprofileを指定
$ AWS_PROFILE=s3local sls offline start --stage local
# タブB
$ curl -X POST -H "Content-Type: application/json" -d '{"textBody": "aaaaa"}' http://localhost:3000/local
{"body":"{\"message\":\"Succeeded.\"}"}
Code 18: ローカルで確認

成功したっぽいので、実際に保存されているか確認してみます。

$ aws --endpoint="http://localhost:8000" s3 cp s3://zeroclock-lambda-rust-sample-bucket-local/test.txt /tmp/s3_result.txt --profile s3local
$ cat /tmp/s3_result.txt
aaaaa
Code 19: ローカルのS3確認

いい感じ。

最後に、手動デプロイを再度実行して問題なく完了することを確認します。

$ sls deploy --stage dev
Code 20: 手動デプロイ

CI/CD環境構築

コーディングしてローカルで検証して手動でデプロイするところまでは問題無かったので、最後に自動デプロイ&カバレッジレポートを設定します。

それぞれ、TravisCIとCodecovを使用しますが、それぞれの連携方法及びCLIツールのインストール方法については割愛します。Githubでログインしてレポジトリを選ぶだけなので。

まず、デプロイに必要な下記の情報を安全にTravisCIに渡せるように暗号化します。

  • AWS IAMユーザのアクセスキーID(aws_access_key_id)
  • AWS IAMユーザのシークレットアクセスキー(aws_secret_access_key)
  • Codecovのトークン(CODECOV_TOKEN)
$ travis encrypt aws_access_key_id="xxxxx..."
$ travis encrypt aws_secret_access_key="xxxxx..."
$ travis encrypt CODECOV_TOKEN="XXXXXXXX-xxxx...."
Code 21: 環境変数の暗号化

TravisCI用の設定ファイル .travis.yml を作成して設定します。 secret には先程暗号化した3つの環境変数の情報が入ります。

language: rust
rust:
  - nightly
cache: cargo
install:
  - cargo install grcov rust-covfix
  - nvm install 12.14.1 --latest-npm
  - nvm alias default 12.14.1
  - npm install serverless -g
  - npm install
  - sls config credentials --stage dev --provider aws --key "${aws_access_key_id}" --secret "${aws_secret_access_key}"
before_script:
  - cargo test
script:
  - npm run coverage:ci-sendcov
  - if [ "$TRAVIS_BRANCH" = "master" ] && [ "$TRAVIS_PULL_REQUEST" = "false" ]; then echo "This is master which released to dev stage." && npm run deploy:dev; fi
  - if [ "$TRAVIS_BRANCH" = "release" ] && [ "$TRAVIS_PULL_REQUEST" = "false" ]; then echo "This is release which released to release stage." && npm run deploy:release; fi
env:
  global:
    - secure: "fugahuga..."
    - secure: "hogehoge..."
    - secure: "hogehoge..."
Code 22: .travis.yml

package.json にscriptを追加しておきます。

{
  "scripts": {
    "start": "AWS_PROFILE=s3local sls offline start --stage local",
    "deploy:dev": "sls deploy --stage dev",
    "deploy:release": "sls deploy --stage release",
    "coverage": "bash coverage.sh",
    "coverage:ci": "bash coverage.sh ontravis",
    "coverage:ci-sendcov": "bash coverage.sh ontravis sendcov"
  },
  ...
}
Code 23: package.jsonへのscript追加

最後に、Codecov用の設定ファイル codecov.yml を作成します。

codecov:
  require_ci_to_pass: yes

coverage:
  precision: 2
  round: down
  range: "70...100"

parsers:
  gcov:
    branch_detection:
      conditional: yes
      loop: yes
      method: no
      macro: no

comment: # See: https://docs.codecov.io/docs/pull-request-comments
  layout: "reach, diff, flags, files"
  behavior: default
  require_changes: no
  require_base: yes
  require_head: yes
Code 24: codecov.yml

これで、PR時には自動的にカバレッジレポートをコメントしてくれるはずです。


動作確認+まとめ

では、実際にmasterブランチとreleaseブランチにそれぞれPushしてみます。

念の為それぞれの環境でAPIを叩いてみます。

問題無さそうですね。

では、プルリクしてみます。

きちんとカバレッジレポートがコメントされています。

なお、今回作ったサンプルは下記のリポジトリになります。

zeroclock/lambda-rust-sample


現状CI/CDサイクル回すのに10分程度かかっちゃってますが、Dockerイメージをキャッシュするとかでもっと早くなりそうな気がしています。

また、ローカルとデプロイ後で若干レスポンスの形式が異なる(ローカルだとbody階層が増えちゃってる)ので、そこも要調整な感じですが、まだマージされていないプルリクを使用しているので、もしかしたらマージされる頃には直っているかも(私の設定ミスの可能性もあり)。

なにはともあれ、これで色々な意味で足かせになっていたLambdaのCI/CD環境が構築できました。同じような悩みを抱えている人に参考になれば幸いです。


related-posts