Lambdaが標準で持ってるAWS-SDKのバージョンをTweetするBotを作ってみた

2020.02.15

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、CX事業本部の夏目です。

Lambdaに最初から入ってるAWS-SDKのバージョンが気になったので、一日一回TweetしてくれるBotを作ったので、構成や工夫を紹介します。

構成

Lambda Runtime Tweeter(1)

  • AWS-SDKのバージョンを取得するLambdaはCloudWatchのScheduleで一日一回動く
  • AWS-SDKのバージョンを取得するLambdaとTweetするLambda(Tweeter)はLambda Destinationでつないで、別々に実装する
  • AWS-SDKのバージョンを取得するLambdaはログを出力させない
  • TwitterのAPI KeyなどはSystems Manager Parameter StoreにKMSで暗号化して保存
  • TweetするLambda(Tweeter)については簡易的なエラー通知を行う

AWS-SDKのバージョンを取得するLambdaはログを出力させない

すごくシンプルな処理かつ通信を行うわけでもないので、ログ出力そのものをさせない。

(今回の使い方であればCloudWatch Logsの料金は気になるほどでないが、そういう使い方もあるんだよという例と思って欲しい)

TweetするLambda(Tweeter)については簡易的なエラー通知を行う

TweeterのLambdaについてはエラーが起きた際に通知を行うようにしている。

MetricsFilterを使用してCloudWatch Logsにエラーログが出力されると、CustomMetricsを投げるようにしている。
投げられたCustomMetricsをCloudWatch Alarmで監視をして、何かあればSNS TopicにPublishをする。

残念ながら、AWSの規約の関係上SNS Topicの次に使っているサービスについては紹介できない。
しかし、SNS TopicをサブスクライブしてSlackへの通知処理を実装すると考えてもらえばよい。

リポジトリ

.
├── .circleci
│   └── config.yml # deployはCircleCIで
├── .gitignore
├── LICENSE # GitHubが生成してくれるやつ 
├── Makefile # Task Runnerとして使う
├── README.md
├── poetry.lock    # poetryのlockファイル
├── pyproject.toml # poetryの設定ファイル
├── sam.yml 
└── src
    ├── layer
    │   ├── Dockerfile
    │   └── requirements.txt
    ├── node
    │   └── index.js
    ├── python
    │   └── index.py
    ├── ruby
    │   └── index.rb
    └── tweet
        ├── index.py
        └── main.py

6 directories, 13 files

リポジトリの構成は上記のようになっている。
src/tweetディレクトリにTweeterの実装を、src/layerディレクトリにLambda Layerの実装を置く想定。
(Lambda Layerの実装は make build 実行時に置かれる)

すべてを説明していたら長くなってしまうので、一部項目について説明する。

Makefile

SHELL = /usr/bin/env bash -xeuo pipefail

stack_name:=lambda-runtime-tweeter-lambda
template_path:=packaged.yml

isort:
	poetry run isort -rc src

black:
	poetry run black src

format: isort black

build:
	cd src/layer; \
	docker build -t my-build .; \
	docker run --name my-container my-build pip3 install -r requirements.txt -t ./python; \
	docker cp my-container:/workdir/python .; \
	docker rm my-container; \
	docker rmi my-build; \
	cd ../../

package:
	poetry run sam package \
		--s3-bucket $$ARTIFACT_BUCKET \
		--output-template-file $(template_path) \
		--template-file sam.yml

deploy: package
	poetry run sam deploy \
		--stack-name $(stack_name) \
		--template-file $(template_path) \
		--capabilities CAPABILITY_IAM \
		--no-fail-on-empty-changeset \
		--role-arn $$CLOUDFORMATION_DEPLOY_ROLE_ARN

.PHONY: \
	deploy \
	package \
	build \
	isort \
	black \
	format

SAMのデプロイ等はmakeコマンドを使って実行する。
実際のデプロイはCircleCI上で行うため、Lambdaのデプロイパッケージを保存するS3 Bucket名やCloudFormationを実行するIAM RoleのARNは環境変数で渡す。
(CircleCIの環境変数を設定できるのはリポジトリでAdminの権限を持ってる人だけ、だったはず。自信ない) (いつの間にか、実行時のログにも出力しないようになってた)

