なになれ

IT系のことを記録していきます

AWS CDKでLambdaをいい感じにIaC化する

AWS Lambdaに対してのIaCのやり方を模索していて、AWS CDKが今のところ一番いい感じだと思ったので試してみた記録です。

各種方法との比較

AWS LambdaをIaC化するやり方は主に以下のやり方があると思います。
他にもいろいろあると思いますが、自分が経験したものの範囲になります。

  • AWS SAM
  • Serverless Framework
  • Terraform
  • AWS CDK

AWS SAMは割と以前から存在するLambdaをIaC化するためのAWS公式の方法です。AWS CDKと比較するとAWS SAMテンプレートと呼ばれるAWS CloudFormationテンプレートを拡張した長大なYAMLを利用しなければいけないところが辛いところです。

Serverless Frameworkは公式ではないところの不安やLambdaのみのIaCの仕組みなので学習コストが気になるところです。AWS CDKと比較しても優位がない感じがします。

Terraformは公式ではないものの広くAWSのIaCで使われていて安心感があります。ただLambdaを管理するには手間がかかります。Terraformの場合、基本的にAWSAPIをHCLでそのまま利用する形です。Lambda関数を作成する場合、zipファイルを用意するか、コンテナイメージを用意するかがあり面倒です。

こういったことを踏まえるとAWS CDKでのやり方は面倒なことがなく、公式で提供されている仕組みということもあり一番良いと考えました。AWS CDKではコンストラクトという要素でCloudFormationのリソースを利用可能ですが、それらを抽象化した高度なコンストラクトも用意されています。AWS Lambdaにおいては楽にリソースを作成する体験が得られます。

以下でどのようになるかを紹介します。

AWS CDKによるLambda関数の作成方法

以下のAWS CDK Workshopの通りにcdk deployができていることを前提としています。

cdkworkshop.com

aws_lambda.FunctionでLambda関数を作成できます。

lib/main.ts

import { aws_lambda, Duration, Stack } from 'aws-cdk-lib';
import { Construct } from 'constructs';

...

export class AwsLambdaCicdExampleStack extends Stack {
  constructor(scope: Construct, id: string, props: LambdaFunctionConfig) {
    super(scope, id);

    new aws_lambda.Function(this, `HelloFunction`, {
      functionName: `hello-function-${props.environmentValues.NODE_ENV}`,
      runtime: props.runtime,
      code: aws_lambda.Code.fromAsset(props.path),
      handler: props.handler,
      timeout: Duration.minutes(15),
      environment: props.environmentValues
    });
  }
}

Lambdaのコードを指定するにはcodeのパラメータにaws_lambda.Code.fromAssetメソッドでLambdaのコードが配置してあるディレクトリパスを指定するだけです。zipファイル化が不要なので楽です。

AWS CDKではTypeScriptのLambda関数も扱えます。aws_lambda_nodejs.NodejsFunctionを使います。

lib/main.ts

import { aws_lambda, aws_lambda_nodejs, Duration, Stack } from 'aws-cdk-lib';
import { Construct } from 'constructs';

...

export class AwsLambdaCicdExampleStack extends Stack {
  constructor(scope: Construct, id: string, props: LambdaFunctionConfig) {
    super(scope, id);

    new aws_lambda_nodejs.NodejsFunction(this, `HelloFunction`, {
      functionName: `hello-function-${props.environmentValues.NODE_ENV}`,
      runtime: props.runtime,
      entry: props.path,
      handler: props.handler,
      timeout: Duration.minutes(15),
      environment: props.environmentValues
    });
  }
}

TypeScriptのLambdaのコードを指定するにはentryのパラメータにTypeScriptのエントリーファイルのパスを指定するだけです。
NodejsFunctionでは内部的にTypeScriptのトランスパイルなどを実行してLambda関数を作成してくれます。

まとめ

