LambdaでFastAPI + NuxtJSなアプリ環境を構築してみる

気軽にWebアプリが作れて、ローカルでの検証もしやすくて、本番デプロイも簡単。
そんな構成を目指して、FastAPIとNuxtJS(SPA)がLambda上で動作するアプリ環境をServerlessFrameworkで構築してみました。

![structure.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92383/382c0b75-055a-bd19-a9b1-8567300cfced.png)


ソースコードはこちら

https://github.com/ng3rdstmadgke/sls-fastapi-nuxt-sample

# 前提条件
ServerlessFrameworkを使います。
nodejsとpython3.8とdockerをインストールしておいてください。

# ServerlessFrameworkインストール & プロジェクト作成
ServerlessFrameworkをグローバルにインストールして、プロジェクトを新規作成します。

```bash
# ServerlessFrameworkをグローバルにインストール
npm install -g serverless
sls --version

# プロジェクト作成
sls create --template aws-python3 --name sample-app --path sample-app
cd sample-app

# pythonのライブラリをlayerとしてまとめるためのプラグインをインストール
sls plugin install -n serverless-python-requirements
```

```ディレクトリ構成
sample-app/
| .gitignore
| handler.py
| package-lock.json
| package.json
| serverless.yml
```

serverless.ymlにデプロイ先リージョンと、**serverless-python-requirements** プラグインを利用する設定を追記しておきましょう。

```serverless.yml
provider:
  region: ap-northeast-1

plugins:
  - serverless-python-requirements
```


# Lambda上でFastAPIを動作させる

## APIGatewayの設定

APIGatewayでLambdaプロキシ統合を設定していきます。
ルーティングはLambda内のFastAPIが担当しますので、APIGatewayはすべてのリクエストを同じLambdaに渡せばよいです。

https://www.serverless.com/framework/docs/providers/aws/events/apigateway#lambda-proxy-integration

**binaryMediaTypes** はAPIGatewayがバイナリファイルを返却するために必要な設定です。
これがないとAPIGatewayは画像ファイルなどを返却できません。(この設定を知らずに4時間くらいハマりました、、、)

https://www.serverless.com/framework/docs/providers/aws/events/apigateway#binary-media-types


```serverless.yml
provider:
  # ... 略 ...
  apiGateway:
    binaryMediaTypes:
      - '*/*'

functions:
  hello:
    handler: handler.hello
    events:
      - http:
          path: /
          method: ANY
      - http:
          path: /{path+}
          method: ANY
```



## Layerの設定

Lambdaが巨大化するとデプロイに時間がかかるので、必要なライブラリはLayerにまとめていきます。
**serverless-python-requirements** プラグインを利用すると、 `serverless.yml` と同階層にある `requirements.txt` をLayerとしてデプロイできます。

https://www.serverless.com/plugins/serverless-python-requirements#lambda-layer

今回必要なライブラリは fastapi と mangum の2つです。
```requirements.txt
fastapi[all]
mangum
```

serverless.yml にLayerをビルドするための下記設定を追加します。
※ `Ref: PythonRequirementsLambdaLayer` は固定値です。**serverless-python-requirements** プラグインによって作成されたLayerを参照します。

```serverless.yml
functions:
  hello:
    # ... 略 ...
    layers: # 作成したLayerをLambdaから利用するための設定
      - Ref: PythonRequirementsLambdaLayer
custom:
  pythonRequirements:
    dockerizePip: non-linux # deploy時にpythonライブラリのinstall & buildをdockerで行う設定
    layer: true # レイヤーを作成する
```

## FastAPIアプリの実装

デフォルトの `handler.py` を削除して、FastAPIのアプリを実装しましょう。

```bash
rm handler.py
mkdir -p api
touch api/main.py api/__init__.py
```

Lambda上でFastAPIを動作させるには Mangum というライブラリを利用します。
MangumはAPIGatewayのイベントをLambda上のASGIアプリケーションが扱える形式に変換するアダプタとして機能します。
ちなみに、APIGatewayから渡されたイベントは `Request.scope['aws.event']` から取得できます。

https://mangum.io/adapter/

とりあえず今回は簡単に `/api/hello` と `/api/event` というAPIを作りたいと思います。

