はじめに
テスト時にデータ作ったからバッチのLambdaを実行して欲しいと依頼されるのってどこでもありますよね。
IAMユーザーを発行しても良いけど、一時的に、そのためだけに、って思うと微妙ですよね。
ということで、簡単な仕組みを用意したので記事に残しておきます。
バッチ実行イメージ
画面イメージ
対象のバッチを選択して、実行するためだけの簡単な画面を用意します。
AWS構成
CloudFrontからLambdaの関数URLを呼び出し、そのLambdaがEventBridgeが呼び出したかのように、バッチのLambdaを非同期で呼び出します。
なお、CloudFrontにはWAFでIP制限を行い、CloudFrontからS3とLambdaの関数URLはOACでアクセスを制限します。
CloudFront設定
オリジン設定
細かいところは書きませんが基本は以下のみです
- S3とLambdaの関数URLをオリジンに追加
- ポイントとしてはS3とLambdaにOACを設定して各ポリシーでアクセス許可を設定
S3のバケットポリシー追加
{
"Version": "2008-10-17",
"Id": "PolicyForCloudFrontPrivateContent",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::<YOUR_BUCKET_NAME>/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::<YOUR_AWS_ACCOUNT_ID>:distribution/<YOUR_DISTRIBUTION_ID>"
}
}
}
]
}
Lambdaのリソースベースのポリシー追加(Lambda→設定→アクセス権限から設定でもOK)
aws lambda add-permission \
--statement-id "AllowCloudFrontServicePrincipal" \
--action "lambda:InvokeFunctionUrl" \
--principal "cloudfront.amazonaws.com" \
--source-arn "arn:aws:cloudfront::<YOUR_AWS_ACCOUNT_ID>:distribution/<YOUR_DISTRIBUTION_ID>" \
--region "ap-northeast-1" \
--function-name <YOUR_FUNCTION_NAME>
ビヘイビア設定
LambdaをAPIとしてパス定義して、それ以外のデフォルトをS3に設定しておきます。
(パスは独自にしているので塗りつぶしてあります。
S3に設定しているレスポンスヘッダーのポリシーは、ブラウザキャッシュをして欲しくないので以下の設定入れています。
Lambda周り
設定
- LambdaのIAMロールにLambda実行権限の追加
- 環境変数に許可するLambdaの先頭文字列を設定
実装
受け付けた情報で、基本はそのままLambdaを非同期実行するための仕組みです。
内部の人しか使わないとはいえ、許可するLambda名の先頭文字を環境変数に設定しておくことで、変なLambdaを呼び出さないようにしています。
(プロジェクトでLambdaの命名規則がちゃんとしていれば、そのまま使えるはずです
const { LambdaClient, InvokeCommand } = require('@aws-sdk/client-lambda')
// 環境変数から許可する Lambda の先頭一致パターン(空文字は除外)
const allowedPrefixes = [process.env.ALLOWED_PREFIX1, process.env.ALLOWED_PREFIX2, process.env.ALLOWED_PREFIX3].filter(p => p)
const client = new LambdaClient({ region: process.env.AWS_REGION })
// プロキシ Lambda のハンドラー
module.exports.handler = async (event) => {
// Function URL 経由の場合、event.body に JSON 文字列が格納されるためパース
let body = event
if (event.body) {
try {
body = JSON.parse(event.body)
} catch (error) {
return {
statusCode: 400,
body: 'JSON のパースに失敗しました'
}
}
}
// リクエストボディから lambdaName, alias, parameters を取得
const { lambdaName, alias, parameters } = body
if (!lambdaName || parameters === undefined) {
return {
statusCode: 400,
body: 'lambdaName と parameters は必須です'
}
}
// 指定された lambdaName が許可されたプレフィックスのいずれかで始まっているかチェック
const isAllowed = allowedPrefixes.some(prefix => lambdaName.startsWith(prefix))
if (!isAllowed) {
return {
statusCode: 403,
body: '指定された Lambda の呼び出しは許可されていません'
}
}
try {
// エイリアスが指定されていれば Qualifier を追加
const invokeParams = {
FunctionName: lambdaName,
InvocationType: 'Event',
Payload: JSON.stringify(parameters),
...(alias ? { Qualifier: alias } : {})
}
const command = new InvokeCommand(invokeParams)
const response = await client.send(command)
return {
statusCode: 200,
body: JSON.stringify({
message: 'Lambda を非同期実行しました',
response: response
})
}
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({
message: 'Lambda の実行中にエラーが発生しました',
error: error.message
})
}
}
}
S3コンテンツ
バッチ設定準備
HTML上から選択できる情報をJSONファイルで用意しておきます。
これを画面表示時にロードして、選択肢として設定することになります。
基本的に項目名のままとなりますが、parameters
に関してはEventBridgeから渡されるPayloadを確認して設定する必要があります。
[
{
"displayName": "バッチ実行テスト用(Hello World)",
"description": "Hello World を出力するサンプルバッチです。\nこのバッチはサンプルとして利用できます。",
"lambdaName": "test-hoge-hello",
"alias": "",
"parameters": {
"jobDefinition": "example-job-def",
"jobQueue": "example-job-queue",
"containerOverrides": {
"command": ["echo", "Hello World"]
}
}
}
]
HTML実装
HTMLは簡単なものを作成しています。
注意点としてはCloudFront経由で関数URLのGETを呼び出す際には特に何も考えなくて良いのですが、POSTで通信する際は、SHA-256でハッシュ値を計算してx-amz-content-sha256
ヘッダーに設定する必要がある点に注意してください。
AWS公式ドキュメントにはCloudFront経由でLambda関数URL(OAC設定)で呼び出す際に、以下の注意事項が記載されています。
詳しくは↓こちらを読んでもらえればと。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>バッチ実行テスト</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background: #f5f5f5;
}
header {
background: #1976d2;
color: #fff;
padding: 1rem;
text-align: center;
font-size: 1.5rem;
}
.container {
padding: 2rem;
max-width: 800px;
margin: 2rem auto 0;
background: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
select, textarea, button {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
box-sizing: border-box;
}
button {
background: #1976d2;
color: #fff;
border: none;
cursor: pointer;
}
button:hover {
background: #125ea0;
}
#result {
margin-top: 1rem;
padding: 1rem;
border: 1px solid #ddd;
background: #fafafa;
white-space: pre-wrap;
}
/* バッチ説明表示用。改行を反映するために pre-wrap を指定 */
#batchDescription {
padding: 0.5rem;
background: #eef;
border: 1px solid #ccd;
margin-top: 0.5rem;
font-size: 0.9rem;
color: #333;
white-space: pre-wrap;
}
</style>
</head>
<body>
<header>バッチ実行テスト</header>
<div class="container">
<div class="form-group">
<label for="batchSelect">バッチ名</label>
<select id="batchSelect">
<option value="">未選択</option>
</select>
</div>
<!-- バッチの説明を表示する領域 -->
<div class="form-group">
<label for="batchDescription">バッチ説明</label>
<div id="batchDescription">バッチを選択してください</div>
</div>
<div class="form-group">
<label for="jsonInput">パラメータ (JSON)</label>
<textarea id="jsonInput" rows="10" placeholder="バッチ選択時に初期値が表示されます" disabled></textarea>
</div>
<div class="form-group">
<button id="executeBtn">バッチ実行</button>
</div>
<div id="result"></div>
</div>
<script>
const batchSelect = document.getElementById('batchSelect')
const jsonInput = document.getElementById('jsonInput')
const executeBtn = document.getElementById('executeBtn')
const resultDiv = document.getElementById('result')
const batchDescriptionDiv = document.getElementById('batchDescription')
let batchData = []
// batches.json を読み込み、プルダウンに選択肢を追加
fetch('batches.json')
.then(response => response.json())
.then(data => {
batchData = data
data.forEach((item, index) => {
const option = document.createElement('option')
option.value = index
option.textContent = item.displayName
batchSelect.appendChild(option)
})
})
.catch(error => {
console.error('batches.json 読込エラー:', error)
})
// プルダウン選択時:選択されたバッチの JSON パラメータと説明を表示、テキストエリアを有効化
batchSelect.addEventListener('change', () => {
const idx = batchSelect.value
if (idx === "") {
jsonInput.value = ""
jsonInput.disabled = true
batchDescriptionDiv.textContent = "バッチを選択してください"
} else {
const selectedBatch = batchData[idx]
jsonInput.value = JSON.stringify(selectedBatch.parameters, null, 2)
jsonInput.disabled = false
batchDescriptionDiv.textContent = selectedBatch.description || ""
}
})
// SHA-256 ハッシュ計算用のヘルパー関数
async function computeSHA256(message) {
const encoder = new TextEncoder()
const data = encoder.encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
return hashHex
}
// バッチ実行ボタン押下時:API (/api/batch-exec) を呼び出す
executeBtn.addEventListener('click', async () => {
if (batchSelect.value === "") {
alert("バッチ名を選択してください")
return
}
resultDiv.textContent = "実行中..."
const selectedBatch = batchData[batchSelect.value]
// payload の作成。alias も含める
const payload = {
lambdaName: selectedBatch.lambdaName,
alias: selectedBatch.alias, // エイリアスを送信
parameters: (() => {
try {
return JSON.parse(jsonInput.value)
} catch (e) {
return {}
}
})()
}
const payloadString = JSON.stringify(payload)
// ペイロードの SHA-256 ハッシュ値を計算
const hashHex = await computeSHA256(payloadString)
fetch('/api/batch-exec', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-amz-content-sha256': hashHex
},
body: payloadString
})
.then(response => {
const status = response.status
return response.text().then(text => ({ status, text }))
})
.then(({status, text}) => {
resultDiv.textContent = "HTTPステータスコード: " + status + "\nレスポンス内容:\n" + text
})
.catch(error => {
resultDiv.textContent = "エラー発生: " + error
})
})
</script>
</body>
</html>
最後に
実際には、これらの設定はCloudFormationで管理しているので、数か月前の記憶を頼りに書いてみたのですが少し間違った内容になっているかもしれません。
(だいたい、やっていることは伝わればOKかなというザックリな気持ちで書いてあります...