LambdaをIaC化する場合、主要な要素がコードになるのでアプリケーション開発者がIaCできると良さそうです。
その場合、AWS CDKのように汎用プログラミング言語を利用できるのは、アプリケーション開発者にとって取り組みやすいのではないかと思います。
また、AWS CDKでは今回紹介したように簡単にLambdaをIaC化できる仕組みになっているのも良いです。

参考

JavaScriptgithub.com

TypeScript版

github.com

cdkworkshop.com

FlutterのGet startedをRiverpodでやってみたのでStatefulWidgetの場合と比較してみる

FlutterのGet startedを一通りやってみたのでその備忘録です。
docs.flutter.dev

Flutterではstate管理にRiverpodが使われることが多いようなのでこちらも試してみました。
Riverpod版と通常のStatefulWidget版で比較をしつつ説明します。

Riverpodでやってみたソースはこちら github.com

説明

Riverpod版とStatefulWidget版それぞれの実装方法で異なる点について説明します。

無限スクロールの実装

Get startedの題材には無限スクロールがあります。StatefulWidget版のソースは以下の通りです。

StatefulWidget版

class _RandomWordsState extends State<RandomWords> {
  final _suggestions = <WordPair>[];
  final _saved = <WordPair>{};
  final _biggerFont = const TextStyle(fontSize: 18);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
...
        body: ListView.builder(
          padding: const EdgeInsets.all(16.0),
          itemBuilder: (context, i) {
            if (i.isOdd) return const Divider();

            final index = i ~/ 2;
            if (index >= _suggestions.length) {
              _suggestions.addAll(generateWordPairs().take(10));
            }
...
            return ListTile(
              title: Text(
                _suggestions[index].asPascalCase,
                style: _biggerFont,
              ),
...
            );
          },
        )
    );
  }
}

StatefulWidget版では、スクロールしてリストの要素が少なくなったら、Stateのインスタンス変数である_suggestionsにリスト要素を追加しています。
_suggestionsが無限スクロールのリストを描画するためのstateを持っています。

Riverpod版のソースは以下の通りです。

Riverpod版

class WordListNotifier extends StateNotifier<List<Word>> {
...
  Future<void> addAll(List<WordPair> wordPairs) async {
    await Future.delayed(const Duration(milliseconds: 1));
    for (var wordPair in wordPairs) {
      state = [...state, Word(wordPair: wordPair)];
    }
  }
...
}

final wordListProvider =
    StateNotifierProvider<WordListNotifier, List<Word>>((ref) {
  return WordListNotifier();
});
...
class RandomWordsState extends ConsumerState<RandomWords> {
  final biggerFont = const TextStyle(fontSize: 18);

  @override
  Widget build(BuildContext context) {
    final words = ref.watch(wordListProvider);
    return Scaffold(
...
      body: ListView.separated(
        padding: const EdgeInsets.all(16.0),
        itemCount: words.length,
        itemBuilder: (context, index) {
          if (index >= words.length - 1) {
            ref
                .read(wordListProvider.notifier)
                .addAll(generateWordPairs().take(10).toList());
          }
          return ListTile(
            title: Text(
              words[index].wordPair.asPascalCase,
              style: biggerFont,
            ),
...
          );
        },
      ),
    );
  }
}

Riverpod版では、StateNotifierProviderというstateを管理するクラスとStateNotifierを継承したstateクラスを使用しています。
やっていることはStatefulWidget版と同じで、リストの要素が少なくなったらリストのstateに新しいリスト要素を追加しています。
Riverpodでstateを更新する際にはimmutableに扱うことを前提としているので、以下のように既存のリストと新しいリストを組み合わせた新しいstateを作成します。

state = [...state, Word(wordPair: wordPair)];

Future.delayedしている部分はWidgetをbuild中にstateを変更しているエラーを回避するためのおまじないです。

ちなみにListView.builderListView.separatedはDividerがリストの要素に含まれるか、含まれないかの違いです。
個人的にわかりづらかったのでListView.separatedに変えました。

