kakakakakku blog

Weekly Tech Blog: Keep on Learning!

AWS Chatbot で AWS Lambda 関数の集約したロググループからログを取得する

AWS Lambda 関数の Errors メトリクスなどを Amazon CloudWatch Alarm でモニタリングして,エラー発生時に Amazon SNS と AWS Chatbot を組み合わせて Slack に通知すると Show error logs ボタンと Show logs ボタンが表示される✅ そして AWS Chatbot に権限を与えておくと,Amazon CloudWatch Logs から関連したログを Slack 上で取得できる.エラー発生時に迅速にエラー詳細を把握できるのは便利だと思う👌

Amazon CloudWatch Logs ロググループに注意する

便利な Show error logs ボタンと Show logs ボタンは AWS Lambda 関数のデフォルトの Amazon CloudWatch Logs ロググループ /aws/lambda/xxx を前提に作られている点は注意しておくと良いと思う.ドキュメントには直接明記されていなかった(見つけられなかった)けど,挙動からそう判断した.

具体的には,2023年11月にリリースされた AWS Lambda 関数の「高度なログ制御機能」を活用して,任意の Amazon CloudWatch Logs ロググループに集約している AWS Lambda 関数でボタンを押すと I can't get the logs for the CloudWatch Alarm sandbox-errors-alarm for you because I cannot find the log group /aws/lambda/sandbox for sandbox. というエラーが出て使えなかった(sandbox-errors-alarm は Amazon CloudWatch Alarm 名 / sandbox は AWS Lambda 関数名).

aws.amazon.com

@aws コマンドを使う

ボタンを押すよりも面倒ではあるけど,ワークアラウンドとして Slack 上で直接 @aws logs filter-log-events コマンドを実行すれば,簡易的に AWS Lambda 関数のエラー詳細を把握できる.AWS Chatbot から You can also run the query directly using the following command としてコマンド例を出してくれていたため,参考にしながら作ってみた.

ポイントを箇条書きで載せておく📝

  • --log-group-name に Amazon CloudWatch Logs ロググループを指定する
  • --log-stream-name-prefix に Amazon CloudWatch Logs ログストリームを指定する
  • --start-time--end-time に Unix Timestamp (Milliseconds) を指定する
@aws logs filter-log-events --region ap-northeast-1 --log-group-name aggregated-function-logs --log-stream-name-prefix 2024/04/12/sandbox[$LATEST] --start-time 1712883000000 --end-time 1712883300000

その他のオプションも活用する場合は CLI ドキュメントを参考で👌

awscli.amazonaws.com

そして @aws コマンドを使って AWS Chatbot 経由でログを取得できた.

まとめ

AWS Lambda 関数で任意の Amazon CloudWatch Logs ロググループに集約してる場合は Slack 上で AWS Chatbot の Show error logs ボタンと Show logs ボタンが使えず,今回は @aws コマンドを使ってワークアラウンドを試してみた.とは言え,正直エラー発生時に @aws コマンドを作るのは面倒ではあるため,本格的に実現するなら「カスタム Lambda アクション (Custom Lambda Action)」を実装する必要がありそう.あと @aws コマンドの引数が多くなければ「カスタム CLI アクション (Custom CLI Action)」を使う案もありそう.

docs.aws.amazon.com

docs.aws.amazon.com

関連記事

kakakakakku.hatenablog.com

Amazon API Gateway の Lambda オーソライザーで "User is not authorized to access this resource" と出たら

Amazon API Gateway の Lambda オーソライザー(旧カスタムオーソライザー)を使ってアクセス制御をするときに,Authorization ヘッダーは正しいはずなのに {"Message":"User is not authorized to access this resource"} というエラーが出てしまう場合,Lambda オーソライザーの設定「認可のキャッシュ (Authorization caching)」に関係してる場合がある💡

前提

今回はサンプルとして Amazon API Gateway に /users リソースを追加して POSTGET をサポートする.そしてどちらにも Lambda オーソライザー(トークンタイプ)によるアクセス制御を設定しておく👌

/users (POST)
/users (GET)

そして,Lambda オーソライザーの実装はドキュメントに載っている Python のサンプルコードをそのまま使う📝実装としては簡易的で Authorization ヘッダーに allow と設定すれば OK という仕組みになっている.

docs.aws.amazon.com

ポイントはこの Lambda オーソライザーの実装では以下のようなポリシーが生成されるところ💡(POST の場合)

Allow

{
    "principalId": "user",
    "policyDocument": {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Action": "execute-api:Invoke",
                "Effect": "Allow",
                "Resource": "arn:aws:execute-api:ap-northeast-1:111111111111:xxxxxxxxxx/Prod/POST/users"
            }
        ]
    },
    "context": {
        "stringKey": "stringval",
        "numberKey": 123,
        "booleanKey": true
    }
}

