takuwan's blog

感じたことを書きますよ

Firebaseを利用したサーバーレス開発にはMonorepoが向いている

Firebaseを採用したサーバーレスのプロダクトでGitリポジトリをちゃんと管理しようとすると、多数のリポジトリが生まれてしまうことって、あるあるなのではないかと思います。あまり話題にはなりませんが、少なくとも私の経験上はそうなりがちです。

私の場合、Firestoreなどのセキュリティルールを管理・テストするリポジトリ、Cloud Functionsで実行するサーバー処理を管理するリポジトリ、マスターデータやマイグレーションスクリプトを管理するリポジトリ、Firestoreのバックアップなどを走らせるWorkerのリポジトリ、Webアプリのリポジトリなどを作成して、複数リポジトリを管理していたときがありました。

いまでは、Firebaseを利用したアプリケーションを開発する際には、lernaを採用したMonorepoでプロダクトを設計しています。Monorepoは、Firebase開発における一つのベストプラクティスであると捉えています。
github.com


なぜMonorepoなのか

Monorepoを採用する一番の理由は、ドメインに関するコードを一元化できることにあります。上記で述べたような、以前私がやっていたようなGitリポジトリの設計では、特にEntiyに関するコードがDRYにならず、辛みとなっていました。

プロダクトが進化するにつれて、フロントのWebアプリケーションが定義するEntityとサーバーサイドのCloud Functionsのリポジトリで定義するEntityが違ったり、マイグレーションスクリプト用のEntityをいじるたびに、マイグレーションが影響する全てのリポジトリのコードを編集する必要があったりと、手と頭を動かすコストが上がっていました。リポジトリ間でEntityの乖離が起きてしまい、何が正しい実装なのか確認する時間が発生したこともありましたが、そのときは「何をやっているんだ俺は...」と思わずにはいられなない無駄を感じました。

さらには、1つのプロダクト内でC向けWebアプリ・B向けWebアプリといった複数のアプリを開発しないといけなくなった際には、Entityの同期に一層の注意が必要になります。C向け・B向けで共有するEntityなんかはちゃんと同期をしないと、おかしな実装が紛れ込む原因になります。ドメインの実装はアプリケーションのUseCaseに引っ張られるべきではないと考えますが、アプリケーションごとにEntityがあることで誤解がうまれ、そういったものが紛れ込んでしまうこともありました。


Monorepoとは何か

そもそもMonorepoって何?って方は、簡潔にまとまっているBabelのドキュメントを読まれると良いと思います。 一言で言うなら、以前であれば複数のGitリポジトリに分けていたようなモジュールを、1つのGitリポジトリで管理するためのツール・考え方です。
Babelをはじめ、React、Angular、Jestなど有名どころもMonorepoだったりします。
github.com


 

FirebaseのプロジェクトにおけるMonorepoの構成

Firebaseを採用したsampleプロジェクトをMonorepoで構成する場合、下記のような構成になります。./packages 配下に各モジュールが並んでいます。アプリケーションが増えれば、./packagesに増やしていきます。

.
├── README.md
├── lerna.json
├── package-lock.json
├── package.json
└── packages
    ├── sample-domain # ドメインの実装
    ├── sample-functions # Cloud Functionsの実装
    ├── sample-scripts # マイグレーションスクリプトなどの実装
    ├── sample-storage # FirestoreやCloud Storageのセキュリティールールなどの実装
    ├── sample-web # ウェブアプリケーションの実装
    └── sample-worker # Workerの実装

lernaの良いところは、./packages配下のモジュールの依存関係を定義できることです。下記のようにコマンドを叩けば、sample-domainがnpm packageとしてsample-webのpackage.jsonに定義されます。sample-webのコードでsample-domainをimportし、sample-domainのエントリを呼び出すことができるようになります。

lerna add sample-domain --scope=sample-web

上記のtreeでいうならば、各moduleはsample-domainに依存する構成を想定しています。
sample-domainの実装如何ではありますが、私が直近開発しているプロダクトでは、下記のようにsample-domainを呼びだせるようにしてあります。

import Facade, { UserRepository } from "sample-domain";

const client = new Facade();
const repository = client.create(UserRepository);
const user = await repository.findByEmail("user@example.com");
...


Monorepoリポジトリでのデプロイ術

Monorepoで開発するうえでちょっと工夫が必要なのは、デプロイの方法です。とはいえ、Dockerコンテナを利用すればシンプルで簡単です。

下記のようなDockerfileを定義し、コンテナイメージを作成し、コンテナをデプロイすれば良いだけです。下記のDockerfileは、Herokuではbuildpackなしにそのまま使えますし、k8、ECSでも基本やることは同じです。

FROM node:12.16.1
WORKDIR /usr/app

COPY package.json .  # プロジェクトルートのpackage.jsonコピー
RUN npm install # lernaなどのpackageをinstall

# 余計なmoduleをCOPYしないことで、lerna bootstrapなどの時間を短縮できる
COPY /packages/sample-domain ./packages/sample-domain # ドメインmoduleをコピー
COPY /packages/sample-web ./packages/sample-web # デプロイ対象のmoduleをコピー

COPY lerna.json .
RUN npm run bootstrap # lerna bootstrapを実行して、各モジュールの依存packagesをインストール
RUN npm run build # 各moduleのビルド処理を走らせる

EXPOSE $PORT # ポートを開く

WORKDIR /usr/app/packages/sample-web
CMD npm run start # サーバーを起動


