AWS Lambda に Rust をコンテナデプロイし、Twitter API を叩く

10 min read読了の目安(約9700字

モチベ

Twitter をやっているとふとアイコンを回転させたいなと思うときが有りませんか?
私はあります。ということで回していきます。

使用したもの

  • Twitter API
  • Rust
  • AWS Lambda

作成

Twitter API

TwitterのAPI を使用するには申請、アプリ作成などが必要となります。私はこれまでTwitterのAPI を触っていなかったので、そこからとなりました。

Twitter API 利用申請

Developer サイト から申請が可能です。
どこから申請するのか分かりづらいですが、ポチポチしていると申請画面へたどり着きます。

申請時に利用目的、取得したツイートで何を行うか、取得した内容をTwitter外で使用するか、政府機関が使用することはあるか等を聞かれましたので、適切に回答します。

調べたところ早ければ数時間で申請は終わり、APIが使用可能になるようです。
(私の場合は3往復程度のメールのやりとりが発生しました。私の拙い英語が読めないのかと思い最終的に日本語で書いたところ審査が通りました。なにゆえ)

Twitter App 作成

権利を得たら App を作成します。
Developer サイトで作成が可能になっているので適当な名前を入力し作成します。
create_app
次に遷移した画面でアクセストークンが表示されますが、再作成が可能なので一旦無視してしまって問題有りません。

アクセストークン取得

App を作成すると左のサイドバー(UI変わっていたらごめんなさい)に App が出現しているので設定画面を開くことができます。
プロフィール画像の変更は Read Only では実行できないので Read and Write 以上の権限へ変更します。
permission

上のタブから Keys and tokens を選択することでキー及びトークンの管理画面へ遷移できます。
token

上から

  • API のキー
  • 権限のないトークン
  • 自アカウントのトークン発行

となっています。Regenerate でこれまでのキーは無効になり、新たに有効なキーが生成されます。
今回使用するのは1番目及び3番目のみです。

API が使用できるようになったのでコードを弄ります。

コーディング

今回は時刻毎の画像を予め生成し、それを1時間毎に Twitter API に送りつけることとします。

画像作成

ということで Python から openCV を使用しアイコンを回転させます。
openCV に詳しくないので Docker コンテナ上に環境を作りました。

https://github.com/uesugi6111/generate-rotated-img
import cv2

INPUT_PATH = "img/"
INPUT_IMG_NAME = "my_icon.jpg"
TARGET_PATH = "img/target/"

def write(img, degree, file_name):
    """
    write file
    """

    height, width, _ = img.shape
    mat = cv2.getRotationMatrix2D((width / 2, height / 2), degree, 1)
    affine_img = cv2.warpAffine(img, mat, (width, height))
    cv2.imwrite(TARGET_PATH+file_name+".jpg", affine_img)

input_img = cv2.imread(INPUT_PATH+INPUT_IMG_NAME)

for num in range(0, 12):
    write(input_img, 360-360//12*num, str(num))

ファイルを取り込み12個の回転画像を生成します。回転の変換行列を生成しアフィン変換します。

参考

https://note.nkmk.me/python-opencv-warp-affine-perspective/

Twitter API 用ライブラリ

ライブラリの選定

Rust で Twitter API を叩くにあたって crate を 検索したところ egg-mode というライブラリがあり、20k 近いDLがあったためこれを使用することにしました。

https://crates.io/crates/egg-mode

ところが今回行いたいのはアイコン画像の回転なので使用したいのは POST account/update_profile_image です。
しかしながら egg-mode さんはこのAPI については未実装で、TODOでもチェックがついていないのがわかります。

というわけで egg-mode さんに実装しました。

https://github.com/uesugi6111/egg-mode/tree/feature/account

自リポジトリを参照しなければならないので cargo.toml では 自リポジトリのurl を指定します。

egg-mode = { git = "https://github.com/uesugi6111/egg-mode.git", branch = "feature/account" }

本体の処理を書いていきます。

lib.rs
use chrono::Timelike;
use chrono::Utc;
use egg_mode::{account::update_profile_image, auth, error::Result};
use once_cell::sync::Lazy;
use serde_json::Value;

static ACCESS_TOKEN: Lazy<Option<String>> = Lazy::new(|| dotenv::var("ACCESS_TOKEN").ok());
static ACCESS_TOKEN_SECRET: Lazy<Option<String>> =
    Lazy::new(|| dotenv::var("ACCESS_TOKEN_SECRET").ok());

static API_KEY: Lazy<Option<String>> = Lazy::new(|| dotenv::var("API_KEY").ok());
static API_KEY_SECRET: Lazy<Option<String>> = Lazy::new(|| dotenv::var("API_KEY_SECRET").ok());

pub async fn run() -> Result<Value> {
    let (_, t) = Utc::now().hour12();
    let file = read_file(std::path::Path::new(&format!("./img/{}.jpg", (t + 9) % 12)));
    let access = auth::Token::Access {
        consumer: auth::KeyPair::new(API_KEY.as_ref().unwrap(), API_KEY_SECRET.as_ref().unwrap()),
        access: auth::KeyPair::new(
            ACCESS_TOKEN.as_ref().unwrap(),
            ACCESS_TOKEN_SECRET.as_ref().unwrap(),
        ),
    };

    let response = update_profile_image(&file, &access).await?;
    println!("{:#?}", response);

    Ok(serde_json::to_value(response.response)?)
}

use std::io::Read;
fn read_file<P: AsRef<std::path::Path>>(file_path: P) -> Vec<u8> {
    let mut file = std::fs::File::open(file_path).expect("file open failed");
    let mut buf = Vec::new();
    file.read_to_end(&mut buf).expect("file read failed");
    buf
}

時刻を取得し、時刻に対応する画像ファイルを読み込み、ライブラリを使用しリクエストを投げています。
APIキー及びアクセストークンの類いは環境変数または .env ファイルから取得することにしました。
画像のbase64変換はライブラリ側で行っています。

lambda ランタイム

上で書いた本処理をLambdaからの呼び出しで実行されるようにします。

main.rs
use lambda_runtime::{handler_fn, Context};
use rotate_icon::run;
use serde_json::Value;

type Error = Box<dyn std::error::Error + Sync + Send + 'static>;
#[tokio::main]
async fn main() -> Result<(), Error> {
    openssl_probe::init_ssl_cert_env_vars();
    lambda_runtime::run(handler_fn(handler)).await?;
    Ok(())
}

async fn handler(_: Value, _: Context) -> Result<Value, Error> {
    Ok(run().await?)
}

lambda_runtime::run に渡す形で書きます。今回は Lambda 関数 として受け取った情報を使用しないので、Value と Context は捨てています。

openssl_probe クレートは後述する openSSL を使用可能にするおまじないです。

lambdaへのデプロイ

ビルド

今回はビルドを行いバイナリファイルを作成し、そのファイルを含んだ Docker image を作成します。そのためライブラリは動的リンクをしている場合動かなくなってしまう恐れがあります。その回避のため musl-builder なるものを使用します。

https://github.com/emk/rust-musl-builder
docker run --rm -it -v {ここに絶対パス}:/home/rust/src ekidd/rust-musl-builder  cargo build --release

musl-builder の使用は特に準備も必要なく上記のコマンドを実行するのみ。
/home/rust/src に対してソースディレクトリをマウントさせ、ビルドを実行させることができる。
docker コマンドは相対パスが使えないとのことで少し不便。
ビルド後の成果物は target/x86_64-unknown-linux-musl/release 配下へ格納されている。
これを利用しイメージを作成していく。

openSSLを使用する場合

※実際に動かなくなるものとして openSSL がある。openSSL のライブラリは musl でのビルドだけでは動かず、証明書に関わる処理が必要となるので openssl_probe クレートを使用している。

openssl_probe::init_ssl_cert_env_vars();

このコードで環境変数から openSSL で使用する証明書の場所? を取得する。これで Rust アプリケーションから https 通信が可能となる。

イメージ作成

ECR(Aamazon Elastic Container Registry) へ push するイメージを作成する。

(AWS Lambda で実行させたいが現時点でソースコードのアップロードに Rust は対応されてていない。そのためコンテナイメージとして作成する。)

今回は Lambda での使用となるので、ベースになるイメージとして Amazon Linux 2 となる。具体的には公式が提供しているイメージ を使用する。

FROM public.ecr.aws/lambda/provided:al2

そしてここに作成したアプリケーションの実行バイナリ、回転用画像、環境設定ファイルのコピー処理を追加。

FROM public.ecr.aws/lambda/provided:al2

ADD ./target/x86_64-unknown-linux-musl/release/rotate-icon ${LAMBDA_RUNTIME_DIR}/bootstrap
ADD ./.env ${LAMBDA_TASK_ROOT}/.env
ADD ./img/ ${LAMBDA_TASK_ROOT}/img/

CMD [ "lambda-handler" ]

LAMBDA_RUNTIME_DIR、LAMBDA_TASK_ROOT は上の階層で指定されている変数となる。
LAMBDA_RUNTIME_DIR 配下に bootstrap という名前で配置しすることで実行バイナリが呼び出される。

とてもハマった

回転用画像及び設定ファイルをはじめは実行バイナリと同じ階層に配置していたが、何度やっても読み込みに失敗していた。ワケワカランと思いながらも実行コンテクストパスが違うのではと思い当たり、提供されているランタイムの Dockerfile を読んでいると怪しいパス(LAMBDA_TASK_ROOT) が存在していたため、設定。
動いた。

ECR へのアップロード

ECR へのアップロードはとても簡単になっており拍子抜けしてしまった。

ecr

"リポジトリを作成" から適当な名前でリポジトリを作成し、リポジトリを選択。
push_command
そうするとプッシュコマンドの表示ボタンが出現。押すと

command
のように push するにあたってローカルで実行すべきことが書かれている。便利。

書かれていた内容としては

  1. docker クライアントに aws の認証情報を紐付け、docker コマンドから ecr へ push できるようにする。
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin 111111111111.dkr.ecr.ap-northeast-1.amazonaws.com

  1. リポジトリ名を付けて Dockerfile をビルド
    作成した Dockerfile が存在するディレクトリで実行してください。
docker build -t slack-bot-lambda-container .
  1. ビルドしたコンテナイメージに latest のタグをつける
docker tag slack-bot-lambda-container:latest 111111111111.dkr.ecr.ap-northeast-1.amazonaws.com/slack-bot-lambda-container:latest
  1. 作成したイメージを push
docker push 111111111111.dkr.ecr.ap-northeast-1.amazonaws.com/slack-bot-lambda-container:latest

注意書きにも書かれている通り

AWS CLI および Docker の最新バージョン

が必要となります。(AWS CLI は認証情報の設定も必要)

一通りコマンドを実行すると latest タグの付いたイメージが push されます。

latest

イメージの準備は終わりました。

Lambda関数の作成

Lambda関数を作っていきます。
このフェーズはとても簡単ですね。
lambda

  • コンテナイメージを選択
  • 好きな関数名を入力
  • コンテナイメージURI でイメージを参照ボタンから ECR に存在するリポジトリが選択できる

これで Lambda 関数が作成できます。

定期実行

Lambda 関数 は作成したのですが、まだ定期的な実行の設定ができていません。
(実行のテスト自体は関数を開き、テストタブから可能です。)

実行の設定は関数のトップから "+トリガーを追加" を押下することで可能です。

トリガーを追加画面が開くので今回はトリガーとして EventBridge (CloudWatch Events) を選択します。
以下の画面が表示されます。

trigger

  • 新規ルールの作成を選択
  • ルール名の入力
  • スケジュール式を選択

を行います。
スケジュール式の部分で定期実行の時間帯、頻度等を設定することができます。記述にはLinux サーバでよくある cron 式を使用します。

今回私は毎日毎時0分に実行させたいので

cron(0 * * * ? *)

としました

これで動かない場合は、イベントバスが動いていない可能性があるので EventBridge の対応するイベントバス(おそらくdefault) で検出を開始させてください。

回りました

ということで回りました。(チューリングさんごめんなさい)
icon

おわり

最初はしっかりとユーザ管理を付けて、全人類がアイコンを回転させることのできるサービスを作ろうかと一瞬考えたのですが、需要が無いことに気がついてしまいました。需要があれば作ります
LambdaのローカルでのテストやGithub へ push された時に自動でECRまで push するようにししようかと思いましたが、気力が有りませんでした。

アイコンが回転している程度では大抵の人は気が付かない説を提唱しています。

https://github.com/uesugi6111/slackbot-lambda-rust