make は強いタスクランナーだった。Lambda Function のライフサイクルを Makefile でまわす

Lambda Function のローカル開発を考察したときに、不十分だったタスクランナーの導入を今回やりました。make についての細かい解説は本稿では行いません。Lambda Function 開発の要件を満たすためにタスクランナーとしてどのような Makefile の書き方があるかを示します。
2018.03.31

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

Lambda Function のローカル開発を考察したとき に、不十分だったタスクランナーの導入を今回やりました。make についての細かい解説は本稿では行いません。Lambda Function 開発の要件を満たすためにタスクランナーとしてどのような Makefile の書き方があるかを示します。

Lambda Function をテストし、デプロイするためのツールはありますが、

  • 複数の Function を全部プロイしたり
  • 逆にひとまとまりの Lambda だけデプロイしたり
  • 環境を指定してデプロイしたり

ということを考えると工夫が必要になります。そこで登場するのがタスクランナーです。たくさんのタスクランナーが候補としてあり、一長一短あるところ、make が要件を満たすのではないかということで Makefile を書いていきます。ちなみに私自身これまで Makefile を書いたことはありません。難しそうなイメージでしたが、考え方を理解するとタスクランナーとしてむしろ書きやすいという感想です。

結論から

make はC言語系向けとあって、考え方を理解するのに時間がかかりました。タスクは、基本的にファイルのことで、依存タスクとはファイルが存在するかどうかを確認することを指す、といったことです。一度このあたりを把握すると、タスクランナーとして強力に利用できると思います。どの環境でもほぼインストールなしで最初から使えるというもの良いです。結局、

make upload-heroes # heroesにかかわる Lambda Function だけをアップロードする
make deploy env=prd # プロダクション環境にすべての Lambda Function をデプロイする

といったように、リソースごと、環境ごとにデプロイできるようになりました。これで Lambda Function の開発をさらに加速できます。

↓S3バケットに heroes のためのソースだけがアップロードされた様子。 s3-heroes.png

↓CloudFormation に heroes と offices のスタックがデプロイされている様子。ここから Lambda Function も展開されている。 deploy-all.png

前提の確認

ある程度の規模(二種類以上のリソースを扱う)のアプリケーションで Lambda Function を開発すると、リソース単位で分けて開発/デプロイするシーンに遭遇します。以下のようなプロジェクトを考えてみます。

概要

ヒーローを管理するAPIを、API Gateway と AWS Lambda, DynamoDB で開発します。

扱うリソース

リソース名 役割
heroes ヒーローの名前や所属事務所を管理する
offices 事務所の名前や住所を管理する

開発ライフサイクル

対象 どうやるか
コーディング作業 ローカルで Python を使う。Intellij でも Atom でもエディタは問わない。
Unit Test pytest。ただしAWSサービスへのリクエスト部分は対象としない。
結合テスト SAM Local、LocalStack、batsを組み合わせて。
ZIPアプロード Lambda の ZIPファイルをアップロードする。
デプロイ Lambda の ZIPファイルを、AWS SAM によってデプロイする。
CI/CD AWS CodeBuild (buldspec.yml) で行う。

利用したツールとバージョン

ツール バージョン
Python 3.6.2
pytest 3.4.2
aws-cli 1.11.150
AWS SAM Local 0.2.8
LocalStack ( Docker ) latest
Bats - Bash Automated Testing System 0.4.0

タスクランナーに求める要件を整理する

Makefile を書き始めるまえに、タスクランナーとしてどんなことを実現したいのかを考えます。今回恩恵をあずかりたいのは、「ZIPファイルのアップロード」と「デプロイ」の部分です。単純なスクリプトだとどうしても複雑になりがちなのが、ターゲットを指定する という部分ですね。具体的には、

  • 環境(Environment): test, stage, production, など。Lambda Function を環境別にデプロイしたい
  • リソース:heroes だけ、 offices だけ、全部というようにデプロイしたい

ということを考えます。コマンドにすると以下のようなことができるイメージ。

make upload-heroes # heroesにかかわる Lambda Function だけをアップロードする
make deploy env=prd # プロダクション環境にすべての Lambda Function をデプロイする

これをタスクランナーの要件に落とし込むと…

  • 特定のタスクで、 env 変数がセットされていることを強要できるようにしたい
  • 特定のタスクは、名前を指定することでリソース個別に実行できるし、指定しなければすべてのリソースに対して実行するようにしたい

ということになります。

makeの他に検討したタスクランナー

Bazel