Deny

{
    "principalId": "user",
    "policyDocument": {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Action": "execute-api:Invoke",
                "Effect": "Deny",
                "Resource": "arn:aws:execute-api:ap-northeast-1:111111111111:xxxxxxxxxx/Prod/POST/users"
            }
        ]
    },
    "context": {
        "stringKey": "stringval",
        "numberKey": 123,
        "booleanKey": true
    }
}

動作確認

Amazon API Gateway の /users リソースに POST リクエストを送信した後すぐに GET リクエストを送信する.すると {"Message":"User is not authorized to access this resource"} というエラーが返ってくる🔥キャッシュの仕組みを理解してないと「なぜー?」となってしまう.キャッシュの TTL はデフォルトで「300秒」に設定されている👀

$ curl -X POST -H 'Authorization: allow' ${ENDPOINT}/users
$ curl -X GET -H 'Authorization: allow' ${ENDPOINT}/users
{"Message":"User is not authorized to access this resource"}

対策

選択肢は大きく2つあると思う👌

選択肢1

Lambda オーソライザーで生成するポリシーの条件を緩和する選択肢がある.AWS re:Post の記事にも Resource/*/* にすれば OK という解決策が紹介されている💡もちろんワイルドカードで許可できない場合もあると思うし,最小権限の原則を考慮すると闇雲にワイルドカードっていう判断が危険なこともあると思う.

repost.aws

ちなみに前に紹介記事を書いた Powertools for AWS Lambda (Python)Event Source Data ClassesAPIGatewayAuthorizerResponse を使ってポリシーを生成する場合,allow_all_routes() を使うと以下のようにワイルドカードでポリシーが生成される.allow_route() を使うと HTTP メソッド・リソースを細かく指定できる.

{
    "principalId": "user",
    "policyDocument": {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Action": "execute-api:Invoke",
                "Effect": "Allow",
                "Resource": "arn:aws:execute-api:ap-northeast-1:111111111111:xxxxxxxxxx/Prod/*/*"
            }
        ]
    }
}

kakakakakku.hatenablog.com

選択肢2

次にキャッシュの TTL を短くする(もしくはキャッシュを無効化する)選択肢がある.キャッシュによる影響はなくなるけど,毎回 Lambda オーソライザーを呼び出すことになるため,パフォーマンスなどの観点で検討が必要になる.

デフォルトで 300 秒、API 所有者が 0~3600 の範囲で設定可能。

docs.aws.amazon.com

AWS CDK で外部パッケージを含む Python の AWS Lambda 関数をデプロイする

AWS CDK で外部パッケージを含む Python の AWS Lambda 関数をデプロイする場合,requirements.txt から依存関係を解決して,デプロイするアセットとして ZIP にまとめる(バンドルする)必要がある💡

今回は aws-cdk-lib.aws_lambda module@aws-cdk/aws-lambda-python-alpha module を使う方法を試す❗️

前提

今回はサンプルとして requests に依存したコードを以下のディレクトリ構成で置いてある前提とする \( 'ω')/

functions/requests
├── app.py
└── requirements.txt

aws_lambda module を使う

まず,AWS CDK で AWS Lambda 関数をデプロイするときによく使う aws_lambda module では Code.fromAsset のオプションとして BundlingOptions を設定できる.

docs.aws.amazon.com

実装としてはザッとこんな感じになる 👾

Python のコンテナ内で pip install コマンドを実行して /asset-output ディレクトリに依存関係をインストールしたら,後はそのまま ZIP にまとめて(バンドルして)デプロイされる👌

new aws_lambda.Function(this, 'PythonFunction', {
  functionName: 'sandbox-python-function',
  runtime: aws_lambda.Runtime.PYTHON_3_12,
  handler: 'app.lambda_handler',
  code: aws_lambda.Code.fromAsset(path.join(__dirname, '../functions/requests'), {
    bundling: {
      image: aws_lambda.Runtime.PYTHON_3_12.bundlingImage,
      command: [
        'bash', '-c',
        'pip install -r requirements.txt -t /asset-output && cp -au . /asset-output'
      ]
    }
  })
})

aws-lambda-python-alpha module を使う

まだ alpha ではあるけど aws-lambda-python-alpha module を使うと entry に指定したディレクトリにある requirements.txtPipfile から自動的に依存関係を解決してくれる👌

実装としてはザッとこんな感じになる 👾

new aws_lambda_python_alpha.PythonFunction(this, 'PythonFunctionAlpha', {
  functionName: 'sandbox-python-function-alpha',
  runtime: aws_lambda.Runtime.PYTHON_3_12,
  index: 'app.py',
  handler: 'lambda_handler',
  entry: path.join(__dirname, '../functions/requests'),
})

