write ahead log

ロールフォワード用

別プロセスのvim間でコピペしたい

あまりないことではあったけど、別プロセスのvim間でコピペしたくなった。

ちょうどよいプラグインがあったので。

yanktmp.vim

GitHubはこっちにあった。

vim-scripts/yanktmp.vim

tmuxとかscreen使ってると割と便利かも。

実装としては仮ファイルに書き出して読みだすだけというシンプルなもの。

こういう単純で役立つやつっていいよね。

すぐインストールしたいときのメモ

プラグインマネージャとかなくてもパッと入れたいときのために。

mkdir -p ~/.vim/plugin
cd ~/.vim/plugin
curl -OL https://raw.githubusercontent.com/vim-scripts/yanktmp.vim/master/plugin/yanktmp.vim

.vimrcにはドキュメント通りこんな感じで追記する。

ドキュメントはsを使っているけど、割と使うのでtが好みだった。

map <silent> ty :call YanktmpYank()<CR>
map <silent> tp :call YanktmpPaste_p()<CR>
map <silent> tP :call YanktmpPaste_P()<CR>

WSLのvimでもクリップボードを使いたくなった

あんまり不便に感じてこなかったけど、最近感じるようになったので。

clipboardに対応したvimを入れる

WSLのUbuntuを使っているんだけど、以下で入れたvimだとクリップボードに対応していない。

sudo apt install vim

確認は以下で。-になってたら未対応。

$ vim --version | grep clipboard
-clipboard         +keymap            +printer           +vertsplit
+eval              -mouse_jsbterm     -sun_workshop      -xterm_clipboard

仕方ないので対応版を入れる。ビルドは面倒だからしたくない。

参考にした以下曰く、対応版のパッケージが結構あるっぽい。

MY ROBOTICS - Vimでクリップボードからのペーストを可能にする

gtk版を入れることにした。gvim使わないけどあると便利になるのかも。

sudo apt-get install vim-gtk

確認しておく。

$ vim --version | grep clipboard
+clipboard         +keymap            +printer           +vertsplit
+eval              -mouse_jsbterm     -sun_workshop      +xterm_clipboard

vimrcの変更

vimrcに以下を加える。

set clipboard&
set clipboard^=unnamedplus

設定説明は以下がとても親切だった。

pockestrap - vimでクリップボード連携を有効にした話

AWS SAMを使ってアプリを作ってみる

事情があってSAMを学ぶ必要があったので。

10分だけログの残る、curlコマンドで使える掲示板を作ることにします。

使うものとしてはだいたい以下です(細かいのは省く)

  • SAM
  • API Gateway
  • Lambda
  • DynamoDB
  • WAF
  • EventBridge

EventBridgeは大体10分ごとにログを消すのに使います。

WAFは使い方を知っとく必要があったので強引に含めました。

リポジトリは以下

twinbird/aws-sam-chat-app - GitHub

言語はTypeScriptを使います。

AWS SAM CLIのインストール

AWSの公式ドキュメントの以下のページに従ってインストールします。

https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/install-sam-cli.html

ユーザーを作成

ルートユーザーで新しいユーザーを作成します。

以下のユーザー名でコンソール画面から作成。

limited-chat-dev-user

コンソールアクセスは許可しない。 「ポリシーを直接アタッチする」を選択して以下を追加。

AWSCloudFormationFullAccess
IAMFullAccess
AWSLambda_FullAccess
AmazonAPIGatewayAdministrator
AmazonS3FullAccess
AmazonEC2ContainerRegistryFullAccess

今回はDynamoDBも使いたいので以下も加えます。

AmazonDynamoDBFullAccess

あとはEventBridgeと

AmazonEventBridgeFullAccess

WAFも使うので。

AWSWAFFullAccess

以下を参考にした。

AWS SAM 開発者ガイド - AWS CloudFormation メカニズムによる権限の管理

アクセスキーの発行

(IAM Identity Centerが推奨されている様子だが、諸事情でここではこちらを採用)

IAMから作成したユーザーを選択し、「セキュリティ認証情報」のタブを選択します。

「アクセスキー」から「アクセスキーを作成」を選択します。

コマンドラインインターフェース(CLI)を選択します。

認証情報はCSVダウンロードしておきます。

aws-cliにプロファイル登録する

aws configure --profile limited-chat-dev

先ほど作成したアクセスキーを使います。 regionはap-northeast-1を利用しました。

プロジェクトの作成

初期化用の以下のコマンドを使ってプロジェクトを作成します。

sam init

対話式で設定できますが、結構長いです。

  • nodejs20
  • Typescript

で作成しました。とりあえず全部載せます。

$ sam init

You can preselect a particular runtime or package type when using the `sam init` experience.
Call `sam init --help` to learn more.

Which template source would you like to use?
        1 - AWS Quick Start Templates
        2 - Custom Template Location
Choice: 1