お気に入りの実装

Get startedの題材ではリストの中からお気に入りを選択する機能があります。StatefulWidget版のソースは以下の通りです。

StatefulWidget版

class _RandomWordsState extends State<RandomWords> {
  final _suggestions = <WordPair>[];
  final _saved = <WordPair>{};
  final _biggerFont = const TextStyle(fontSize: 18);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
...
        body: ListView.builder(
          padding: const EdgeInsets.all(16.0),
          itemBuilder: (context, i) {
...
            final alreadySaved = _saved.contains(_suggestions[index]);
            return ListTile(
...
              onTap: () => {
                setState(() {
                  if (alreadySaved) {
                    _saved.remove(_suggestions[index]);
                  } else {
                    _saved.add(_suggestions[index]);
                  }
                })
              },
            );
          },
        ));
  }

  void _pushSaved() {
    Navigator.of(context).push(
      MaterialPageRoute<void>(
        builder: (context) {
          final tiles = _saved.map(
            (pair) {
              return ListTile(
                title: Text(
                  pair.asPascalCase,
                  style: _biggerFont,
                ),
              );
            },
          );
...
        },
      ),
    );
  }
}

_savedインスタンス変数にお気に入りとして選択したリストの要素を追加することで、リストの保存を実現しています。
_suggestionsの全体のリストに_savedに保存したリストの要素があるかどうかでお気に入りに保存するか、削除するかを決めています。
_suggestions_savedで変数が分かれていますが、どちらもリストのstateなので分かれていると扱いが面倒です。

Riverpod版のソースは以下の通りです。

class WordListNotifier extends StateNotifier<List<Word>> {
...
  void toggle(int idx) {
    state = [
      for (int i = 0; i < state.length; i++)
        if (i == idx)
          Word(wordPair: state[i].wordPair, isFavorite: !state[i].isFavorite)
        else
          state[i],
    ];
  }
}

class RandomWordsState extends ConsumerState<RandomWords> {
  final biggerFont = const TextStyle(fontSize: 18);

  @override
  Widget build(BuildContext context) {
    final words = ref.watch(wordListProvider);
    return Scaffold(
...
      body: ListView.separated(
        padding: const EdgeInsets.all(16.0),
        itemCount: words.length,
        itemBuilder: (context, index) {
...
          return ListTile(
...
            onTap: () {
              ref.read(wordListProvider.notifier).toggle(index);
            },
          );
        },
      ),
    );
  }

  void pushSaved() {
    final words = ref.watch(wordListProvider);
    final saved = words.where((word) => word.isFavorite == true);
    Navigator.of(context).push(
      MaterialPageRoute<void>(
        builder: (context) {
          final tiles = saved.map(
            (pair) {
              return ListTile(
                title: Text(
                  pair.wordPair.asPascalCase,
                  style: biggerFont,
                ),
              );
            },
          );
...
        },
      ),
    );
  }
}

WordListNotifierのtoggleメソッドでお気に入りのstateを処理しています。
前述のとおり、immutableに扱うため、お気に入りのstateを変更した要素だけでなく、全てのリスト要素を作成しています。
全体のリストのstate内にお気に入りのstateも保持しているので、stateの扱いがわかりやすくなっています。

まとめ

Riverpodでstateを扱うためにはimmutableを意識する、Providerを利用するなど気をつけるポイントがあります。
Riverpodでのテスト方法も学習しておいた方がよいテーマだと思います。次の機会にやってみたいと思います。

Redshift Serverlessを用いてdbtのGetting startedを実施する

本投稿では、Redshift Serverlessを用いてdbtのGetting startedを実施します。
dbtだとBigQueryを使っている事例が多い印象です。Redshiftでもできるということを確認してみたく実施してみました。

実施するのはdbtの公式ページにある下記の内容です。異なる点として、dbt Cloudではなく、dbt Coreを使用します。

docs.getdbt.com