ちなみに、インフラ環境で楽をしたいならHerokuが無難でおすすめです。下記のように、moduleごとにDockerfileとheroku.yml(Herokuの実行環境を定義するファイル)を用意しておいて、Github ActionsなどのCIで両ファイルをcopy、mvして利用するのがシンプルではないかと考えています。もちろん、Zeit Nowでも似たようなことすれば動きますが、ちょっと凝ったことするとすぐ詰まるので(now.jsonがブラックボックスすぎます)、詰まりポイントが少なくエコシステムが充実しているHerokuのほうが後悔は少ないはずです。

プロジェクトの構成

.
├── README.md
├── lerna.json
├── package-lock.json
├── package.json
└── packages
    ├── sample-domain # ドメインの実装
    ├── sample-web-a # ウェブアプリケーションの実装 A
    │   ├── Dockerfile
    │   └── heroku.yml
    └── sample-web-b # ウェブアプリケーションの実装 B
        ├── Dockerfile
        └── heroku.yml

CIのスクリプト例

name: Deploy to Heroku
on:
  push:
    branches: [ release ]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Setup node
      uses: actions/setup-node@v1
      with:
        node-version: '12.x'
    - name: Copy heroku.yml
      run: cp -f packages/sample-web-a/heroku.yml heroku.yml
    - name: Copy Dockerfile
      run: cp -f packages/sample-web-a/Dockerfile Dockerfile
    - name: Install dependencies
      run: npm install; npm run bootstrap
    - name: Build packages
      run: npm run build
    - name: Deploy to Heroku
      uses: AkhileshNS/heroku-deploy@v3.0.0
      with:
        heroku_api_key: ${{ secrets.HEROKU_API_KEY }}
        heroku_email: yourmail@example.com
        heroku_app_name: sample-web-a
    - name: Slack notify if success
      if: success()
      uses: rtCamp/action-slack-notify@v2.0.0
      env:
        SLACK_CHANNEL: sample-channel
        SLACK_MESSAGE: 'Success to deploy sample-web-a to Heroku.'
        SLACK_TITLE: Succeeded in deployment
        SLACK_USERNAME: sample name
        SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
    - name: Slack notify if failed
      if: failure()
      uses: rtCamp/action-slack-notify@v2.0.0
      env:
        SLACK_CHANNEL: sample-channel
        SLACK_MESSAGE: 'Failed to deploy sample-web-a to Heroku.'
        SLACK_TITLE: Failed to deploy
        SLACK_COLOR: '#f44336'
        SLACK_USERNAME: sample name
        SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}


Monorepoのデメリット

基本的にはBabelのドキュメントに書いてある通りです。また、一見するとlernaはひとクセあるので、利用方法をキャッチアップする必要があります。

 

おわりに

Firebaseを利用したサーバーレス開発においては、Monorepoを利用しプロダクトのドメイン層を共通化することで、実装の無駄・工数の無駄を省くとともに、共通言語としてのドメインを明示できるようになります。
もちろん、Git submoduleを利用することでもドメインの共通化は実現可能ではありますが、lernaを利用した依存関係の解決(npm packageとしてdomain moduleを利用すること)の利便性は結構良いものです。リポジトリの管理自体も楽です。
もっとも、iOS・AndroidをSwift・Kotlinでネイティブ開発していたり、JavaScriptエコシステムから出るとMonorepo(lerna)の恩恵には与れないため、iOS・Android側でもEntityを定義・管理する必要は出てきます。
こういうときこそ、ReactNativeの生産性は高まるよなと考える次第です。

Firestoreのオペレーションで困っていること3選

Cloud Firestoreを採用した開発をはじめて1年半ほど経ちました。GoogleさんはFirestoreにちゃんと投資してくれていて、年々進化していますね。Firestoreを使った開発で課題に感じていたことは、この前10月のFirestore Summitで大方片付いた印象です。ドキュメントも読みやすくて充実しているので、ただ開発するだけならあまり困ることもないのではないかと思います。

一方で、オペレーションに関する機能はまだ弱いので、工夫とかノウハウが必要な部分だと感じています。そこで3つ、実際に辛みを感じて工夫しているものを挙げてみようと思います。これらは、私も満足に解決できているものではありません。また、これらは人に依っては、細かい事柄だと感じるかもしれません。実際、全くのゼロイチの新規事業開発においては、事業や組織の性質に依っては、あまり考慮しなくても済む内容かもしれません。しかし、私が開発しているプロダクトは、ある程度腰を据えて開発をすることが既定路線になっていて、細かいオペレーション(とはいえ、一般的なAPI開発とかでは普通のオペレーション)にもリソースを割きたくなっているという背景があります。

Firestoreを既に利用している方で、「こうしたら良いよ」というアドバイスがある方は、ぜひtwitterとかで教えてもらえると嬉しいです。Firestoreの採用を検討している方へは、判断材料の手助けになっていたら嬉しいです。

 

 

1. マイグレーションが辛い

みなさん、マイグレーションどのように行っているのでしょうか。私は極力、マイグレーションは行わないように避けていますが、どうしてもマイグレーションを行わないといけないときがたまにあります。

Firestoreにおけるマイグレーションの辛さの原因は2つあります。1つ目は、Firestore自体がスキーマレスでマイグレーションという概念は持ち合わせていないことです。RDBのようにchange tableを気軽にできないのは、もう構造上仕方ないです。もう1つは、FirestoreはiOS・Androidのクライアントアプリが直接クエリを発行するため、アプリのバージョン(アップデート)を考慮する必要があることです。OSでアプリのバージョンを自動的にアップデートする機能を切っているユーザーは一定数いて、そうしたユーザーは古いバージョンのアプリをいつまでも使う傾向にあります。従来のWeb APIであれば、DBはマイグレーションしてもAPIのインターフェースは変えなかったり、エンドポイントのバージョンをincrementして複数バージョンを同時に走らせるといった対応を取ればよかったですが、Firestoreに乗っかったiOS・Androidアプリを開発していると、そうした選択肢を取ることはできません。