Choose an AWS Quick Start application template
        1 - Hello World Example
        2 - Data processing
        3 - Hello World Example with Powertools for AWS Lambda
        4 - Multi-step workflow
        5 - Scheduled task
        6 - Standalone function
        7 - Serverless API
        8 - Infrastructure event management
        9 - Lambda Response Streaming
        10 - Serverless Connector Hello World Example
        11 - Multi-step workflow with Connectors
        12 - GraphQLApi Hello World Example
        13 - Full Stack
        14 - Lambda EFS example
        15 - Hello World Example With Powertools for AWS Lambda
        16 - DynamoDB Example
        17 - Machine Learning
Template: 1

Use the most popular runtime and package type? (Python and zip) [y/N]: N

Which runtime would you like to use?
        1 - aot.dotnet7 (provided.al2)
        2 - dotnet8
        3 - dotnet6
        4 - go1.x
        5 - go (provided.al2)
        6 - go (provided.al2023)
        7 - graalvm.java11 (provided.al2)
        8 - graalvm.java17 (provided.al2)
        9 - java21
        10 - java17
        11 - java11
        12 - java8.al2
        13 - nodejs20.x
        14 - nodejs18.x
        15 - nodejs16.x
        16 - python3.9
        17 - python3.8
        18 - python3.12
        19 - python3.11
        20 - python3.10
        21 - ruby3.2
        22 - rust (provided.al2)
        23 - rust (provided.al2023)
Runtime: 13

What package type would you like to use?
        1 - Zip
        2 - Image
Package type: 1

Based on your selections, the only dependency manager available is npm.
We will proceed copying the template using npm.

Select your starter template
        1 - Hello World Example
        2 - Hello World Example TypeScript
Template: 2

Would you like to enable X-Ray tracing on the function(s) in your application?  [y/N]:

Would you like to enable monitoring using CloudWatch Application Insights?
For more info, please view https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch-application-insights.html [y/N]:

Would you like to set Structured Logging in JSON format on your Lambda functions?  [y/N]:

Project name [sam-app]: limited-chat-app

    -----------------------
    Generating application:
    -----------------------
    Name: limited-chat-app
    Runtime: nodejs20.x
    Architectures: x86_64
    Dependency Manager: npm
    Application Template: hello-world-typescript
    Output Directory: .
    Configuration file: limited-chat-app/samconfig.toml

    Next steps can be found in the README file at limited-chat-app/README.md


Commands you can use next
=========================
[*] Create pipeline: cd limited-chat-app && sam pipeline init --bootstrap
[*] Validate SAM template: cd limited-chat-app && sam validate
[*] Test Function in the Cloud: cd limited-chat-app && sam sync --stack-name {stack-name} --watch

ビルド&デプロイしてみる

ここまででビルドしてデプロイしてみます。

先ほど作成したプロジェクトディレクトリへ移動します。

cd limited-chat-app

まずは以下のコマンドでテンプレートファイルが正しいか確認できるようです。

$ sam validate
【ディレクトリ名】/limited-chat-app/template.yaml is a valid SAM Template

続いてビルドしてみます。

sam build
...【色々出る】
Build Succeeded
...【色々出る】

このままデプロイします。

sam deploy --guided --profile limited-chat-dev

Configuring SAM deploy
======================

        Looking for config file [samconfig.toml] :  Found
        Reading default arguments  :  Success

        Setting default arguments for 'sam deploy'
        =========================================
        Stack Name [limited-chat-app]:
        AWS Region [ap-northeast-1]:
        #Shows you resources changes to be deployed and require a 'Y' to initiate deploy
        Confirm changes before deploy [Y/n]: Y
        #SAM needs permission to be able to create roles to connect to the resources in your template
        Allow SAM CLI IAM role creation [Y/n]: Y
        #Preserves the state of previously provisioned resources when an operation fails
        Disable rollback [y/N]: y
        HelloWorldFunction has no authentication. Is this okay? [y/N]: y
        Save arguments to configuration file [Y/n]: Y
        SAM configuration file [samconfig.toml]:
        SAM configuration environment [default]:
【以下略】

コンソールの最後に出るAPI GatewayのURLにアクセスして、JSONでHello WorldされればOKです。

一度削除する

とりあえずdeployしてみたので一度リソースを消します。

$ sam delete --profile limited-chat-dev
        Are you sure you want to delete the stack limited-chat-app in the region ap-northeast-1 ? [y/N]: y
        Are you sure you want to delete the folder limited-chat-app in S3 which contains the artifacts? [y/N]: y
【以下略】

投稿用の関数を作る

とりあえずhelloworldの設定を置き換える

template.yamlを見ると色々な箇所にHelloWorldが存在するので全てPostMessageへ置き換えていきます。

ディレクトリ名(hello-world)やnpmのpackage.jsonも変えてみます。

DynamoDBへテーブルを用意する

template.yamlを編集します。

Resources以下へ以下を追加します。

  ChatTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: ChatTable
      AttributeDefinitions:
        - AttributeName: uuid
          AttributeType: S
      KeySchema:
        - AttributeName: uuid
          KeyType: HASH
      ProvisionedThroughput:
        ReadCapacityUnits: 10
        WriteCapacityUnits: 10

DynamoDBへ書き込みしてみる

