GAミント至上主義

Web Monomaniacal Developer.

Firebase HostingからFirebase Functions を使ってGKEのアプリケーションにプロキシする

Firebase Hostingを使ったサイトで、一部のパス(/api以下等)へのリクエストを別サーバーに投げられる仕組みを考えた。

別サーバーでCloud Functions(Node.js)で出来ないことをしたかった。

結果として動いてしまったけどなぜこんなことしてるのか分からなくなってきたのでまず整理。

1、アプリケーション概要

フロントエンド

Vue.jsでビルドしたSPA(Firebase Hostingで配信)

バックエンド

GKEで動いているヘッドレスChromeDjango等を使ったAPIサーバー。Cloud Functionsじゃむりぽ。

2、動機

上記構成を普通につくるとホスト名やSSL証明を分ける必要があり、下記のデメリットが発生する

  • バックエンドではHTTPS Load Balancerを立てる必要があり、SSL証明書の設定が必要
  • フロントエンドからAPIにリクエストする際、別ドメインだとCORSの設定が必要になるなど面倒が多い

なのでhttps://{firebase hostingのURL}/api等に来たリクエストを、そのままバックエンドにプロキシすることができたらいいなぁと思った。

3、手順

1、Firebase Functionsでリクエストをプロキシする関数を作る

まずFunctionsのinitは公式通りに行う。

https://firebase.google.com/docs/functions/get-started?hl=ja


今回はHTTPがトリガーなので下記を理解する
HTTP リクエスト経由で関数を呼び出す  |  Firebase

で、さっそく作り始める。
expressでプロキシを作るのは、ぐぐったら今回にぴったりなパッケージが見つかった。
www.npmjs.com

とりあえずindex.jsはこんな感じになった.
最後でexposeしているapiProxyが名前として使われ、URLの一部にもなる。
functions/index.js

const functions = require('firebase-functions');
const proxy = require('express-http-proxy');
const app = require('express')();
const apiHost = 'your.api.example.com' 
app.use('/', proxy(apiHost));
exports.apiProxy = functions.https.onRequest(app);

あとでいろいろ必要になるとも思うけど6行でできてしまった

npmのインストール情報
package.json

]
{
  "name": "functions",
  "description": "Cloud Functions for Firebase",
  "scripts": {
    "lint": "eslint .",
    "serve": "firebase serve --only functions",
    "shell": "firebase functions:shell",
    "start": "npm run shell",
    "deploy": "firebase deploy --only functions",
    "logs": "firebase functions:log"
  },
  "dependencies": {
    "express": "^4.16.3",
    "express-http-proxy": "^1.2.0",
    "firebase-admin": "~5.12.1",
    "firebase-functions": "^1.0.3"
  },
  "devDependencies": {
    "eslint": "^4.12.0",
    "eslint-plugin-promise": "^3.6.0"
  },
  "private": true
}

これで

firebase deploy

して成功すると下記のようなURLが表示されるのでアクセスしてみる

https://{region}-{project-id}.cloudfunctions.net/apiProxy/

まだGKEではDjangoをデバックで動かしているのでこんな感じで出た。
f:id:uyamazak:20180525122837p:plain

ちゃんとつながった。

あとはこれをFirebase Hosting側に設定する

firebase.json

{
  "hosting": {
    "public": "dist",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "/api/**",
        "function": "apiProxy"
      },
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  },
  "functions": {
    "predeploy": [
      "npm --prefix \"$RESOURCE_DIR\" run lint"
    ]
  }
}