```api/main.py
import os
from fastapi import FastAPI, APIRouter
from mangum import Mangum
from starlette.requests import Request
from fastapi.middleware.cors import CORSMiddleware

router = APIRouter()
@router.get("/hello")
def hello():
    return {"Hello": "World"}

@router.get("/event")
def event(request: Request):
    return {
        "event": request.scope["aws.event"],
    }

app = FastAPI()
app.include_router(router, prefix="/api")

# CORS: https://fastapi.tiangolo.com/tutorial/cors/
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

handler = Mangum(app) # このhandlerをLambdaのハンドラとして指定します。
```

Lambdaのハンドラに `app/main.py` の `handler = Mangum(app)` を指定し、デプロイパッケージには `api/` のみを含めるように設定します。

```serverless.yml
functions:
  hello:
    handler: api.main.handler
    # ... 略 ...

package:
  patterns:
    - '!**' # すべてのファイルをexclude
    - 'api/**' # fastapiのソースコードをinclude
```


## 一度デプロイ

これで一度デプロイしてみましょう

```bash
sls deploy --stage dev
```

こんな感じの構成で、`/dev/api/hello` `/dev/api/event` へのリクエストに成功すればOKです。

https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/api/hello
https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/api/event

![スクリーンショット 2022-03-26 16.44.56 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92383/ff10e56e-0092-715f-aa18-b4550e9f01a3.png)


# NuxtJSの導入

NuxtJSはSPA方式を利用します。
`npm run generate` で静的ファイルを生成して、FastAPIの静的ファイルサーバー機能で提供します。

## NuxtJSのプロジェクト作成

`front` ディレクトリに **sample-app** というプロジェクトを作成します。
`ssr=false` `target='static'` なSPA方式とします。

```bash
npm init nuxt-app front

create-nuxt-app v4.0.0
✨  Generating Nuxt.js project in front
? Project name: sample-app
? Programming language: JavaScript
? Package manager: Npm
? UI framework: Vuetify.js
? Nuxt.js modules: Axios - Promise based HTTP client
? Linting tools: (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Testing framework: Jest
? Rendering mode: Single Page App
? Deployment target: Static (Static/Jamstack hosting)
? Development tools: jsconfig.json (Recommended for VS Code if you're not using typescript)
? Continuous integration: None
? Version control system: Git
```

```bash
# nuxtプロジェクトの.gitを削除します。
rm -rf front/.git 
```

## NuxtJSプロジェクトの設定

### ビルド結果の出力先を変更

パッケージのビルド先を `front/dist` から `front_dist` に変更します。

```front/nuxt.config.js
import colors from 'vuetify/es5/util/colors'

export default {
  // ... 略 ...

  // npm run generate で生成されるビルド済みファイルの出力先ディレクトリの指定
  generate: {
    dir: "../front_dist"
  },
}
```

NuxtJSでビルドした静的ファイルをLambdaのパッケージに含めるため、**patterns** に `front_dist` ディレクトリを追加します。

```serverless.yml
package:
  patterns:
    - '!**' # すべてのファイルをexclude
    - 'api/**' # fastapiのソースコードをinclude
    - 'front_dist/**' # nuxtのビルド結果をinclude
```

### ベースURIの設定

APIGatewayを利用するとURIの先頭にステージ名がついてしまいます。
※ 例えば `sls deploy --stage dev` でデプロイすると `/api/hello` は `/dev/api/hello` となります。
この仕様、本気でいらないと思うのですが、どうしようもないのでNuxtJS側にベースURIを設定します。

今回は ビルド時に`API_BASE_PATH` という環境変数を定義することで、ベースURIを設定できるようにします。

```front/nuxt.config.js
import colors from 'vuetify/es5/util/colors'

export default {
  // ... 略 ...
  head: {
    // ... 略 ...
    link: [
      // ファビコンのパスの先頭にAPI_BASE_PATHを設定します。
      { rel: 'icon', type: 'image/x-icon', href: (process.env.API_BASE_PATH ?? "") + '/favicon.ico' }
    ]
  },

  // ... 略 ...

  axios: {
    // axiosによるリクエスト送信時にベースURIが付与されます。
    // API_BASE_PATHが存在しない場合はローカル開発サーバーとみなして、//127.0.0.1:8000/をベースURIとします。
    baseURL: process.env.API_BASE_PATH ?? "//127.0.0.1:8000",
  },

  // ... 略 ...

  router: {
    // 生成されるリンクの先頭にベースURIが付与され、HTMLにbaseタグ( `<base href="xxxx">` ) が追加されます。
    // https://nuxtjs.org/docs/configuration-glossary/configuration-router/#base
    base: process.env.API_BASE_PATH ?? ""
  },
}
```