こうやって、隠すべき情報はGitに載せないことで、パブリックリポジトリに安心して置くことができる。

buildではCircleCI上での動作を想定して、Docker Imageの作成と実行、実行したコンテナからのファイルコピーで依存ファイルを手元に持ってきている。
(CircleCIではdockerコマンドが実行されるホストが別なので、-vオプションとかでファイルをマウントすることができない)
(Docker Imageを作成する際には別ホストでもローカルのファイルをコピーしてくれるのを利用してファイルを送り込んでいる)

.circleci/config.yml

version: 2

jobs:
  deploy:
    docker:
      - image: circleci/python:3.8.1
    steps:
      - run:
          name: poetry in-project true
          command: |
            set -x

            poetry config virtualenvs.in-project true
      - checkout
      - setup_remote_docker
      - restore_cache:
          keys:
            - layer-{{ checksum "src/layer/Dockerfile" }}-{{ checksum "src/layer/requirements.txt" }}
      - restore_cache:
          keys:
            - poetry-{{ checksum "pyproject.toml" }}-{{ checksum "poetry.lock" }}
      - run:
          name: install dependencies
          command: |
            set -x

            poetry install

            if [ ! -d src/layer/python ]; then
              make build
            fi
      - save_cache:
          paths:
            - .venv
          key: poetry-{{ checksum "pyproject.toml" }}-{{ checksum "poetry.lock" }}
      - save_cache:
          paths:
            - src/layer/python
          key: layer-{{ checksum "src/layer/Dockerfile" }}-{{ checksum "src/layer/requirements.txt" }}
      - run:
          name: install dependencies
          command: |
            set -x

            make deploy

workflows:
  version: 2
  deploy:
    jobs:
      - deploy:
          filters:
            branches:
              only: master

pipモジュールの管理にはpoetryを使用しています。
poetry config virtualenvs.in-project trueと設定しておくとprojectのルートディレクトリに.venvという仮想環境を作成するので、CircleCIでキャッシュさせておく。
キャッシュが残っていればpoetryはpipモジュール再インストールを行わない。
キャッシュのキーとして、pyproject.tomlpoetry.lockのチェックサムを使用しているので、変更があった際にはキャッシュがアップデートされる。

Lambda LayerにするpipモジュールもCircleCIでキャッシュさせておく。

src/tweet/main.py

import boto3
from botocore.client import BaseClient
from jeffy.framework import setup
from twitter import Api

app = setup()


def main(event: dict, ssm_client: BaseClient = boto3.client("ssm")):
    app.logger.info({"name": "event", "value": event})
    keys = get_keys(ssm_client)
    message = get_message(event)
    tweet(message, keys)


def get_keys(ssm_client: BaseClient) -> dict:
    option = {"Path": "/twitter/keys", "WithDecryption": True}
    resp = ssm_client.get_parameters_by_path(**option)
    keys = {x["Name"]: x["Value"] for x in resp["Parameters"]}
    return {
        "consumer_key": keys["/twitter/keys/consumer_key"],
        "consumer_secret": keys["/twitter/keys/consumer_secret"],
        "access_token_key": keys["/twitter/keys/access_token_key"],
        "access_token_secret": keys["/twitter/keys/access_token_secret"],
    }


def get_message(event: dict) -> str:
    return event["responsePayload"]


def tweet(message: str, keys: dict):
    resp = Api(**keys).PostUpdate(message)
    app.logger.info({"name": "tweet response", "value": resp})

TwitterのAPI Keyは4つ必要になるのだが、それぞれ Systems Manager Parameter StoreにKMSで暗号化して保存している。

  • consumer_key: /twitter/keys/consumer_key
  • consumer_secret: /twitter/keys/consumer_secret
  • access_token_key: /twitter/keys/access_token_key
  • access_token_secret: /twitter/keys/access_token_secret

それぞれのキーを上記名前で保存しているのだが、このときGetParametersByPathというAPIを使用してまとめて値を取得している。
(正確にはまとめて値を取得できるようにこのような名前にした)

また、取得時に"WithDecryption": Trueを指定することで、復号化された値を取得できる。

まとめ

ということで、以上作ったものの工夫等でした。

これらの工夫が何かのやくにたてば幸いです。