Docker上でFastAPIとVue.jsのアプリ作成メモ

ごくまれにWebアプリ的な物を実装するんですが、毎回忘れちゃうのでメモ。

FastAPIでバックエンドを、フロントエンドをVue.jsで作成し、nginxでリバースプロキシをDockerで組んでみます。
各コンテナはdockerのbridgeネットワークで繋がっており、 nginxのコンテナだけポート80をホストに公開してアクセスしてきた人にアプリを提供します。

f:id:hollyhockberry:20211120181848p:plain

いきなり情けない話ですが、上記のイメージはnginxの設定がよくわかってないので実現できていません。
実際は各コンテナ内で公開する予定のURLで稼働してます。nginx難しいです・・

f:id:hollyhockberry:20211121103836p:plain

Docker network作成

デフォルトで作られるものでもいいんですが、コンテナ間で共有するネットワークを作っておきます。

docker network create -d bridge app_bridge

バックエンド(FastAPI)

バックエンドはFastAPIを使って作ります。
以前の記事で使用しましたが、pythonで簡単にAPIが作れてかっこいいドキュメントもできちゃうスゴイ奴です。

hollyhockberry.hatenablog.com

最終的なファイル構成はこんな感じになります。*.prod なファイルはリリース用の環境です。

f:id:hollyhockberry:20211121111133p:plain

サンプルとして、'/'にアクセスするとランダムで文字列を返すAPIを作成しました。

main.py

app = FastAPI()

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

@app.get('/')
def read_root():
  series = [ '', 'Stars', 'Friends', 'On parade', 'planet']
  i = randrange(len(series))
  return {"Message": f'Aikatsu {series[i]}!'}

if __name__ == '__main__':
  uvicorn.run('main:app', host='0.0.0.0', port=80, reload=True)

まずは開発用の環境から。
変更→確認のサイクルを回したいので、

  • ホストのソースコードをコンテナにマウント
  • uvicornをreload=Trueで起動
  • ポート80をホストのポート8000に公開

この条件でコンテナを設定します。

Dockerfile

FROM python:3.9-slim-buster

WORKDIR /code/app

# 必要なモジュールをインストール
COPY ./requirements.txt /code/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt

# Appをデバッグ起動
CMD [ "python", "main.py" ]

docker-compose.yml

version: '3.8'

services:
  api:
    container_name: sample-api-dev
    build: ./fastapi
    restart: always
    volumes:
      - ./fastapi/src:/code/app
    ports:
      - 8000:80

続いてリリース環境。

ポイントは以下の通り。

  • ソースコードは変更しないのでコンテナへコピー
  • ポートは公開しない
  • Dockerネットワークapp_bridgeにアタッチ

ホストからネットワーク繋がらないので、後ほど紹介するリバースプロキシを立ち上げないと確認できません。

docker-composeでファイルを指定するには-fオプションを使います。

docker-compose -f docker-compose.prod.yml up -d --build

Dockerfile.prod

FROM python:3.9-slim-buster

COPY ./requirements.txt .
RUN pip install --no-cache-dir --upgrade -r requirements.txt

COPY ./src /app

EXPOSE 80
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]

docker-compose.prod.yml

version: '3.8'

services:
  api:
    container_name: sample-api
    build:
      context: ./fastapi
      dockerfile: Dockerfile.prod
    restart: always
    networks:
      - app_bridge

networks:
  app_bridge:
    external: true

フロントエンド1 (Vue.js)

フロントエンドはVue.jsで作成します。

vuejs.org

ファイル構成はこんな感じ。

f:id:hollyhockberry:20211121123336p:plain

こちらもまずは開発用の環境から。

Dockerfile

FROM node:lts-alpine

WORKDIR /app
RUN yarn global add @vue/cli

USER node

docker-compose.yml

version: '3.8'

services:
  app:
    container_name: sample-app1-dev
    build: ./vue
    ports:
      - 8001:8080
    volumes:
      - ./vue/src/:/app
    tty: true

立ち上げたコンテナに入ってプロジェクトを準備していきます。

コンテナのビルドと起動
% docker-compose up -d --build
Building app
     :
   (中略)
     :
sample-app1-dev is up-to-date
コンテナの中に入る
% docker-compose exec app ash
vueがバージョン確認
/app $ vue -V
@vue/cli 4.5.15
カレントディレクトリにVue.jsのプロジェクトを作成
/app $ vue create .
?  Your connection to the default yarn registry seems to be slow.
   Use https://registry.npm.taobao.org for faster installation? (Y/n) 
Vue CLI v4.5.15
? Generate project in current directory? (Y/n) 
? Please pick a preset: (Use arrow keys)
❯ Default ([Vue 2] babel, eslint) 
  Default (Vue 3) ([Vue 3] babel, eslint) 
  Manually select features 
? Pick the package manager to use when installing dependencies: (Use arrow keys)
❯ Use Yarn 
  Use NPM 
:

Backend APIにアクセスするので axios をインストールします。

$ yarn add axios vue-axios

VueAxiosを読み込むため、src/main.jsを編集。

import Vue from 'vue'
import axios from 'axios'         // Add!
import VueAxios from 'vue-axios'. // Add!
import App from './App.vue'

Vue.config.productionTip = false
Vue.use(VueAxios, axios).        // Add!