dbtはSQLを使ってデータ変換処理を行い、データモデルを手軽に作成できるツールです。
dbtを試すにはdbtに対応したデータの環境が必要です。今回はRedshift Serverlessを使用します。
Redshift Serverlessには300ドル分の無料トライアルがありますので始めやすいと思います。
手順の詳細については参考情報も合わせてご覧ください。

手順

Redshift Serverlessを用意する

Redshift Serverlessの環境を作成する

AWSマネージメントコンソールを開いた状態です。
Amazon Redshiftの画面を開き、Redshift serverlessを選択します。
Amazon Redshift サーバーレスの使用を開始する」画面では、「デフォルト設定を使用」を選択します。
関連づけられたIAMロール→IAMロールを作成→「デフォルトのIAMロールを作成する」画面では、「任意のS3バケット」を選択し、IAMロールを作成します。そして、設定を保存します。
これでRedshift Serverlessが使えるようになります。

Redshift Serverlessに外部からアクセスできるようにする

Redshift Serverlessの画面から「default」のワークグループを選択し、設定画面を表示します。
ネットワークとセキュリティの項目で編集をします。
セキュリティグループを変更し、自身のローカルマシンからのアクセスを許可します。
「[パブリックにアクセス可能] をオンにする」に有効にして変更を保存します。

Redshift Serverlessの接続先情報をメモする

Redshift Serverlessの接続先情報をメモしておきます。
ワークグループの画面に表示されているエンドポイント名をメモしておきます。
名前空間の「default」を選択し、管理者パスワードを変更します。
管理者ユーザー名と管理者パスワードをメモしておきます。

これでRedshift Serverlessの準備は完了です。

dbtのGetting startedを実施する

dbtをインストールする

macの場合、brewでインストールできます。

$ brew update
$ brew tap dbt-labs/dbt
$ brew install dbt-redshift

バージョンが表示できればインストールできています。

$ dbt --version
Core:
  - installed: 1.2.1
  - latest:    1.2.1 - Up to date!

Plugins:
  - redshift: 1.2.1 - Up to date!
  - postgres: 1.2.1 - Up to date!

データを準備する

S3バケットを用意して、以下のファイルを該当のS3バケットにアップロードします。

Redshift Servelessの画面からRedshift query editor v2を表示します。
devデータベースに対して以下のクエリを実行します。

create schema if not exists jaffle_shop;
create schema if not exists stripe;

テーブルを作成します。

create table jaffle_shop.customers(
  id integer,
  first_name varchar(50),
  last_name varchar(50)
);

create table jaffle_shop.orders(
  id integer,
  user_id integer,
  order_date date,
  status varchar(50),
  _etl_loaded_at timestamp default current_timestamp
);

create table stripe.payment(
  id integer,
  orderid integer,
  paymentmethod varchar(50),
  status varchar(50),
  amount integer,
  created date,
  _batched_at timestamp default current_timestamp
);

データをコピーします。
fromには先ほどS3にアップロードしたファイルのS3 URIを指定します。
IAMロールにはRedshift Serverlessの環境を作成したときに同時作成したデフォルトのIAMロールのARNを指定します。

copy jaffle_shop.customers( id, first_name, last_name)
from 's3://dbt-data-lake-xxxx/jaffle_shop_customers.csv'
iam_role 'arn:aws:iam::XXXXXXXXXX:role/RoleName'
region 'ap-northeast-1'
delimiter ','
ignoreheader 1
acceptinvchars;
       
copy jaffle_shop.orders(id, user_id, order_date, status)
from 's3://dbt-data-lake-xxxx/jaffle_shop_orders.csv'
iam_role 'arn:aws:iam::XXXXXXXXXX:role/RoleName'
region 'ap-northeast-1'
delimiter ','
ignoreheader 1
acceptinvchars;

