かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Serverless Framework で deploy 用ディレクトリへ移動→環境変数を設定する方法で deploy する環境を切り替える(その1)

概要

記事一覧はこちらです。

開発環境(dev)、ステージング環境(stg)、本番環境(prod)を異なるアカウント、あるいは同一アカウント内でリソース名を切り替えて deploy する方法を記述します。

Terraform の場合、適用先の環境用ディレクトリに移動してから terraform apply コマンドを実行するようにしていて(direnv を利用して .envrc に記述した AWS_PROFILE 等の環境変数を設定します)、

root_directory
└ stages
  ├ dev   ← ここに移動してから terraform apply すれば開発環境に適用される
  │  └ .envrc
  ├ stg   ← ここに移動してから terraform apply すればステージング環境に適用される
  │  └ .envrc
  └ prod  ← ここに移動してから terraform apply すれば本番環境に適用される
      └ .envrc

個人的にこの方法がやりやすいので、Serverless Framework でも同じ方法で deploy できないかと思い考えてみました。

今回は CircleCI から deploy する方法も記述するので、新規の Repository を作成します。 https://github.com/ksby/ksbysample-serverless-deploy

ローカルPC から開発環境(dev)、ステージング環境(stg)を deploy する方法を2回に分けて記述し、CircleCI から本番環境(prod)に deploy する方法をその後に1回で記述します。

参照したサイト・書籍

  1. AWS - Credentials
    https://www.serverless.com/framework/docs/providers/aws/guide/credentials/

  2. cross-env
    https://www.npmjs.com/package/cross-env

  3. npm-run-all
    https://www.npmjs.com/package/npm-run-all

  4. per-env
    https://www.npmjs.com/package/per-env

  5. aws-lambda-context
    https://pypi.org/project/aws-lambda-context/

目次

  1. 概要
  2. clone してプロジェクトを設定する
  3. npm から cross-env、npm-run-all、per-env をインストールする
  4. Python で使用するモジュールを pip でインストールし、requirements.txt を作成する
  5. リリース用フォルダ(stages/dev、stages/stg、stages/prod)を作成し .envrc と serverless.yml の custom セクション用 yaml ファイルを作成する
  6. Lambda Layer 用の layers/shared_package_layer を作成し serverless.yml を変更する
  7. services/image_service を作成し serverless.yml を変更する
  8. services/sample_service を作成し serverless.yml を変更する
  9. ユニットテストを作成する
  10. 続く。。。

手順

概要

  • deploy 先の環境用ディレクトリへ移動してから npm-scripts(内部で sls deploy を呼び出す)を実行することで deploy する。例えば開発環境(dev)へ deploy する場合には以下のコマンドを実行する。
> cd stages/dev
  ※direnv により環境変数 AWS_PROFILE、AWS_DEFAULT_REGION、STAGE が設定される。
> npm run deploy:all
  • CircleCI から deploy する場合には管理画面か .circleci/config.yml で環境変数 AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY、AWS_DEFAULT_REGION、STAGE を設定しておいて、以下のコマンドを実行する。
> NODE_ENV=ci npm run deploy:all
  • STAGE は開発環境(dev)、ステージング環境(stg)、本番環境(prod)の3つとする。
  • 開発環境(dev)、ステージング環境(stg)はローカルPC から、本番環境(prod)は CircleCI から deploy する。
  • ローカルPC から deploy する時には aws-vault exec $AWS_PROFILE -- bash -c "npx sls deploy -v" コマンドを、CircleCI から deploy する時には npx sls deploy -v コマンドを実行したいので、NODE_ENV の値を見て npm-scripts を切り替えられる per-env を使用する。
  • serverless.yml の provider.profile は記述しない。sls deploy--aws-profile オプションも使用しない。
  • Lambda Layer x 1、Service x 2(sample-service、image-service)を作成する。以下の目的である。
    • Lambda Layer を利用する複数の Service を deploy するサンプルを作成する。
    • OS 依存のバイナリがある Pillow を含む deploy をしても正常に動作するサンプルを作成する。