DynamoDBへのアクセスにライブラリを使うので、post-messageディレクトリへ移動して以下でライブラリを導入します。

cd post-message
npm i --save-dev @aws-sdk/client-dynamodb
npm i --save-dev @aws-sdk/lib-dynamodb

使い方はこちらが参考になります。 SDK for JavaScript (v3) を使用した DynamoDB の例 DynamoDB での単一項目の読み取りと書き込み

このような感じでコードを書いて

/**
 * メッセージをデータベースへ保存する
 */
const storePost = async (message: string): string => {
  const client = new DynamoDBClient({});
  const docClient = DynamoDBDocumentClient.from(client);

  const uuid = crypto.randomUUID();
  const now = new Date();
  const createdAt = now.toLocaleString('ja-JP', { timeZone: 'UTC' });

  const command = new PutItemCommand({
    TableName: 'ChatTable',
    Item: {
      uuid: { S: uuid },
      createdAt: { S: createdAt },
      message: { S: message },
    },
  });

  const response = await client.send(command);
  return uuid;
};

以下でビルドします。

sam build

こんな感じでESbuildのエラーメッセージが出ました。

Esbuild Failed: ✘ [ERROR] Could not resolve "@aws-sdk/client-dynamodb"

以下を参考に解決しています。

AWS SDK V3 + Lambda を SAM deploy した際の "Could not resolve" の対処法

template.yamlを以下のように変更しました。

Externalの箇所が必要なようです。Bundleするなと指示が必要らしい。

     Metadata: # Manage esbuild properties
       BuildMethod: esbuild
       BuildProperties:
         Minify: true
         Target: "es2020"
         Sourcemap: true
         EntryPoints:
         - app.ts
         External:
           - "@aws-sdk/lib-dynamodb"
           - "@aws-sdk/client-dynamodb" 

これでsam buildが通ります。

デプロイしてみます。

2度目なので以下だけでOKです。

sam deploy --profile limited-chat-dev

curlで試してみます。

curl -X POST  【APIゲートウェイのエンドポイントURL】 -H "Content-Type: application/json" -d '{ "message" : "Hello world" }'

開発中はエラーが起きると思うので、以下で確認できます。

sam logs --region=ap-northeast-1 --profile limited-chat-dev

あるいはブラウザでCloudWatchをコンソールから見る方がわかりやすいかも。

上記までで試すと以下のようなエラーが出ていました。

INFO AccessDeniedException: User: arn:【略】 is not authorized to perform: dynamodb:PutItem on resource: arn:aws:dynamodb:【略】 because no identity-based policy allows the dynamodb:PutItem action

権限をlambdaに与えてあげる必要があるようです。

template.yamlを書き換えます。

   PostMessageFunction:
     Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-appli    cation-model/blob/master/versions/2016-10-31.md#awsserverlessfunction                                                17     Properties:
       CodeUri: post-message/
       Handler: app.lambdaHandler
       Runtime: nodejs20.x
       Policies: AmazonDynamoDBFullAccess # 追加

再度ビルドとデプロイし、curlでアクセスしてみます。

ここまでで投稿用のAPIができました。

関数を追加する

まず、lambda関数を加えます。

ディレクトリを作成します。 mkdir list-messages

簡単なサンプル関数をまずは書きます。

/**
 * API成功時のレスポンスを返す
 */
const successResponse = (): Response => {
  return {
    statusCode: 200,
    body: JSON.stringify({
      message: 'success',
    }),
  };
};

/**
 * エラー発生時のレスポンスを返す
 */
const errorResponse = (): Response => {
  return {
    statusCode: 500,
    body: JSON.stringify({
      message: 'some error happened',
    }),
  };
};

/**
 * メッセージ取得API
 *
 * Request Bodyに以下の形式のJSONが含まれることを期待します。
 * { message: 'チャット投稿用メッセージ' }
 */
export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  try {
    return successResponse();
  } catch (err) {
    console.log(err);
    return errorResponse();
  }
};

設定ファイルはsam initで作成した最初の関数からコピーしてきました。

cp -a post-message/package.json list-messages/
cp -a post-message/tsconfig.json list-messages/
cp -a post-message/jest.config.ts list-messages/

一応package.jsonのnameとdescriptionくらいは書き換えておきます。

依存モジュールをインストールしておきます。

cd list-messages
npm i

template.yamlを変更します。

これもコピペです。

  ListMessagesFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: list-messages/
      Handler: app.lambdaHandler
      Runtime: nodejs20.x
      Policies: AmazonDynamoDBFullAccess
      Architectures:
        - x86_64
      Events:
        PostMessage:
          Type: Api
          Properties:
            Path: /listMessages
            Method: get
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: "es2020"
        Sourcemap: true
        EntryPoints:
        - app.ts
        External:
          - "@aws-sdk/lib-dynamodb"
          - "@aws-sdk/client-dynamodb"

ビルドします。

sam build

ビルドが通ったのでデプロイしてみます。

sam deploy --profile limited-chat-dev

新しく追加したAPI GatewayのURLにアクセスするとレスポンスが返ってきます。