実現したいことに応じて色んなマイグレーション戦術は考えられると思いますが、基本的には下記のフローに則ってマイグレーションを行っています。

  1. 前提として、既存のフィールド(カラム)の変更・削除は行わず、新しいフィールドを生やす対応を採る
  2. 対象ドキュメントのアップデートをトリガーとしてCloud Functionsを走らせて、マイグレーション処理を走らせる(トリガーが無限ループしないように注意)
  3. マイグレーション対象のドキュメントを全て読み込み、各ドキュメントのtimestampを更新する(2の処理をトリガーする)
  4. Firestoreのセキュリティールールで、新旧フィールドが両方通るように更新する
  5. クライアントアプリを書き換え、新しいフィールドを参照する
  6. アプリの強制アップデートのタイミングで、2で設けたCloud Functionsの関数を削除、また4で書き換えたセキュリティールールを修正して新しいフィールドのみ許可する
  7. 問題なければ、旧フィールドを削除

小さいコレクション・ドキュメントのマイグレーションであれば、これで大方問題はありません。強制アップデート(6)のタイミングまで、マイグレーションの管理をしないといけないのがちょっと面倒なくらいです。しかし、コレクション内のドキュメント数が多いと辛みが出てきます。

というのも、コレクション内のドキュメント数が多くなると、コレクションを一気に読み込むことができなくなり、(3)の処理に時間がかかったり、変なスクリプトを書く必要がでてきます。うまいことクエリカーソルを使えれば良いですが、得てしてそんな都合の良いフィールドがなかったりするので、timestampを使って日・時間ごとにpagingする処理を書いていったりする必要がでてきます。(もしtimestampをフィールドに生やしていない場合は、どうやってpagingすれば良いかを考える必要があります。)各ドキュメントのサイズにも依るでしょうが、コレクション配下のドキュメント数が10万を超えるなら、メモリ的にもきついでしょうし、まずクエリがタイムアウトします。ドキュメント数に応じた時間をかけて、地道にtimestampを更新していくしかありません。

あと、マイグレーションの結果ドキュメントのフィールド数が大幅に増える場合は、セキュリティルールの制限も気をつける必要があります。私は、マイグレーションでは引っかかったことはないのですが、普通に開発をしていて「リクエストあたりの式評価の最大数」が1000であることに引っかかったことはあります(mapやarrayを多用していたため)。まあ、これに引っかかるようでは、ドキュメントの設計が間違っている可能性のほうが高くはありますが、マイグレーションのときには気にはしています。

良いマイグレーションの方法があったら、ぜひ教えて頂きたいです。

 

2. DDoS対策を満足にできない

クライアントサイド側から直接Firestoreに対するDDoS攻撃があった場合、それを防ぐ有効な手段は乏しいです。Firebase AuthenficationとFirestoreのセキュリティルールを利用して、できる限りの予防はしていますが、不十分なのでもっと良い方法はないかと模索しています。

過去に2回、FirebaseのサポートチームにDDoSに関する問い合わせをしているのですが、FirestoreにはL7のDDoS攻撃を緩和する内部的な仕組みがまだないようなのです。一方で、Firebase Authentificationの認証さえクリアすればDDoS攻撃は可能だと考えられます。もしDDoS攻撃を受けてしまった場合は、Firebaseサポートチームに助けを求める他ないように思えます。

Firestoreは、Webブラウザやスマホアプリなどのクライアントから直接クエリを叩くことができます。Cloud Functionsを介してFirestoreのクエリを発行することでクライアントからの直接的な操作を控えることも可能ではありますが(実際、セキュリティ面を懸念してそうしているプロダクトも少なからずあるようですが)、レイテンシーは長くなりがちですし、Firestoreを活かしたオフライン対応のアプリではクライアントからの直接的な操作が必須となってきます。

クライアントから直接Firestoreを操作するということは、セキュリティの仕組みはFirebase側が用意したものに則るしかありません。つまり、セキュリティルールで実現可能な範囲で、セキュリティ対策を行うしかないということになります。

しかし、このセキュリティールールでのアクセス制御は、Firebase Authentificationの認証情報を元に行います。Facebook認証、メールアドレス認証、匿名認証などの情報をもって、どのコレクション・ドキュメントにリクエストすることができるのかを判定・認可します。

これはDDoSの文脈において何を意味するかと言うと、一度Firebase Authentificationで認証さえしてしまえば、Firestoreにいくらでもクエリを投げられるということです。特に、プロダクトが匿名認証を採用している場合は、相対的に、悪意ある攻撃の被害にあいやすい状態にあると言えそうです。

DDoS攻撃を受けた場合、書き込み制約(全コレクションの書き込み速度は1秒あたり500までとか)に引っかかったり、意図的にホットパーティション問題を再現されたりして、ユーザー体験が大きく損なわれる可能性があります。もちろん、セキュリティールールがしっかり設定出来ていてサーバーレスで設計されている場合、DDoS攻撃の規模によっては問題なくリクエストを捌けてしまうかもしれません。しかし、大量のリクエストを処理するためにかかったお金の問題は事業に直結するものですし、Firebaseサポートチームのサポート内容に期待して良いのかを判断できる材料を、今のところ私は持っていません。

