AWSから請求書が来て、API Gateway が有料サービスであったことに気づきました。
これまでのIPアドレスを取得する方法は、2つとも有料のAPI Gateway を使う方法だったので、それを使わない方法を考えます。
- Raspberry Pi でグローバルIPを取得し、AWS Lambda に送信する手順でルータのIPアドレスを自動で取得することに成功しました。
- この方法は ラズパイからのIPアドレス送信を5分おきに行いlambda関数を動かしていないと、IPアドレスを忘れてしまう欠点があります。
- 対策として、IPアドレスをDynamoDBに保存して、適時、呼び出す方法を考えました。
- しかし、なぜか1秒おきだったり、1分おきだったりまちまちなのですが、ログが吐き出される現象があります。どうも、API Gatewayが悪さしているようでした。
- 設定などを何度も見直しましたが、改善することはなく、結局はAPI Gateway を削除するしか、吐き出しを止める方法がありませんでした。
- この現象により、約10日間で15,649 件のRequests が発生し、$0.54 が請求されることになりました。
専門知識がないので、生成AIが勧めてきたAPI Gatewayを使って構築したのが失敗でした。
API Gatewayを使わず、Lambda関数URL を使う
グローバルIPアドレスの取得はRaspberry Pi側で行い、取得したIPアドレスをLambda関数に送信することで、Lambda関数のIPアドレスではなく、ユーザーの実際のグローバルIPアドレスをDynamoDBに保存できます。
- Raspberry Pi側でグローバルIPアドレスを取得する方法は、いくつかあります。
- 例えば、
http://checkip.amazonaws.com
などの外部サイトにアクセスする方法や、ルーターのUPnP機能を利用する方法などがあります。 - どちらの方法でも、Lambda関数の実行ロールにDynamoDBへの書き込み権限を付与する必要があります。
- セキュリティ対策として、Lambda関数URLの場合は認証タイプを「AWS_IAM」にするか、Lambda関数内で認証処理を実装することを推奨します。
変更内容
- Raspberry Piのスクリプト変更:
- Raspberry Piで動作しているPythonスクリプトを変更します。
- API GatewayのエンドポイントにIPアドレスを送信していた部分を、Lambda関数URLにIPアドレスを送信するように変更します。
- IPアドレスは、HTTPのPOSTリクエストの「body」に入れて送信します。
- Lambda関数URLの有効化:
- AWSのLambdaコンソールで、変更したLambda関数の設定を開きます。
- 「関数URL」という項目があるので、そこでLambda関数URLを有効にします。
- 認証の設定は、今回は簡単に「認証なし」を選びます。ただし、セキュリティを考慮する場合は、「AWS IAM」を選択してください。
- 有効にすると、Lambda関数URLが表示されます。これは、インターネットからLambda関数にアクセスするためのURLです。
- Lambda関数の変更:
- 現在のLambda関数を少し変更します。
- API Gatewayから送信されるイベントの形式ではなく、Lambda関数URLから送信されるイベントの形式に対応するように変更します。
- 具体的には、Raspberry Piから送信されるIPアドレスを、イベントの「body」の部分から取得するように変更します。
- Alexaスキルの変更:
- Alexaスキルは、変更する必要はありません。Lambda関数URLを使っても、これまで通りLambda関数を呼び出すことができます。
1. Raspberry Piのスクリプト変更
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import requests
import json
import datetime
import time
from requests_aws4auth import AWS4Auth
# Lambda関数URLに変更
LAMBDA_ENDPOINT = "YOUR_LAMBDA_FUNCTION_URL" # Lambda関数URLを入力
# IAMユーザーの認証情報を設定
AWS_ACCESS_KEY_ID = "YOUR_AWS_ACCESS_KEY_ID"
AWS_SECRET_ACCESS_KEY = "YOUR_AWS_SECRET_ACCESS_KEY"
REGION = "ap-northeast-1"
auth = AWS4Auth(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, REGION, 'lambda')
def get_global_ip():
"""checkip.amazonaws.comでグローバルIPアドレスを取得"""
try:
response = requests.get("https://checkip.amazonaws.com")
return response.text.strip()
except requests.exceptions.RequestException as e:
print(f"{datetime.datetime.now()} - Error getting global IP: {e}")
return None
def send_ip_to_lambda(ip):
"""取得したIPアドレスをLambda関数URLに送信"""
try:
payload = {'ip': ip}
headers = {'Content-Type': 'application/json'}
response = requests.post(LAMBDA_ENDPOINT, json=payload, headers=headers, auth=auth)
print(f"{datetime.datetime.now()} - Sent IP: {ip}, Response: {response.status_code}")
except requests.exceptions.RequestException as e:
print(f"{datetime.datetime.now()} - Error sending IP to Lambda: {e}")
if __name__ == "__main__":
ip = get_global_ip()
if ip:
send_ip_to_lambda(ip)
変更内容
1. LAMBDA_ENDPOINT の変更- API GatewayのエンドポイントからLambda関数URLに変更します。
YOUR_LAMBDA_FUNCTION_URL
の部分を、実際のLambda関数URLに置き換えてください。- 以下は、IAMユーザーの認証情報の設定になります。(後で説明)2つの項目をIAM認証設定で得られた情報に置き換えてください。
YOUR_AWS_ACCESS_KEY_ID
YOUR_AWS_SECRET_ACCESS_KEY
send_ip_to_lambda
関数の変更headers
からAPIキー(x-api-key
)を削除します。Lambda関数URLではAPIキーは不要です。
2. Lambda関数URLの有効化
今回はセキュリティ強化のためIAM認証されたLambda関数URLを使います。
Lambda関数URLで「AWS IAM」認証を使用する場合は、IAMユーザーを作成し、ポリシーをアタッチする必要があります。
その手順を説明します。
2.1 Lambda関数URLの作成(AWS IAM認証)
- Lambdaコンソールを開きます。
- AWSマネジメントコンソールにログインし、Lambdaのページを開きます。
- 対象のLambda関数を選択します。
- readTemperature_local(例)関数を選択します。
- 「設定」タブを開きます。
- 左側のメニューから「関数URL」を選択します。
- 「関数URLを作成」ボタンをクリックします。
- 認証タイプで「AWS IAM」を選択します。
- 「保存」ボタンをクリックします。
これでLambda関数URLが作成され、URLが表示されます。
「設定」から「関数URL」
「関数URLを作成」から「AWS IAM」を選択
2.2 IAMポリシーの作成
Lambda関数URLで「AWS IAM」を選択した場合のIAM設定について説明します。
「AWS IAM」を選択すると、IAMユーザーまたはIAMロールにLambda関数URLへのアクセス権限を付与する必要があります。これにより、認証されたユーザーまたはロールのみがLambda関数を呼び出すことができます。
今回のケースでは、Raspberry PiからLambda関数URLを呼び出すために使用するIAMユーザーを作成することを推奨します。
IAMポリシーでは、どのAWSサービスへ、誰(ユーザ、ロール)がアクセスでき、どういった操作を許可するかを定義します。
IAMポリシーでは、どのAWSサービスへ、誰(ユーザ、ロール)がアクセスでき、どういった操作を許可するかを定義します。
- IAMコンソールを開きます。
- AWSマネジメントコンソールにログインし、IAMのページを開きます。
- 「ポリシー」を選択し、「ポリシーの作成」をクリックします。
- 「JSON」タブを選択し、以下のポリシーを貼り付けます。
- リージョン、アカウントID、関数名を実際の値に置き換えます。
- IAMポリシーはグローバルサービスであるため、特定のリージョンに限定されません。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "lambda:InvokeFunctionUrl",
"Resource": "arn:aws:lambda:リージョン:アカウントID:function:関数名"
}
]
}
- 「次のステップ:タグ」をクリックします。
- 必要に応じてタグを追加し、「次のステップ:確認」をクリックします。
- ポリシー名を入力し、「ポリシーの作成」をクリックします。
- IAMポリシーのポリシー名は、任意の名前を設定できます。ただし、ポリシーの内容を分かりやすく表す名前を付けることを推奨します。
- 今回の場合、Lambda関数URLへのアクセス権限を付与するポリシーであるため、ポリシー名は LambdaFunctionUrlAccessPolicy にします。
2.3 IAMユーザーの作成とポリシーのアタッチ
Raspberry PiからLambda関数URLを呼び出す場合:
IAMユーザーを作成し、アクセスキーとシークレットアクセスキーを使用します。
- IAMコンソールを開きます。
- AWSマネジメントコンソールにログインし、IAMのページを開きます。
- 「ユーザー」を選択し、「ユーザーを追加」をクリックします。
- ユーザー名を入力し、「次へ」をクリックします。
- IAMユーザーのユーザー名は、任意の名前を設定できます。ただし、ユーザーの役割や目的を分かりやすく表す名前を付けることを推奨します。(例 ip-reporter-user)
- 「既存のポリシーを直接アタッチ」を選択し、作成したポリシー(LambdaFunctionUrlAccessPolicyなど)を検索してチェックボックスをオンにして、「次のステップ:タグ」をクリックします。
- 必要に応じてタグを追加し、「次のステップ:確認」をクリックします。
- ユーザー情報を確認し、「ユーザーの作成」をクリックします。
- アクセスキーIDとシークレットアクセスキーを安全な場所に保存します。
- これらのキー情報を前述のRaspberry Piのスクリプトに書き込みます。
「ユーザー」から「ユーザーを追加」
ユーザー名を入力
2.4 参考:Lambda関数URLの作成(認証なし)
IAM認証なしの場合は、IAMの設定は不要で簡単に関数URLを作成できます。
- Lambdaコンソールを開きます。
- AWSマネジメントコンソールにログインし、Lambdaのページを開きます。
- 対象のLambda関数を選択します。
- readTemperature_local関数を選択します。
- 「設定」タブを開きます。
- 左側のメニューから「関数URL」を選択します。
- 「関数URLを作成」ボタンをクリックします。
- 認証タイプで「認証なし」を選択します。
- 「保存」ボタンをクリックします。
3. Lambda関数の変更
変更内容
- IPアドレスの取得方法:
- event['body']からJSON形式の文字列を取得し、json.loads()で辞書型に変換後、['ip']でIPアドレスを取得します。
- DynamoDBテーブル名の変更:
- YourDynamoDBTableNameの部分を、実際のDynamoDBテーブル名に置き換えてください。
- DynamoDBのパーティションキーの変更:
- idの値を、実際のDynamoDBのパーティションキーの値に変更してください。
- blynkAuthToken のURLエンコード:
- 既報のLambda関数では出なかったエラーメッセージ TypeError [ERR_UNESCAPED_CHARACTERS]: Request path contains unescaped characters が発生しました。これは、HTTPリクエストのパスに問題があることが明確です。
- blynkAuthTokenの値を
encodeURIComponent()
でエンコード して対策しました。
'use strict';
const AWS = require('aws-sdk');
const dynamoDb = new AWS.DynamoDB.DocumentClient();
const tableName = 'IPTable'; // DynamoDBのテーブル名
const http = require('http');
// --------------- Helpers that build all of the responses -----------------------
function buildSpeechletResponse(title, output, repromptText, shouldEndSession) {
return {
outputSpeech: {
type: 'PlainText',
text: output,
},
card: {
type: 'Simple',
title: `SessionSpeechlet - ${title}`,
content: `SessionSpeechlet - ${output}`,
},
reprompt: {
outputSpeech: {
type: 'PlainText',
text: repromptText,
},
},
shouldEndSession,
};
}
function buildResponse(sessionAttributes, speechletResponse) {
return {
version: '1.0',
sessionAttributes,
response: speechletResponse,
};
}
// --------------- Functions that control the skill's behavior -----------------------
function getWelcomeResponse(callback) {
const sessionAttributes = {};
const cardTitle = 'Welcome';
const speechOutput = "二階の温度を教えてなどと問いかけると、それを答えます。";
const repromptText = "私はこの部屋を見てるわよっ!何が知りたいのっ! ってかっ";
const shouldEndSession = false;
callback(sessionAttributes,
buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}
function handleSessionEndRequest(callback) {
const cardTitle = 'Session Ended';
const speechOutput = 'Thank you for trying the Alexa Skills Kit sample. Have a nice day!';
const shouldEndSession = true;
callback({}, buildSpeechletResponse(cardTitle, speechOutput, null, shouldEndSession));
}
function createLocationAttributes(location) {
return { location };
}
async function readTemperatureInSession(intent, session, callback) {
const cardTitle = intent.name;
const LocationSlot = intent.slots.Location;
const MeasurementSlot = intent.slots.Measurement;
let repromptText = '';
let sessionAttributes = session.attributes || {};
const shouldEndSession = false;
let speechOutput = '';
let body = '';
const blynkport = '8080';
let blynkAuthToken;
let blynkPin;
let location = LocationSlot.value || sessionAttributes.location || '二階';
sessionAttributes.location = location;
let ip;
try {
// DynamoDBからIPアドレスを取得
const params = {
TableName: tableName,
Key: {
id: 'globalIP' // パーティションキーは固定値
}
};
const data = await dynamoDb.get(params).promise();
const item = data.Item;
if (!item || !item.ipAddress) {
console.error('IPアドレスがDynamoDBに存在しません。');
speechOutput = 'IPアドレスが取得できませんでした。';
repromptText = 'もう一度お試しください。';
callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
return;
}
ip = item.ipAddress; // DynamoDBから取得したIPアドレス
} catch (error) {
console.error(`DynamoDBからIPアドレスを取得中にエラーが発生しました: ${error}`);
speechOutput = 'IPアドレスの取得中にエラーが発生しました。';
repromptText = 'もう一度お試しください。';
callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
return; // エラー発生時は処理を中断
}
switch (location) {
case '二階':
blynkAuthToken = encodeURIComponent('********************************');
break;
case '仏間':
blynkAuthToken = encodeURIComponent('********************************');
break;
case '屋根裏':
blynkAuthToken = encodeURIComponent('********************************');
break;
default:
speechOutput = '指定された場所が見つかりませんでした。';
repromptText = '他の場所を指定してください。';
callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
return;
}
let measurement = MeasurementSlot.value || '温度';
switch (measurement) {
case '何度':
case '気温':
case '室温':
case '温度':
blynkPin = 'V0';
break;
case '湿気':
case '湿度':
blynkPin = 'V1';
break;
case '大気圧':
case '気圧':
blynkPin = 'V2';
break;
case '熱中度指数':
case '熱中度':
case '暑さ指数':
case '暑さ度':
case '暑さ':
blynkPin = 'V4';
break;
default:
speechOutput = '指定された測定項目が見つかりませんでした。';
repromptText = '他の測定項目を指定してください。';
callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
return;
}
try {
console.log(`Sending request to Blynk server with Auth Token: ${blynkAuthToken}, Pin: ${blynkPin}, IP: ${ip}`);
const httpPromise = new Promise((resolve, reject) => {
http.get({
host: ip,
path: `/${encodeURIComponent(blynkAuthToken)}/get/${blynkPin}`,
port: blynkport,
}, (response) => {
response.on('data', (d) => {
body += d;
});
response.on('end', () => {
resolve('Done Sending');
});
}).on('error', (e) => {
reject(e); // エラーをreject
console.error(`Blynkサーバーへのリクエストエラー: ${e}`); // エラーログ
});
});
await httpPromise;
console.log(`Received response from Blynk server: ${body}`);
let info;
try {
info = parseFloat(JSON.parse(body)); // パースを試みる
} catch (parseError) {
console.error(`JSONパースエラー: ${parseError}, body: ${body}`);
info = NaN; // パースに失敗したらNaNを設定
}
if (isNaN(info)) {
speechOutput = '温度の取得に失敗しました。';
repromptText = 'もう一度お試しください。';
callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
} else {
// ... (温度表示処理)
speechOutput = location + 'の ' + measurement + 'は ';
repromptText = speechOutput;
switch (measurement) {
default:
case '何度':
case '気温':
case '室温':
case '温度':
speechOutput += `${info.toFixed(1)}度 です。`;
repromptText += `${info.toFixed(1)}℃です。`;
break;
case '湿気':
case '湿度':
speechOutput += `${info.toFixed(1)}パーセント です。`;
repromptText += `${info.toFixed(1)}%です。`;
break;
case '大気圧':
case '気圧':
speechOutput += `${info.toFixed(0)}ヘクトパスカル です。`;
repromptText += `${info.toFixed(0)}hPaです。`;
break;
case '熱中度指数':
case '熱中度':
case '暑さ指数':
case '暑さ度':
case '暑さ':
let annotation = '';
if (info >= 31) {
annotation = '危険! ブラックです。';
} else if (info >= 28) {
annotation = '厳重警戒! レッドです。';
} else if (info >= 25) {
annotation = '警戒! オレンジです。';
} else if (info >= 21) {
annotation = '注意です。';
}
speechOutput += `${info.toFixed(0)}度 です。${annotation}`;
repromptText += `${info.toFixed(0)}℃です。${annotation}`;
break;
}
callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}
} catch (error) {
console.error(`温度取得処理全体のエラー: ${error}`);
speechOutput = '温度の取得中にエラーが発生しました。';
repromptText = 'もう一度お試しください。';
callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}
}
// ... (onSessionStarted, onLaunch, onIntent, onSessionEnded関数は変更なし)
function onSessionStarted(sessionStartedRequest, session) {
console.log(`onSessionStarted requestId=${sessionStartedRequest.requestId}, sessionId=${session.sessionId}`);
}
function onLaunch(launchRequest, session, callback) {
console.log(`onLaunch requestId=${launchRequest.requestId}, sessionId=${session.sessionId}`);
getWelcomeResponse((sessionAttributes, speechletResponse) => {
callback(sessionAttributes, speechletResponse);
});
}
function onIntent(intentRequest, session, callback) {
console.log(`onIntent requestId=${intentRequest.requestId}, sessionId=${session.sessionId}`);
const intent = intentRequest.intent;
const intentName = intentRequest.intent.name;
if (intentName === 'GetMeasurement') {
readTemperatureInSession(intent, session, callback);
} else if (intentName === 'AMAZON.HelpIntent') {
getWelcomeResponse((sessionAttributes, speechletResponse) => {
callback(sessionAttributes, speechletResponse);
});
} else if (intentName === 'AMAZON.StopIntent' || intentName === 'AMAZON.CancelIntent') {
handleSessionEndRequest((sessionAttributes, speechletResponse) => {
callback(sessionAttributes, speechletResponse);
});
} else {
throw new Error('Invalid intent');
}
}
function onSessionEnded(sessionEndedRequest, session) {
console.log(`onSessionEnded requestId=${sessionEndedRequest.requestId}, sessionId=${session.sessionId}`);
// callbackを呼び出す必要はありません。
// context.succeed()も不要です。
}
exports.handler = (event, context) => {
try {
console.log('Event:', JSON.stringify(event));
let globalIP;
if (event.session && event.session.attributes && event.session.attributes.globalIP) {
globalIP = event.session.attributes.globalIP;
console.log(`Using globalIP from session attributes: ${globalIP}`);
continueProcessing(event, context); // IPアドレス取得後に実行したい処理
} else {
console.log("globalIP not found in session attributes. Retrieving from DynamoDB or IP");
// IPアドレスの取得処理 (DynamoDB or event.ip)
if (event.body && JSON.parse(event.body).ip) { // Lambda関数URLからのIPアドレス取得
globalIP = JSON.parse(event.body).ip;
if (!event.session) {
event.session = {};
}
if (!event.session.attributes) {
event.session.attributes = {};
}
event.session.attributes.globalIP = globalIP; // セッション属性に保存
console.log(`IP from event body: ${globalIP}`);
updateIPAddress(event, context); // IPアドレス更新処理
} else {
getIPAddressFromDynamoDB(event, context);
}
}
} catch (e) {
console.error(`Exception: ${e}`);
context.fail(`Exception: ${e}`);
}
};
function updateIPAddress(event, context) {
const params = {
TableName: tableName,
Item: {
id: 'globalIP',
ipAddress: JSON.parse(event.body).ip // event.bodyからIPアドレスを取得
}
};
dynamoDb.put(params).promise()
.then(() => {
console.log(`Successfully updated IP address in DynamoDB: ${JSON.parse(event.body).ip}`);
if (!event.session) {
event.session = {};
}
if (!event.session.attributes) {
event.session.attributes = {};
}
event.session.attributes.globalIP = JSON.parse(event.body).ip;
console.log(`Updated globalIP to: ${JSON.parse(event.body).ip}`);
continueProcessing(event, context);
})
.catch(err => {
console.error(`Error updating IP address in DynamoDB: ${err}`);
context.fail(`Error updating IP address: ${err}`);
});
}
function getIPAddressFromDynamoDB(event, context) {
const params = {
TableName: tableName,
Key: {
id: 'globalIP'
}
};
dynamoDb.get(params).promise()
.then(data => {
if (data.Item) {
const ipAddress = data.Item.ipAddress;
if (!event.session) {
event.session = {};
}
if (!event.session.attributes) {
event.session.attributes = {};
}
event.session.attributes.globalIP = ipAddress;
console.log(`Retrieved IP address from DynamoDB: ${ipAddress}`);
continueProcessing(event, context);
} else {
console.error('IP address not found in DynamoDB.');
context.fail('IP address not found.');
}
})
.catch(err => {
console.error(`Error retrieving IP address from DynamoDB: ${err}`);
context.fail(`Error retrieving IP address: ${err}`);
});
}
function continueProcessing(event, context) {
if (event.request && event.request.type) { // event.requestの存在チェックを追加
// Alexaからのリクエストの場合の処理
if (event.session && event.session.new) {
onSessionStarted({ requestId: event.request.requestId }, event.session);
}
if (event.request.type === 'LaunchRequest') {
onLaunch(event.request, event.session, (sessionAttributes, speechletResponse) => {
context.succeed(buildResponse(sessionAttributes, speechletResponse));
console.log('LaunchRequest succeeded');
});
} else if (event.request.type === 'IntentRequest') {
onIntent(event.request, event.session, (sessionAttributes, speechletResponse) => {
context.succeed(buildResponse(sessionAttributes, speechletResponse));
console.log('IntentRequest succeeded');
});
} else if (event.request.type === 'SessionEndedRequest') {
onSessionEnded(event.request, event.session);
context.succeed();
console.log('SessionEndedRequest succeeded');
}
} else {
// Lambda URLからのリクエストの場合
context.succeed(); // Lambda関数を正常終了
console.log('Lambda URL request completed.');
}
}
// ... (buildResponse, buildSpeechletResponseなどの関数は省略)
まとめ: IPアドレスの優先順位:
プログラムは、IPアドレスを取得する際に、以下の優先順位で処理を行います。
- まず、以前の会話で保存された情報(セッション属性)の中にIPアドレスがないかを確認します。
- もしセッション属性にIPアドレスがあれば、それを最優先で使用します。
順位2. 受け取ったIPアドレス:
- セッション属性にIPアドレスがない場合、次に直接送られてきたIPアドレス(受け取ったIPアドレス)を使用します。
- このIPアドレスは、データベースのIPアドレスを更新するために使用されます。
順位3. データベース(DynamoDB):
- 受け取ったIPアドレスが使用された後に、そのIPアドレスでデータベースのIPアドレスを更新します。
- これは、次回のIPアドレス取得のために使用されます。