これは AWS CDK Advent Calendar 2021 4日目の記事です。
こんにちは。米田 (@komeda-shinji)です。インフラエンジニアをしています。
みなさん、AWS CLI を使っていますか。AWS Lambda を手軽にシェルスクリプトで書けたらなあ、と思うことはありませんか。
その夢、叶います。
この記事では @aws-cdk/lambda-layer-awscli モジュールをつかって、シェルスクリプトで Lambda 関数を書いてデプロイする方法を説明します。
はじめに
先日 lambda-layer-awscli を使おうとしたときに CDK に @aws-cdk/lambda-layer-awscli モジュールがあることに気が付きました。
Lambda Layer は、ライブラリのような追加のコードやデータを含めることができるしくみです。
AWS Lambda には、カスタムランタイムという機能があり、Lambda Layer によるカスタムランタイムを作成することで、標準で用意されているランタイムの言語以外でも、Lambda 関数を書くことができるようになります。
そして lambda-layer-awscli は、Lambda 関数のランタイムにAWS CLI コマンドをバンドルして、シェルスクリプトから使えるようにしたものです。
つまり Lambda 関数をシェルスクリプトで書くことができるんです。
@aws-cdk/lambda-layer-awscli モジュールのバージョン履歴を見ると、最初に登場したのは 1.81.0 で、筆者が存在に気がついていなかっただけで、随分古くから存在していたようです。
モジュールのコードを覗くと Lambda Layer の内容が lib/layer.zip にあるので、中身を確認できます。
AWS CLI を利用したシェルスクリプトを書くときにおおよそ必要なコマンドが用意されていることがわかります。
以前は、sam.CfnApplication
で AWS Serverless Application Repository(SAR) にある lambda-layer-awscli の Lambda レイヤー ARN を指定してデプロイしていましたが、CDK のモジュールになったおかげでずいぶん扱いやすくなりました。
ちなみに SAR にある Lambda Layer の内容を確認してみましたが、AWS CLI は 1.18.142 と、少し前のバージョンでした。AWS CDK 1.134.0 の @aws-cdk/lambda-layer-awscli モジュールでは 1.18.198 が利用可能です。
lambda-layer-awscli モジュールの使いかた
CDK での lambda-layer-awscli モジュールの 説明では、次のようにCDKでのコードをごくあっさりと説明してあるだけです。
AWS Lambda Layer with AWS CLI
This module exports a single class called AwsCliLayer which is a lambda.Layer that bundles the AWS CLI.
Usage:
// AwsCliLayer bundles the AWS CLI in a lambda layer import { AwsCliLayer } from '@aws-cdk/lambda-layer-awscli'; declare const fn: lambda.Function; fn.addLayers(new AwsCliLayer(this, 'AwsCliLayer'));
The CLI will be installed under /opt/awscli/aws.
Lambda Layer に詳しい人はこのサンプルだけでも分かるのかもしれませんが、説明が簡潔すぎますね。
これでは、はじめて使う人は、どのようにデプロイすればよいか、戸惑うでしょう。
lambda-layer-awscli の README.md ではもう少し具体的なコードがありますが、これも Lambda Layer 部分のデプロイしか示されていません。
Basic Usage
import { App, CfnOutput, Construct, Stack, StackProps } from '@aws-cdk/core'; import * as layer from '@aws-cdk/lambda-layer-awscli'; export class MyStack extends Stack { constructor(scope: Construct, id: string, props: StackProps = {}) { super(scope, id, props); const awscliLayer = new layer.AwsCliLayer(this, 'AwsCliLayer'); new CfnOutput(this, 'LayerVersionArn', { value: awscliLayer.layerVersionArn > }) } } const devEnv = { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION, }; const app = new App(); new MyStack(app, 'awscli-layer-stack', { env: devEnv }); app.synth();
じつは aws-lambda-layer-awscli には、以前はもっとわかりやすいサンプルが含まれていたのですが、[PR#85](https://github.com/aws-samples/aws-lambda-layer-awscli/pull/85) で大きくリファクタリングされたときにサンプルが削除されてしまいました。
削除されたサンプルファイルは PR#85 の[前のコミット](https://github.com/aws-samples/aws-lambda-layer-awscli/tree/5afd3196f15d1a6c93cf64933292743fe27470f1)を参照することで入手できます。
古いサンプルではこのようなデプロイ方法になっています。
**[samples/aws-version/cdk/lib/cdk-stack.ts](https://github.com/aws-samples/aws-lambda-layer-awscli/blob/5afd3196f15d1a6c93cf64933292743fe27470f1/samples/aws-version/cdk/lib/cdk-stack.ts)**
```ts
import cdk = require('@aws-cdk/core');
import sam = require('@aws-cdk/aws-sam');
import lambda = require('@aws-cdk/aws-lambda');
const AWSCLI_LAYER_APP_ARN = 'arn:aws:serverlessrepo:us-east-1:903779448426:applications/lambda-layer-awscli';
const AWSCLI_VERSION = '1.16.238+1';
/**
* An AWS Lambda layer and sample function that includes the AWS CLI.
*
* @see https://github.com/aws-samples/aws-lambda-layer-awscli
*/
export class CdkStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const resource = new sam.CfnApplication(this, 'awscliLayer', {
location: {
applicationId: AWSCLI_LAYER_APP_ARN,
semanticVersion: AWSCLI_VERSION
}
})
const layerVersionArn = resource.getAtt('Outputs.LayerVersionArn').toString()
const func = new lambda.Function(this, 'AwsVersionFunc', {
code: new lambda.AssetCode('../func.d'),
handler: 'main',
runtime: lambda.Runtime.PROVIDED,
memorySize: 512,
})
func.addLayers(
lambda.LayerVersion.fromLayerVersionArn(this, 'LayerVersion', layerVersionArn)
)
new cdk.CfnOutput(this, 'LayerVersionArn', {
value: layerVersionArn,
})
new cdk.CfnOutput(this, 'FuncArn', {
value: func.functionArn,
})
}
}
SAR にある Lambda Layer を sam.CfnApplication
を利用して Lambda 関数をデプロイしています。
いまは @aws-cdk/lambda-layer-awscli モジュールをつかうことでもっと簡潔に書くことができます。
@aws-cdk/lambda-layer-awscliを使って書き直した例
import cdk = require('@aws-cdk/core');
import lambda = require('@aws-cdk/aws-lambda');
import layer = require('@aws-cdk/lambda-layer-awscli');
/**
* An AWS Lambda layer and sample function that includes the AWS CLI.
*
* @see https://github.com/aws-samples/aws-lambda-layer-awscli
*/
export class CdkStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const awscliLayer = new layer.AwsCliLayer(this, 'awscliLayer');
const func = new lambda.Function(this, 'AwsVersionFunc', {
code: new lambda.AssetCode('../func.d'),
handler: 'main',
runtime: lambda.Runtime.PROVIDED,
memorySize: 512,
})
func.addLayers(awscliLayer)
new cdk.CfnOutput(this, 'LayerVersionArn', {
value: awscliLayer.layerVersionArn,
})
new cdk.CfnOutput(this, 'FuncArn', {
value: func.functionArn,
})
}
}
AWSCLI_LAYER_APP_ARN や AWSCLI_VERSION の指定も不要で、layer.AwsCliLayer()
で Lambda Layer を作成できるようになっています。
Lambda 関数の本体であるシェルスクリプトは handler: 'main'
で指定している名前にしたがって main.sh
というファイル名で、lambda.AssetCode()
で指定した func.d
ディレクトに置いておきます。
bootstrap ファイル
lambda-layer-awscli はカスタムランタイムとして実現されているため、デプロイパッケージには bootstrap という名前の実行ファイルを含めておく必要があります。
bootstrap は Lambda 関数の初期化タスクと、関数が呼び出されたときの処理タスクを受け持っています。詳しい説明はAWS Lambda のカスタムランタイムを参照してください。
Lambda 関数本体となるシェルスクリプトを起動してくれる、とっても大切なファイルなのですが、残念ながら bootstrap のサンプルも、現在は aws-lambda-layer-awscli のリポジトリからは削除されてしまっています。リファクタリングされる前の[コミット]
(https://github.com/aws-samples/aws-lambda-layer-awscli/tree/5afd3196f15d1a6c93cf64933292743fe27470f1)を参照してファイルを入手できます。
- [bootstrap]
(https://github.com/aws-samples/aws-lambda-layer-awscli/blob/5afd3196f15d1a6c93cf64933292743fe27470f1/bootstrap) - [main.sh]
(https://github.com/aws-samples/aws-lambda-layer-awscli/blob/5afd3196f15d1a6c93cf64933292743fe27470f1/main.sh)
また AWS Lambda デベロッパーガイドの「チュートリアル - カスタムランタイムの公開」にも bootstrap のサンプルがあります。ただし、このドキュメントにあるサンプルはそのままでは実行時にエラーが出るため少し修正する必要があります。
修正済み bootstrap はこのようになります。
bootstrap
#!/bin/sh
set -euo pipefail
# Initialization - load function handler
#source $LAMBDA_TASK_ROOT/"$(echo $_HANDLER | cut -d. -f1).sh"
# Processing
while true
do
HEADERS="$(mktemp)"
# Get an event. The HTTP request will block until one is received
EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
# Extract request ID by scraping response headers received above
REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
# Run the handler function from the script
RESPONSE=$(./$(echo "$_HANDLER" | cut -d. -f2).sh "$EVENT_DATA")
# Send the response
curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response" -d "$RESPONSE"
done
修正点は次のとおりです。
-
RESPONSE=$(...)
で実行するシェルスクリプトは、パスの指定を./
か$LAMBDA_TASK_ROOT/
で始める必要があります。 -
スクリプトのサフィックスをつけるかどうかはどちらでも良いのですが、aws-lambda-layer-awscli のコードに習って
.sh
を付与しておくことにしています。
diff -u bootstrap bootstrap-fixed
--- bootstrap 2021-11-30 18:50:23.000000000 +0900
+++ bootstrap 2021-11-30 18:51:10.000000000 +0900
@@ -3,7 +3,7 @@
set -euo pipefail
# Initialization - load function handler
-source $LAMBDA_TASK_ROOT/"$(echo $_HANDLER | cut -d. -f1).sh"
+#source $LAMBDA_TASK_ROOT/"$(echo $_HANDLER | cut -d. -f1).sh"
# Processing
while true
@@ -16,7 +16,7 @@
REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
# Run the handler function from the script
- RESPONSE=$($(echo "$_HANDLER" | cut -d. -f2) "$EVENT_DATA")
+ RESPONSE=$(./$(echo "$_HANDLER" | cut -d. -f2).sh "$EVENT_DATA")
# Send the response
curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response" -d "$RESPONSE"
サンプルコード
サンプルを挙げておきます。このコードはリファクタリング前の aws-lambda-layer-awscli にあったサンプルを少し手直ししたものです。
bin/awscli.ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';
import { AwscliStack } from '../lib/awscli-stack';
const app = new cdk.App();
new AwscliStack(app, 'AwscliStack', {});
lib/awscli.ts
import * as cdk from '@aws-cdk/core';
import * as layer from '@aws-cdk/lambda-layer-awscli';
import * as lambda from '@aws-cdk/aws-lambda';
export class AwscliStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const awscliLayer = new layer.AwsCliLayer(this, 'AwsCliLayer');
const startFunc = new lambda.Function(this, 'FunctionCDKAdventCalendar2021', {
functionName: 'lambda-function-CDK-Advent-Calendar-2021',
code: new lambda.AssetCode('./func.d'),
handler: 'main',
runtime: lambda.Runtime.PROVIDED,
memorySize: 512,
timeout: cdk.Duration.seconds(900),
})
startFunc.addLayers(awscliLayer)
}
}
func.d/bootstrap
#!/bin/sh
set -euo pipefail
# Initialization - load function handler
#source $LAMBDA_TASK_ROOT/"$(echo $_HANDLER | cut -d. -f1).sh"
export PATH=$PATH:/opt
# Processing
while true
do
HEADERS="$(mktemp)"
# Get an event
EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
LAMBDA_RUNTIME_INVOKED_FUNCTION_ARN=$(grep -Fi Lambda-Runtime-Invoked-Function-Arn "$HEADERS" | tr -d '[:space:]' | cut -d: -f2-)
# AWS_ACCOUNT_ID from env variables
export LAMBDA_RUNTIME_INVOKED_FUNCTION_ARN AWS_ACCOUNT_ID
#cat $HEADERS
# Execute the handler function from the script
RESPONSE=$(./$(echo "$_HANDLER" | cut -d. -f2).sh "$EVENT_DATA")
echo "=========[RESPONSE]======="
echo "$RESPONSE"
echo "=========[/RESPONSE]======="
# Send the response
curl -s -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response" -d "$RESPONSE"
done
func.d/main.sh
#!/bin/bash
export PATH=$PATH:/opt/awscli
result=$(aws --version 2>&1)
cat << EOF
{"body": "$result", "headers": {"content-type": "text/plain"}, "statusCode": 200}
EOF
exit 0
サンプルコードをデプロイ
cdk コマンドで雛形をつくります。
$ mkdir awscli
$ cd awscli
$ cdk init -l typescript
今回使用する @aws-cdk/lambda-layer-awscli をインストールします。
$ npm install @aws-cdk/lambda-layer-awscli
ディレクトリ配置はこのようになります。
さきほどのサンプルコードを配置してください。
.
└── awscli
├── bin
│ └── awscli.ts
├── lib
│ └── awscli.ts
└── func.d
├── bootstrap
└── main.sh
func.d に置くファイルには実行権限を付与しておきます。
$ chmod a+x func.d/{bootstrap,main.sh}
ビルドしてデプロイします。
$ npm run build
$ cdk synth
$ cdk deploy LambdaLayerAwscliStack
テストしてみましょう。
$ aws lambda invoke --function-name "lambda-function-CDK-Advent-Calendar-2021" --payload '{}' /dev/stdout
{"body": "aws-cli/1.18.198 Python/2.7.18 Linux/4.14.246-198.474.amzn2.x86_64 botocore/1.19.38", "headers": {"content-type": "text/plain"}, "statusCode": 200}{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
Lambda 関数と AWSCLI コマンドの出力が続けて出ていますが、前半の JSON 部分が Lambda 関数の出力です。body
の部分に aws --version
の実行結果が出ていて、シェルスクリプトが Lambda 関数として実行されているのがわかります。
すこし実用的なサンプル
定刻になれば EC2 インスタンスを起動・停止するスクリプトです。
Name タグが cdk-dev* にマッチする EC2 インスタンスが対象になりますが、AWS Systems Manager Change Calendar を見て、休日のスケジュールが入っていれば、インスタンスは起動しません。
スケジュールによる起動・停止はAWS Instance Schedulerでできるのですが、休日カレンダー機能がないため、休日は起動しないでおきたいといった場合には Instance Scheduler では対応できません。
というわけで、このようなスクリプトを書いて代用します。
ec2-up.d/main.sh
#!/bin/bash
export PATH=$PATH:/opt/awscli
describe_instances()
{
aws ec2 describe-instances \
--query 'Reservations[].Instances[?State.Name==`stopped`].[InstanceId]' \
--filters "Name=tag:$1,Values=$2" \
--output text
}
State=$(aws ssm get-calendar-state --calendar-names holidays --query "State" --output text)
case "$State" in
OPEN) ;;
CLOSED) exit 0;;
esac
instances=$(describe_instances "Name" "cdk-dev*")
if [ -n "$instances" ]; then
aws ec2 start-instances --instance-ids $instances
fi
exit 0
ec2-down.d/main.sh
#!/bin/bash
export PATH=$PATH:/opt/awscli
describe_instances()
{
aws ec2 describe-instances \
--query 'Reservations[].Instances[?State.Name!=`stopped` && State.Name!=`terminated`].[InstanceId]' \
--filters "Name=tag:$1,Values=$2" \
--output text
}
instances=$(describe_instances "Name" "cdk-dev*")
if [ -n "$instances" ]; then
aws ec2 stop-instances --instance-ids $instances
fi
exit 0
lib/awscli.ts
import cdk = require('@aws-cdk/core');
import layer = require('@aws-cdk/lambda-layer-awscli');
import iam = require('@aws-cdk/aws-iam');
import lambda = require('@aws-cdk/aws-lambda');
import { Rule, Schedule } from '@aws-cdk/aws-events';
import targets = require('@aws-cdk/aws-events-targets');
export class AwscliStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const region = cdk.Stack.of(this).region;
const awscliLayer = new layer.AwsCliLayer(this, 'AwsCliLayer');
const lambdaFunctionPolicy = new iam.CfnManagedPolicy(this, 'lambdaFunctionPolicy', {
managedPolicyName: 'lambda-ec2-up-down-func',
description: 'lambda-ec2-up-down-func',
path: "/",
policyDocument: {
Version: "2012-10-17",
Statement: [
{
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:StartInstances",
"ec2:StopInstances",
"ssm:GetCalendarState",
],
"Resource": "*"
}
]
}
});
const lambdaFunctionRole = new iam.CfnRole(this, 'lambdaEc2UpDownFunctionRole', {
roleName: 'lambda-ec2-up-down-func',
description: 'lambda-ec2-up-down-func',
assumeRolePolicyDocument: {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
},
managedPolicyArns: [
'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole',
lambdaFunctionPolicy.ref
],
path: "/",
});
const lambdaRole = iam.Role.fromRoleArn(this, 'LambdaEc2UpDownRole', lambdaFunctionRole.attrArn)
const startFunc = new lambda.Function(this, 'Ec2UpFunc', {
functionName: 'ec2-up',
code: new lambda.AssetCode('./ec2-up.d'),
handler: 'main',
runtime: lambda.Runtime.PROVIDED,
memorySize: 512,
timeout: cdk.Duration.seconds(900),
role: lambdaRole,
})
startFunc.addLayers(awscliLayer)
new Rule(this, 'UpScheduleRule', {
schedule: Schedule.cron({ minute: '0', hour: `${10 - 9}`, weekDay: 'MON-FRI' }),
targets: [new targets.LambdaFunction(startFunc, {})],
});
const stopFunc = new lambda.Function(this, 'Ec2DownFunc', {
functionName: 'ec2-down',
code: new lambda.AssetCode('./ec2-down.d'),
handler: 'main',
runtime: lambda.Runtime.PROVIDED,
memorySize: 512,
timeout: cdk.Duration.seconds(900),
role: lambdaRole,
})
stopFunc.addLayers(awscliLayer)
new Rule(this, 'DownScheduleRule', {
schedule: Schedule.cron({ minute: '0', hour: `${19 - 9}`, weekDay: 'MON-FRI' }),
targets: [new targets.LambdaFunction(stopFunc, {})],
});
}
}
おわりに
AWS CLI に慣れていると、ちょっとした定形作業を書くときに、シェルスクリプトで Lambda 関数が書けると AWS CLI の絶大な力が発揮できます。
シェルスクリプトなので、CPU やメモリの効率は多少犠牲になりますが、CDK と AWS CLI の組み合わせで、ささっとスクリプトを書いて Lambda 関数として動かせるのはたいへん便利です。
どうぞお楽しみください。
参考
AWS Lambda デベロッパーガイド Lambda 関数でのレイヤーの使用