Rustネイティブのクラウド開発プラットフォーム「Shuttle」にWebアプリをデプロイしてみる

最近Rustの勉強がてらにwebアプリをサンプルで作っているんですが、デプロイ時の構成にここしばらく悩んでいました。
個人用のサンプルなのであまり労力やお金がかからないと嬉しいなー…、と思って探していたら、Webアプリのフレームワークとして利用しているActix-webのドキュメント内に「Shuttle」というプラットフォームへのデプロイ手順が書いてありました。

https://www.shuttle.rs/

調べてたらバックエンドのアプリ開発に必要なものがある程度そろっていて、小さなプロジェクトであれば導入も楽そうだったので、試しがてら紹介してみようと思います。

# Shuttleのいいところ

## Rust利用のWebプロジェクトを、外部リソース含めて楽に開発、デプロイできる

公式で「Rustネイティブのクラウド開発プラットフォーム」と書いてあるように、下記のようなRustのwebフレームワークに数行変更を加えれば導入できるようになっています。

- Axum
- Actix-web
- Rocket
- Warp
- …

また、Webアプリを開発するときに発生するインフラやDB等外部リソースの構成を、マクロなどを利用して開発者が手間をかけることなく構築できるようにしています。
これを、ドキュメント内では「Infrastructure from Code」と呼んでいます。

## 無料でもある程度使える

筆者は無料プランで試してみてますが、プロジェクトの構築数は3つ、postgresかMongoDBの共用DBは1GBまで利用可能なので、個人で小規模のWebアプリを置いておくには十分なものが揃っている印象です。

# やってみた

## 弊環境構成

- 言語・ライブラリ
    - Rust 1.78.0
    - Actix-web 4
- 開発環境
    - Github Codespace
- CI/CD
    - Github Actions

## 移行手順

### shuttleにログイン