[baseタグ](http://www.htmq.com/html/base.shtml)は相対パスで記述されたURIの基準となるので、絶対パスを相対パスに書き換えていきます。

`/v.png` -> `v.png`

```front/pages/inspire.vue
<template>
  <v-row>
    <v-col class="text-center">
      <img
        src="v.png"
        alt="Vuetify.js"
        class="mb-5"
      >
      <!-- 略 -->
```

`/vuetify-logo.svg` -> `vuetify-logo.svg`

```front/components/VuetifyLogo.vue
<template>
  <img
    class="vuetify-logo"
    alt="Vuetify Logo"
    src="vuetify-logo.svg"
  >
  <!-- 略 -->
```

## APIをコールするページの作成

ボタンをクリックすると `/api/hello` APIをコールする簡単なサンプルを実装してみます。

```front/pages/hello.vue
<template>
  <div>
    <div>
      <v-btn @click="hello">HELLO</v-btn>
    </div>
    <h1>{{ message }}</h1>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: ""
    }
  },
  methods: {
    hello() {
      let data = this.$data;
      this.$axios.get("/api/hello")
        .then(res => {
          data.message = res.data["Hello"];
        })
        .catch((e) => {
          console.error(e);
        })
    }
  }
}
</script>
```

## FastAPIでfront_dist配下を静的ファイルとして返却する

NuxtJSのビルド結果の静的ファイルをFastAPIの静的ファイルサーバー機能で提供できるようにします。

FastAPI側でもベースURIの設定を行うため、 `API_BASE_PATH` を環境変数としてLambdaに渡します。

```serverless.yml
provider:
  # ... 略 ...
  environment:
    API_BASE_PATH: ${env:API_BASE_PATH, ""}

```

FastAPIの初期化時に引数 `root_path` に `API_BASE_PATH` を設定し、 `app.mount` で `front_dist` ディレクトリを `/` にマウントします。
こうすることで、ブラウザで `/` にアクセスしたときに `front_dist/index.html` が参照されるようになります。

https://fastapi.tiangolo.com/tutorial/static-files/

```api/main.py
# ... 略 ...

# 追加: StaticFilesインポート
from fastapi.staticfiles import StaticFiles

# ... 略 ...

# 追加: root_pathにAPI_BASE_PATHを設定する
app = FastAPI(root_path=os.getenv(f"API_BASE_PATH", ""))
app.include_router(router, prefix="/api")

# 追加: front_distディレクトリを "/" にマウント
# StaticFilesのオプションはこちらを参照:  https://www.starlette.io/staticfiles/
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
app.mount("/", StaticFiles(directory=f"{PROJECT_ROOT}/front_dist", html=True), name="front")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

handler = Mangum(app)
```

## デプロイ

```bash
export API_BASE_PATH="/dev"

# 環境変数でベースURIを定義してビルド
(cd front && npm run generate)

# デプロイ
sls deploy --stage dev
```

### 動作確認

https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/ にアクセスするとNuxtのWelcomeページが表示されます。


![スクリーンショット 2022-03-26 19.08.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92383/9e02658a-5cd1-73d9-0934-5e1c067fccd4.png)


https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/hello にアクセスして「HELLO」ボタンをクリックすると「World」が表示されます。

![スクリーンショット 2022-03-26 22.16.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92383/d3171002-a814-220c-cf9f-9b346b7fcb13.png)



# おまけ: ローカルで開発サーバーを起動する
ローカルで開発サーバーを起動する場合は、FastAPIとNuxtJSの開発サーバーをそれぞれ起動します。

## FastAPIの開発サーバー起動
```bash
export API_BASE_PATH=""

# pythonパッケージをローカルにインストール
python3.8 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt 

# FastAPI開発サーバー起動
uvicorn api.main:app --reload
```
http://127.0.0.1:8000/ にアクセス

## NuxtJSの開発サーバー起動
```bash
export API_BASE_PATH=""
cd front

# パッケージをローカルにインストール
npm install

# NuxtJSの開発サーバー起動
npm run dev
```
http://127.0.0.1:3000/ にアクセス

# 参考

https://fastapi.tiangolo.com/

https://nuxtjs.org/docs/get-started/installation

https://www.serverless.com/plugins/serverless-python-requirements

https://www.serverless.com/framework/docs/providers/aws/events/apigateway