copy stripe.payment(id, orderid, paymentmethod, status, amount, created)
from 's3://dbt-data-lake-xxxx/stripe_payments.csv'
iam_role 'arn:aws:iam::XXXXXXXXXX:role/RoleName'
region 'ap-northeast-1'
delimiter ','
ignoreheader 1
Acceptinvchars;

データが存在することを確認します。

select * from jaffle_shop.customers;
select * from jaffle_shop.orders;
select * from stripe.payment;

dbt CoreでGetting startedを実施する

dbtの初期セットアップをします。

$ dbt init jaffle_shop

Redshift Serverlessの接続情報に変更します。
hostにはRedshift Serverlessのエンドポイント名を記載します。
userpasswordにはRedshift Serverlessの管理者ユーザー名と管理者パスワードを記載します。
dbnameにはdevデータベースを指定します。

~/.dbt/profiles.yml

jaffle_shop:
  outputs:
    dev:
      dbname: dev
      host: default.xxxxxxxxxx.ap-northeast-1.redshift-serverless.amazonaws.com
      password: xxxxxx
      port: 5439
      schema: dbt
      threads: 1
      type: redshift
      user: xxxxxx
  target: dev

接続できるかを確認します。

$ dbt debug

ここまででdbtの環境が作れました。以降はdbtのドキュメントどおりです。
dbtを使ってデータモデルを作ります。

models/customers.sql

with customers as (

    select
        id as customer_id,
        first_name,
        last_name

    from jaffle_shop.customers

),

orders as (

    select
        id as order_id,
        user_id as customer_id,
        order_date,
        status

    from jaffle_shop.orders

),

customer_orders as (

    select
        customer_id,

        min(order_date) as first_order_date,
        max(order_date) as most_recent_order_date,
        count(order_id) as number_of_orders

    from orders

    group by 1

),

final as (

    select
        customers.customer_id,
        customers.first_name,
        customers.last_name,
        customer_orders.first_order_date,
        customer_orders.most_recent_order_date,
        coalesce(customer_orders.number_of_orders, 0) as number_of_orders

    from customers

    left join customer_orders using (customer_id)

)

select * from final

dbtを実行します。

$ dbt run

これでデータモデルが作成されます。

データモデルの作り方を変更します。
デフォルトはViewの形式でモデルを作りますが、テーブルの形式で実態がある状態でモデルを作ることができます。

dbt_project.yml

models:
  jaffle_shop:
    +materialized: table
    example:
      +materialized: view
$ dbt run

dbt.customersテーブルが新しく作成されていることが確認できます。

別のモデルを作成し、それを再利用する形でモデルを作ります。

models/stg_customers.sql

select
    id as customer_id,
    first_name,
    last_name

from jaffle_shop.customers

models/stg_orders.sql

select
    id as order_id,
    user_id as customer_id,
    order_date,
    status

from jaffle_shop.orders

models/customers.sql

with customers as (

    select * from {{ ref('stg_customers') }}

),

orders as (

    select * from {{ ref('stg_orders') }}

),

customer_orders as (

    select
        customer_id,

        min(order_date) as first_order_date,
        max(order_date) as most_recent_order_date,
        count(order_id) as number_of_orders

    from orders

    group by 1

),

final as (

    select
        customers.customer_id,
        customers.first_name,
        customers.last_name,
        customer_orders.first_order_date,
        customer_orders.most_recent_order_date,
        coalesce(customer_orders.number_of_orders, 0) as number_of_orders

    from customers

    left join customer_orders using (customer_id)

)

select * from final

models/customers.sqlでref関数を使用してmodels/stg_customers.sqlmodels/stg_orders.sqlを利用して、モデルを作成します。

各データモデルに対して、テストを実施します。

models/schema.yml

version: 2

models:
  - name: customers
    columns:
      - name: customer_id
        tests:
          - unique
          - not_null

  - name: stg_customers
    columns:
      - name: customer_id
        tests:
          - unique
          - not_null

  - name: stg_orders
    columns:
      - name: order_id
        tests:
          - unique
          - not_null
      - name: status
        tests:
          - accepted_values:
              values: ['placed', 'shipped', 'completed', 'return_pending', 'returned']
      - name: customer_id
        tests:
          - not_null
          - relationships:
              to: ref('stg_customers')
              field: customer_id
