🌓

Rust | Axum プロジェクトを Shuttle にデプロイする

2023/11/06に公開

Shuttle とは

https://www.shuttle.rs/

Shuttle は、すべてのインフラストラクチャを管理しながらアプリをデプロイできる Rust ネイティブのクラウド開発プラットフォームです🚀

アプリケーションの構築とデプロイを簡単にすること を目標としています。

Shuttle の Infrastructure from Code は、リソースのプロビジョニングがシンプルかつ手間のかからない仕組みです。

PostgreSQL をプロビジョニングしたい場合は、アプリケーションコードに以下のようなマクロを追加します。

#[shuttle_runtime::main]
async fn main(
    // automatic db provisioning + hands you back an authenticated connection pool
    #[shuttle_shared_db::Postgres] pool: PgPool,
) -> ShuttleRocket<...> {
    // application code
}

Migrating from Render to Shuttle

codemountains/mountix というプロジェクトは現在 Render で動作しています。

そのプロジェクトを Shuttle に移行してみたいと思います!

Shuttle では examples が充実していますが、
Cargo Workspaces を使ったケースがありませんでした。

以下のドキュメントに沿って、マイグレーションを進めていきます。

  1. Installation
  2. Migrating to Shuttle
  3. Quick Start

マイグレーションされたソースコードは Github でも確認できます。

https://github.com/codemountains/mountix-shuttle

cargo-shuttle をインストール

まずは cargo-shuttle をインストールします。

cargo install cargo-shuttle

次にログインします。

ログインには API Key の入力が必要です。

cargo shuttle login

クレートの追加

Shuttle 環境で Axum 実行させるために必要なクレートを追加していきます。

cargo add shuttle-runtime shuttle-axum
cargo add shuttle-secrets

ソースコードのマイグレーション

src/main.rs

src 直下に main.rs を配置します。