過去に二度、Firebaseのサポートチームにこうした見解を相談していますが、否定はされないうえ、「攻撃を緩和する手段はいまのところない。予算アラートを設定してくれ。DDoS攻撃と思われる被害にあったらすぐ連絡してくれ。」という回答をもらっており、的外れではないのだろうと認識しています。過去に開発をしていたウェブサービスで、DDoS攻撃を何度か受けた経験があることもあり、率直に、ちょっと怖いなーという印象です。

対策としてはまず、writeに属するcreate, update, deleteのリクエストであれば、リクエストごとにタイムスタンプを取得できるので、下記のようにリクエストに制限を加えることができます。これでupdateやdeleteに関しては、大量のリクエストは送れなくなります。また、ブラックリストのコレクションを作成し、ブラックリストに載っているユーザーはリクエストを許可しないといった処理も書くことができます。

Firestore security rule

readクエリでの対策方法は、リクエストごとにドキュメントを都度作成して...といった富豪的なアプローチしか思いついていません。よって対策できていません。またcreateクエリの対策も、上記ではhasTimestamp()で制約はかけていますが、本質的には気休めでしかありません。イベントソーシングで設計しているEntityとかもあり、本当は対策したいのですが、富豪的なアプローチ以外は思いついていません。

 

次に、Firebase Authentificationでは同一IPで作成できるアカウント数を制御することができます。下記のスクショはデフォルト値の100になっていますが、プロダクトに合わせた数字を設定する必要があります。こちらは、例えばなにかの中・大規模イベント会場でアプリが喧伝されたりする可能性があるならば、閾値が100だと足りないという判断もできると思います。どちらかというと、あまり大きな数字にしすぎないという意識でいれば良いと思います。

f:id:takuwan0405:20200207180113p:plain

 

最後に、DDoS攻撃を認知するために、予算の閾値を設定します。閾値を超えた場合にはアラートを飛ばすことができます。ただし、下記スクショにあるように、予算アラートの機能はあくまでアラートを飛ばすだけで、Rate Limiting(APIの利用制限)の機能ではありません。問題にはやく気づくことができるようにするための機能です。

f:id:takuwan0405:20200208012224p:plain

 

本来であればユーザー単位でのRate Limitingの仕組みだったり、IP単位のアクセス排除の機能であったり、WAFのオプションなどが付いていてほしいところではあります。AWSのドキュメントを見る限りでは、AWSのLambdaやDynamoDBにはWAFを付けれたりするそうなので、隣の芝が青く見える次第です。

ちなみに、Cloud Storage for Firebaseでも、本質的には同様のことが生じるかと思います。また、Cloud FunctionsでのDDoS対策は、Expressのミドルウェアで行うのがベストプラクティスなようです。

 

3. 監視項目で足りないものがある

Firestoreの監視は、以前は何もできませんでしたが、徐々に監視項目が増えてきました。現在は読み込み数・書き込み数・スナップショットのリスナー数・アクティブな接続数・セキュリティールールの評価数と評価結果を監視することができます。しかし、気になる異常系の捕捉やレイテンシーの計測は、まだ不十分なのかなと思います。クライアントサイドでデータを収集するする必要があります。

Firestoreのリソース状況はStackdriverで計測・監視することができます。StackdriverはFirebaseのラインナップではないため、GCPのコンソールからアクセスする必要があります。リソースの利用状況のグラフを見るだけなら、Firebaseのコンソールからでも可能です。

f:id:takuwan0405:20200208014014p:plain

Stackdriverで監視できる項目は先に述べた通りです。中でもセキュリティールールの評価結果に関しては、アラートを設定して重宝していて、基本的にDenyやErrorは極力ゼロになるように意識しています。最近の事例だと、アプリの起動直後にFirestoreのクエリを叩くと、2~3%の確率で”Missing or insufficient permissions”を返すケースがあったのですが、アラートですぐ気づけてすぐ対応できたというのがあったりします。(ルールの監視は昨年末?追加されたためか、いま見たら日本語のドキュメントだと記載がなかったのですが、英語だとちゃんと書かれてます)。

しかし、Firestoreで監視したい項目は他にもいくつかあって、特にシステムエラーなどの異常系と、レイテンシーは最低限監視したいと考えています。前者に関しては、FirestoreのSLAは東京リージョンだと99.99%と指定されているので、年間あたり1時間弱はダウンタイムが許容されていますし、実際たまに障害は起きているためです(ドキュメント)。また、Firestoreは急な負荷がかかるとエラーを返したりと(ドキュメント)、Firestoreも例に漏れなくシステムエラーが発生しうる設計であるためです。後者のレイテンシーの監視に関しては、大量のドキュメントを読み込んでレスポンスが遅くなっているreadクエリの発見や、ホットパーティション問題の発見に繋げたいためです。無駄にドキュメントが重複しないようにドキュメントIDを開発者側で指定したりする設計もあるので、そういったホットスポットがちょっとでも発生しうるコレクションは特にレイテンシが気になるところです。

前者のシステムエラーの監視については、クエリを叩くクライアントサイドでエラーをある程度捕捉することは可能です。SDKにはエラーコードが17つ定義されているので、エラーコードが具体的にどのような事象を指しているのかよくわからないものもありますが、Crashlyticsにエラーログを送っています。Stackdriverでのセキュリティールールの評価結果の監視に関しても、Stackdriver上ではErrorやDenyが発生した件数しか確認できないので、結局はCrashlyticsで具体的なエラーの内容を分析するような運用になっています。

f:id:takuwan0405:20200211041500p:plain

 