aws_lambda.Function のプロパティもサポートされてるし,Lambda Layer も簡単にデプロイできるし便利〜 \( 'ω')/

docs.aws.amazon.com

デプロイ確認

期待通りに requirements.txt に定義した requests をデプロイできている❗️

JSON Schema で簡単にバリデーションを実装できる Powertools for AWS Lambda (Python) の Validation

Powertools for AWS Lambda (Python)「Validation」を使うと AWS Lambda 関数に渡されたイベント情報のバリデーションを JSON Schema に沿って実現できる.例えば,必須パラメータ・文字数制限・ENUM・正規表現などをチェックできる👌

Powertools for AWS Lambda (Python) 自体は Tracer / Logger / Event Source Data Classes などをよく使うけど,Validation は今まで活用できてなく,試してみたらとても便利だったので,今回試した結果を簡単にまとめておく \( 'ω')/

docs.powertools.aws.dev

検証環境

今回は AWS SAM を使って Amazon API Gateway (REST API) と AWS Lambda 関数を構築する.あくまでサンプルとして Amazon API Gateway の / に POST リクエストを送るとバリデーションロジックを含んだ AWS Lambda 関数が実行されるようにした.また Powertools は Lambda Layer でセットアップする.

AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31

Resources:
  Function:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: powertools-validation
      CodeUri: src/
      Handler: app.lambda_handler
      Runtime: python3.12
      Architectures:
        - x86_64
      Layers:
        - arn:aws:lambda:ap-northeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:67
      Events:
        Api:
          Type: Api
          Properties:
            Path: /
            Method: POST

最終的なディレクトリ構成は以下のようになる💡

├── events
│   └── event.json
├── samconfig.toml
├── src
│   └── app.py
└── template.yaml

👾 app.py

今回はバリデーションを紹介するサンプルとして,特にロジックはなく Amazon API Gateway への POST リクエストに対して 200 OK もしくは 400 BAD_REQUEST を返す実装にした.また AWS Lambda 関数のベストプラクティスを参考に Handler とロジックを分割して main() にまとめてある(実際にはもっと細かく分割しても良さそう).

そして,今回は超簡易的な「TODO アプリ」を例として,title / category / description / link というパラメータを受け取る API のバリデーションを実装した.パラメータごとのバリデーション要件は以下のコードの SCHEMA を見てもらえればと❗️さらに Powertools for AWS Lambda (Python) の Validation ではバリデーションロジックを @validator デコレータを使った実装と validate() 関数を使った実装から選べる.どちらも試してみて,個人的には以下の2つの理由から validate() 関数を使うのが良いと思った💡

  • validate() 関数は Lambda コンテキストに依存してなくてローカル開発がしやすかった
  • 例外発生時のハンドリングなどを柔軟に実装しやすかった

また validate() 関数を呼び出すときに envelope を指定できて,イベントオブジェクトの中からバリデーションする箇所を限定できる.今回は Amazon API Gateway (REST API) から渡されるイベントをバリデーションするため,envelopes.API_GATEWAY_REST を指定した.すると自動的に body がバリデーション対象になる👌現状は8種類の envelope が提供されている \( 'ω')/

  • API_GATEWAY_HTTP
  • API_GATEWAY_REST
  • CLOUDWATCH_EVENTS_SCHEDULED
  • CLOUDWATCH_LOGS
  • EVENTBRIDGE
  • KINESIS_DATA_STREAM
  • SNS
  • SQS
import json
from aws_lambda_powertools.utilities.validation import SchemaValidationError, envelopes, validate
from http import HTTPStatus
from jmespath.exceptions import JMESPathTypeError

SCHEMA = {
    '$schema': 'http://json-schema.org/draft-07/schema',
    'type': 'object',
    'required': ['title', 'category'],
    'properties': {
        'title': {
            'type': 'string',
            'pattern': '^[a-zA-Z0-9_]*$'
        },
        'category': {
            'type': 'string',
            'enum': ['Python', 'Go', 'Java']
        },
        'description': {
            'type': 'string',
            'minLength': 10,
            'maxLength': 100
        },
        'link': {
            'type': 'string',
            'format': 'uri'
        }
    },
}


def main(event):
    try:
        validate(event=event, schema=SCHEMA, envelope=envelopes.API_GATEWAY_REST)
    except JMESPathTypeError as e:
        return {
            'statusCode': HTTPStatus.BAD_REQUEST,
            'body': json.dumps({'message': str(e)})
        }
    except json.JSONDecodeError as e:
        return {
            'statusCode': HTTPStatus.BAD_REQUEST,
            'body': json.dumps({'message': e.msg})
        }
    except SchemaValidationError as e:
        return {
            'statusCode': HTTPStatus.BAD_REQUEST,
            'body': json.dumps({'message': e.validation_message})
        }

    return {
        'statusCode': HTTPStatus.OK,
        'body': json.dumps({'message': 'ok'})
    }


