RustとAWS Lambdaで定期的に価格チェックしてメール送信する
作ったもの
狙っている商品の出品価格と割引率をスクレイピングして、希望価格以下になったらメール通知する仕組みを Rust と AWS Lambda を使って作りました。
購入対応可能な時間帯のみ通知して欲しいので、Lambda のトリガーに EventBridge を使用しています。
通知メールはこんな感じです。
やったこと
以下、Rustでの実装 〜 AWS Lambda へのデプロイ について
注意点と一緒に説明していきます。
スクレイピングしても OK なのか確認
スクレイピング対象のWebサイトに迷惑をかけない & 自分の身を守るために調査!
自分がスクレイピングしたい記事が許可されているのか禁止されているのかはそのサイトのURLの末尾に「/robots.txt」と入力して、検索すると調べることができます。
- Webサイトの利用規約に違反する(利用規約で触れている場合は違反になる)
- サーバに過度の負荷をかける(アクセス不能になり業務妨害にあたる)
- 著作権を侵害する(抽出したデータを無断で公開・販売するなど)
今回は頻繁に情報をチェックして通知が大量に来ても困るので、
・8-22時の間、1時間に1回のみスクレイピングする
・価格が設定した閾値を超えた場合のみメール通知する
としました。
Rust でスクレイピング
この↓記事を参考にして実装しました。
以下のコードをほぼそのまま使いました。
スクレイピング対象のHTMLが取得できるように変更します。
use reqwest::StatusCode;
use scraper::{Html, Selector};
mod utils;
#[tokio::main]
async fn main() {
let client = utils::get_client();
let url = "https://finance.yahoo.com";
let result = client.get(url).send().await.unwrap();
// HTMLを取得
let raw_html = match result.status() {
StatusCode::OK => result.text().await.unwrap(),
_ => panic!("Something went wrong"),
};
let document = Html::parse_document(&raw_html);
// 取得対象の要素
let article_selector = Selector::parse("a.js-content-viewer").unwrap();
// 取得!
for element in document.select(&article_selector) {
let inner = element.inner_html().to_string();
let href = match element.value().attr("href") {
Some(target_url) => target_url,
_ => "no url found",
};
println!("Title: {}", &inner);
println!("Link: {}", &href);
}
}
SendGrid でメール送信
SendGrid のアカウント作成してAPIキーを取得しておきます。
Rust では SendGrid 非公式の crate:sendgrid-rs があります。
(一応、SendGridのサイトにも記載がありました。)
この↓サンプル通りに実装しました。
環境変数の設定
- APIキーの漏洩防止
- Lambda用に何回もビルドしたくない
の理由で、通知可否の判定に使用する閾値
、メールアドレス
、APIキー
などは dotenv を使って環境変数から取得するようにしました。
こうしておくと、AWS Lambda のGUIコンソールから、ビルド不要で手軽に環境変数を変更できます。
AWS Lambda 用の実装に修正
aws-lambda-rust-runtimeを使って AWS Lambda で実行可能な形に修正します。
GitHubのサンプルを参考に実装しました。
use lambda_runtime::{service_fn, LambdaEvent, Error};
use serde_json::{json, Value};
#[tokio::main]
async fn main() -> Result<(), Error> {
let func = service_fn(func);
lambda_runtime::run(func).await?;
Ok(())
}
// このメソッドをスクレイピングとメール送信のメソッドを呼び出すように修正します。
// これはサンプルコードそのままなので、一旦、 Hello World の表示を確認しても良いかもです。
async fn func(event: LambdaEvent<Value>) -> Result<Value, Error> {
let (event, _context) = event.into_parts();
let first_name = event["firstName"].as_str().unwrap_or("world");
Ok(json!({ "message": format!("Hello, {}!", first_name) }))
}
注意点
非同期処理をネストするとエラーが発生します。
今回の場合、非同期のfunc
メソッドから呼び出すスクレイピング
とメール送信
部分が非同期処理となっており、以下のエラーが発生。
Cannot start a runtime from within a runtime. This happens because a function (like `block_on`) attempted to block the current thread while the thread is being used to drive asynchronous tasks.'
解決方法
tokio::task::spawn_blocking
を使って解決できました。
async fn func(_event: LambdaEvent<Value>) -> Result<Value, Error> {
tokio::task::spawn_blocking(|| {
// 非同期処理はこの中で実行する
let price_and_rates = utils::get_price_and_rates(); // スクレイピング
utils::send_email(&price_and_rates); // メール送信
})
.await
.expect("Blocking task panicked");
Ok(json!({ "message": "success" }))
}
AWS Lambda 用にビルド
参考
注意点
実装する上で常に気をつけることが1つある。それは、SSLライブラリの取り扱いである。reqwest 等の通信ライブラリはデフォルトでOpenSSLを使うように設定されているが、クロスビルド環境に OpenSSL が入っていなかったり Custom Container で Open SSL を使えるようにしたりするなど手間が増える。
RustのDockerイメージに必要なものをインストールしても良さそうですが、OpenSSL関連のエラーがなかなか消えませんでした。。
なので、以下のように既存のDockerイメージ:rust-musl-builderを使って、コンテナ内でビルドコマンドを実行しました。
# マルチステージビルドで rust-musl-builder
FROM ekidd/rust-musl-builder:1.57.0 AS builder # versionには注意が必要
# build
USER rust
ADD . ./
RUN cargo build --release
# とりあえず、コンテナ内でビルドするために用意したもの
version: '3.7'
services:
builder:
build:
context: .
target: builder
image: builder:latest
volumes:
- ./:/home/rust/src/ # lambda.zipをコンテナ内で作成した時にホスト側でファイル取得できるようにする
- builder-cargo-cache:/usr/local/cargo/registry
tty: true # コンテナにattachしてビルドする用
stdin_open: true # コンテナにattachしてビルドする用
...
volumes:
builder-cargo-cache:
コンテナ内で実行するビルド用のコマンド
# x86_64-unknown-linux-musl 向けにクロスコンパイルする
cargo build --release --target x86_64-unknown-linux-musl
# lambda.zip を作成する
cp ./target/x86_64-unknown-linux-musl/release/${PROJECT_NAME} ./bootstrap
zip lambda.zip bootstrap
ローカルでの動作確認
参考:https://komorinfo.com/blog/rust-aws-lambda/
既存のDockerイメージ:lambci/lambdaを使って動作確認しました。
ビルドして作成した bootstrap があれば、Docker コマンドを実行するだけで動作確認できます。
# bootstrap があるディレクトリでコマンド実行
docker run -it --rm -v $(pwd):/var/task:ro,delegated -e DOCKER_LAMBDA_USE_STDIN=1 -e AWS_LAMBDA_FUNCTION_MEMORY_SIZE=128 -e RUST_LOG=info lambci/lambda:provided
# コマンド実行後、引数の待ち状態になるので引数を入力して Ctrl+D を押下
# 引数不要なら {} でOK
# 実行結果例
START RequestId: 3387c069-4e4e-12d9-86aa-182c38367bf4 Version: $LATEST
END RequestId: 3387c069-4e4e-12d9-86aa-182c38367bf4
REPORT RequestId: 3387c069-4e4e-12d9-86aa-182c38367bf4 Init Duration: 4524.22 ms Duration: 3.58 ms Billed Duration: 4 ms Memory Size: 128 MB Max Memory Used: 10 MB
{"message":"success"}
AWS Lambda にデプロイ
参考
Lambda 関数を作成する
「一から作成」を選択して関数を作成します。
関数名やIAMなど設定して空の関数作成後、「アップロード元」から作成しておいたlambda.zip
をアップロードします。
Lambda の設定
環境変数
.env
に設定したものと同じ環境変数を設定します。
# 環境変数が未設定だとエラーが発生
thread 'tokio-runtime-worker' panicked at 'called Result::unwrap() on an Err value: EnvVar(NotPresent)', src/utils.rs:54:59
定期実行
「トリガーを追加」から EventBridge を追加します。
注意点
・cron設定に使用されるタイムゾーンはGMT
・AWS の cron は設定方法が少し異なる
# 日本時間 8-22時に動かす設定
cron(0 23,0-13 * * ? *)
Lambda実行時にタイムアウトのエラーが発生した場合
「設定 > 一般設定」 のタイムアウトを変更すれば解決できるはずです。
今回の場合は10秒に変更して解決しました。
# デフォルトのタイムアウト3秒で発生するエラー
"errorMessage": "2018-04-26T08:40:15.398Z 6f404961-492d-11e8-9047-f3c59f4ef90f Task timed out after 3.00 seconds"
完
最後まで読んでいただきありがとうございました!
参考にさせていただいた記事のみなさまありがとうございました!
Discussion