Rust＋Locoでサービス開発してShuttleにデプロイまで試してみる

# Locoとは

RailsにインスパイアされたRust製のウェブアプリケーションフレームワークです。

https://loco.rs/

主な機能はこんな感じです。Railsに慣れていればとても親しみやすい構成です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64098/ae3fc5d1-2abc-75d7-4ecf-a4e97ad599da.png)

※MVCではありますが、ViewはJSONがデフォルトなので、RailsのAPIモードのような感じです。

# Shuttleとは

Rust用のHerokuのような感じです。Rustアプリケーションを簡単にデプロイできます。
Postgresqlなどのサービスもある程度無料で使えます。
Heroku同様、コマンド１つでデプロイできてSSLのURLが付与されて即アクセスできて便利です。

https://www.shuttle.dev/

# 作ったもの

[haikunator](https://github.com/usmanbashir/haikunator) でランダムな命名を生成するサービスです。
調べれば他にいくらでもありそうではありますが、今回はRustの練習がてら作ってみました。

https://haikunator-generator.shuttle.app/

![screenshot_top.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64098/e21dd502-1288-3ec3-a7cf-9788e2692515.png)

https://github.com/goosys/muddy-wildflower-779

# 作っていく

## 環境構築

今回はGitHub Codespeces上にDevContainerで構築していきます。
Ctrl＋P → Add Dev Container Configuration Files と選択していき、Rustのコンテナを構成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64098/a7201ef8-ec83-6c20-b444-f74ab4008348.png)

Dev Containerの構成ファイルができたので、Locoを使えるように少し修正していきます。

```json:.devcontainer/devcontainer.json
{
    "name": "Loco",
    "dockerComposeFile": "compose.yaml",
    "postCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
    "service": "app",
    "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
    "forwardPorts": [
        5150, 5153
    ],
    "features": {
        "ghcr.io/devcontainers/features/node:1": {}
    }
}
```

- `image`を削除し、Dockerfileとcompose.ymlを使うように変更する
- フロントエンドはJSにしたいのでfeaturesでnodeを入れる
- フロントエンドのホットリロード用に5153ポートを空ける（5150はLoco用）

```Dockerfile:.devcontainer/Dockerfile
FROM mcr.microsoft.com/devcontainers/rust:1

RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
     && cargo install loco-cli cargo-insta cargo-cache cargo-shuttle \
     && chown -R vscode /usr/local/cargo

COPY .env /.env
```

- LocoのCLIやShuttleのCLIなどを入れる

ここまで変更したら、Dev Containerをビルドします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64098/7648a08a-7525-f8a0-330a-f29e05ab5743.png)

※すごく時間がかかるので、気長に待ちます。（１５分くらい）

### 環境確認

ビルドが終わったら各種コマンドを確認してみます。

```console
$ rustc --version
rustc 1.86.0 (05f9846f8 2025-03-31)
$ cargo --version
cargo 1.86.0 (adf9b6ad1 2025-02-28)
$ loco --version
loco 0.15.0
$ cargo shuttle --version
cargo-shuttle 0.53.0
$ npm --version
10.9.2
$ pnpm --version
10.9.0
```

## Loco new

`loco new` コマンドで雛形を作ります。
lightweight-serviceはあまりにもminimalするぎるので、一旦SaaS Appを作成します。不要なものはあとで全部消します。

```console
$ loco new
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64098/570caa96-2433-7598-63c7-c34cb8065476.png)

### ディレクトリ構成

このような構成で生成されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64098/6b0392e4-d4ee-6815-7ad7-eb21600331ad.png)

## フロントエンド

`frontend/` ディレクトリにReactアプリが生成されているので、一旦そのままビルドします。

```console
$ cd frontend
$ pnpm install
$ pnpm build
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64098/4a322545-08a8-f9c9-bd73-cc04a942a83f.png)

### フロントエンドファイル配信設定

Rust側にある`static`フォルダを配信するかフロント側の`dist/`フォルダを配信するか選べるので、今回はフロント側を有効にします。

```yaml:config/development.yml
    #
    # (1) Server-side static assets config
    # ====================================
    #
    # for use with the view_engine in initializers/view_engine.rs
    # static:
    #   enable: true
    #   must_exist: true
    #   precompressed: false
    #   folder:
    #     uri: "/static"
    #     path: "assets/static"
    #   fallback: "assets/static/404.html"
    #

    # (2) Client side app static config
    # =================================
    #
    # Note that you need to go in `frontend` and run your frontend build first,
    # e.g.: $ npm install & npm build
    #
    # (client-block-start)
    static:
      enable: true
      must_exist: true
      precompressed: false
      folder:
        uri: "/"
        path: "frontend/dist"
      fallback: "frontend/dist/index.html"
    # (client-block-end)
    #
```

## Loco start

この状態で試しに起動してみます。
環境に合わせて以下どちらかで起動すると思います。

```console
$ cargo loco start
$ cargo loco start --binding 0.0.0.0
```

無事表示できました。 :raised_hands:

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64098/b1c64406-af02-7a97-34af-d6f70cb689f5.png)

:::note warn
GitHub CodeSpacesの場合は、アクセスしても表示されない場合があるので、ポート設定で「公開設定」を一瞬publicにしてまたすぐprivateにしたりしてみると何故かアクセスできるようになったりします。
:::