とりあえず関数の追加はできたようです。

追加した関数へDynamoDBから読み出しするための処理を加える

コードを変更します。こんな感じで読み出し用の関数を用意して返す感じで。

/**
 * メッセージをデータベースから取り出す
 */
const fetchPosts = async (): [Post] => {
  const client = new DynamoDBClient({});
  const docClient = DynamoDBDocumentClient.from(client);
  const command = new ScanCommand({
    TableName: 'ChatTable',
  });
  const data = await docClient.send(command);
  console.log(data);
  return data.Items.map((m) => {
    return {
      createdAt: m.createdAt,
      message: m.message,
    };
  });
}

もはやクドイですが、ビルドしてデプロイします。

sam build
sam deploy --profile limited-chat-dev

curlで試してみます。

curl 【取得関数のURL】

一覧が取れました。

jqを使えばちょっとそれっぽく見えるかも。

curl 【取得関数のURL】 | jq '.posts[] | .createdAt + " " + .message'

脱線する話題だけど、jqのパイプを初めて使った。便利だった。

jqコマンドで複数フィールドの値を1行に表示させる

日付でソートする

掲示板なので昇順でも降順でも良いから並べたいです。

が、DynamoDBの設計がよくわかってなかったのでこのままだと上手くソートできない感じ。

【AWS】DynamoDBのキーとソートとフィルタについて

実務ならできないだろうけど、まぁお遊びだからええやろということでLambdaでソートしてやることに。

10分以上経過した投稿を削除する

先に書いておくと、DynamoDB自体にはTTL機能というものがあるので、本来こんな機能は実装の必要がありません。

あくまでも学習用のお試しです。

有効期限 (TTL) - AWS

DynamoDBの各データを自動で削除する機能(TTL:Time to Live)を試してみた - Developers IO

AWS::DynamoDB::Table TimeToLiveSpecification - AWS

さて、最終的にはEventBridgeで定期実行したいですが、とりあえずLambdaでバッチ削除できる関数を作ります。

ディレクトリを作成します。 mkdir delete-old-messages

前回同様設定ファイルはsam initで作成した最初の関数からコピーしてきました。

cp -a post-message/package.json delete-old-messages/
cp -a post-message/tsconfig.json delete-old-messages/
cp -a post-message/jest.config.ts delete-old-messages/

package.jsonのnameとdescriptionは書き換えておきます。

次に関数を書いていきます。

vi delete-old-messages/app.ts

SDK使う部分主要なだけ抜き出すとこんな感じ。

/**
 * 引数の投稿を削除する
 */
const deletePost = async (post: Post) => {
  const command = new DeleteCommand({
    TableName: 'ChatTable',
    Key: {
      uuid: post.uuid,
    },
  });

  await docClient.send(command);
};

template.yamlも編集します。

API Gatewayと紐づける必要はないので、以下を参考にEvents以下は削除しました。

AWS SAM Lambda関数だけを単体でデプロイしたいときのテンプレート設定例

   DeleteOldMessagesFunction:
     Type: AWS::Serverless::Function
     Properties:
       CodeUri: delete-old-messages/
       Handler: app.lambdaHandler
       Runtime: nodejs20.x
       Policies: AmazonDynamoDBFullAccess
       Architectures:
         - x86_64
     Metadata:
       BuildMethod: esbuild
       BuildProperties:
         Minify: true
         Target: "es2020"
         Sourcemap: true
         EntryPoints:
         - app.ts
         External:
           - "@aws-sdk/lib-dynamodb"
           - "@aws-sdk/client-dynamodb"

とりあえず実行は管理コンソールから。一応動作しているようです。

日付の扱い方を変える

ここまで来て今さら気づいたのが、文字列をISO 8601形式にしておけば日付をQueryで絞り込めるのではということ。

Amazon DynamoDB で日時データを扱う方法

ということで変えてみました。

-  const createdAt = now.toLocaleString('ja-JP', { timeZone: 'UTC' });
+  const createdAt = now.toISOString();

filterの方法はドキュメントを読んでそれっぽく書いてみました。

AWS SDK for JavaScript v3 - ScanCommand

Filterの書き方を間違えていて、何度やっても以下のエラーが出ていたけど

Invalid FilterExpression: Incorrect operand type for operator or function; operator or function: <, operand type: M

StackOverflowに救われて進めた。

DynamoDB: Query Incorrect operand type

Scanのページング?に対応する

DynamoDBのドキュメントをあれこれ読んでいるとこんなのを見つけた。

テーブルクエリ結果をページ分割する

あー、なんかこんな制約あった気がする。

この辺りを真似する。

How to paginate dynamodb results using aws sdk sendCommand - Stack Overflow

/**
 * メッセージをデータベースから取り出す
 */
const fetchPosts = async (): [Post] => {
  const client = new DynamoDBClient({});
  const docClient = DynamoDBDocumentClient.from(client);

  let data = [];
  const getChunk = async (key: string): string => {
    const command = new ScanCommand({
      TableName: 'ChatTable',
      Limit: 10,
      ExclusiveStartKey: key,
    });
    const result = await docClient.send(command);
    data.push(...result.Items);

    return result.LastEvaluatedKey;
  };

  let key;
  do {
    key = await getChunk(key);
  } while (key);
  return data;
}