後者のレイテンシに関しては、Firebase Performance Monitoringで気になるクエリをトレースしています(ドキュメント)。特に、先に述べたような、ドキュメントIDを自動で設定せず開発者で設定しているようなコレクションのクエリに関しては、必ずトレースを仕込むようにしています(ホットパーティションに関して問題を検知したことはまだないです)。

また、大量のドキュメントを読み込むケースが生じうるクエリに関しても、トレースを仕込んでいます。DBの監視からは少々話が逸れるかもしれませんが、Firestoreのオフラインキャッシュへのクエリは必ずしも高速ではないため、という理由もあります。まあ実際のところ、一見Firestoreのクエリが遅いようにみえても、JSONのデコード(O/R Mapper)に時間を取られてるケースのほうが多かったりはします。

もっとも、こうしたFirestoreの監視は、極力アプリケーションレイヤーで担うべきではという考えがあったとしても、わからなくもないです。一般的なWeb APIとDBの監視を考えても、経験上なんだかんだWebアプリケーション側でエラーを捕捉していることは多いように思います。また、FirestoreではCPUやストレージ容量といったマシンリソースを気にする必要はほとんどないため、実際にDB側で監視しないといけないような項目は、相対的には少なかったりもします。ただ、Firestoreは他に明らかに足りない機能があってそちらに開発リソースを割かれていただけだとは思うので(最近だとEmulator Suiteとかインパクト大きいです)、今後充実してくるものだろうとは思います。

 

 

おわりに

以上、Firestoreのオペレーション周りで困っていることを3つ挙げてみました。Firestoreは着実に良くなっているので、いま私が書いたような内容も、2年後には解決していることでしょう。

Firestoreを利用した開発は、基本的には素晴らしいものです。1年半前だと、クライアントサイドに集中したい、さらにオフライン対応もしたいとなると、Firestore以外に良い選択肢はほぼない状態だったかと思います。そういった意味でも、Firestoreの存在に感謝しています。

しかし、現在であればFirestore以外にも色んな選択肢があります。DynamoDBやLambdaの資産を活かせるAmplifyは機能面ではFirestore(Firebase)に勝るとも劣らないですし、hasuraのgraphql-engineのようなものもあります。Replicacheも興味深いなと注目しています。

良くも悪くもFirebaseはマーケティング(見せ方)の部分で成功している一面があるとは思うので、流行りに惑わされず、自分のプロダクトに合ったものを採用していきたいものだなと考える次第です。特に、今回書いたようなオペレーションでの悩みは、AWSだと問題ないものもあると思います。

何かアドバイスなどあれば、twitterでお願いします。

ウェブ・アプリから組み込み系ソフトウェアを扱うTips

ウェブやiOS・Androidのアプリケーションから、プリンタ・POS・体重計などのいわゆる組み込み系と言われるようなハードウェア上のソフトウェアと疎通・連携する機能を開発するときのTipsをまとめます。

開発者が利用するための環境・情報が整っているOSSや各種クラウドサービスとは異なり、組み込み系のソフトウェアは外部の開発者にとっては優しくないことが多く、見積もり通りの開発にはならない傾向が強いためです。

  

 

設計・実装をはじめる前にやること

ハードウェアを選定する

実現したい機能から逆算して、ウェブ・アプリから利用するハードウェアを選定します。外部の開発者に開かれていてSDK等が提供されているハードウェアとそうでないものがあったり、iOS・Android・Windowsアプリはサポートしていてもウェブ(JS)はサポートしていないものがあったりします。

ハードウェアの機能重視で比較検討すると、外部開発者の利用を想定して設計されていない(けどリバースエンジニアリングはできそうな)製品が候補にあがったりしますが、意図せぬバグの可能性、将来性、法律の問題など色々問題があるので、潔く諦めましょう。妥協できないようであれば、ハードウェアベンダと提携したり、自社ブランドでハードウェアを開発する道を検討しましょう。

 
Windowsの開発機を用意する

ウェブやiOS・Androidアプリを開発する人であれば、MacやLinuxで開発をするエンジニアが多いと思いますが、Windowsの開発機は事前に用意しておくほうが無難です。Windowsでの開発しかサポートしていない組み込み系ソフトウェアは結構多くて、なんだかんだ必要になってくるケースがあります。

ドキュメントを一見して、Windows機は使わなくてもなんとかなりそうと感じても、実はWindows機でのみ動く開発用ソフトウェアを使わないと利用できない機能とかあったりします。実際に、SSL周りの一部設定はWindow機がなくてもできたのに、肝心なSSL証明書のインポートにはWindows機が必要だったとか、そういう経験があります。実装中にWindows機が必要であることがはじめて発覚し、そこからWindows機の選定と発注をしているようだと、余計なリードタイムが生じてしまいます。選定次第では数万円程度で済むので先行投資しましょう。

 

ハードウェアを販売している営業マンとつながっておく

ハードウェアを販売している営業マン、さらにできれば、その先にいる開発組織と繋がっておけると開発が行き詰まる可能性がぐっと減ります。というのも、組み込み系のソフトウェアは、まともなエラーログを吐いてくれないことが往々にしてあります。組み込み系ソフトウェアは外部開発者にとってはブラックボックスなので(OSSと違ってissueもコードも何も見えないので)、ログがないとなると何か詰まったときにはどうしても内部のつくりを知っている開発者に問い合わせたくなってきます。問い合わせると意外な答えだったり、隠れた機能を教えてもらえたりするものです。