[公式サイト](https://www.shuttle.rs/)右上の「Log in」から、Githubのアカウントを利用してログインができます。
ログイン後の画面でAPI_KEYが取得できるので、この後の手順のためにコピーしておきます。

### Shuttleのインストール

下記のコマンドで、開発環境にShuttleをインストールします。
（他にも方法はありますが、詳細は[こちら](https://docs.shuttle.rs/getting-started/installation)）
```bash
cargo install cargo-shuttle
```
インストールが終わったら、下記のコマンドでShuttleにログインします。
認証情報を聞かれると思うので、1つ前の手順で取得したAPI_KEYを入力します。
```bash
cargo shuttle login
```
正常終了すれば、ログイン完了です。

### Cargo.tomlへの追記

元々入れていたActix-webに加えて、Shuttle関連のクレートを追加します。

```diff_rust:Cargo.toml
[dependencies]
actix-web = "4"
+ shuttle-actix-web = "0.46.0"
+ shuttle-runtime = "0.46.0"
```

### main.rs、lib.rsの修正

エントリーポイントのマクロと戻り値を、ドキュメントに従って修正します。
弊環境ではmain.rsとlib.rsにファイルを分けて実際の初期化処理はlib.rsに記載していたので、そちら側に書いていたサーバを立てる処理も修正しています。

```diff_rust:main.rs
+ use actix_web::web::ServiceConfig;
+ use shuttle_actix_web::ShuttleActixWeb;

- #[actix_web::main]
- async fn main() -> std::io::Result<()> {
-     sample_project::run_server().await
+ #[shuttle_runtime::main]
+ async fn main() -> ShuttleActixWeb<impl FnOnce(&mut ServiceConfig) + Send + Clone + 'static> {
+     sample_project::run_server()
}
```

```diff_rust:lib.rs
- use std::net::Ipv4Addr;
- use actix_web::{App, HttpServer};
+ use actix_web::web::ServiceConfig;
+ use shuttle_actix_web::ShuttleActixWeb;

mod controller {
    pub mod root;
}

- pub async fn run_server() -> std::io::Result<()> {
-     HttpServer::new(|| {
-         App::new()
-             .service(controller::root::index)
-     })
-     .bind((Ipv4Addr::UNSPECIFIED, 8080))?
-     .run()
-     .await
+ pub fn run_server() -> ShuttleActixWeb<impl FnOnce(&mut ServiceConfig) + Send + Clone + 'static> {
+     let config = move |cfg: &mut ServiceConfig| {
+         cfg.service(controller::root::index);
+     };
+ 
+     Ok(config.into())
}
```

## 実行

下記のコマンドを実行すると、開発環境が立ち上がります。
```bash
cargo shuttle run
```

## デプロイ

### まずはローカルからCLIでデプロイ

まず下記のコマンドで、Shuttle内にプロジェクトを作成します。
作成されるプロジェクトは、開いているRustプロジェクトの名称になります。
```bash
cargo shuttle project start
```

プロジェクトが作成されたら、下記のコマンドを実行することでshuttle内の環境にデプロイされます。

```bash
cargo shuttle deploy --allow-dirty
```

### Github ActionsでのCI/CD構築

コマンドでデプロイした環境が正常に動いているのを確認したら、CI/CDの構築も試してみます。
Github actionsの中にShuttleへのデプロイ処理が用意されているので、それを利用します。

```yaml
name: Shuttle Deploy

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: shuttle-hq/deploy-action@main
        with:
          deploy-key: ${{ secrets.SHUTTLE_API_KEY }}
```

Shuttleのダッシュボードで取得したAPI_KEYを、リポジトリのシークレットにSHUTTLE_API_KEYとして設定し、ワークフローを実行します。
正常終了すれば成功です。

## DBアクセスの追加

続いて、RDBへのアクセスを構成します。
今回はShuttleが共用DBとして用意しているpostgreSQLへの接続を追加します。

### Dockerの準備

postgreSQLへの接続構成を追加すると、`cargo shuttle run`を実行した際に内部でpostgreSQLのコンテナを追加し接続を構成するので、あらかじめ開発環境にDockerを入れておきます。

弊環境はCodespaceなので、設定ファイルにDockerを追加しています。

```diff_json:.devcontainer.json
{
	"name": "Rust",
	"image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye",
-     "features": {}
+     "features": {
+         "ghcr.io/devcontainers/features/docker-in-docker:2": {}
+     }
}
```

### Cargo.tomlへの追記

共用DBアクセス用のクレートを追加します。
接続にはsqlxというライブラリを利用します。
```diff_rust:Cargo.toml
[dependencies]
actix-web = "4"
tera = "1"
serde = { version = "1.0", features = ["derive"] }
shuttle-actix-web = "0.46.0"
shuttle-runtime = "0.46.0"
+ shuttle-shared-db = { version = "0.46.0", features = ["postgres", "sqlx"] }
+ sqlx = "0.7.1"
```

### main.rs、lib.rsの修正

ドキュメントに従って、main.rsとlib.rsにコネクションプールの情報を追記します。
エントリーポイントにマクロ付きで引数を追加すると、実行時やデプロイ時にDBとそこへの接続を勝手に構成してくれます。
```diff_rust:main.rs
use actix_web::web::ServiceConfig;
use shuttle_actix_web::ShuttleActixWeb;
+ use sqlx::PgPool;

#[shuttle_runtime::main]
- async fn main() -> ShuttleActixWeb<impl FnOnce(&mut ServiceConfig) + Send + Clone + 'static> {
-     htmx_in_rust::run_server()
+ async fn main(
+     #[shuttle_shared_db::Postgres] pool: PgPool,
+ ) -> ShuttleActixWeb<impl FnOnce(&mut ServiceConfig) + Send + Clone + 'static> {
+     htmx_in_rust::run_server(pool)
}
```

```diff_rust:lib.rs
- use actix_web::web::ServiceConfig;
+ use actix_web::web::{self, ServiceConfig};
use shuttle_actix_web::ShuttleActixWeb;
+ use sqlx::PgPool;

mod controller {
    pub mod root;
}

+ #[derive(Clone)]
+ struct AppState {
+     pool: PgPool,
+ }

- pub fn run_server() -> ShuttleActixWeb<impl FnOnce(&mut ServiceConfig) + Send + Clone + 'static> {
+ pub fn run_server(pool: PgPool) -> ShuttleActixWeb<impl FnOnce(&mut ServiceConfig) + Send + Clone + 'static> {
    let config = move |cfg: &mut ServiceConfig| {
+         let state = web::Data::new(AppState { pool });

        cfg.service(controller::root::index);
+         cfg.app_data(state);
    };

    Ok(config.into())
}
```

### 実行、デプロイ

編集後に `cargo shuttle run` でローカル実行すると、先述の通りpostgreSQLのコンテナが実行されます。
Shuttleの環境にデプロイすると、該当アプリ内のリソースにpostgresのサービスが追加されます。

# 参考

https://actix.rs/docs/shuttle

https://www.shuttle.rs/