しかしキャパシティユニット無駄に使うなこれ。Query使いたいが。

EventBridgeで定期実行してみる

簡潔でありがたい。

【AWS SAM】EventBridgeでLambdaを定期実行するテンプレートの書き方

詳細はここを見ればよさそう。

AWS Serverless Application Model 開発者ガイド - ScheduleV2

なんか「年」のフィールドがある。cronにあったっけ?

あとこんなことも書かれているし、微妙にcronとは違う気がする。

cron 式の日フィールドと曜日フィールドを同時に指定することはできません。一方のフィールドに値または * (アスタリスク) を指定する場合、もう一方のフィールドで ? (疑問符) を使用する必要があります。

とりあえず以下のように書いてみた。

   DeleteOldMessagesFunction:
     Type: AWS::Serverless::Function
     Properties:
       CodeUri: delete-old-messages/
       Handler: app.lambdaHandler
       Runtime: nodejs20.x
       Policies: AmazonDynamoDBFullAccess
       Architectures:
         - x86_64
       Events:
         ScheduledFunction:
           Type: ScheduleV2
           Properties:
             ScheduleExpression: cron(0/10 * * * ? *)
             ScheduleExpressionTimezone: "Asia/Tokyo"
             State: ENABLED
             Name: limited-chat-app-delete-old-messages-function-schedule

あと、deployにあたって以下のポリシーがないとダメだった。そりゃそうだ。

AmazonEventBridgeFullAccess

AWS WAFを設定してみる

これは使うケースが少なそうなんですが、諸事情で必要なので試してみます。

簡単な同一IPからのRate Limitをかけたい。

とりあえず使ったこともないのでコンソールから試してみる。

AWS WAF でアクセス数が一定回数を超えた IP アドレスを自動的にブラックリストに追加させる方法

一回作ったらコンソールからJSONがダウンロードできたので、ダウンロードして削除した。

{
  "Name": "test-waf",
  "Id": "【IDが入ってた】",
  "ARN": "【ARNが入ってた】",
  "DefaultAction": {
    "Allow": {}
  },
  "Description": "",
  "Rules": [
    {
      "Name": "rate-limit-rule-100-request-per-5-minutes",
      "Priority": 0,
      "Statement": {
        "RateBasedStatement": {
          "Limit": 100,
          "EvaluationWindowSec": 300,
          "AggregateKeyType": "IP"
        }
      },
      "Action": {
        "Block": {}
      },
      "VisibilityConfig": {
        "SampledRequestsEnabled": true,
        "CloudWatchMetricsEnabled": false,
        "MetricName": "rate-limit-rule-100-request-per-5-minutes"
      }
    }
  ],
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "test-waf"
  },
  "Capacity": 2,
  "ManagedByFirewallManager": false,
  "LabelNamespace": "awswaf:381491890434:webacl:test-waf:"
}

次にSAMで設定したい。

SAMでネイティブにサポートされているわけではないのかな。でもYAML書けば使えそうではある。

CloudFormationでググった方が情報出てくるかも。とググってると以下が出てきた。

Support WAF for API Gateway

参考にしつつ以下のtemplate.yamlへ書く。

API Gatewayの記述をこんな感じで分離しないとRefが取れないので面倒くさい。

Resources:
  PostMessageApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: Prod
  PostMessageFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: post-message/
      Handler: app.lambdaHandler
      Runtime: nodejs20.x
      Policies: AmazonDynamoDBFullAccess
      Architectures:
        - x86_64
      Events:
        PostMessage:
          Type: Api
          Properties:
            Path: /postMessage
            Method: post
            RestApiId:
              Ref: PostMessageApi

WAFの記述はマジでわからん。特にAssociationsの部分はGitHubのIssueほぼ丸パクリ。

ドキュメントもどこ見ればよいのかサッパリや。。。

(そもそも元のGitHubの人もめちゃ苦労してそう)

LimitedChatAppApiWebAcl:
  Type: AWS::WAFv2::WebACL
  Properties:
    DefaultAction:
      Allow: {}
    Scope: REGIONAL
    Rules:
      - Action:
          Block: {}
        Name: rate-limit-rule-100-request-per-5-minutes
        Priority: 0
        Statement:
          RateBasedStatement:
            Limit: 100
            AggregateKeyType: IP
        VisibilityConfig:
          SampledRequestsEnabled: true
          CloudWatchMetricsEnabled: false
          MetricName: rate-limit-rule-100-request-per-5-minutes
    VisibilityConfig:
      SampledRequestsEnabled: true
      CloudWatchMetricsEnabled: true
      MetricName: limited-chat-app-waf-acl
PostApiWebAclLimitedChatAppAssociations:
  Type: AWS::WAFv2::WebACLAssociation
  DependsOn: PostMessageApiProdStage
  Properties:
    WebACLArn: !GetAtt LimitedChatAppApiWebAcl.Arn
    ResourceArn: !Sub 'arn:aws:apigateway:${AWS::Region}::/restapis/${PostMessageApi}/stages/Prod'