new Vue({
  render: h => h(App),
}).$mount('#app')

HelloWorld.vue の代わりに HelloAikatsu.vue を追加します。
1000msec毎にAPIからメッセージをGETして表示を更新します。

<template>
  <div class="hello">
    <h1>{{ message }}</h1>
  </div>
</template>

<script>
export default {
  name: 'HelloAikatsu',
  props: {
    api: String
  },
  data() {
    return {
      message: '',
    }
  },
  mounted() {
    setInterval(() => {
      this.axios.get(this.api)
      .then(response => {
        this.message = response.data.Message)
      })
    }, 1000)
  },
}
</script>

App.vueも変更してコンポーネントHelloAikatsuを組み込みます。
データapiprocess.env.VUE_APP_API_URLとなっていますが、これは.envファイルに記述された変数が読み込まれます。

<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png">
    <HelloAikatsu :api="api"/>
  </div>
</template>

<script>
import HelloAikatsu from './components/HelloAikatsu.vue'

export default {
  name: 'App',
  components: {
    HelloAikatsu
  },
  data() {
    return {
      api: process.env.VUE_APP_API_URL,
    }
  }
}
</script>

冒頭に泣き言を言った通り、URLのパスを指定したいのでvue.config.jsを追加してパスを記述します。

vue.config.js

module.exports = {
  publicPath: '/app1/',
}

ここまで準備できたらyarn serveで好きなだけ動作確認できます。

/app $ yarn serve
yarn run v1.22.15
$ vue-cli-service serve
 INFO  Starting development server...
98% after emitting CopyPlugin

 DONE  Compiled successfully in 9788ms                                                                             3:13:20 AM

  App running at:
  - Local:   http://localhost:8080/ 

  It seems you are running Vue CLI inside a container.
  Access the dev server via http://localhost:<your container's external mapped port>/

  Note that the development build is not optimized.
  To create a production build, run yarn build.

そしてリリース環境。

開発環境では動的に作成していましたが、リリース環境ではyarn buildしてできたファイルを静的にサーブします。
そしてBackendと同様にDockerネットワークにアタッチします。

また、APIのURLを指定する.envはリリース環境では固定なのでdot.envファイルを用意してコンテナにコピーする運用としました。

Dockefile.prod

FROM node:lts-alpine as build-stage
WORKDIR /app
COPY ./src/package*.json ./
COPY ./src/yarn.lock ./
RUN yarn global add @vue/cli

RUN yarn install

COPY ./src .
COPY ./src/dot.env ./.env
RUN yarn build

FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

docker-compose.prod.yml

version: '3.8'

services:
  app:
    container_name: sample-app1
    build:
      context: ./vue
      dockerfile: Dockerfile.prod
    networks:
      - app_bridge

networks:
  app_bridge:
    external: true

フロントエンド2 (Vue.js)

もう一つのAppもVue.jsで作成します。

フロントエンド1と同じ内容でもいいですが、ちょっとだけ内容を変更してみます。
ロード時にAPIからGETした文字列をランダムで大文字小文字切り替えて表示しています。

<template>
  <div class="hello">
    <h1>{{ message }}</h1>
  </div>
</template>

<script>
export default {
  name: 'HelloAikatsu2',
  props: {
    api: String
  },
  data() {
    return {
      message: '',
    }
  },
  mounted() {
    this.loadMessage()

    setInterval(() => {
      this.message = this.upperLower(this.message)
    }, 100)
    setInterval(() => {
      this.loadMessage()
    }, 5000)
  },
  methods: {
    loadMessage() {
      this.axios.get(this.api)
      .then(response => this.message = {
        response.data.Message
      })
    },
    upperLower(str) {
      function* ulg(str) {
        for (var c of [...str]) {
          if (c.search(/[^A-Za-z]/) == -1) {
            yield ((Math.floor(Math.random() * 10) >= 5) ? c.toLowerCase() : c)
          } else {
            yield c
          }
        }
      }
      var s = ''
      for (var c of ulg(str.toUpperCase())) {
        s += c
      }
      return s
    }
  }
}
</script>

手順はフロントエンド1と同じなので割愛します。

リバースプロキシ

リバースプロキシはnginxを使います。

f:id:hollyhockberry:20211121125135p:plain

プロキシはリリース用だけでいいので、環境は一つだけ作ります。
各コンテナのリリース環境がぶら下がっているDockerネットワークにアタッチして、ポート80をホストに公開します。

version: '3.8'

services:
  proxy:
    image: nginx:latest
    container_name: sample-proxy
    volumes:
      - ./conf.d/:/etc/nginx/conf.d
    ports:
      - "80:80"
    networks:
      - app_bridge

networks:
  app_bridge:
    external: true

nginxのconfファイルはこんな感じです。
コンテナのアドレスはコンテナ名で解決するようです。

server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    location /app1/ {
        proxy_pass  http://sample-app1/;
    }

    location /app2/ {
        proxy_pass  http://sample-app2/;
    }

    location /api/ {
        proxy_pass  http://sample-api/;
    }
}

実際に動かしてみました。

http://localhost/app1http://localhost/app2でそれぞれのAppが表示できました。

今はこれが精一杯・・・

ソースコード

ソース全体をGithubにアップしています。

github.com