/api/**をapiProxyにリライトする設定を追加した。

これをまたdeployして、今度はhosting側のURLに/apiをつけてアクセスしてみる

https://{project-id}.firebaseapp.com/api/

同じくつながってた。
f:id:uyamazak:20180525125206p:plain

こちらは/apiもリクエストに含まれる模様。
とりあえずここまでできることを確認できた。

デメリット

レスポンスが遅くなる

簡略化して書くと

GKE → ロードバランサー → ユーザー
で済んでいたのが

GKE →  ロードバランサー → Firebase Functions → Firebase Hosting → ユーザー
となるので、遅い。

しかもやっかいなのがFirebase Functionsのリージョンは今のところ選択できないため、usになってしまい、さらに遅くなる。

上記のテストでもレスポンスが600ms程度かかっていた。

結果としてこの遅さは致命的なので、今回は使うことはなさそう。

レスポンスの遅さが気にならないものや、何としても同一ドメインで別サーバーで処理をさせたい場合には使い道があるかも。

開発用Dockerコンテナ内でVueアプリケーションをFirebase Hosting に1コマンドでdeployする

Vue + Webpackでの開発は社内サーバーのDockerコンテナで行っているが、ビルドしたファイルはFirebase Hositingを使って配信したくなった。

どうせならfireabase CLIもホストには入れずDockerコンテナ上にインストールしてやってしまいたいと思い、やってみたら出来たのでメモ。

Vue + Webpackは下記テンプレートではじめて大きく変更はしていない。

1、 開発用Dockerコンテナ
まず下記のようなnodeと最低限のDockerから始める。gitとbzip2はnpm installで必要だった気がする。
shanyangはプロジェクト名なのでなんでもいい(ヤギの中国語ピンイン)。appも特に意味はない。

FROM node:9-slim
RUN mkdir /shanyang/
RUN apt-get update --fix-missing && apt-get -y upgrade

RUN apt-get install -y \
    git \
    bzip2

RUN npm install -g vue-cli firebase-tools

COPY app /shanyang/app
WORKDIR /shanyang/app/
EXPOSE 8080
CMD ["bash"]

npmでいれるものはDockerfileでやっていると開発中は頻繁にビルドが必要になって面倒なので、appディレクトリをマウントしておき、そこでインストールしてその状態を保存する。

Dockerfileにまとめたいところだけど、npmはpakage.jsonがあるのでnpm install時に--saveを付けてそちらで管理することにした。


頻繁にいちいちdockerコマンドをたくさんオプション付けるのは面倒なので、コンテナごとに下記のようにshを作って実行している。当たり前だけどchmod 700等で実行権限を付ける。
build.sh

sudo docker build -t varuna-shanyang:latest .

こっちもshで実行。
run.sh

sudo docker run -it --rm \
        -v `pwd`/app:/shanyang/app \
        -v `pwd`/home:/root \
        -p 8080:8080 \
        --name varuna-shanyang \
        varuna-shanyang:latest

/homeのマウントは認証情報のためなので後述する。

runするとbashが立ち上がるのでvue-cliを使ってプロジェクトを生成する。
GitHub - vuejs-templates/pwa: PWA template for vue-cli based on the webpack template

あとはnpm run devで開発中サーバーを起動したり、Vueの開発をホストのappディレクトリ内で行う。開発サーバーはファイルの変更があると自動でリロードしてくれるので便利。


2、Firebase CLIをコンテナ上でインストール

で、アプリができてnpm run buildするとapp/dist配下にproductionのファイルが書き出されると思う。

そうしたらようやく、Firebase Hostingにデプロイ。

Hosting を使ってみる  |  Firebase

コンテナを上記のrun.sh(起動中だったらexec)でbashを起動し、その中でfirebase CLIのセットアップを始める。

まずはログインだが、デフォだとWEBサーバーが起動してブラウザ経由になるが、ホストにそのためだけにポートを通すのが面倒なので端末上で完結できるように--no-localhostを使う

% ./run.sh
root@9e9b076ba98b:/shanyang/app# firebase login --no-localhost

URLが出るのでブラウザで開くとコードがもらえるので貼れば認証完了。

次にinitする

root@c28d3a32cb7b:/shanyang/app# firebase init

     ######## #### ########  ######## ########     ###     ######  ########
     ##        ##  ##     ## ##       ##     ##  ##   ##  ##       ##
     ######    ##  ########  ######   ########  #########  ######  ######
     ##        ##  ##    ##  ##       ##     ## ##     ##       ## ##
     ##       #### ##     ## ######## ########  ##     ##  ######  ########

You're about to initialize a Firebase project in this directory:

  /shanyang/app

Before we get started, keep in mind:

  * You are currently outside your home directory
  * You are initializing in an existing Firebase project directory

? Which Firebase CLI features do you want to setup for this folder? Press Space to select features, then Enter to confirm your choices.
 ◯ Database: Deploy Firebase Realtime Database Rules
 ◯ Firestore: Deploy rules and create indexes for Firestore
 ◯ Functions: Configure and deploy Cloud Functions
❯◯ Hosting: Configure and deploy Firebase Hosting sites
 ◯ Storage: Deploy Cloud Storage security rules

とりあえず今回はHostingだけチェックして進む。

公開ディレクトリを聞かれるので、今回はdistディレクトリなのでdistと入力。もちろんwebpackの設定で変更しても良い。
vue-routerを使ったSPAなのでyes、
index.htmlは上書きされたくないのでNo

? What do you want to use as your public directory? dist
? Configure as a single-page app (rewrite all urls to /index.html)? Yes
? File dist/index.html already exists. Overwrite? No
i  Skipping write of dist/index.html

i  Writing configuration info to firebase.json...
i  Writing project information to .firebaserc...

✔  Firebase initialization complete!

これでfirebase deployを叩き、正常にデプロイできるか確認する。

完了かと思ったが、コンテナを起動し直すと認証情報が消えてしまい再度ログインが必要になってしまった。
書き出されたfirebase.json、.firebasercにはそれらしい情報は含まれていなかった。

おそらく、ユーザーディレクトリに入っていると思い、/rootを見に行くと.configディレクトリがあり、そこに格納されていたので、これもマウントしてホストマシンで持ってしまえばいい。
ということで上記のrun.shに含まれている。

これで認証情報も保持されるようになったので、あとはデプロイ用にコマンドを書く。
開発サーバーは起動している前提なのでexecを使う。


firebase_deploy.sh

sudo docker exec -it \
    $(sudo docker ps -a -q --filter="name=varuna-shanyang") \
    npm run build

sudo docker exec -it \
    $(sudo docker ps -a -q --filter="name=varuna-shanyang") \
    firebase deploy

不格好だけどnpm run build && firebase deployだとダメだったので2行書いた。

$(sudo docker ps -a -q --filter="name=varuna-shanyang") はよく使う。毎回docker psで手でIDをコピペするのが面倒なのでnameを元に取得するようになっている。

これでDockerコンテナ内でビルドしてデプロイが一発でできるようになった。

Vue.js のmountedでaddEventListenerやsetIntervalするとき、後始末を忘れない

当たり前だけどvue routerで読み込んでいるコンポーネントのmountedでaddEventListenerやsetIntervalをしていると、ページの行き来のたびに登録されて、大変なことになる。リクエスト単位でスレッド、プロセスなどが変わるサーバー側の処理とSPAの違いを意識しないといけない。

destroyedで削除をセットにするのを忘れないようにする。

removeEventListenerとclearIntervalには同じ関数オブジェクトを渡す必要があるため、いつもは無名関数で書いてしまうが、methodsに書いて使う。

setIntervalの方は、返り値のオブジェクトをclearIntervalするのでdataに入れておく。

コードを書いてちゃんと削除できているかどうかは、登録する関数でconsole.logするなどして、ページを移動して不要に何度も登録して実行されていないか確認した。

  data: function(){
    return {
       updateMessageTimer : null
    }
  },
 methods: {
    updateMessage: function(){
      // do something
    },
    changeState: function(){
      // do something
    }
  },
  mounted: function(){
    window.addEventListener('popstate', this.changeState, false)
    this.updateMessageTimer = setInterval(this.updateMessage, 5000)
  },
  destroyed: function () {
    window.removeEventListener('popstate', this.changeState, false)
    clearInterval(this.updateMessageTimer)
  }

addEventListenerのuseCaptureオプションはとりあえずfalseだけど実はよくわかっていない。登録時、削除時で揃えないといけないのは分かる。

developer.mozilla.org