バックエンドもちゃんと動いていそうです。 :raised_hands:

```console
$ curl localhost:5150/_health
{"ok":true}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64098/53469754-3454-25ae-4725-f65da1666f45.png)

## 不要な機能の削除

現状、雛形で生成されたフロントエンドは何故かバックエンドとはなんの関係もないただのペライチでした。バックエンドにはauthやuserのAPIが実装されていますが、そこと連動した動作までは試せないようでした。

今回はその辺の機能は必要ないため、削除していきます。
また、database周りやworker、mailerなども不要なので削除していきます。

量が多いので省略しますが、差分はこんな感じです。

https://github.com/goosys/muddy-wildflower-779/commit/b75f48d8f18a1badf7d7f9ac22251bce50a2e082

## Haikunator Generator

環境整備が済んだのでアプリケーションを実装していきます。
ざっくり以下のような感じです。

### API

```rust:src/controllers/haikunator.rs
use axum::debug_handler;
use loco_rs::prelude::*;

use crate::views::haikunator::GeneratorResponse;

#[debug_handler]
async fn generate() -> Result<Response> {
    let generated = haikunator::Haikunator::default().haikunate();
    format::json(GeneratorResponse::generate(&generated))
}

async fn generate_txt() -> Result<Response> {
    let generated = haikunator::Haikunator::default().haikunate();
    format::text(&generated)
}

pub fn routes() -> Routes {
    Routes::new()
        .prefix("api")
        .add("/gen", get(generate))
        .add("/gen.txt", get(generate_txt))
}
```

### フロントエンド

```tsx:frontend/src/pages/HaikunatorGenerator.tsx
'use client'

import { useState } from 'react'

export const HaikunatorGenerator = () => {
  const [generatedString, setGeneratedString] = useState<string>('')

  const generateString = async () => {
    try {
      const response = await fetch('/api/gen')
      if (response.ok) {
        const data: { name: string } = await response.json()
        setGeneratedString(data.name)
      } else {
        throw new Error('Failed to generate string')
      }
    } catch (error) {
      console.error(error)
    }
  }

  return (...)
}
```

### ４０４ページ

デフォルトのfallbackのままだと、存在しないパスにアクセスした場合にも全てindexページがレスポンスされてしまうので、404ページを返すようにします。

```tsx:frontend/src/404.tsx
import React from "react";
import ReactDOM from "react-dom/client";

import "./index.css";

const root = document.getElementById("root");

if (!root) {
  throw new Error("No root element found");
}

ReactDOM.createRoot(root).render(
  <React.StrictMode>
    <div className="flex items-center justify-center h-screen">
      <h1 className="text-4xl font-bold text-gray-500">404 Not Found</h1>
    </div>
  </React.StrictMode>
);
```

```yaml:config/development.yml
  middlewares:
    fallback:
      enable: false
    # (client-block-start)
    static:
      ... 
      fallback: "frontend/dist/404.html"
    # (client-block-end)
```

他にもいくつか設定していくと、こんな感じに表示できました。 :raised_hands:
レスポンスステータスもきちんと404でした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64098/223ceb27-e660-9f31-d78d-dc18b177a673.png)

## Shuttleにデプロイ

Shuttleへはとても簡単にデプロイできるので詳細は省略します。

<details><summary>
注意点としては、ShuttleにはGit管理下のファイルしか転送できないため、フロントエンドのビルド成果物は一時的にでもGit管理下に追加する必要があります。
（あまりにも不便なので今後改善されると期待してます）
</summary>

これを踏まえて、デプロイの流れは以下のようになります。

```console
$ pushd frontend
$ echo !dist/ >> .gitignore
$ pnpm build
$ popd
$ cargo shuttle login
$ cargo shuttle deploy --allow-dirty
$ git checkout frontend/.gitignore
```

1. `frontend/dist` を一時的にGit管理に追加（無視を無視）
1. フロントエンドをビルド
1. Shuttleにログイン
1. Shuttleにデプロイ
1. 一時的な変更を元に戻す

ちょっとめんどいですが、デプロイも自動化する前提であれば大したことではない気もします。
</details>

:::note info
追記：shuttle.rsからshuttle.devへのプラットフォームアップデート後、上の問題は解消されました。
https://docs.shuttle.dev/platform-update/migration
:::

ビルドする前に、Shuttle.tomlで以下のようにconfigファイルと静的アセットを追加します。

```toml:Shuttle.toml
[deploy]
include = [
    "config/production.yaml",
    "frontend/dist/*",
    "assets/*"
]

[build]
assets = [
    "config/production.yaml",
    "frontend/dist/*",
    "assets/*"
]
```

あとはフロントをビルドしてShuttleにdeployするだけです。

```console
$ pushd frontend
$ pnpm build
$ popd
$ cargo shuttle login
$ cargo shuttle deploy
```

# おわり

以上です。
とても簡単にサービスをひとつリリースできてしまいました。

Locoの所感としては、Railsのようなフルスタックフレームワークというよりも、Rustでバックエンドを構築する際の雛形として優秀という印象でした。
今回のように、使わない機能は全て削除してしまえるし、雛形で用意されているモジュールが自分好みでなければ差し替えてしまうこともできるしといった感じで、柔軟に使えるような気がしました。