f:id:ksby:20200713234022p:plain:w450

  • Python で使用するモジュール(aws-lambda-powertools、Pillow)は全て Lambda Layer にインストールする。全ての Lambda 関数はこの Lambda Layer を使用する。
  • deploy は直接 sls deploy コマンドを実行するのではなく npm-scripts 経由で実行する。
  • git-bash で設定された環境変数を npm-scripts から呼び出された sls deploy で利用できるようにするために cross-env を使用する。
  • Lambda 関数の実装で aws-lambda-powertools を使用する。
  • ユニットテストも作成する。
  • aws-lambda-powertools を利用した実装でユニットテストを作成すると引数 context に必要な値がセットされたオブジェクトを渡す必要があるので、aws-lambda-context を使用する。
  • requirements.txt は Lambda Layer や Service 用のディレクトリの下ではなくプロジェクトのルートディレクトリ直下に作成する。

clone してプロジェクトを設定する

https://github.com/ksby/ksbysample-serverless-deploy の repository を clone して必要な設定を行います。

具体的な手順は IntelliJ IDEA+Node.js+npm+serverless framework+Python の組み合わせで開発環境を構築して AWS Lambda を作成してみる 参照。

  • .gitignore を https://github.com/ksby/ksbysample-serverless からコピーする。
  • Python の仮想環境を作成する。
  • Serverless Framework をインストールする。
    • npm init -y
    • npm install --save-dev serverless
  • serverless-python-requirements をインストールする。
    • npm install --save-dev serverless-python-requirements

npm から cross-env、npm-run-all、per-env をインストールする

npm で今回使用する cross-envnpm-run-allper-env のパッケージをインストールします。

cross-env は npm-scripts 内で環境変数を設定したり、git-bash に設定されている環境変数を npm-scripts に渡すために使用します。

npm-run-all は npm-scripts を sequential あるいは parallel に実行するために使用します。ただし run-p を使って service の deploy を parallel に実行しようと思ったのですがエラーが出てダメでした。。。

per-env は npm-scripts を実行する時に設定されている NODE_ENV(何も設定していなければ development になる)の値により実際に実行される npm-scripts を切り替えるために使用します。

  • npm install --save-dev cross-env
  • npm install --save-dev npm-run-all
  • npm install --save-dev per-env

f:id:ksby:20200712112110p:plain f:id:ksby:20200712112306p:plain f:id:ksby:20200712112401p:plain

Python で使用するモジュールを pip でインストールし、requirements.txt を作成する

実装に必要なモジュールとして aws-lambda-powertools、boto3、Pillow をインストールします。

  • pip install aws-lambda-powertools
  • pip install boto3
  • pip install Pillow

ユニットテストで使用するモジュールとして aws-lambda-context、moto をインストールします。

  • pip install aws-lambda-context
  • pip install moto

最後にプロジェクトのルートディレクトリ直下に requirements.txt を作成し、インストールしたモジュールを全て記述します。

aws-lambda-context==1.1.0
aws-lambda-powertools==1.0.1
boto3==1.14.20
moto==1.3.14
Pillow==7.2.0

リリース用フォルダ(stages/dev、stages/stg、stages/prod)を作成し .envrc と serverless.yml の custom セクション用 yaml ファイルを作成する

プロジェクトのルートディレクトリ直下に stages ディレクトリを作成し、その下に dev、stg、prod ディレクトリを作成します。

f:id:ksby:20200712160802p:plain:w450

ディレクトリの下に .envrc、custom-services.yml、custom-shared-package-layer.yml を作成します。

  • .envrc
    • direnv が参照するファイル。設定する環境変数を記述する。
  • custom-services.yml
    • services ディレクトリの下に作成する各サービスの serverless.yml の custom セクションに読み込ませるファイル。
    • 今回は内容は全て同じ。環境毎に custom セクションに読み込ませる値を変更できることを示すためにわざと分けて書いている。
  • custom-shared-package-layer.yml
    • layers/shared_package_layer の下の serverless.yml の custom セクションに読み込ませるファイル。
    • ローカルPC から deploy する開発環境(dev)、ステージング環境(stg)と、CircleCI から deploy する本番環境(prod)で設定が異なる。