Google が開発するビルドツールです。多くの言語をサポートしているようですが、目的に対して新しい記法をゼロから学ぶ元気がなく諦めました。すいません。

invoke

Python で書けるビルドツールです。途中まで触ってみて、「別にタスク定義を Python で書きたいわけではない」と気づいてやめました。

Rake

私が Ruby を触れないのと、すでにプロジェクトに Node.js と Python の環境をインストールしているためこれ以上インストールする言語環境を増やしたくないと理由でやめました。CIに時間がかかるので。

task, robo

YAMLベースでタスク定義ができるタスクランナーです。シンプルで使いやすいのですが、リソースごとにタスクを走らせたり、ワイルドカードを指定したりといった面がすんなりいきませんでした。また、変数の定義がすべて静的に評価されてしまい、ファイルハッシュの取得など動的な評価はすべてスクリプトにおこさなくてはならないといった面もありました。シンプルな用途にはマッチするツールだと思います。

結局、弊社中山齋藤の勧めもあり、make を使うことにしました。

作った Makefile のタスク一覧

タスク名 役割
all clean, test-unit, test-integ, dist, upload, deploy を実行する。
install pip で requirements.txt をインストールする。
localstack-up docker-compose.yml で定義している LocalStack を起動する。
localstack-stop LocalStack を停止する。
test-unit ユニットテストを実行する。
test-integ 結合テストを実行する。
clean デプロイのために必要になる zip ファイルなどを削除する。
dist アップロード用の zip ファイルを作成する。
upload すべてのリソースに対してアップロードを実行する。
upload-% リソースを指定し、そのリソースについてアップロードする。
deploy すべてのリソースに対してデプロイ実行する。
deploy-% リソースを指定し、そのリソースに対してデプロイ実行する。
guard-% タスクの依存関係に記載することで、%で指定した変数がセットされていることを強要する。

要件にかかわるところを詳しく見ていきます。

guard-%

他のタスクに指定することで、%に指定した変数が make 実行時にセットされていることを強要します。

Makefile

guard-%:
	@ if [ "${${*}}" = "" ]; then \
		echo "Environment variable $* not set"; \
		exit 1; \
	fi

以下のように使います。

Makefile

deploy: guard-env

deploy を実行するときに、env がセットされていないと先に進めないようになります。

$ make deploy

+ find test -name '*.bats'
+ git log -n 1 --format=%h
+ '[' '' = '' ']'
+ echo 'Environment variable env not set'
Environment variable env not set
+ exit 1
make: *** [guard-env] Error 1

upload, upload-%

upload-% によって個別にアップロードできるようにし、upload では src/functions/以下をさがしてアップロード対象をすべて抽出してから、すべてのアップロードを実行します。

# src/functions以下をさがして、upload-heroes upload-offices という配列を作成
BASE := src/functions
DIR := $(sort $(dir $(wildcard $(BASE)/*/)))
TARGETS := $(patsubst $(BASE)/%/, %, $(DIR))
UPLOAD_TASK := $(addprefix upload-, $(TARGETS))

# $(UPLOAD_TASK) を展開すると upload-heroes upload-offices となる
upload: guard-env clean dist $(UPLOAD_TASK)
	@echo $(TARGETS)
	@echo $(UPLOAD_TASK)
	@echo $(DEPLOY_TASK)

# % に指定された名前を使ってターゲットを特定しアップロード
upload-%: guard-env $(UPLOAD_FILE)
	@ if [ "${*}" = "" ]; then \
		echo "Target is not set"; \
		exit 1; \
	elif [ ! -d "$(BASE)/${*}" ]; then \
		echo "Target directory $(BASE)/$* does not exists."; \
		exit 1; \
	else \
		s3_keyname="${*}/$(ZIP_FILE)" && \
		echo $${s3_keyname} && \
		aws s3 cp $(UPLOAD_FILE) $(S3_BUCKET_URL)/$${s3_keyname} ; \
	fi

deploy, deploy-%

考え方は uploadと同じです。

# src/functions以下をさがして、deploy-heroes deploy-offices という配列を作成
BASE := src/functions
DIR := $(sort $(dir $(wildcard $(BASE)/*/)))
TARGETS := $(patsubst $(BASE)/%/, %, $(DIR))
DEPLOY_TASK := $(addprefix deploy-, $(TARGETS))

# $(DEPLOY_TASK) を展開すると deploy-heroes deploy-offices となる
deploy: guard-env $(DEPLOY_TASK)
	@echo $(TARGETS)
	@echo $(UPLOAD_TASK)
	@echo $(DEPLOY_TASK)