$ dbt test
23:29:11  Running with dbt=1.2.1
23:29:11  Found 3 models, 7 tests, 0 snapshots, 0 analyses, 289 macros, 0 operations, 0 seed files, 0 sources, 0 exposures, 0 metrics
23:29:11
23:29:12  Concurrency: 1 threads (target='dev')
23:29:12
23:29:12  1 of 7 START test accepted_values_stg_orders_status__placed__shipped__completed__return_pending__returned  [RUN]
23:29:12  1 of 7 PASS accepted_values_stg_orders_status__placed__shipped__completed__return_pending__returned  [PASS in 0.41s]
23:29:12  2 of 7 START test not_null_customers_customer_id ............................... [RUN]
23:29:13  2 of 7 PASS not_null_customers_customer_id ..................................... [PASS in 0.55s]
23:29:13  3 of 7 START test not_null_stg_customers_customer_id ........................... [RUN]
23:29:13  3 of 7 PASS not_null_stg_customers_customer_id ................................. [PASS in 0.23s]
23:29:13  4 of 7 START test not_null_stg_orders_order_id ................................. [RUN]
23:29:13  4 of 7 PASS not_null_stg_orders_order_id ....................................... [PASS in 0.21s]
23:29:13  5 of 7 START test unique_customers_customer_id ................................. [RUN]
23:29:14  5 of 7 PASS unique_customers_customer_id ....................................... [PASS in 0.56s]
23:29:14  6 of 7 START test unique_stg_customers_customer_id ............................. [RUN]
23:29:14  6 of 7 PASS unique_stg_customers_customer_id ................................... [PASS in 0.58s]
23:29:14  7 of 7 START test unique_stg_orders_order_id ................................... [RUN]
23:29:15  7 of 7 PASS unique_stg_orders_order_id ......................................... [PASS in 0.37s]
23:29:15
23:29:15  Finished running 7 tests in 0 hours 0 minutes and 3.58 seconds (3.58s).
23:29:15
23:29:15  Completed successfully
23:29:15
23:29:15  Done. PASS=7 WARN=0 ERROR=0 SKIP=0 TOTAL=7

各モデルのtestsに指定したuniquenot_nullの指定にしたがってテスト用のクエリが実行されます。

データモデルについてのドキュメントを作成します。

models/schema.yml

version: 2

models:
  - name: customers
    description: One record per customer
    columns:
      - name: customer_id
        description: Primary key
        tests:
          - unique
          - not_null
      - name: first_order_date
        description: NULL when a customer has not yet placed an order.

  - name: stg_customers
    description: This model cleans up customer data
    columns:
      - name: customer_id
        description: Primary key
        tests:
          - unique
          - not_null

  - name: stg_orders
    description: This model cleans up order data
    columns:
      - name: order_id
        description: Primary key
        tests:
          - unique
          - not_null
      - name: status
        tests:
          - accepted_values:
              values: ['placed', 'shipped', 'completed', 'return_pending', 'returned']

データモデルのdescriptionやカラムのdescriptionに説明を記述できます。

$ dbt docs generate
$ dbt docs serve

データモデルの説明などがわかるドキュメントサイトが表示されます。

まとめ

dbtの公式Getting startedをRedshift Serverlessで試しました。dbtの使い方を知ることができる良いGetting startedだと思いました。
dbtにおいては、SQLを再利用できる他にテストやドキュメント生成をする機能が使えそうという印象を持ちました。機会があれば本格的に利用したいと思います。
レガシーなSQLコードをどのようにdbt向けにリファクタリングするかという内容やベストプラクティスの内容など本格的に使う場合にも参考になる情報が記載されていて良いと感じました。

参考情報