TerraformでLambda[Python]のデプロイするときのプラクティス

2020.02.23

※先に断っておきますがベストプラクティスではないです。

訳あって最近は、Lambda FunctionとLayerをTerraformでデプロイしています。

CloudFormationと比べてると自動ロールバック機能はないのですが、デプロイが早く気に入っています。

ただ、いくつかハマりポイントがあったので、今回はそこらへんの知見を紹介したいともいます。

せっかちな人へ

GitHubにソースコードあげています。

概要

ファイル構成

$ tree -L 2
.
├── README.md
├── main.tf
├── src
│   └── get_unixtime.py
├── requirements.txt
├── build-lambda.sh
└── build
  • main.tf
  • Terraformのtfファイル
  • src/get_unixtime.py
  • Lambda Function用のコード
  • requirements.txt
  • 依存ライブラリ
  • build-lambda.sh
  • ビルドスクリプト
  • build
  • ソースコードとライブラリのビルド後の一時ファイル置き場

Pythonのソースコード

UNIXTIMEのタイムスタンプを返却するシンプルなコードです。pytzライブラリをつかっています。

src/get_unixtime.py

import os
import sys
import pytz
from datetime import datetime, timedelta

def lambda_handler(event: dict, context):
    UTC = pytz.timezone('UTC')
    now = datetime.now(UTC)
    timestamp = now.timestamp()
    return timestamp

Terraformのtfファイル

Lambda Function用とLambda Layer用のZipファイルをそれぞれ作成しています。

source_code_hashを使っているので差分が発生しない限りデプロイされないという作りになっています。

差分とは、

  • ソースコードが変化した
  • ライブラリが変化した(バージョンの更新など)

のどちらかを指します。

main.tf

# Terraform Setting
terraform {
  required_version = "0.12.6"
}

# Provider
provider "aws" {
  region  = "ap-northeast-1"
  version = "~>2.34.0"
}

# Variables
variable "system_name" {
  default="terraform-lambda-deployment"
}

# Archive
data "archive_file" "layer_zip" {
  type        = "zip"
  source_dir  = "build/layer"
  output_path = "lambda/layer.zip"
}
data "archive_file" "function_zip" {
  type        = "zip"
  source_dir  = "build/function"
  output_path = "lambda/function.zip"
}

# Layer
resource "aws_lambda_layer_version" "lambda_layer" {
  layer_name = "${var.system_name}_lambda_layer"
  filename   = "${data.archive_file.layer_zip.output_path}"
  source_code_hash = "${data.archive_file.layer_zip.output_base64sha256}"
}

# Function
resource "aws_lambda_function" "get_unixtime" {
  function_name = "${var.system_name}_get_unixtime"

  handler                        = "src/get_unixtime.lambda_handler"
  filename                       = "${data.archive_file.function_zip.output_path}"
  runtime                        = "python3.6"
  role                           = "${aws_iam_role.lambda_iam_role.arn}"
  source_code_hash               = "${data.archive_file.function_zip.output_base64sha256}"
  layers = ["${aws_lambda_layer_version.lambda_layer.arn}"]
}

# Role
resource "aws_iam_role" "lambda_iam_role" {
  name = "${var.system_name}_iam_role"

  assume_role_policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
POLICY
}

# Policy
resource "aws_iam_role_policy" "lambda_access_policy" {
  name   = "${var.system_name}_lambda_access_policy"
  role   = "${aws_iam_role.lambda_iam_role.id}"
  policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogStream",
        "logs:CreateLogGroup",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:*"
    }
  ]
}
POLICY
}

ビルド

今回の肝となるビルドスクリプトです。

ここでは

  • Lambda Function用のソースコードをコピー
  • Lambda Layer用のデプロイに含めるライブラリをインストール

しています。

※詳しい処理は後半で説明します。内容を踏まえた上で、利用を検討してください。

build-lambda.sh

#!/usr/bin/env bash

if [ -d build ]; then
  rm -rf build
fi

# Recreate build directory
mkdir -p build/function/ build/layer/

# Copy source files
echo "Copy source files"
cp -r src build/function/

# Pack python libraries
echo "Pack python libraries"
pip install -r requirements.txt -t build/layer/python

# Remove pycache in build directory
find build -type f | grep -E "(__pycache__|\.pyc|\.pyo$)" | xargs rm

デプロイ

下記の手順でデプロイできます。

# ビルドスクリプトを実行
sh lambda-build.sh

# Terraformのデプロイコマンドを実行
terraform apply

ビルドスクリプトの解説

ここでは、ビルドスクリプトbuild-lambda.shでおこなっている内容について詳しく説明します。

ソースファイルのコピー