ただ、組み込み系ソフトウェアを開発している開発組織の情報はなかなか手に入らないので、販売会社にまずは問い合わせることになります。コールセンターやメールでの問い合わせは大抵役に立たないので(ソフトウェアに関することを答えるケーパのない人たちに繋がるので仕方ないですね)、そこから頑張って営業マンや開発者に繋いでもらいます。コールセンターや問い合わせフォームの中の人からはマニュアル化された答えしか返ってこない、さらには営業マンと繋いではもらえないと思いますが、そこは頑張るしかありません。(電話での交渉が得意な方に頼りましょう。)

所謂ウェブ系のエンジニアなら大丈夫だとは思いますが、ベンダーが外資の場合は当然英語でのやりとりになります。

  

 

実装・保守で意識すること

ドキュメントを過信しない

外部開発者の利用を想定している組み込み系ソフトウェアには大抵ドキュメントがあり、ウェブ上からダウンロードできるようになっています。SDKごとにサンプルコードとかが付いてたりします。しかし残念なことに、何故か肝心なことが書かれていなかったり、普通に間違った記載がされていることがあるので、ドキュメントに頼ってばかりではいけません。

大抵SDKの中を読むことになりますし(そしてバグを見つけたりする)、結構雑なつくりだったりするのでびっくりすることもあります。パフォーマンス上の課題とかも、SDKを読み解くことで見つかったりします。組み込み系とウェブ系で言語や慣習が異なる以上、元々SDKの開発や言語に慣れていない人がSDKをつくってたりもするでしょうし、仕方ないのかなと思ったりもします。

 

なるべく早い段階で、リアルな現場で一通りの動作確認をする

なるべく早い段階で、ハードウェアを設置するリアルな現場で一通りの動作確認をします。ハードウェアの置き場がない、電源を確保できない、ネットワークが不安定だとか、ただ開発しているだけでは気づけない課題を発見することがあります。(実際に、そういう経験があります。)

 

SSL通信の動作確認をする

組み込み系ソフトウェアとの疎通には、プライベートなIPに対してHTTPやWebsocketで疎通したり、Bluethoothで疎通することになると思います。(ハードウェアにパブリックIPを当ててドメイン付与とかはしないでしょう。)仮にウェブフロントからSDKを叩いてハードウェアを操作する場合には、プライベートなIPを叩く以上はSSL周りを工夫しないとブラウザからMixed Contentで怒られてblockされたりするケースがあるでしょうし、iOSならATSの対応をどうするかといった話があります。

 

ファームウェアのアップデートを欠かさない

アップデート内容次第ですが、ファームウェアのアップデートには追従できるようにします。OSSのバージョンアップデートの考え方とほぼ同じ考え方でいますが、例えばセキュリティの脆弱性対策とか、ブラウザの自己証明書に対する扱いの変更に関するアップデートとか重要なものもあり、アップデートに追従できないと機能が死ぬケースもあります。 

私はまだ開発したことはないですが、他社にハードウェア一式もろともSaaSとして貸し出すPOSシステムなど(Airregiとか)は、どうやってファームウェアアップデートを管理するかとか、ちゃんと設計しないといけなさそうですね。

 

 

まとめ

組み込み系ソフトウェアとの連携機能をそんなすごい数開発したわけではないですが、いままで詰まったこと、こうしたほう良いなと思ってたこと、またよく起きることをまとめてみました。

個人的に、大抵どこかで詰まるのであまり開発に乗り気になれる分野ではないのですが、でも機能が一通りできたときの達成感は並以上のものがあるので、また機会があったら率先してやってみようと思っている所存です。

5年前に自分がつくったRailsアプリをリファクタした話

8月に入り、晴れてフリーランスになりました。
ありがたいことに色々とお仕事のお話を頂きまして、今月から創業間もないスタートアップ2社に微力ながらお力添えさせていただくことにしています。楽しみです。

7月はほとんど有給消化期間で、結構のんびりしていたのですが、いろんな人にお会いしてお話したり積読本を消化したりする傍ら、5年くらい前に自分がぱっとつくってほぼ放置していたRailsアプリのリファクタ、パフォーマンス・チューニングをしていました。

30万pv/monthしかない小規模なウェブサイトですが、一週間くらい頑張ったらだいぶマシになったので、やったことを晒します。(大したことやってないですが、近況報告程度に。)

 

Railsアプリの概要

ikstudieという、独学の大学受験生を支えたいというコンセプトのウェブサービスが対象です。私自身、大学受験には独学で臨んだのですが、何かと不安に思うところがあって辛かったので、私のような思いをする受験生の支えになりたいと思ったのが立ち上げのきっかけでした。

以下、このサイトの特徴を挙げます。

  • 私が個人ではじめてつくったウェブサービス(手抜き、超短期開発、糞コード)
  • ここ数年、30-50万 pv/month。数日に一回程度、受験生からの質問がある。
  • リリースしてから5年くらい経つものの、Rails3系から4.2までのバージョンアップと、クリティカルなボトルネック対応以外、ほぼ手付かずで保守できていない
  • 有志で、View部分だけずっと書いてくれている仲間がいるものの、staticなページが多数出来ている
  • 当然のようにユニット・テストはない
  • モデルは30個前後の小規模アプリ
  • 当たり前品質を満たせていない(レイアウト崩れ、機能が壊れている等)
  • インフラに関しては、完全に私の実験場になっていて、これまでEC2 + capistrano2系 ー> Opsworks + Chef ー> Elastic Beanstalk ー> Terraform + Packer + (Itamae or Ansible) on AWS  ー> Google Kubernetes Engine ー> Google App Engineと色々お引越ししてきている(チューニングはしていないが、最低限のインフラ環境は整っている)

