RustとAWS Lambdaで定期的に価格チェックしてメール送信する


作ったもの

https://github.com/ysirman/price_check_with_lambda
狙っている商品の出品価格と割引率をスクレイピングして、希望価格以下になったらメール通知する仕組みを Rust と AWS Lambda を使って作りました。

購入対応可能な時間帯のみ通知して欲しいので、Lambda のトリガーに EventBridge を使用しています。

通知メールはこんな感じです。

やったこと

以下、Rustでの実装 〜 AWS Lambda へのデプロイ について
注意点と一緒に説明していきます。

スクレイピングしても OK なのか確認

スクレイピング対象のWebサイトに迷惑をかけない & 自分の身を守るために調査!

https://lifeinfo-navi.com/p=1311

自分がスクレイピングしたい記事が許可されているのか禁止されているのかはそのサイトのURLの末尾に「/robots.txt」と入力して、検索すると調べることができます。

https://www.octoparse.jp/blog/10-myths-about-web-scraping/#div1

  • Webサイトの利用規約に違反する(利用規約で触れている場合は違反になる)
  • サーバに過度の負荷をかける(アクセス不能になり業務妨害にあたる)
  • 著作権を侵害する(抽出したデータを無断で公開・販売するなど)

今回は頻繁に情報をチェックして通知が大量に来ても困るので、
・8-22時の間、1時間に1回のみスクレイピングする
・価格が設定した閾値を超えた場合のみメール通知する
としました。

Rust でスクレイピング

この↓記事を参考にして実装しました。

https://tms-dev-blog.com/how-to-scrape-websites-with-rust-basic-example/

以下のコードをほぼそのまま使いました。
スクレイピング対象の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のサイトにも記載がありました。)

この↓サンプル通りに実装しました。

https://github.com/gsquire/sendgrid-rs/blob/master/examples/main.rs

環境変数の設定

  • 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.'

解決方法

参考:https://stackoverflow.com/questions/62536566/how-can-i-create-a-tokio-runtime-inside-another-tokio-runtime-without-getting-th

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 用にビルド

参考

https://zenn.dev/kasega0/articles/18c225bae23a00
https://komorinfo.com/blog/rust-aws-lambda/

注意点

実装する上で常に気をつけることが1つある。それは、SSLライブラリの取り扱いである。reqwest 等の通信ライブラリはデフォルトでOpenSSLを使うように設定されているが、クロスビルド環境に OpenSSL が入っていなかったり Custom Container で Open SSL を使えるようにしたりするなど手間が増える。

RustのDockerイメージに必要なものをインストールしても良さそうですが、OpenSSL関連のエラーがなかなか消えませんでした。。
なので、以下のように既存のDockerイメージ:rust-musl-builderを使って、コンテナ内でビルドコマンドを実行しました。

Dockerfile
# マルチステージビルドで rust-musl-builder
FROM ekidd/rust-musl-builder:1.57.0 AS builder # versionには注意が必要
# build
USER rust
ADD --chown=rust:rust . ./
RUN cargo build --release
docker-compose.yml
# とりあえず、コンテナ内でビルドするために用意したもの
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 にデプロイ

参考

https://komorinfo.com/blog/rust-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"

最後まで読んでいただきありがとうございました!
参考にさせていただいた記事のみなさまありがとうございました!