mountix-driver
├── Cargo.toml
├── Secrets.dev.toml
├── Secrets.toml
├── Secrets.toml.example
└── src
    ├── lib.rs
    ├── main.rs  (contains #[shuttle_runtime::main])
    ├── model
    │   ├── information.rs
    │   ├── mod.rs
    │   ├── mountain.rs
    │   └── surrounding_mountain.rs
    ├── module
    │   └── mod.rs
    ├── routes
    │   ├── health.rs
    │   ├── information.rs
    │   ├── mod.rs
    │   ├── mountain.rs
    │   └── surrounding_mountain.rs
    └── startup
        └── mod.rs

codemountains/mountix のプロジェクト構成の状態で、
mountix-driver/src/bin/bootstrap.rs のままでは実行時にエラーで動きませんでした。

ドキュメントを見ても、src 直下に main.rs (contains #[shuttle_runtime::main]) があることが分かります。

.
├── .gitignore
├── Cargo.toml
├── Shuttle.toml      (optional)
├── backend
│   ├── Cargo.toml
│   └── src
│       └── main.rs   (contains #[shuttle_runtime::main])
├── frontend
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── shared
    ├── Cargo.toml
    └── src
        └── lib.rs

main.rs の修正

主な変更点です。

  • #[shuttle_runtime::main] に修正
  • #[shuttle_shared_db::MongoDb] で MongoDB をプロビジョニング
  • shuttle_secrets::Secrets でシークレット情報を取得して環境変数に書き込む
    • Axum の State で引き回せるようにするなど、改善の余地あり?
use mountix_driver::module::Modules;
use mountix_driver::startup::startup;
use shuttle_secrets::SecretStore;
use std::sync::Arc;
use tracing::info;

#[shuttle_runtime::main]
pub async fn axum(
    #[shuttle_secrets::Secrets] secret_store: SecretStore,
    #[shuttle_shared_db::MongoDb] db: Database,
) -> shuttle_axum::ShuttleAxum {
    let _ = write_env(&secret_store);

    let modules = Modules::new(db).await;
    let router = startup(Arc::new(modules)).await;

    Ok(router.into())
}

// Write to environment variables
// See more: https://docs.shuttle.rs/resources/shuttle-secrets#caveats
fn write_env(secret_store: &SecretStore) {
    info!("Write to environment variables");
    let mountains_url = secret_store
        .get("MOUNTAINS_URL")
        .expect("MOUNTAINS_URL is undefined.");
    std::env::set_var("MOUNTAINS_URL", mountains_url);
    let documents_url = secret_store
        .get("DOCUMENTS_URL")
        .expect("DOCUMENTS_URL is undefined.");
    std::env::set_var("DOCUMENTS_URL", documents_url);
    let default_distance = secret_store
        .get("DEFAULT_DISTANCE")
        .expect("DEFAULT_DISTANCE is undefined.");
    std::env::set_var("DEFAULT_DISTANCE", default_distance);
    let max_distance = secret_store
        .get("MAX_DISTANCE")
        .expect("MAX_DISTANCE is undefined.");
    std::env::set_var("MAX_DISTANCE", max_distance);
}

.env で環境変数として管理していた部分は、SecretStore をうまく扱うことで改善できそうです。

今回はドキュメントに記載があった「シークレットをロードした後 (ライブラリをロードする前) に変数を手動で設定する」という回避策で対応しています。

この部分に関しては、Axum の State で引き回せるようにする方が良いのかもしれません。

dotenv と tracing

以下のような、dotenv の初回の読み取りや tracing の初期化は不要です。

pub fn init_app() {
    dotenv().ok();
    tracing_subscriber::fmt::init();
}

dotenvshuttle_secrets::Secrets が変わりとなります。

また、デフォルト機能として tracing の初期化が shuttle-runtime に含まれています。

詳細は以下のドキュメントを確認してください。

初期データのインサート

マスタデータとして、1059 件の山岳情報一覧を登録しておく必要があります。

ただ、1059 件の配列やベクタを生成しようとした際に以下のエラーに遭遇しました。

thread 'tokio-runtime-worker' has overflowed its stack
fatal runtime error: stack overflow

おそらく、規定のスタックサイズである 2 MB をオーバーしているものと思われます。

苦肉の策として、初期データを 4 つに分割しています。(init_data_1 ~ init_data_4

use std::sync::Arc;

use crate::model::mountain::MountainDocument;
use crate::persistence::{init_data_1, init_data_2, init_data_3, init_data_4};
use mongodb::Database;

#[derive(Clone)]
pub struct Db(pub(crate) Arc<Database>);

impl Db {
    pub async fn new(db: Database) -> Db {
        // insert initial data
        let _ = init(db.clone()).await;

        Db(Arc::new(db))
    }
}

async fn init(db: Database) {
    let count = db
        .collection::<MountainDocument>("mountains")
        .count_documents(None, None)
        .await
        .expect("Failed to connect database.");

    if count > 0 {
        return;
    }

    // MEMO: To prevent overflow, `mountains` was divided
    //
    // ```
    // thread 'tokio-runtime-worker' has overflowed its stack
    // fatal runtime error: stack overflow
    // ```
    for idx in 1..=4 {
        let mountains = match idx {
            1 => init_data_1(),
            2 => init_data_2(),
            3 => init_data_3(),
            _ => init_data_4(),
        };

        let _ = db
            .collection("mountains")
            .insert_many(mountains, None)
            .await
            .expect("Failed to insert initial data.");
    }
}

デバッグ(ローカル実行)

以下のコマンドでローカル実行することができます。

cargo shuttle run

なお、MongoDB は Docker 上で起動します。

If you do not specify a local_uri, then cargo-shuttle will attempt to spin up a Docker container and launch the database inside of it. For this to succeed, you must have Docker installed and you must also have started the Docker engine. If you have not used Docker before, the easiest way is to install the desktop app and then launch it in order to start the Docker engine.

Shuttle Shared Databases - Shuttle

デプロイ

Shuttle.toml を作成し、name を設定します。

name = "my-unique-app-name-here"

あとは、次のコマンドを実行するだけです。

cargo shuttle project start
cargo shuttle deploy

これでプロジェクトがデプロイされます🚀🚀🚀

注意

MongoDB Atlas 上に構築したデータベースに Shuttle から接続する場合、0.0.0.0/0 をホワイトリストに追加する必要があります。

0.0.0.0/0 を許容するのはちょっと... という思いもあり、
MongoDB も Shuttle Shared Databases に移行させてみました。

ちなみに、Render ではダッシュボードから IP アドレスを確認することができます。

まとめ

非常に簡単にデプロイまで進めることができました!

Rust に特化したプラットフォームというのも面白いですよね。

ドキュメントを見ていく中で、Shuttle-next という WASM Web フレームワークがあることも知りました...

気になる...👀

また触ってみようと思います🫣✨

参考

コラボスタイル Developers

Discussion