Lambda + Rust + Mysqlは意外と難しい?
Rustを愛する皆さんこんにちは。
意外とRustが知られていないのと同じように、Lambda + MySQLの組み合わせもまだまだ知られていないようです。
いろんな案件にどんどんRustを使いたい! サーバー見たくない! っていのうと
RDS Proxyが去年リリースされたので、labmda + mysql という選択肢も入ってきます。
AWS Lambda + Rust1.51.0 + Aurora mysql 5.7 + Serverless Framework
今回は、以下の図のようにシンプルなLine bot + 管理画面のAPIの部分を作るお話です。
AWS環境構築
さあ、AWSの環境構築を始めましょう。
今回はRust + ServerlessFramework + RDSProxyなところがメインなので、
環境構築について他所様のわかりやすいサイトにお任せすることにします。(そのうちコード化したい)
Cloud9の設定
今回は開発環境にCloud9を使います。
AWS周りや必要なツールが一通り揃っているので楽チンです。
(使い方はググってください)
Rustのインストール
公式ページをみてRust本体をインストールします。
ビルド用にはrust-musl-builderをインストールするので直接インストールしなくても大丈夫です。
Aurora MySQL 5.7 を起動する
RDS -> データベース からAurora MySQL5.7 (not serverless) を作成します。
Serverlessな奴にはまだしない方が良いと思います。
(コールドタイムからの復帰に数分必要です)
セキュリティグループ設定
データベースへの3306アクセスを許可するためのセキュリティグループの設定です。
Cloud9とRDSを別のVPCに作った場合、Cloud9から接続するためにピアリンクを作成します。
RDSのサブネット 10.1.0.0/16 と、Cloud9のサブネット10.2.0.0/16を相互に接続できるようにします。
次にインバウンドルールを追加し、3306ポートに対して自分自身のセキュリティグループとCloud9のサブネットを追加します。
データベースにつながらない場合は、3306を0.0.0.0/0で許可して、ルールが足りないか確認します。
RDS + Proxyの設定
ロールやポリシーを設定したりとなかなか面倒です。 DeveloperIOさんの記事を参考にしましょう!
https://dev.classmethod.jp/articles/rds-proxy-ga/
パラメータグループの設定
パラメータグループ(クラスターの方)を作成し、文字コードをutf8mb4にします。
こちらの記事を参考にしました。
https://qiita.com/ein-san/items/ed192526f68ceb1ec60b
開発環境構築
さて、やっと環境構築に入ります。
需要があれば簡単なサンプル一式をアップロードします。(需要ないかな?)
$ tree . -L 3
├── Cargo.lock
├── Cargo.toml
├── conf # 変数とか
│ ├── dev.yml
│ └── prd.yml
├── db # マイグレ用のファイル
│ ├── dbconfig.yml
│ ├── migrations
│ │ └── 20210516023800-create_table.sql
│ └── test_data.sql
├── Dockerfile
├── func1.zip # 関数ごとにbootstrapをzipにしたもの
├── Makefile # コマンドはこれにまとめる
├── serverless.yml # 設定はここ
├── src # いわゆるクリーンアーキテクチャ
│ ├── application
│ │ └── func1.rs
│ ├── application.rs
│ ├── bin # ここに関数のエントリポイントをおく。ビルド時にファイル毎にバイナリにしてくれる
│ │ └── func1.rs
│ ├── lib.rs
│ ├── model
│ │ ├── error.rs
│ │ ├── rdb.rs # インターフェースの定義。ビルド時にモックに差し替えたりする。
│ │ └── hoge.rs
│ ├── model.rs
│ ├── repository
│ │ └── rdb.rs # 実際のクエリはここ
│ └── repository.rs
└── target
└── x86_64-unknown-linux-musl
└── ビルドされたバイナリはここ
MySQL初期化・マイグレ
今回は sql-migrate を使います。
-env=developmentのように環境を切り替えられるツールが好みなんですが、意外と少ない。。
RDS Proxyにはこんな感じでアクセスします。
reset-db:
cd db \
&& ~/go/bin/sql-migrate down -env=development \
&& ~/go/bin/sql-migrate up -env=development \
&& mysql -u admin -p"パスワード" -h [データベース名].proxy-xxxxxx.ap-northeast-1.rds.amazonaws.com [データベース] < 初期データ.sql
serverless framework設定
設定は以下のようになります。
ポイントとしては
-
- serverless-rust は使わない (後述)
-
- runtimeはprovided
-
- zipは自分(Makefile)で作る
- 関数毎に artifact でzipを指定する (これで複数関数にも対応できます)
service: simple-bot
provider:
name: aws
runtime: provided
region: ap-northeast-1
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:*
Resource: "*"
- Effect: 'Allow'
Action:
- 'lambda:InvokeFunction'
- 'rds-data:*'
- 'ec2:CreateNetworkInterface'
- 'ec2:DescribeNetworkInterfaces'
- 'ec2:DeleteNetworkInterface'
Resource:
- '*'
stage: ${opt:stage, self:custom.defaultStage}
vpc:
securityGroupIds:
- sg-[RDSに適用したセキュリティグループ]
subnetIds:
- subnet-RDSのサブネット1
- subnet-RDSのサブネット2
- subnet-RDSのサブネット3
custom:
defaultStage: dev
otherfile:
environment:
dev: ${file(./conf/dev.yml)}
prd: ${file(./conf/prd.yml)}
package:
individually: true
functions:
func1:
handler: func1
environment: ${self:custom.otherfile.environment.${self:provider.stage}}
package:
artifact: func1.zip # ここ重要!
Rustのプログラム
Docker
スタティックリンクなバイナリを作るため、筋肉もりもりmuslな環境を作ります。
昔は自分でmusl環境を作ろうとしてOpenSSL周りでハマることがありましたが、
今回は rust-musl-builder にlibmysqlclient-devを追加するだけです。
FROM ekidd/rust-musl-builder:1.51.0
USER root
RUN sudo apt-get update && sudo apt-get install -y libmysqlclient-dev
そして、Dockerをローカルでビルドしておきます。
$ docker image build -t rust-musl-builder:1.51.0
Cargo.toml
AWSの公式のカスタムランタイムが 0.3.0 になり、future 0.3.0系になりました。
また、libmysqlclient-devを静的リンクするためにパッチを当てます。
[package]
name = "simple_bot"
version = "0.1.0"
authors = [""]
edition = "2018"
# スタティックリンクするようにします
[target.x86_64-unknown-linux-musl]
rustflags = ["-C", "link-args= -static"]
[[bin]]
name = "func1"
path = "src/bin/func1.rs"
[dependencies]
tokio = "1.6.0"
rand = "0.7"
log = "*"
chrono = "*"
bytes = "1.0.1"
simple_logger = "*"
lambda_runtime = "0.3.0"
lambda_http = { version = "0.3.0", git = "https://github.com/awslabs/aws-lambda-rust-runtime.git" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "*"
thiserror = "1.0"
# mysqlをfeaturesに追加します。DATETIME用にchronoを指定します。
diesel = { version = "*", features = ["mysql", "chrono"] }
# libmysqlclient-devをスタティックリンクするためのパッチクレートです。
[patch.crates-io]
mysqlclient-sys = { git = "https://github.com/pzmarzly/mysqlclient-sys", rev = "acd1b2b" }
設定まわり
serverless.ymlに環境変数として conf/dev.ymlを読み込むように指定あります。
そのため、プログラムからは環境変数を使います。(※機密情報はSecretManagerやIAM認証を使うことが推奨されています)
ENV: "dev"
DB_USER: user
DB_PASSWORD: password
DB_HOST: [RDS Proxyのエンドポイント].proxy-xxxxxxxx.ap-northeast-1.rds.amazonaws.com
DB_PORT: 3306
DB_NAME: 接続するデータベース名
ソースコード
ここでは 文字列 phone をパラメータとして受け取り、該当する複数のstoreレコードをjsonとして返すAPIを例にしています。
main
やっとRustのソースコードです。
関数毎のエントリポイントを src/bin/func1.rs に書きます。
asyncがデフォルトになりました。
そろそろjsの感覚で適当に並行プログラミングから逃げられなくなってくる頃合いでしょうか。
いろいろなライブラリがasync対応して非同期処理がデフォルトになってきています。
#![deny(warnings)]
use lambda_runtime::{handler_fn, Context, Error};
use log::LevelFilter;
use simple_logger::SimpleLogger;
use simple_bot::application::func1::{self, Event, Response}; // 処理自体と型定義はここ
use simple_bot::model::error::ApplicationError;
use simple_bot::repository::rdb::Rdb; // ここでSQLを実装しています
#[tokio::main]
async fn main() -> Result<(), Error> {
SimpleLogger::new()
.with_level(LevelFilter::Info)
.init()
.unwrap();
lambda_runtime::run(handler_fn(handler)).await?;
Ok(())
}
async fn handler(e: Event, _: Context) -> Result<Response, ApplicationError> {
let r = func1::main::<Rdb>(e)?; // ここでSQL実装をインジェクションします。テスト時はここをモックに差し替えます
Ok(r)
}
application
アプリケーションロジックです
selectしたレコードの配列から、レスポンス用の配列に変換するのにトレイトの実装が役立ちます!
use serde::Deserialize;
use crate::model::error::ApplicationError;
use crate::model::rdb::RdbRepo;
use crate::model::store::ResponseStore;
// パラメータです
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Event {
phone: String,
}
// 結果
pub type Response = Vec<ResponseStore>;
pub fn main<DB: RdbRepo>(e: Event) -> Result<Response, ApplicationError> {
// 接続を作って、、、
let db = DB::new()?;
// ここでSELECT
let stores = db.get_store_by_phone(e.phone)?;
// ここでテーブルのレコードから結果の型に変換しています。(処理は下記のmodelを参照)
Ok(stores.into_iter().collect())
}
model
今回は簡単なテーブルをselectするだけです。
ポイントとしては、MySQLのDATETIMEはタイムゾーンがないので、chrono::NativeDateTimeで受け取るところです。
String型で受け取るにはDB接続のパラメータに ?parse=true かなんかが必要だった気がします。(このコードではStringで受け取ろうとするとエラーになります)
実用面で言えば、テーブルの型情報と実際にAPIで返す型とは当然別の型にする訳ですが、
Goにしろ他の言語にしろ、変換する際にカラムの見落としがあったり処理が重複したりしていました。
Rustだとカラムの代入もれはコンパイル時エラーになりますし、FromIteratorで変換処理を一箇所にまとめることができます。(素晴らしい!)
use std::iter::FromIterator;
// DATETIME型を
use chrono::NaiveDateTime;
use serde::Serialize;
#[derive( Clone)]
pub struct Store {
pub id: i32,
pub phone: String,
pub name: String,
pub detail: String,
pub link: String,
pub image: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
// これがAPIで返す型です (updated_atとか要らないですからね)
#[derive(Serialize, Debug, Clone)]
pub struct ResponseStore {
pub id: i32,
pub phone: String,
pub name: String,
pub detail: String,
pub link: String,
pub image: String,
}
// selectで複数レコード取得した情報をこのトレイトの実装で一箇所にまとめることができる。
// 上のapplicationのソースを参照
// もう、配列を変換するのに for ... { 変換処理() } とか書かなくて良い!
impl FromIterator<Store> for Vec<ResponseStore> {
fn from_iter<I: IntoIterator<Item=Store>>(iter: I) -> Self {
let mut v = Vec::new();
for i in iter {
v.push(ResponseStore {
id: i.id,
phone: i.phone,
name: i.name,
detail: i.detail,
link: i.link,
image: i.image,
});
}
v
}
}
エラーはthiserrorクレートを使うと楽チンです。
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ApplicationError {
#[error("{0} is not found.")]
NotFound(String),
#[error("json error. {0}")]
Json(#[from] serde_json::Error),
#[error("rds error. {0}")]
RdbQuery(#[from] diesel::result::Error),
#[error("rds connection error. {0}")]
RdbConnection(#[from] diesel::result::ConnectionError),
}
データベースの処理
まずはテストで処理を差し替えられるように、トレイトと実装を分けておきます。
trait
こんな感じでインターフェースを定義します。
use crate::model::error::ApplicationError;
use crate::model::store::Store;
pub trait RdbRepo where Self: Sized {
fn new() -> Result<Self, ApplicationError>;
fn get_store_by_phone(&self, phone: String) -> Result<Vec<Store>, ApplicationError>;
}
実装
実際にMySQLに接続したりクエリを投げたりするところです。
ポイントとしてはDATETIME or TIMESTAMPのようにあいまいな場合はちゃんと型指定する必要があるところでしょうか。
NULLかもしれないカラムに対しては、json同様Optionで対応します。
use std::env;
use chrono::NaiveDateTime;
use diesel::mysql::{Mysql, MysqlConnection};
use diesel::deserialize::QueryableByName;
use diesel::sql_query;
use diesel::sql_types::{Text, Datetime};
use diesel::prelude::*;
use crate::model::rdb::RdbRepo;
use crate::model::store::Store;
use crate::model::error::ApplicationError;
// SELECTの結果から構造体にバインドする処理です。
// model定義の時にderiveで処理を追加することもできますが、モデル定義と実装を分けたかったので自分でトレイトを実装しています。
impl QueryableByName<Mysql> for Store {
fn build<R: diesel::row::NamedRow<Mysql>>(
row: &R,
) -> diesel::deserialize::Result<Self> {
Ok(Store {
id: row.get("id")?,
phone: row.get("phone")?,
name: row.get("name")?,
detail: row.get("detail")?,
link: row.get("link")?,
image: row.get("image")?,
created_at: row.get::<Datetime, _>("created_at")?,
updated_at: row.get::<Datetime, _>("updated_at")?,
})
}
}
pub struct Rdb {
db: MysqlConnection,
}
impl RdbRepo for Rdb {
// 接続するところ
fn new() -> Result<Self, ApplicationError> {
let database_url = format!(
"mysql://{}:{}@{}:{}/{}?parseTime=true",
env::var("DB_USER").unwrap(),
env::var("DB_PASSWORD").unwrap(),
env::var("DB_HOST").unwrap(),
env::var("DB_PORT").unwrap(),
env::var("DB_NAME").unwrap(),
);
let conn = MysqlConnection::establish(&database_url)?;
Ok(Rdb { db: conn })
}
// SELECTするところ
fn get_store_by_phone(&self, phone: String) -> Result<Vec<Store>, ApplicationError> {
Ok(sql_query("SELECT * FROM stores WHERE phone = ?")
.bind::<Text, _>(phone)
.load(&self.db)?)
}
... // クエリをいろいろ実装していく
}
デプロイ・実行
デプロイするにはserverless frameworkは使いません。
rust-musl-builderをベースとしたdockerでビルドし、それぞれbootstrapという名前にしてから、関数名.zipにします。
build:
docker run --rm -it \
-v "$(CURDIR)":/home/rust/src \
-v cargo-git:/home/rust/.cargo/git \
-v cargo-registry:/home/rust/.cargo/registry \
-v "$(CURDIR)"/target:/home/rust/src/target \
rust-musl-builder:1.51.0 \
cargo build
package:
cp target/x86_64-unknown-linux-musl/debug/func1 /tmp/bootstrap
zip -j ./func1.zip /tmp/bootstrap
deploy: package
npx sls deploy --verbose --stage dev
出来上がったバイナリはスタティックリンクになっています。
$ ldd -v target/x86_64-unknown-linux-musl/debug/get_store_by_phone
not a dynamic executable
デプロイ
やっとデプロイです。
最終的にこうなっていればOKです。
serverless.yml
src/
...
func1.zip
bootstrap // ビルドされた実行ファイル。 bin/bootstrap とか func1/bootstrap とか func1 ではダメ
func2.zip
bootstrap // 同様
普通にデプロイします。
$ npx sls deploy
確認してみます (データベースを起動していない場合は起動しておくのを忘れずに!)
$ npx sls invoke -f func1 -d '{"phone":"03-1111-22222"}'
[
{"id":1, "name": "store1", ....},
{"id":3, "name": "store3", ....}
]
おまけ : ハマりどころポイントまとめ
さて、なかなかエラーやらうまくいかないところやら多々あったのですが、
つらつらと書いていたら長くてあまり役に立たないような気がしたので雑ですが覚書程度に残しておきます。
serverless-rustの本体バージョンが古い (1.45.0)
LabmdaでRust開発するなら、serverless framework + serverless-Rustという方法があるかと思います。
ところがこのプラグインで使われているコンテナのRustのバージョンは1.45.0です。
serverless-rustを使うか、使わないのか
serverless-rustプラグインは複数関数に対応していて便利な反面、上記のバージョンが古い問題に加えて幾つかの問題があります。
cargo test が気軽にできない、関数ごとに毎回ビルドが走る、ターゲット指定ができない、フラグが渡しにくい
などなど痒いところに手が届きません。
かといって、serverless-rustを使わない場合、どうやってパッケージ化するのかわからない問題があります。
src/bin/*.rs をビルドしてbootstrapというファイルにし、それをハンドラ名のzipにすれば良いのですが、
ymlをどう書けば良いのかわかりにくいです。
今回は、Makefileでビルドとパッケージ化までを行い、artifactで関数ごとにzipを指定する方法ができました。
lambdaの複数関数の場合のパッケージについてはこの辺りの公式サイトを参照
https://www.serverless.com/framework/docs/providers/aws/guide/packaging/
ライブラリリンクとGLIBCバージョン依存問題
さて、serverless-rustで使われているコンテナ softprops/lambda-rust
は確かにバージョン1.45.0と古いですが、
githubの方は1.51.0ですので、こちらは使えそうです。
libmysqlclient が必要
MySQL接続にはdieselを使いますが、普通にビルドすると実行時にlibmysqlclient.soが必要です。
まず、上記のDockerをgit cloneし、Dockerfileに yum update && yum install -y libmysqlclientで、依存ライブラリをインストールします。
さらに、リンクパスが通らないので、lib64/mysql/*.soをlib64/にコピーしたりしてビルドできるようにします。
次に、serverless-rustのpackageフック (.lambda-rsut/package.shにスクリプトを書く)を使ってライブラリを同梱することができます。
しかし、ここまでやってもLambdaにアップロードすると、libc.so.6 … GLIBC2.18が見つからないという実行時エラーが出ます。
Lambdaの実行環境がおそらく Amazon Linux 1であり、どうにかしてAmazon Linux2を使えば良いような感じなのですが、方法がよくわからず、
スタティックリンクするために musl に走ることにします。
結論としては、rust-musl-builderを使ってビルドは手動で行い、デプロイだけserverless-rustを使う感じしました。
エラーポイントまとめ
Lambdaがtimeoutする
MySQLに接続できていない可能性が高い。
– Cloud9からProxy経由で接続できるか確認する。
– セキュリティグループのインバウンドルールに自身のセキュリティグループが許可されているか確認する。
– Lambdaをデプロイする際にRDSのセキュリティグループを指定していることを確認する。
– Lambdaをデプロイする際にRDSと同じVPC・サブネットを指定していることを確認する。
MySQLにつながらない
セキュリティグループのインバウンドルールを見直す
特に3306に自分自身のセキュリティグループを設定していることを確認する。
Couldn’t find bootstrap[s]
hoge_func.zip の直下にboootstrapという実行ファイルが必要
.serverlessの下にデプロイされるファイルが生成されるので中身を確認する。
Lambdaがエラーをはかずに exit する
main() のシグネチャがあっていない。
async fn main() -> Result<(), 独自のError> {} ではだめで
async fn main() -> Result<(), lambda_runtime::Error> {} とする。
invalid utf-8 sequence
dieselでDATETIME型をstring変数で受け取ろうとするとエラーになる。
GLIBC2.18がうんたらかんたら
libcのバージョン依存の問題。
実行環境がAmazon Linux1だと起こるらしい。
Amazon Linux2にしないとどうしようもないらしい。
libc.so.6を同梱したりしても干渉して動かない
muslでシングルバイナリにすべし。
libmysqlclientがないと怒られる
yum install -y libmysqlclient-dev して、musl用のパッチクレートを当てる。