ずっとikstudieに関わってきてIT系に勤める有志の仲間たちと、これからちゃんとエンハンスをしようという話になったのが、今回のリファクタのきっかけです。中のコードがひどかったり、当たり前品質と言われるようなところに問題があったので、まずはリファクタしないと先に進めないという認識でした。

 

何はともあれまずは現状把握と解析

ぱっと調べられる範囲でいろんなツールを使って解析したり、主要ページのコードを眺めたり、全画面に遷移して挙動の確認をしてみました。

 

rubocop(linter)を走らせる

f:id:takuwan0405:20180802165103p:plain

rubocopはRubyのlinterです。設定は妥協に妥協、かなり甘くしてあります。autocorrectを走らせ、linterのターゲットをコントローラーとモデルだけに絞った結果、これだけoffensesが出てきました。(意外と少なかった。)

Fatなコントローラー・モデルに対して、もっと小さくしろよと怒ってるのがほとんどでした。
自分の目でコード一行一行を読んでいくともっとツッコミどころ満載だったので、あくまでrubocopで補足できる範囲での数値です。

もっとRubyっぽく柔軟に書けば読みやすいしコード量も1/3以下で済むのに、みたいな記述がたくさんあった感じです。

 

Railsアプリとしてのコード品質をrails_best_practicesでチェック

f:id:takuwan0405:20180802174305p:plain

rails_best_practicesは、その名の通り、Railsらしい記述を指南してくれる静的解析ツールです。Warningの数もさることながら、カラムにindexが付いていないだとか、結構重要なところでも怒られました。
Warningのほとんどは、viewの書き方が汚いよ・間違っているよと怒っていて、地道な修正が必要そうでした。

 

Skylightでパフォーマンスモニタリング

f:id:takuwan0405:20180802170321p:plain

Newrelicでも良かったのですが、今回ははじめてSkylightをつかってパフォーマンスモニタリングをしてみました。Newrelicのほうが機能はリッチでしたが、価格もリッチだったうえ、Skylightでも今回は困りそうになかったためです。

Typical Responseで244msは、うーんって感じですね。基本的にマシンリソースには余裕があって負荷もないので、worker、thread、connection poolとかにボトルネックがある様子もありません。Problem Responseでは1.5s以上のものも散見されました。

ActiveRecordのクエリは遅くても20msで返ってきていて、Viewの生成にすごい時間がかかっていました。

 

Lighthouseによるフロントからの評価

f:id:takuwan0405:20180802171838p:plain

SEOの100点が眩しいですね。(一応メディアなので、ここはクリティカルなポイントでした。)
PWAは対応する気がないので問題ないですが、PerformanceとBest Practicesが気になります。

細かい設定は色々指摘されましたが、画像周りが大変そうな印象。

 

Railsアプリのセキュリティ脆弱性をBrakemanでチェック

f:id:takuwan0405:20180802163354p:plain

やたらErrorsが出てますが、全部Parsing Errorでした。
数年前にbrakeman走らせたときはXSSのSecurity Warningが出てたりしたので(パラメーターを直に使ってSQL叩いてたところがあったので)、本当に駄目なやつは昔直してました。

 

Githubのセキュリティアラート

f:id:takuwan0405:20180802165915p:plain

自分は、つくっては放置しているリポジトリがいくつもあるので毎週怒られてます。
ikstudieもいくつかの依存ライブラリでsecurity alertが挙がっていました。(が、内容ちら見したうえでいままで後回しにしてました。)

 

使われている機能・使われていない機能の確認

管理画面を見たり、本番データを複製したDBにクエリ投げてみたりして使われていない機能をぱっと洗い出しました。

記事をマイリストする機能、いいね!する機能などが使われていないことがわかったり、使われていないカラムが沢山あることが判明しました。データ量がそんなないので、slow queryは引っかかりませんでした。

 

愚直に手動でE2Eテスト

テストケースを列挙して丁寧にやったわけではないですが、全画面の挙動を確認しました。ログイン機能が一部壊れてたり、もう色々壊れてました。

全体的にひどかったのがレスポンシブデザインで、例えばトップページでも下記のように崩れていました。

f:id:takuwan0405:20180802173316p:plain

jpmobileという、Railsのview層でUserAgentを判別できるgemが導入されていて、viewで ` request.smart_phone? ` といった感じの分岐がいたるところに入っていました。リリース当時はレスポンシブデザインだったはずなのですが。。(UserAgentで切り分けているので、スマホでikstudieを見たら崩れてはいなかったです。)

CSSに一貫した命名規則がなく(bootstrapっぽいもの、SMACSSっぽいもの、安易な命名とか混在してた)、少し触るとすぐレイアウトが崩れそうなので、それが一因してjpmobileを多用したのかなと伺えます。たぶんそんな考えてないです。

 

目標を決める

現状把握は一日くらいでぱっと済ませたので、その後4日くらいで達成する、リファクタのMUST要件(目標)をゆるく、ざっくり決めました。上から優先度高めです。

  • 壊れているレイアウト・機能の改修(UX目線での当たり前品質を満たす)
  • SkylightでのTypical Responseを80ms以下にする(UXを向上させ、SEO上のマイナスをなくす)
  • 不要な機能、コード、テーブル、カラムの除却(エンハンスしやすい形にしたい)
  • コード品質向上・維持のために、CIを導入してrubocop、rails_best_practices、brakeman、Github security alertでのwarningとerrorをゼロにする(負債を貯めないようにしたい)

 

リファクタ結果

当たり前品質(壊れたレイアウト・機能の改修)