dev ディレクトリの下に .envrc、custom-services.yml、custom-shared-package-layer.yml を作成し、以下の内容を記述します。

■.envrc

export AWS_PROFILE=<deployで使用するprofile名>
export AWS_DEFAULT_REGION=ap-northeast-1
export STAGE=dev

■custom-services.yml

queueName: "sample-queue-${env:STAGE}"
tableName: "sample-table-${env:STAGE}"
uploadBucketName: "ksbysample-upload-bucket-${env:STAGE}"
resizeBucketName: "ksbysample-resize-bucket-${env:STAGE}"

■custom-shared-package-layer.yml

pythonRequirements:
  dockerizePip: true
  fileName: ../../requirements.txt
  noDeploy:
    - aws-lambda-context
    - boto3
    - moto
  layer:
    name: "shared-package-layer-${env:STAGE}"
    description: 共通パッケージ用 Lambda Layer

stg ディレクトリの下に .envrc、custom-services.yml、custom-shared-package-layer.yml を作成し、以下の内容を記述します。

■.envrc

export AWS_PROFILE=<deployで使用するprofile名>
export AWS_DEFAULT_REGION=ap-northeast-1
export STAGE=stg

■custom-services.yml
※dev と同じ。

■custom-shared-package-layer.yml
※dev と同じ。

prod ディレクトリの下に .envrc、custom-services.yml、custom-shared-package-layer.yml を作成し、以下の内容を記述します。custom-shared-package-layer.yml の dev、stg ディレクトリ版との違いは CircleCI の deploy を記述する回で説明します。

■.envrc

export AWS_PROFILE=<deployで使用するprofile名>
export AWS_DEFAULT_REGION=ap-northeast-1
export STAGE=prod

■custom-services.yml
※dev と同じ。

■custom-shared-package-layer.yml

pythonRequirements:
  # dockerizePip: true
  fileName: ../../requirements.txt
  noDeploy:
    - aws-lambda-context
    - boto3
    - moto
  useStaticCache: true
  useDownloadCache: true
  cacheLocation: /root/project/.cache
  staticCacheMaxVersions: 3
  layer:
    name: "shared-package-layer-${env:STAGE}"
    description: 共通パッケージ用 Lambda Layer

Lambda Layer 用の layers/shared_package_layer を作成し serverless.yml を変更する

プロジェクトのルートディレクトリ直下に layers ディレクトリを作成し、git-bash から layers ディレクトリの下へ移動した後 npx sls create --template aws-python3 --path shared_package_layer を実行します。

f:id:ksby:20200712163134p:plain

handler.py は不要なので削除します。

serverless.yml には以下の内容を記述します。

service: shared-package-layer

plugins:
  - serverless-python-requirements

custom: ${file(../../stages/${env:STAGE}/custom-shared-package-layer.yml)}

provider:
  name: aws
  runtime: python3.8
  stage: ${env:STAGE}
  region: ${env:AWS_DEFAULT_REGION}

resources:
  Outputs:
    # 他の Stack から Lambda Layer を参照できるようにする
    # Value に記載している "PythonRequirementsLambdaLayer" はこの文字列固定である
    SharedPackageLayer:
      Value:
        Ref: PythonRequirementsLambdaLayer

services/image_service を作成し serverless.yml を変更する

プロジェクトのルートディレクトリ直下に services ディレクトリを作成します。

git-bash から services ディレクトリの下へ移動した後 npx sls create --template aws-python3 --path image_service を実行します。

f:id:ksby:20200712163951p:plain

handler.py → s3_handler.py にリネームした後、https://github.com/ksby/ksbysample-serverless-deploy/blob/master/services/image_service/s3_handler.py の内容を記述します(今回の本題ではないのでコードは載せません)。

serverless.yml には以下の内容を記述します。

service: image-service

custom: ${file(../../stages/${env:STAGE}/custom-services.yml)}