DependsOnを入れてやらないとAPI Gatewayを作る前にWAFを設定しようとして死ぬ。

参考ページ曰く、命名規則があるらしく

  • <api‑LogicalId><stage‑name>Stage(Stage 名をベタ書きする場合)
  • <api‑LogicalId>Stage(Stage 名を Parameters から取得する場合)

らしい。ありがたや。

AWS SAM で Amazon API Gateway の Usage Plan を作るときに API Stage not found と出たら - kakakakakku blog

こっちは同じ問題をCDKで対応された方っぽい。エラーが一緒だった。

AWS WAF couldn?t perform the operation because your resource doesn?t exist.

[AWS CDK] APIGateway+WAFv2 Web ACL構成でデプロイしようとしてちょっとハマった点 - Zenn

実行時には権限が必要なのでとりあえず以下を追加した。

AWSWAFFullAccess

環境変数の制御

Lambda内では以下のように環境変数を取得できる。

Lambda 環境変数の使用 - AWS

let region = process.env.AWS_REGION

で、ローカル実行時にはパラメータを渡すと環境変数を上書きできるらしい。

ローカルでの Lambda 関数の呼び出し

deploy時にも変えたいんだよね。こちらを参考にしてみる。

AWS Lambdaで使いたい環境変数をAWS SAM CLIでどうするか

こんな感じでtemplate.yamlを書いて。

# 環境変数用に用意
Parameters:
  Secret:
    Type: String

Resources:
  PostMessageApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: Prod
  PostMessageFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: post-message/
      Handler: app.lambdaHandler
      Runtime: nodejs20.x
      Policies: AmazonDynamoDBFullAccess
      Architectures:
        - x86_64
      Environment:
        Variables:
          SECRET: !Ref Secret
      Events:
        PostMessage:
          Type: Api
          Properties:
            Path: /postMessage
            Method: post
            RestApiId:
              Ref: PostMessageApi

こんな感じでデプロイする。

sam deploy --profile limited-chat-dev --parameter-overrides Secret="THIS_IS_A_SECRET"

そうするとLambdaでこうできる。

let secret = process.env.SECRET

試しにレスポンスに含めてみたら取得できた。全然SECRETじゃないが。。。

ローカルでの開発環境を整える

ここまで来て?という感じだけどローカルでDynamoDBが動かせるらしいので。

とりあえずDynamoDBローカルを用意してみる

コンピュータ上で DynamoDB をローカルでデプロイする

公式の指示通り以下をdocker-compose.yamlとして保存して

version: '3.8'
services:
 dynamodb-local:
   command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data"
   image: "amazon/dynamodb-local:latest"
   container_name: dynamodb-local
   ports:
     - "8000:8000"
   volumes:
     - "./docker/dynamodb:/home/dynamodblocal/data"
   working_dir: /home/dynamodblocal

データ保存先のディレクトリを用意してやる。

これをうっかりやらないとコンテナ側で書き込み権限がないので「cannot open DB[]: com.almworks.sqlite4java.SQLiteException: [] unable to open database file」とかエラーを吐く。

mkdir -p docker/dynamodb
chmod 777 docker/dynamodb

ふつーに起動する。

docker compose up -d

とりあえずawsコマンドでつないでみる。

aws dynamodb list-tables --profile limited-chat-dev --endpoint-url http://localhost:8000

結果が返ってきたらとりあえずOKだろう。

テーブルは自分で用意してやらないといけないのでこんな感じでCLIで用意した。

aws dynamodb create-table \
    --table-name ChatTable \
    --attribute-definitions AttributeName=uuid,AttributeType=S \
    --key-schema AttributeName=uuid,KeyType=HASH \
    --provisioned-throughput ReadCapacityUnits=25,WriteCapacityUnits=25 \
    --profile limited-chat-dev \
    --endpoint-url http://localhost:8000

SAMのlocal invokeからつなぎたい

この辺りを参考にして環境変数で切り替えられるようにする。

AWS SAM+DynamoDBのDockerローカル開発環境を作る - Zenn まくろぐ - DynamoDB を Node.js で操作する(SDK ver.3 の場合)

まずはアプリ側でDynamoDBへ接続している個所を環境変数で切り替えられるようにする。

const config: DynamoDBClientConfig = {
  endpoint: process.env.DYNAMODB_ENDPOINT,
};
const client = new DynamoDBClient(process.env.DYNAMODB_ENDPOINT ? config : {});

template.yamlも同じように変える。

# 環境変数用に用意
Parameters:
  Secret:
    Type: String
  DynamoDbEndpoint:
    Type: String
【中略】
   PostMessageFunction:
     Type: AWS::Serverless::Function
     Properties:
       CodeUri: post-message/
       Handler: app.lambdaHandler
       Runtime: nodejs20.x
       Policies: AmazonDynamoDBFullAccess
       Architectures:
         - x86_64
       Environment:
         Variables:
           DYNAMODB_ENDPOINT: !Ref DynamoDbEndpoint

