Shuttleを使ってRustアプリケーションをデプロイする
Shuttle は Rust ネイティブのクラウド開発プラットフォームです。
大きな特徴として、アプリケーションのコードにマクロを宣言することでプラットフォーム上にリソースをプロビジョニングできます。この機能は Shuttle 上で Infrastructure from Code
と表現されています。
例えば、PostgreSQL をプロビジョニングしたい場合はアプリケーションコードに次のようなコードを書きます。
#[shuttle_runtime::main]
async fn axum(#[shuttle_shared_db::Postgres] pool: PgPool) -> shuttle_axum::ShuttleAxum {
...code
}
#[shuttle_shared_db::Postgres]
マクロを使うだけでデータベースをプロビジョニングできます。すごいですね。
現在、サポートされているリソースは以下のものとなります。
- Shuttle AWS RDS
- AWS RDS 上のデータベース
- Shuttle Persist
- KVS
- Shuttle Shared Databases
- Shuttle 上の他のサービスと共有されるデータベース
- Shuttle Secrets
- シークレットマネージャー
- Shuttle Static Folder
- ランタイムで静的フォルダへのパスを取得可能
また、チュートリアルや example も豊富に展開されています。
今回はチュートリアルの URL 短縮アプリ を試してみました。筆者は Axum が好きなので、 Rocket で実装されているところを Axum を使って実装しています。
ソースコードはこちらです。
Shuttle CLI をインストールする
今回は cargo-binstall を使ってインストールするので事前にインストールしておいてください。
cargo-shuttle
のバイナリをインストールします。
$ cargo binstall cargo-shuttle
Shuttle にログインします。
API Key を求められるのでログイン後の画面から取ってきたものを入力します。
$ cargo shuttle login
プロジェクトの作成
cargo-shuttle
を使ってプロジェクトを作ります。
$ cargo shuttle init
対話形式になっているので進めていきますが、プロジェクトの名前はグローバルに一意なので注意してください。
作成が終わったらローカルで起動できる状態になります。
$ cd <あなたが設定したプロジェクト名> && cargo shuttle run
http://localhost:8000/hello で Hello World!
の文字列が返ってくるはずです。
試しにデプロイすると https://あなたが設定したプロジェクト名.shuttleapp.rs/hello で同じ結果が得られます。
--allow-dirty
は Git コミットしていない場合にもデプロイできるようにするオプションです。
コミット済みの場合は不要です。
$ cargo shuttle deploy --allow-dirty
実装
Cargo.toml
このアプリケーションを作るために必要なモジュールたちです。
[package]
name = "あなたが設定したプロジェクト名"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.6.18"
shuttle-axum = "0.21.0"
shuttle-runtime = "0.21.0"
shuttle-shared-db = { version = "0.21.0", features = ["postgres"] }
tokio = "1.28.2"
sqlx = { version = "0.6.2", features = ["runtime-tokio-native-tls", "postgres"] }
nanoid = "0.4.0"
url = "2.3.1"
serde = "1.0.163"
serde_json = "1.0.96"
thiserror = "1.0.40"
mime = "0.3.17"
マイグレーションファイル
sqlx のマイグレーション機能を使うので、migrations/yyyymmddhhmmss_init.sql
を作ります。
CREATE TABLE IF NOT EXISTS urls (
id VARCHAR(6) PRIMARY KEY,
url VARCHAR NOT NULL
);
エンドポイントを実装
最初にエンドポイントを実装します。
- sqlx を使ってマイグレーション
- Router に path と handler を指定
-
with_state
を使ってコネクションプールをハンドラで受け取れるようにする - アプリケーションの起動
#[shuttle_runtime::main]
async fn axum(#[shuttle_shared_db::Postgres] pool: PgPool) -> shuttle_axum::ShuttleAxum {
sqlx::migrate!()
.run(&pool)
.await
.expect("Failed to migrate database");
let router = Router::new()
.route("/", post(shorten))
.route("/:id", get(redirect))
.with_state(pool);
Ok(router.into())
}
handler を実装
このアプリケーションは以下の handler を必要とします。
-
/
に URL が入った JSON を入れて POST すると短縮された URL が返ってくる -
/id
で短縮元の URL にリダイレクトする
#[derive(Serialize, Deserialize, Debug)]
pub struct ApiError {
pub message: String,
pub status_code: u16,
}
// アプリケーション上のエラー定義
#[derive(Error, Debug)]
enum AppError {
#[error("Database Error: {0}")]
DatabaseError(sqlx::Error),
#[error("URL Parse Error")]
URLParseError,
}
// アプリケーション上のエラーをレスポンスとして返せるようにAppErrorにIntoResponseトレイトを実装する
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let code = match self {
AppError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::URLParseError => StatusCode::UNPROCESSABLE_ENTITY,
};
(
code,
[(
header::CONTENT_TYPE,
HeaderValue::from_static(mime::APPLICATION_JSON.as_ref()),
)],
Json(ApiError {
status_code: code.as_u16(),
message: self.to_string(),
}),
)
.into_response()
}
}
#[derive(serde::Deserialize)]
pub struct Input {
url: String,
}
// URLを短縮する handler
async fn shorten(State(pool): State<PgPool>, input: Json<Input>) -> Result<String, AppError> {
let id = nanoid::nanoid!(6);
let parsed_url = Url::parse(&input.url).map_err(|_| AppError::URLParseError)?;
sqlx::query("INSERT INTO urls (id, url) VALUES ($1, $2)")
.bind(&id)
.bind(parsed_url.as_str())
.execute(&pool)
.await
.map_err(|err| AppError::DatabaseError(err))?;
Ok(format!("https://<あなたが設定したプロジェクト名>.shuttleapp.rs/{id}"))
}
#[derive(Serialize, FromRow)]
struct Urls {
id: String,
url: String,
}
// 短縮したURLを受け取ってリダイレクトする handler
async fn redirect(
State(pool): State<PgPool>,
Path(id): Path<String>,
) -> Result<Redirect, AppError> {
let url = sqlx::query_as::<_, Urls>("SELECT * FROM urls WHERE id = $1")
.bind(&id)
.fetch_one(&pool)
.await
.map(|u| u.url)
.map_err(|err| AppError::DatabaseError(err))?;
Ok(Redirect::to(&url))
}
実装全体
デプロイ
アプリケーションは準備が整ったのでいよいよ Shuttle にデプロイします。
$ cargo shuttle deploy --allow-dirty
以下のように POST すると短縮した結果が返ってくるので、 URL にアクセスすると元の URL にリダイレクトするはずです。
$ curl -X POST -H "Content-Type: application/json" -d '{"url":"https://www.youtube.com/"}' https://<あなたが設定したプロジェクト名>.shuttleapp.rs
https://<あなたが設定したプロジェクト名>.shuttleapp.rs/<id>
さいごに
マクロを定義することで必要なリソースをプロビジョニングしてくれる体験はとても良かったです。
この思想は Vercel の Fdi と同じ印象を受けました。
未来に向けても様々な構想があるようなので楽しみにしたいです。個人的には独自ドメインがほしいです。
みなさんもぜひ試してみてくださいね!
Discussion