provider:
  name: aws
  runtime: python3.8
  stage: ${env:STAGE}
  region: ${env:AWS_DEFAULT_REGION}
  environment:
    # aws-lambda-powertools 用環境変数
    LOG_LEVEL: DEBUG
    POWERTOOLS_LOGGER_LOG_EVENT: false
    POWERTOOLS_METRICS_NAMESPACE: serverless-deploytest-project
    POWERTOOLS_SERVICE_NAME: image-service
  tracing:
    lambda: true

  iamRoleStatements:
    - Effect: Allow
      Action:
        - s3:GetObject
      Resource:
        - "arn:aws:s3:::${self:custom.uploadBucketName}/*"
    - Effect: Allow
      Action:
        - s3:PutObject
      Resource:
        - "arn:aws:s3:::${self:custom.resizeBucketName}/*"

functions:
  resize:
    handler: s3_handler.resize
    environment:
      RESIZE_BUCKET_NAME: ${self:custom.resizeBucketName}
    events:
      - s3: ${self:custom.uploadBucketName}
    layers:
      - ${cf:shared-package-layer-${env:STAGE}.SharedPackageLayer}

resources:
  Resources:
    KsbysampleResizeBucket:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ${self:custom.resizeBucketName}

services/sample_service を作成し serverless.yml を変更する

git-bash から services ディレクトリの下へ移動した後 npx sls create --template aws-python3 --path sample_service を実行します。

f:id:ksby:20200712193847p:plain

handler.py → apigw_handler.py にリネームした後、https://github.com/ksby/ksbysample-serverless-deploy/blob/master/services/sample_service/apigw_handler.py の内容を記述します。

sqs_handler.py を作成した後、https://github.com/ksby/ksbysample-serverless-deploy/blob/master/services/sample_service/sqs_handler.py の内容を記述します。

serverless.yml には以下の内容を記述します。

service: sample-service

custom: ${file(../../stages/${env:STAGE}/custom-services.yml)}

provider:
  name: aws
  runtime: python3.8
  stage: ${env:STAGE}
  region: ${env:AWS_DEFAULT_REGION}
  environment:
    # aws-lambda-powertools 用環境変数
    LOG_LEVEL: INFO
    POWERTOOLS_LOGGER_LOG_EVENT: false
    POWERTOOLS_METRICS_NAMESPACE: serverless-deploytest-project
    POWERTOOLS_SERVICE_NAME: sample-service
    # service 固有の設定
    QUEUE_URL: !Ref SampleQueue
    TABLE_NAME: ${self:custom.tableName}
  tracing:
    apiGateway: true
    lambda: true

  iamRoleStatements:
    - Effect: Allow
      Action:
        - sqs:*
      Resource:
        - Fn::GetAtt: [ SampleQueue, Arn ]
    - Effect: Allow
      Action:
        - dynamodb:Query
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource:
        - Fn::GetAtt: [ SampleTable, Arn ]

functions:
  hello:
    handler: apigw_handler.hello
    events:
      - http:
          path: hello
          method: get
          cors: true
    layers:
      - ${cf:shared-package-layer-${env:STAGE}.SharedPackageLayer}

  saveTable:
    handler: sqs_handler.save_table
    events:
      - sqs:
          arn:
            Fn::GetAtt: [ SampleQueue, Arn ]
    layers:
      - ${cf:shared-package-layer-${env:STAGE}.SharedPackageLayer}

resources:
  Resources:
    SampleQueue:
      Type: AWS::SQS::Queue
      Properties:
        QueueName: "${self:custom.queueName}"

    SampleTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: "${self:custom.tableName}"
        AttributeDefinitions:
          - AttributeName: timestamp
            AttributeType: S
        KeySchema:
          - AttributeName: timestamp
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1

ユニットテストを作成する

プロジェクトのルートディレクトリ直下に tests ディレクトリを作成し、その下に common、image_service、sample_service ディレクトリを作成します。最終的には以下のディレクトリ・ファイル構成になります。

f:id:ksby:20200712195957p:plain:w450

プロジェクトのルートディレクトリ直下に .envrc を作成し、以下の内容を記述します。

export LOG_LEVEL=ERROR
export POWERTOOLS_TRACE_DISABLED=true
  • ログの出力を抑制したいので LOG_LEVEL は ERROR に設定します。
  • aws-lambda-powertools の Tracer を使用しているとそのままではユニットテストが失敗するため、POWERTOOLS_TRACE_DISABLED=true を設定します。