参考サイトだとsam localで動く時には環境変数のAWS_SAM_LOCALがTrueになるそうなので、その辺を使ってもよさそう。

で、以下のように実行する。

sam local invoke ListMessagesFunction --parameter-overrides DynamoDbEndpoint="http://dynamodb-local:8000" --docker-network="limited-chat-app_default"

DynamoDbEndpointは環境変数に渡すURLだけど、これはdocker-composeで用意したホスト名で「dynamodb-local」を渡した。

--docker-networkにもdocker-composeで用意したネットワークをそのまま渡している。(CLIで表示されたもの)

sam local invokeもdockerで動くっぽいのでlocalhostとか指定しても動かない。

環境変数はこんな感じでファイルで定義もできるらしい。

ローカルでの Lambda 関数の呼び出し - AWS

あとはAPI Gatewayとかもエミュレートできるっぽいので試す。

sam local start-api --parameter-overrides DynamoDbEndpoint="http://dynamodb-local:8000" --docker-network="limited-chat-app_default"

これでサーバーが動くので、あとはcurlとかで試せる。

curl -X POST http://localhost:3000/postMessage/ -H "Content-Type: application/json" -d '{ "message" : "Hello, sam local" }'
curl http://localhost:3000/listMessages/ | jq '.posts[] | .createdAt + " " + .message'

sam deployするときにはこんな感じにするのでOKだった。

sam deploy --profile limited-chat-dev --parameter-overrides='Secret="THIS_IS_A_SECRET" DynamoDbEndpoint=""'

他にも色々できそうなので、あとは必要に応じて。

sam local を使用する - AWS

テストを書く

ここまで書いてきて今さらなんだけどテストが欲しい。

一応公式がこういうページを出しているので結合テストにsam localを使うのは想定されているらしい。

自動化されたテストとの統合

E2Eのテストは上記までで作った環境で作れそうな気がする。こんな感じかな。

  1. docker compose upでDynamoDB Localを立ち上げる
  2. sam local start-api
  3. curlとかhttpのライブラリで実行
  4. なんやかんや結果を見る

何かいい情報ないのかと思ったら公式があった。

サーバーレス関数とアプリケーションのテスト - AWS

リンク先にTypescriptのテストサンプルもあった。

aws-samples/serverless-test-samples | TypeScript Test Samples - GitHub

API Gateway + Lambda + DynamoDBというまさにそのままのパターンのコードがあったので見てみる。

Unitテストの方はaws-sdk-client-mockを使ってテストしている。

テストのパターンとしてもSAMのQuickStartで作成されたものと同じで、aws-lambdaライブラリのAPIGatewayProxyEventとAPIGatewayProxyResultを関数ハンドラに渡して結果を見るというものだ。

integrationテストの方はもっと直接的で、DynamoDBへつないでデータを投入して、Axiosで直接APIを叩いて試している。

どうやってDynamoDBの接続情報を取得しているのか疑問だったけど、ライブラリは現在の環境のAWSプロファイルを使うらしい。

環境変数AWS_PROFILEを使って明示してやればうまいこと動作しそう。

というわけで簡単なテストを用意してみる。

Unitテスト

postMessageをテストしてみる。

とりあえずmockライブラリを導入.

cd post-message
npm install -D aws-sdk-client-mock

簡単なテストコードを用意する。

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { lambdaHandler } from '../../app';
import { expect, describe, it } from '@jest/globals';
import { mockClient } from "aws-sdk-client-mock";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { PutItemCommand, DynamoDBClient } from "@aws-sdk/client-dynamodb";

// 元々あったeventのJSON
const postMessageEvent: APIGatewayProxyEvent = {
  httpMethod: 'post',
  body: '{ "message": "test message" }',
【略】
};

const ddbMock = mockClient(DynamoDBClient);

beforeEach(() => {
  ddbMock.reset();
});

describe('Post message API Tests', function () {
  it('valid parameter post', async () => {
    process.env.DYNAMODB_TABLE_NAME = 'unit_test_dynamodb_table';
    ddbMock.onAnyCommand().resolves({}); // 全Command に対して定義
    ddbMock.on(PutItemCommand).resolves({
      '$metadata': {
        httpStatusCode: 200,
        requestId: 'e230fba2-732b-4f44-a0f2-ddca742f63fe',
        extendedRequestId: undefined,
        cfId: undefined,
        attempts: 1,
        totalRetryDelay: 0
      },
      Attributes: undefined,
      ConsumedCapacity: undefined,
      ItemCollectionMetrics: undefined
    });
    const expectMessage = "TEST_MESSAGE";
    postMessageEvent.body = JSON.stringify({ message: expectMessage });
    const result: APIGatewayProxyResult = await lambdaHandler(postMessageEvent);

    expect(result.statusCode).toEqual(200);
    const resultObj = JSON.parse(result.body);
    expect(resultObj.message).toEqual('success');
    expect(resultObj.postMessage).toEqual(expectMessage);
    expect(resultObj.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i); // UUID
  });
});

実行

npm run test