View層で動的な部分はほぼ全部書き直しました。CSSの設計が破綻してたので、エンハンスする可能性がありそうなページは書き直しておくことで後々うれしい気持ちになりそうだったためです。問題になっていたjpmobileは極力取り除き、書き直したページは一部を除きレスポンシブデザインに戻りました。(UserAgentでのViewの条件分岐はなくしました。)
View層は私以外にも触る人がいるので、Reactjsとかは入れずに、以前から入っていたslimというテンプレートエンジンを採用し続けました。私以外の人はエンジニアとして生活しているわけではないので、学習コストがない状態にしたかったためです。

ただただ、愚直に直しただけです。
静的なページは、後日優先順位をつけて改修する必要があるかもしれません。

 

f:id:takuwan0405:20180802184816p:plain

ちなみに上の画像のフッターに出てるLINEへの送客は、どれくらい送客できるか、ikstudieのユーザー層はどうなっているかを検証・確認するためにやってみたのですが、結構良い・面白い数字が出ています。受験生の集客、つまり広告として成り立ちそうだなーとか考えています。(受験に関連するiOS/Androidアプリつくって送客しても良さそう。)

 

UXとSEO向上(パフォーマンス・チューニング)

f:id:takuwan0405:20180802190947p:plain

Typical Responseは大体40~60msになりました。(1/5くらいになりました。)

ループを回してHTMLを生成している部分が概して遅く、Railsが標準で提供しているフラグメントキャッシュという、生成したHTMLをキャッシュする機構を多く使いました。キャッシュストア、またセッションストアにはRedisを採用しています。

N+1は意外と少なかったのですが、利用しない(検討した結果利用する予定もない)AccessLogやEventLogをinsertするクエリが各リクエストにあったり、他にも不要なクエリがいくつかあったので削ったのも少々効きました。あとはrenderメソッドのチューニングとかも効いてそうです。

キャッシュのヒット率を高めるためにキャッシュの有効期限は長めに取ってますが、記事の検索結果のページとかはヒット率が低いので、ひどいと500msとかかかってしまうケースもまだあります。

Skylightをつかってて嬉しかったこととして、週次でどれくらいパフォーマンスを改善したのかが送られてきます!StaticPageController#homeはトップページなのですが(全然Staticじゃないので命名が意味不明ですね。負債具合が伝わりそう。)、9倍になったらしいです。記事のページは141msとかかかってるので、まだ改善が必要そうですね。

 

f:id:takuwan0405:20180802190949p:plain

 

エンハンスしやすい形に(不要な機能、コード、テーブル、カラムの除却)

前述したAccessLogやEventLogもそうですが、不要なテーブルやカラムが散見されたので、凍結したりdropしました。

また、マイリストの機能は改修がちょっと大変な割には本当に使われてなかったので、一旦凍結しました。また、ログインが必要なために使われていなかった「いいね!」機能は、ログインしなくても利用できるようにしました。

コード量も全体的に削っていて、PRのコード量を単純にみたら3600行削ったようです。

f:id:takuwan0405:20180803005720p:plain

 

コード品質向上・維持 

まず、rubocop、rails_best_practices、brakeman、Github security alertでのwarningやerrorは全てゼロにしました。静的コード解析上では常にクリーンな状態を保つことである程度のコード品質を担保したかったのと、コード品質を表現する尺度を他のコントリビューターにもみえる形で設定したかったという意図です。

CIは、いまのikstudieでは無料で十分使えるCircleCIを採用し、元々あったdockerの開発環境をそのまま利用する形でぱっと構築し静的解析を走らせるようにしました。(コンテナのキャッシュとか設定してないので1回のRunで13分前後かかってて、いつか改善したいです。)

ブランチ戦略も簡単に設けて、masterとdevelopブランチはprotectし、基本的にdevelopからfeatureブランチを切る形にしました。
Staging環境をHerokuで無料で構築し、developブランチにPRがmergeされたらデプロイされるようにしました。また、masterブランチにPRがmergeされたら本番環境にデプロイされるようにもしました。

ユニットテストとかは書いてないですが、コントローラーもモデルもスリムであまり量がないので、これからエンハンスしながら書き加えていこうと思います。

 

余談

リファクタのPRを本番デプロイ後にLighthouseで確認してみたところ、数値がそこそこ改善していました。

Nginxとかの細かい修正が少なくなかったですが、Performanceに関しては、CDNとしてCloudFrontを導入してCache-Controlを長めに設定したこと、S3に置かれている画像オブジェクトを全部圧縮したこと(pngの圧縮は時間がかかりました)、またLayzr.jsをつかって一部の画像を遅延読み込みさせたこととかが、効いていそうです。

f:id:takuwan0405:20180803013031p:plain

あと余った時間でやったこととして、たまたまGoogle Cloud Load Balancerの障害に当たってしまったり、ikstudieにとってGAEは割高だと感じていたこともあって、本番環境をElastic Beanstalk(というかAWS)に移行しています。ついでにPumaのworker、thread、postgres、connection pool、GC周りとかもそれっぽい数値を改めてぱっと設定して様子見しています。

 

これから

優先順位は高くないもののまだリファクタしたいことはいくつもあるので、「心置きなく」というわけにはいきませんが、エンハンスには着手できそうです。

今年はぼちぼち既存のウェブサイトの最適化を図る予定ですが、アプリの開発やリアルでのイベント開催などでもできること・やれることは沢山あるので、仮説検証をしつつコードも書いていていきます。

興味がある方はtwitterでDMください。

 

リファクタの感想

あくまで個人開発なので全然しっかりしていない開発・リファクタですが、一応満足。