__init__.py を tests、tests/image_service、tests/sample_service の下に作成します。

tests/common の下に test_utils.py を作成し、以下の内容を記述します。aws_lambda_context を使用して context の mock を作成するメソッドです。

from aws_lambda_context import LambdaContext


def mock_context():
    context = LambdaContext()
    context.function_name = 'test'
    context.function_version = 'test'
    context.invoked_function_arn = 'test'
    context.memory_limit_in_mb = 'test'
    context.aws_request_id = 'test'
    context.log_group_name = 'test'
    context.log_stream_name = 'test'
    return context

tests/common の下に aws_resource.py も作成し、以下の内容を記述します。setUp、tearDown で AWS リソースの mock を作成しますが、都度ユニットテストのクラスに記述するのは冗長になるのでこのファイルに必要なメソッドを記述するようにしました。

import os

import boto3

UPLOAD_BUCKET_NAME = 'ksbysample-upload-bucket'
RESIZE_BUCKET_NAME = 'ksbysample-resize-bucket'
QUEUE_NAME = 'sample-queue-test'
TABLE_NAME = 'sample-table-test'


def create_s3_bucket(self):
    s3_client = boto3.client('s3')
    s3_client.create_bucket(Bucket=UPLOAD_BUCKET_NAME)
    s3_client.create_bucket(Bucket=RESIZE_BUCKET_NAME)
    self._upload_bucket_name = UPLOAD_BUCKET_NAME
    self._resize_bucket_name = RESIZE_BUCKET_NAME


def create_sqs_queue(self):
    sqs_client = boto3.client('sqs')
    response = sqs_client.create_queue(QueueName=QUEUE_NAME)
    self._queue_url = response['QueueUrl']


def create_dynamodb_table(self):
    dynamodb_client = boto3.client('dynamodb')
    dynamodb_client.create_table(
        TableName=TABLE_NAME,
        AttributeDefinitions=[
            {
                "AttributeName": "timestamp", "AttributeType": "S"
            }
        ],
        KeySchema=[
            {
                "AttributeName": "timestamp", "KeyType": "HASH"
            }
        ],
        ProvisionedThroughput={"ReadCapacityUnits": 1, "WriteCapacityUnits": 1},
    )

    self._table_name = TABLE_NAME


def delete_s3_bucket(self):
    s3 = boto3.resource('s3')
    upload_bucket = s3.Bucket(UPLOAD_BUCKET_NAME)
    upload_bucket.objects.all().delete()
    upload_bucket.delete()
    resize_bucket = s3.Bucket(RESIZE_BUCKET_NAME)
    resize_bucket.objects.all().delete()
    resize_bucket.delete()


def delete_sqs_queue(self):
    with self.env:
        sqs_client = boto3.client('sqs')
        sqs_client.delete_queue(
            QueueUrl=os.environ['QUEUE_URL']
        )


def delete_dynamodb_table(self):
    with self.env:
        dynamodb_client = boto3.client('dynamodb')
        dynamodb_client.delete_table(TableName=os.environ['TABLE_NAME'])

tests/image_service の下に test_resize.py を作成し、以下の内容を記述します。

import json
import os
import unittest
from unittest.mock import patch

import boto3
from moto import mock_s3

from tests.common import aws_resource, test_utils


@mock_s3
class TestResizeService(unittest.TestCase):

    def setUp(self):
        aws_resource.create_s3_bucket(self)
        self.env = patch.dict('os.environ', {
            'UPLOAD_BUCKET_NAME': self._upload_bucket_name,
            'RESIZE_BUCKET_NAME': self._resize_bucket_name
        })

    def tearDown(self):
        aws_resource.delete_s3_bucket(self)

    def test_resize(self):
        with self.env:
            from services.image_service import s3_handler

            s3_client = boto3.client('s3')
            s3_client.upload_file('tests/image_service/sample.jpg',
                                  os.environ['UPLOAD_BUCKET_NAME'], 'sample.jpg')

            with open('tests/image_service/s3_event.json', 'r') as f:
                event = json.load(f)

            s3_handler.resize(event, test_utils.mock_context())

            thumb_object = s3_client.get_object(Bucket=os.environ['RESIZE_BUCKET_NAME'],
                                                Key='sample_thumb.jpg')
            self.assertEqual(thumb_object['ResponseMetadata']['HTTPStatusCode'], 200)
            self.assertGreater(int(thumb_object['ResponseMetadata']['HTTPHeaders']['content-length']), 0)

            # 生成されたサムネイル画像をダウンロードすることも出来る(実際に作成される)
            # s3_client.download_file(TestResizeService.RESIZE_BUCKET, 'sample_thumb.jpg',
            #                         'tests/sample_thumb.jpg')

