🚀

Shuttleを使ってRustアプリケーションをデプロイする

2023/07/15に公開

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 を使って実装しています。

ソースコードはこちらです。
https://github.com/Colt45s/mijikaku

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/helloHello 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 を作ります。

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 を使ってコネクションプールをハンドラで受け取れるようにする
  • アプリケーションの起動
main.rs
#[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))
}

実装全体

https://github.com/Colt45s/mijikaku/blob/main/src/main.rs

デプロイ

アプリケーションは準備が整ったのでいよいよ 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 と同じ印象を受けました。
未来に向けても様々な構想があるようなので楽しみにしたいです。個人的には独自ドメインがほしいです。
みなさんもぜひ試してみてくださいね!

https://www.shuttle.rs/beta#06
https://github.com/orgs/shuttle-hq/projects/4

株式会社モニクル

Discussion