def lambda_handler(event, context):
    return main(event)


if __name__ == '__main__':
    with open('../events/event.json', 'r') as f:
        event = json.load(f)

    main(event)

動作確認

data must contain ['category', 'title'] properties

event.json

{}

必須の titlecategory プロパティがなくバリデーションエラー🙅‍♂️

$ curl -s -X POST --data @event.json ${ENDPOINT}
{"message": "data must contain ['category', 'title'] properties"}

data.title must match pattern ^[a-zA-Z0-9_]*$

event.json

{
    "title": "Powertoolsを試す",
    "category": "Python"
}

title に日本語を含んでいるためバリデーションエラー🙅‍♂️

$ curl -s -X POST --data @event.json ${ENDPOINT}
{"message": "data.title must match pattern ^[a-zA-Z0-9_]*$"}

data.category must be one of ['Python', 'Go', 'Java']

event.json

{
    "title": "Powertools",
    "category": "AWS"
}

category の ENUM 以外の値を指定しているためバリデーションエラー🙅‍♂️

$ curl -s -X POST --data @event.json ${ENDPOINT}
{"message": "data.category must be one of ['Python', 'Go', 'Java']"}

data.description must be longer than or equal to 10 characters

event.json

{
    "title": "Powertools",
    "category": "Python",
    "description": ""
}

description が10文字以上になっていないためバリデーションエラー🙅‍♂️

$ curl -s -X POST --data @event.json ${ENDPOINT}
{"message": "data.description must be longer than or equal to 10 characters"}

event.json

{
    "title": "Powertools",
    "category": "Python",
    "description": "Try Powertools for AWS Lambda.",
    "link": "docs.powertools.aws.dev"
}

link が URL 形式になっていないためバリデーションエラー🙅‍♂️

$ curl -s -X POST --data @event.json ${ENDPOINT}
{"message": "data.link must be uri"}

まとめ

Powertools for AWS Lambda (Python)「Validation」を使うと AWS Lambda 関数に渡されたイベント情報のバリデーションを柔軟に実装できてとても便利だった❗️

今後は積極的に使っていくぞー \( 'ω')/

関連リンク🔗

github.com

Lambda オーソライザーのポリシーを簡単に出力できる Powertools for AWS Lambda (Python) の APIGatewayAuthorizerResponse

Amazon API Gateway の Lambda オーソライザー(旧カスタムオーソライザー)を使ってアクセス制御をするときに Lambda オーソライザーの仕様に沿ったポリシーを出力する必要がある💡詳しくは以下のドキュメントに載っている.

docs.aws.amazon.com

今まではドキュメントに載っているコードを参考に実装することが多かったけど,Powertools for AWS Lambda (Python)Event Source Data ClassesAPIGatewayAuthorizerResponse を使うと比較的簡単にポリシーを出力できて良かった❗️

docs.powertools.aws.dev

サンプルコード

今回トークンベースの Lambda オーソライザーで検証したときに使ったコードを載せておく📝

from aws_lambda_powertools.utilities.data_classes import event_source
from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import (APIGatewayAuthorizerTokenEvent, APIGatewayAuthorizerResponse)
from aws_lambda_powertools.utilities.typing import LambdaContext


@event_source(data_class=APIGatewayAuthorizerTokenEvent)
def lambda_handler(event: APIGatewayAuthorizerTokenEvent, context: LambdaContext):
    arn = event.parsed_arn

    policy = APIGatewayAuthorizerResponse(
        principal_id='user',
        region=arn.region,
        aws_account_id=arn.aws_account_id,
        api_id=arn.api_id,
        stage=arn.stage
    )

    if event.authorization_token == 'allow':
        policy.context = {
            'key1': 'value1',
            'key2': 'value2',
            'key3': 'value3'
        }
        policy.allow_all_routes()
    else:
        policy.deny_all_routes()

    return policy.asdict()

ドキュメントに載っている Authorization ヘッダーに allow という文字列が設定されていれば OK という簡易的な実装だけど,ポリシーを出力するときは APIGatewayAuthorizerResponse に値を設定して allow_all_routes() 関数もしくは deny_all_routes() 関数で出力すれば良くて簡単👍

また認証後に実行される AWS Lambda 関数などに追加情報を渡す場合は context に dict で Key-Value を設定すれば OK👌

docs.aws.amazon.com

動作確認

$ curl -H 'Authorization: allow' ${ENDPOINT}
ok

$ curl -H 'Authorization: deny' ${ENDPOINT}
{"Message":"User is not authorized to access this resource with an explicit deny"}