tests/sample_service の下に test_apigw_handler.py を作成し、以下の内容を記述します。

import json
import os
import unittest
from unittest.mock import patch

import boto3
from moto import mock_sqs

from tests.common import aws_resource, test_utils


@mock_sqs
class TestApigwHandler(unittest.TestCase):
    def setUp(self):
        aws_resource.create_sqs_queue(self)
        self.env = patch.dict('os.environ', {
            'QUEUE_URL': self._queue_url,
        })

    def tearDown(self):
        aws_resource.delete_sqs_queue(self)

    def test_hello(self):
        with self.env:
            from services.sample_service import apigw_handler

            sqs_resource = boto3.resource('sqs')
            queue = sqs_resource.Queue(os.environ['QUEUE_URL'])

            with open('tests/sample_service/apigw_event.json', encoding='utf-8', mode='r') as f:
                apigw_event = json.load(f)

            response = apigw_handler.hello(apigw_event, test_utils.mock_context())
            self.assertEqual(response['statusCode'], 200)

            messages = queue.receive_messages(QueueUrl=os.environ['QUEUE_URL'])
            self.assertEqual(len(messages), 1)
            self.assertEqual(messages[0].body, "これはテストです")

tests/sample_service の下に test_sqs_handler.py を作成し、以下の内容を記述します。

import json
import os
import unittest
from unittest.mock import patch

import boto3
from moto import mock_sqs, mock_dynamodb2

from tests.common import aws_resource, test_utils


@mock_sqs
@mock_dynamodb2
class TestSqsHandler(unittest.TestCase):
    def setUp(self):
        aws_resource.create_sqs_queue(self)
        aws_resource.create_dynamodb_table(self)
        self.env = patch.dict('os.environ', {
            'QUEUE_URL': self._queue_url,
            'TABLE_NAME': self._table_name,
        })

    def tearDown(self):
        aws_resource.delete_sqs_queue(self)
        aws_resource.delete_dynamodb_table(self)

    def test_save_table(self):
        with self.env:
            from services.sample_service import sqs_handler

            dynamodb_sample_table_tbl = boto3.resource('dynamodb').Table(os.environ['TABLE_NAME'])

            with open('tests/sample_service/sqs_event.json', encoding='utf-8', mode='r') as f:
                sqs_event = json.load(f)

            sqs_handler.save_table(sqs_event, test_utils.mock_context())

            items = dynamodb_sample_table_tbl.scan()['Items']
            self.assertEqual(len(items), 1)
            self.assertEqual(items[0]['message'], "これはテストです")

python -m unittest -v を実行してユニットテストが成功することを確認します。.envrc でユニットテストに必要な環境変数を設定しているので git-bash 上で実行します。

f:id:ksby:20200712201038p:plain

IntelliJ IDEA 上でもユニットテストが成功するようにします。メインメニューから「Run」-「Edit Configurations...」を選択して「Run/Debug Configurations」ダイアログを表示した後、「Templates」-「Python tests」-「Unittests」を選択して以下の画像の赤枠の部分を設定します。

f:id:ksby:20200712201942p:plain

Project Tool Window 上で tests ディレクトリを選択してコンテキストメニューを表示してから「Run 'Unittests in tests'」を選択してユニットテストを実行し、成功することを確認します。

f:id:ksby:20200712202132p:plain

続く。。。

次回は npm-scripts を定義した後、ローカルPC から開発環境(dev)、ステージング環境(stg)を deploy して動作確認します。

履歴

2020/07/19
初版発行。