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回で記述します。
参照したサイト・書籍
AWS - Credentials
https://www.serverless.com/framework/docs/providers/aws/guide/credentials/cross-env
https://www.npmjs.com/package/cross-envnpm-run-all
https://www.npmjs.com/package/npm-run-allaws-lambda-context
https://pypi.org/project/aws-lambda-context/
目次
- 概要
- clone してプロジェクトを設定する
- npm から cross-env、npm-run-all、per-env をインストールする
- Python で使用するモジュールを pip でインストールし、requirements.txt を作成する
- リリース用フォルダ(stages/dev、stages/stg、stages/prod)を作成し .envrc と serverless.yml の custom セクション用 yaml ファイルを作成する
- Lambda Layer 用の layers/shared_package_layer を作成し serverless.yml を変更する
- services/image_service を作成し serverless.yml を変更する
- services/sample_service を作成し serverless.yml を変更する
- ユニットテストを作成する
- 続く。。。
手順
概要
- 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 をしても正常に動作するサンプルを作成する。
- 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-env、npm-run-all、per-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
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 ディレクトリを作成します。
各ディレクトリの下に .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
を実行します。
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
を実行します。
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
を実行します。
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 ディレクトリを作成します。最終的には以下のディレクトリ・ファイル構成になります。
プロジェクトのルートディレクトリ直下に .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 上で実行します。
IntelliJ IDEA 上でもユニットテストが成功するようにします。メインメニューから「Run」-「Edit Configurations...」を選択して「Run/Debug Configurations」ダイアログを表示した後、「Templates」-「Python tests」-「Unittests」を選択して以下の画像の赤枠の部分を設定します。
Project Tool Window 上で tests ディレクトリを選択してコンテキストメニューを表示してから「Run 'Unittests in tests'」を選択してユニットテストを実行し、成功することを確認します。
続く。。。
次回は npm-scripts を定義した後、ローカルPC から開発環境(dev)、ステージング環境(stg)を deploy して動作確認します。
履歴
2020/07/19
初版発行。