# % に指定された名前を使ってターゲットを特定しデプロイ
deploy-%: template_%.yaml guard-env
	@ if [ "${*}" = "" ]; then \
		echo "Target is not set"; \
		exit 1; \
	elif [ ! -d "$(BASE)/${*}" ]; then \
		echo "Target directory $(BASE)/$* does not exists."; \
		exit 1; \
	else \
		s3_keyname="${*}/$(ZIP_FILE)" && \
		aws cloudformation package \
			--template-file template_${*}.yaml \
			--s3-bucket $(S3_BUCKET_URL) \
			--output-template-file packaged-${*}.yaml && \
		aws cloudformation deploy \
			--template-file packaged-${*}.yaml \
			--stack-name $${env}-${*}-lambda  \
			--capabilities CAPABILITY_IAM \
			--parameter-overrides \
				Env=$${env} \
				CodeKey=$${s3_keyname} ; \
	fi

あとは実行していくだけです。わかりづらかったスクリプトがすっきり。

buldspec.yaml ビフォーアフター

make によって 以下のように buildspec.yamlが改善しました。

before

pre_build:
  commands:
    - pip install -r requirements.txt
    - docker-compose up -d
build:
  commands:
    - python -m pytest
    - ./integration_test.sh
post_build:
  commands:
    - ./deploy.sh

after

pre_build:
  commands:
    - make install
    - make localstack-up
build:
  commands:
    - make test-unit
    - make test-integ
post_build:
  commands:
    - make upload env=$ENV
    - make deploy env=$ENV

なにをやっているのかわかりやすくなりましたね。

他のリソースを追加したいときは

ヒーロー、事務所に加えて、「スポンサー会社」を追加したいとしましょう。ソースツリーは以下のようになるはずです。

tree -L 2 src

src
└── functions
    ├── heroes
    ├── offices
    └── sponsors # 追加

デプロイのために template_sponsors.yaml を追加します。

template_sponsors.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Simple CRUD webservice. State is stored in a SimpleTable (DynamoDB) resource.
Parameters:
  Env:
    Type: String
    Default: local
  DynamoDBEndpoint:
    Type: String
    Default: https://dynamodb.ap-northeast-1.amazonaws.com/
  SponsorTableName:
    Type: String
    Default: CM-Sponsors
  BucketName:
    Type: String
    Default: hero-lambda-deploy
  CodeKey:
    Type: String
    Default: sponsor/0000.zip
Resources:
  GetSponsors:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName:
        Fn::Sub: ${Env}-heroes-GetSponsors
      Handler: src/functions/sponsors/index.get
      Runtime: python3.6
      CodeUri:
          Bucket: !Ref BucketName
          Key: !Ref CodeKey
      Policies: AmazonDynamoDBReadOnlyAccess
      Environment:
        Variables:
          DYNAMODB_ENDPOINT: !Ref DynamoDBEndpoint
          SPONSOR_TABLE_NAME:
            Fn::Sub: ${Env}-${SponsorTableName}
  PutSponsors:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName:
        Fn::Sub: ${Env}-heroes-PutSponsors
      Handler: src/functions/sponsors/index.put
      Runtime: python3.6
      CodeUri:
          Bucket: !Ref BucketName
          Key: !Ref CodeKey
      Policies: AmazonDynamoDBFullAccess
      Environment:
        Variables:
          DYNAMODB_ENDPOINT: !Ref DynamoDBEndpoint
          SPONSOR_TABLE_NAME:
            Fn::Sub: ${Env}-${SponsorTableName}

この後デプロイをしたいとします。このとき、 Makefile を編集する必要はありません。ディレクトリからターゲットをさがしてくれるので、我々は make タスクを実行するだけです。

bash-3.2$ make upload-sponsors
sponsors/6a3da43.zip
+ aws s3 cp deploy/6a3da43.zip s3://hero-lambda-deploy/sponsors/6a3da43.zip
upload: deploy/6a3da43.zip to s3://hero-lambda-deploy/sponsors/6a3da43.zip

bash-3.2$ make deploy-sponsors env=prd
...中略*...
Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - prd-sponsors-lambda

Makefile を編集することなく、スポンサーの Lambda をデプロイするための CloudFormation スタックが作成されました。

sponsor.png

まとめ

Lambda Function 開発のためのタスクランナーとして、make を使いました。デプロイ先の環境 であったり、ヒーローや事務所など Lambda Function のグループ を考える場合は、タスクランナーとしての機能が強力です。Lambda Function の数が多くなってきてどう管理したものかという段階になってきた方々の参考になれば幸いです。

ソースコード

参考