cp -r src build/function/の部分です。Lambda Functionのデプロイパッケージに含めるソースコードをbuildディレクトリに配置しています。

ファイルのコピーをしているだけなので、一見必要無さそうに見えます。

しかし、仮にtfファイルのarchive_fileの部分を

data "archive_file" "function_zip" {
  type        = "zip"
  source_dir  = "src"
...省略

みたいに書き換えると、Lambdaパッケージにはsrc部分が含まれず、src直下のディレクトリからデプロイパッケージが作成されます。

つまり、handlerや他のファイルとのパスの関係から、このような構成にしています。

ライブラリ群のインストール

pip install -r requirements.txt -t build/layer/pythonの部分です。Lambda Layerのデプロイパッケージに含めるライブラリ一式をbuildディレクトリに配置しています。

ポイントはpython配下にライブラリ群を一式インストールしている点です。Lambda FunctionからLambda Layerを参照するパスをうまく通すためこのような構成にしています。

キャッシュ(pycache)の削除

ここが今回の一番のポイントです。find build -type f | grep -E "(__pycache__|\.pyc|\.pyo$)" | xargs rmの部分ですが、ここではインストールしたライブラリに含まれる__pycache__配下のファイルを一式削除しています。

[追記 2020/02/26] こちら をよく見てみると、pycleanというDebianパッケージがあるらしいです。これをベースにPipyでpycleanというライブラリを提供している人がいまいした。こちらを利用した方が良さそうです。

[さらに追記 2020/03/30] 前回追記したpycleanというライブラリですが、どうやらOSのパッケージを参照しているらしく、環境依存になるため使えませんでした。

これは、pip installで同じライブラリをインストールする際でも__pycache__配下のファイルの影響で差分が発生してしまうからです。

たとえば、2回目以降のデプロイで__pycache__配下のファイルを削除をせずsh lambda-build.shを実行したとします。 その後terrform planを実行すると、source_code_hashのハッシュ値が一致しせず差分が検知されます。

試しに、

pip install -r requirements.txt -t lib1
pip install -r requirements.txt -t lib2

を実行してdiffをとってみましたが、__pycache__配下がすべて差分として出力されました。

$ diff -r lib1/ lib2/
Binary files lib1/pytz/__pycache__/exceptions.cpython-36.pyc and lib2/pytz/__pycache__/exceptions.cpython-36.pyc differ
Binary files lib1/pytz/__pycache__/__init__.cpython-36.pyc and lib2/pytz/__pycache__/__init__.cpython-36.pyc differ
Binary files lib1/pytz/__pycache__/lazy.cpython-36.pyc and lib2/pytz/__pycache__/lazy.cpython-36.pyc differ
Binary files lib1/pytz/__pycache__/reference.cpython-36.pyc and lib2/pytz/__pycache__/reference.cpython-36.pyc differ
Binary files lib1/pytz/__pycache__/tzfile.cpython-36.pyc and lib2/pytz/__pycache__/tzfile.cpython-36.pyc differ
Binary files lib1/pytz/__pycache__/tzinfo.cpython-36.pyc and lib2/pytz/__pycache__/tzinfo.cpython-36.pyc differ

これを回避するため、下記の方法を調べてみましたが、良さそうながありませんでした。。。

  1. Terraformのビルドイン関数でなんか使えそうなものはないか? => なさそう
  2. Terraformでzip化する際にexclude指定できないか? => なさそう
  3. pip install時に__pycache__の作成が行われないようなオプションはないか? => export PYTHONDONTWRITEBYTECODE=1python -B -m pip installを試してみましたが、pip installしたファイルへの影響しない

いろいろ調べた結果、あくまでキャッシュということで消しても問題なさそうなため、インストール後に一括で削除することにしました。デプロイして動作確認はしましたが、問題なく動きます。

余談

  • そもそもpycacheとはなんなのか?

公式ドキュメントを確認しましたが、__pycache__配下に作成されるファイルは、Pythonの実行時に生成されるコンパイルされたモジュール群のキャッシュだそうです。

.pycを残しておけば、モジュールの読み込みにかかる時間が短縮されるので、全体の実行時間が速くなるらしいです。ただ、プログラムを変更するたびに書き換えられます。

GitなどでPythonのコードを管理する際は、.gitignoreに追記して除外するのが一般的です。

今回のケースの場合で言えば、(ともとも除外していたものなので)削除しても影響無さそうに思えますが、pip installしたライブラリへの影響まではわからないのが正直なところです。

まとめ

いかがだったでしょうか。

繰り返しになりますが、この方法でデプロイする際は十分に検討してからおこなってください。

また、他にいい方法知っているという方いれば是非おしてください。