結構簡単に試せる。

ただ、mockを作るオブジェクトを間違えて1時間以上ハマった。

// サンプルコードの例
const ddbMock = mockClient(DynamoDBDocumentClient);

// 実際に試した例
const ddbMock = mockClient(DynamoDBClient);

あと、eventsディレクトリが最上位階層にあったのに気付かなかった。

ここにeventを書いておいてテストからは読み込んで使う方法の方がよいのかもしれない。

Integrationテスト

基本的に直接APIを使ってクラウド側の動作確認をするだけなので、jsのfetchやawsのライブラリの知識さえあれば書ける。

ここのサンプルの通りunitテストとintegrationテストで実行を分けれるようにしておいた方が良いと思う。

雑だけどこんな感じで書いて

import { GetItemCommand, DynamoDBClient } from "@aws-sdk/client-dynamodb";

describe('Post message API integration tests', () => {
  let baseApiUrl: string;

  beforeAll(() => {
    if (!process.env.API_URL) {
      throw new Error('API_URL environment variable is not set.');
    }
    baseApiUrl = process.env.API_URL;
  });

  describe('Post valid message', () => {
    it('post 1 char message', async () => {
      const data = {
        message: '1',
      };
      const url = `${baseApiUrl}/postMessage`;

      // Postリクエストしてみてレスポンスを確認する
      const res = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(data)
      });
      const body = await res.json();

      expect(body.message).toBe('success');
      expect(body.postMessage).toBe('1');
      const id = body.id;

      // DynamoDBに問い合わせて確認する
      const client = new DynamoDBClient({});
      const command = new GetItemCommand({
        TableName: 'ChatTable',
        Key: {
          uuid: { S: id },
        },
      });
      const dynamoRes = await client.send(command);
      expect(dynamoRes?.Item?.uuid?.S).toBe(id);
    });
  });
});

実行時にプロファイルやテスト環境のURLを指定して動かす感じにした。

AWS_PROFILE="limited-chat-dev" API_URL="xxxxxxx/Prod" npm run test

その他

API Gatewayでstageが作成されるバグの回避

バグらしい。たしかにApiの個所を追記するとstageが消えた。

Globals:
  Function:
    Timeout: 3
  Api:
    OpenApiVersion: 3.0.2

AWS SAMで「Stage」ステージが作られるバグを回避する

テンプレートファイルの検証

以下のコマンドでtemplate.yamlを検証できる。

sam validate

Windows11 proでクイック作成でWindows10のVMを作りたい

これで作って

いまさら聞けないWindows 10のTips「Hyper-V」の“クイック作成”でWindows環境を作成する際に押さえておきたい3つの注意点 - 窓の杜

これで日本語化した

いまさら聞けないWindows 10のTips「Hyper-V」の“クイック作成”で展開したWindows 10を日本語化しよう - 窓の杜

なんか日本語化してもちょっとおかしくなったりするけど、再起動するといい感じになる。

90日の開発ライセンスだけど、環境汚さず別環境が必要な時とか便利。

bashのターミナルでコマンド履歴を検索するやつの設定

bashはコマンド履歴を検索できる。便利。

キー 意味
ctrl + r 履歴を後方に検索
ctrl + g 検索を中止
ctrl + s 履歴を前方に検索

ただ、ctrl+sはデフォルトで端末の出力を止めてしまうので初期状態では使えない。

ということで以下の設定を行う。

stty stop undef

これを.profileとか.bash_profileとかに書いておく。

ctrl+sとctrl+qはマジで過去の遺物感があるな。

PHPでmultipart/formdataの中身を直接見たい

PHPで以下のようにするとHTTPのbodyをそのまま見ることができる。

$postBody = file_get_contents("php://input");

なんだけど、multipart/form-dataの場合にはこの手が使えないらしい。

過去には$HTTP_RAW_POST_DATAという変数があったらしいが削除されたっぽい。

PHP - $HTTP_RAW_POST_DATA

困っていたのだが、enable_post_data_reading というフラグはまだ生きているらしい。

PHP - コア php.ini ディレクティブに関する説明

apacheを使っていれば以下のように.htaccessに書いてやれば試すことができる。

php_flag enable_post_data_reading Off

postgresql-setup --initdbでinvalid locale settings; check LANG and LC_* environment variables エラーが出る

Alma Linuxでdnfで入れたPostgreSQL10を設定しようとした。

こんな感じ。

# postgresql-setup --initdb

すると以下のエラーメッセージがログへ出た。

initdb.bin: invalid locale settings; check LANG and LC_* environment variables

ロケールを確認すると日本語になっているっぽい。

# localectl
   System Locale: LANG=ja_JP.utf8
       VC Keymap: us
      X11 Layout: us

でも利用可能なロケールを見ると「ja_JP.utf8」が入っていない。えぇ。

# locale -a

以下で入れて解決した。

# dnf -y install langpacks-ja glibc-langpack-ja.x86_64

PostgreSQLは再設定するときに一度以下を消してやらないとならない。

# rm -rf /var/lib/pgsql/*
# postgresql-setup --initdb