Serverless Architectureを採用したMF KESSAI Tech Blogについて

MF KESSAIの篠原(@shinofara)です。

これまでは、本社のマネーフォワードの開発ブログに場所を借りて投稿していました。 これからは先日公開した当ブログ「MF KESSAI Tech Blog」をMF KESSAIのものづくりチーム全員で更新していきます。

そして今回は当ブログがどのようなアーキテクチャを採用して運用しているのかをお話します。

このブログで伝えたい事を3行で

文量のあるブログとなっていますので、読む時間を取れなくても結論は何かさくっと理解できるように、3行にまとめました。

  • MF KESSAIは元々ブログを投稿していたマネーフォワードの開発ブログを卒業して独立した
  • HugoとGithubを使ってPull Requestベースでブログの運用を出来る仕組みを整えた
  • インフラの運用にサーバレスアーキテクチャ(Firebase HostingやGoogle App Engine)を採用した

ブログを新しく作る際の要件

ブログを新設するに当たって実現したい事は何か

  1. Pull Requestベースで記事の管理をしたい
  2. インフラ運用にかかるコストを可能な限り0にしたい
  3. 記事作成やレビューの際にWEB上で検証できる環境が欲しい

実現したい事のイメージ図

要件を元にイメージを書き出してみました。 下記の図は初期構成イメージですので、GAEをメインに考えています。

1. Pull Requestベースで記事の管理をしたい

何故Pull Requestベースで記事の公開を行いたいのか

  • 記事のバージョンを管理したい
  • 記事に対してのレビューを記事に対して書き込みたい
  • 不慣れなツールではなく、開発者として慣れ親しんだツールを使いたい

Githubを使うとしても何でブログを構築するのか

先に理由を書きましたがGithubでの管理、Markdownで記事管理を行うにあたって、何を使うかを決める必要があります。 弊社ではサービス紹介サイトコーポレートサイトで導入実績のあるHugoを使う事に決めました。 これらのサイトも既にGithubで管理しておりPull Requestベースで運用しております。またインフラなどのシステム要件も同じです。 その為、この記事では過去に採用はしているものの、お話できていなかった内容をブログの公開のタイミングでお話している形にもなります。

Hugoについて

Hugoについては色々ググってもらったら十分情報が出てきますので、当ブログでは割愛します。 hugo コマンドで作られる public ディレクトリが公開するために必要なディレクトリとなります。

$ hugo
$ tree
├── archetypes
│   └── default.md
├── config.toml
├── content
│   └── post
├── public
│   ├── categories
│   ├── css
│   ├── fonts
│   ├── images
│   ├── index.html
│   ├── index.xml
│   ├── js
│   ├── post
│   ├── scss
│   ├── sitemap.xml
│   └── tags
├── static
│   └── images
└── themes
    └── theme-hugo-foundation6

CircleCIでビルドする

実際にビルド・デプロイをする環境はCircleCIです。 CircleCIではこのように記述しています。

version: 2
jobs:
  hugo-build:
    root: /go/src/github.com/example/blog
    docker:
      - image: jojomi/hugo
    steps:
      - checkout
      - run:
          command: hugo
      - persist_to_workspace:
          root: /path/to/blog
          paths:
            - public

persist_to_workspace で指定したディレクトリは、workflow内であれば任意のjobにアタッチして再利用する事が出来るようになります。 ビルド結果の public ディレクトリは後半に登場するデプロイジョブ部分で利用します。

2. インフラ運用にかかるコストを可能な限り0にしたい

インフラ運用にかかるコストとは何があるのか

  • サーバのセットアップ、アップデートなどの管理
  • ハードウェア障害などに備えて監視
  • リソース負荷に応じたスケール対応
  • CVE対応などセキュリティ関連の対応

なぜ運用コストを下げたいのか

技術ブログを公開したいが、MF KESSAIは創業1年と少しでまだまだやるべき事が多い為、ドメイン知識が必要な領域の開発に専念したいと思ったからです。

どういったアーキテクチャを採用したか

ブログの運用にあたり記事の内容にフォーカスし、サーバの運用に関しては可能な限り意識しなくても良いようにしたいので、サーバレスアーキテクチャを採用する事にしました。

サーバレスアーキテクチャとは

サーバーレスコンピューティングにより、アプリケーションとサービスを構築して実行する際に、サーバーについて検討する必要がなくなります。サーバーレスアプリケーションでは、サーバーのプロビジョニング、スケーリング、および管理は必要ありません。サーバーレスアプリケーションはほぼすべてのタイプのアプリケーションやバックエンドサービス用に構築でき、高可用性を実現しながら、アプリケーションの実行およびスケーリングに必要なことをすべて自動的に行います。

(引用:AWS|サーバーレスコンピューティングとアプリケーション)

サーバレスアーキテクチャを採用することで、サーバ運用の事を考える必要なくブログの記事作成・公開に専念出来ます。

アーキテクチャ実現の為に何を選択してどの様に実装したか

サーバレスアーキテクチャと一言で言っても色々な方法があります。弊社ではGCPを採用(過去記事参考)している為、最初はGAEを採用するか、Cloud StorageとLBを活用する方法が候補に上がりました。

そして最終的にFirebase Hostingを採用をする事となりました。 理由は後半に書いてます。

なぜGAEを採用しなかったのか

後述しますが検証環境には GAE を採用しています。ですが世の中に公開するブログ本番環境ではGAEを使用しない事にしました。 理由は1つで、アイドルタイムが多いブログでは、起動時間課金のGAEではコスパが良くないなと思ったからです。

なぜCloud Storageを採用しなかったのか

Cloud Storageでも静的ウェブサイトのホスティングは可能です。 ですが、HTTPS を介してコンテンツを提供したいにも書かれている通り、Cloud Storage単体ではHttpsで配信する事はできません。 もしCloud Storageでhttps化までして配信しようとした場合、以下が必要になります。

  • LBの設定
  • CDNの設定(全部するか、全部しないか)
  • 自前で証明書の管理

これらを準備して進めるという事ももちろん出来ます。 ですが、こちらのページにはこう書かれています。

または Google Cloud Storage の代わりに Firebase Hosting から静的ウェブサイトのコンテンツを提供します。

公式が推奨しているのであれば、無理に頑張るより推奨されてる方法を採用したほうがいいなとなりました。

なぜFirebase Hostingを採用したのか

公式で推奨されている事も1つの理由ですが、Firebase Hostingですと以下を意識する事が不要になるという事が体験として大きかったです。

  • 圧倒的安さ
  • デフォルトでHTTPS公開できる
  • スケール設定など考えなくても勝手に負荷対策してくれる
  • ロールバックが簡単に行える

Firebase Hostingで公開するまで

基本的には公式ドキュメントの手順に沿って行えばOKです。

firebaseの設定ファイル(firebase.json)

firebase.json は公開したいサイトの構成で記述内容が変わりますが、弊社ではhugoで書き出した物を公開しますので以下の内容を記述する事になります。

# firebase.json
{
  "hosting": {
    "public": "public"
  }
}

こちらpublic ディレクトリをルートディレクトリとして公開する様に設定しています。

Firebaseにデプロイする手順

Firebaseへのデプロイ時にTokenが必要です。その為 firebase コマンドをインストールしてtoken取得コマンドを実行します。

# firebaseのインストール
$ npm install -g firebase-tools
# firebase tokenの取得
$ firebase login:ci

作成したTokenと対象のプロジェクト名をCircleCIの環境変数に追加すればあとは実行するだけです。

$ firebase deploy --token ${FIREBASE_TOKEN} --project ${FIREBASE_PROJECT}

※CircleCIでは Environment VariablesFIREBASE_TOKEN という名前で設定する必要があります。

CI経由でFirebase Hostingに公開する

CircleCIから実際にデプロイする時の設定内容です。

docker-deploy-firebase: &docker-deploy-firebase
  working_directory: /go/src/github.com/example/blog
  docker:
    - image: nohitme/hugo

deploy-firebase:
  <<: *docker-deploy-firebase
  steps:
    - checkout
    - run: apk add --update wget ca-certificates
    - attach_workspace:
        at: /path/to/blog
    - run: firebase deploy --token ${FIREBASE_TOKEN} --project ${FIREBASE_PROJECT}

事前にビルドしていた public ディレクトリを attach_workspace でworkspaceにアタッチしています。 こうする事で、deploy-firebase jobには存在しなかったpublicディレクトリが作成されます。

3. 記事作成やレビューの際にWEB上で検証できる環境が欲しい

どういうことか

  • 記事を公開する前に実際の見え方を確認したい
  • レビュアがローカルで環境を立ち上げなくても確認できるようにブラウザで見れる環境が欲しい
  • 複数のPull Requestが発生した場合はそれぞれに環境が作られて欲しい
  • 不要になった検証環境は破棄したい

どの様に実現したか

そこで、弊社ではGAEを一時環境として採用しています。 検証環境をGAEにするなら本番もGAEにしたら楽なのにという意見が上がってきそうですが、GAEを選択しなかった理由は前に上げたとおりです。

ここからは実際にどの様に実現しているかを書いていきます。 ざっくりとした流れとしては、CirlceCIからGAE/pythonに足しいてdeployコマンドを発火してます。 そしてマージされたらPull Request元のブランチ用環境を削除しています。

GAEに作成するHugoの検証環境用app.yaml

CIからGAEにDeployする為に必要なIAMの作成

GCP外からデプロイ作業を行う為には、特定の権限を持ったサービスアカウントを作成する必要があります。 その時必要な権限は以下になります。

サービスアカウント作成後json.keyをbase64 encode化した文字列をCircleCIに設定します。

$ cat /path/to/key.json | base64

取得した文字列をCircleCIの Environment VariablesGAE_AUTHという名前で設定しています。

CIでの検証環境作成と破棄する方法

GAEに対してデプロイする為に必要な準備として、google_appengine_1.9.69.zip の取得とgcloudのauth設定をします。 執筆時点では 1.9.69 が最新です。

curl -o $HOME/google_appengine_1.9.69.zip https://storage.googleapis.com/appengine-sdks/featured/google_appengine_1.9.69.zip
unzip -q -d $HOME $HOME/google_appengine_1.9.69.zip
echo $GAE_AUTH | base64 --decode -i > ${HOME}/gcloud-service-key.json
gcloud auth activate-service-account --key-file ${HOME}/gcloud-service-key.json

レビュー完了してマージしたら、Pull Request元のブランチ用環境を削除しています。 Pull Request元のブランチが設定されたバージョンを削除しています。 削除後Slackに削除した事を通知しています。

requested_branch=`git log -1 --oneline --pretty=format:"%s" --merges  | awk '{ print $6 }'`
array=( `echo $requested_branch | tr -s '/' ' '`)
$CI_TOOLKIT/gcloud/gae delete -project ${TEST_PROJECT_ID} -branch ${array[1]} -name pre-blog || error=true
if [ ! ${error} ]; then
  # Slackに削除した事を通知する
  $CI_TOOLKIT/slack/post "検証環境削除完了\nBranch: <${GITHUB_BRANCH_URL}${array[1]}|${array[1]}>"
fi

最後に、リポジトリ内のいずれかのブランチにPushが実行されたら、それぞれに応じた環境を構築・更新しています。 --version ${CIRCLE_BRANCH} とする事で、更新されたブランチ名でversionを切って環境を構築する事が出来ます。

# deploy
gcloud --project ${PROJECT_ID} app deploy --quiet --version ${CIRCLE_BRANCH}

# deploy後のURLを取得
SITE_URL=`$CI_TOOLKIT/gcloud/gae get_url -project ${TEST_PROJECT_ID} -branch ${CIRCLE_BRANCH}`
# slackに作成した環境のURLをPOST
$CI_TOOLKIT/slack/post "検証環境作成完了\nBranch: <${GITHUB_BRANCH_URL}${CIRCLE_BRANCH}|${CIRCLE_BRANCH}>\nURL: ${SITE_URL}"

この時Slackには以下の内容が投稿されます。

結果どの様な形に落ち着いたか

最終的なアーキテクチャ図

結果、記事前半でイメージしたイメージ図は、下記図の様になりました。

CirlceCIのconfig.yml

当ブログの本番、検証環境構築の仕組みを支えているCircleCIの設定も可能な限りそのまま公開します。

docker-deploy-firebase: &docker-deploy-firebase
  working_directory: /go/src/github.com/example/blog
  docker:
    - image: nohitme/hugo-firebase

docker-mfk-gcloud: &docker-mfk-gcloud
  working_directory: /go/src/github.com/example/blog
  docker:
  - image: <MFK PRIVATE REPOSITORY>/ci-toolkit:master
    auth:
      username: _json_key
      password: $MFK_SHARED_GCR_AUTH
    environment:
      CLOUDSDK_COMPUTE_ZONE: asia-northeast1-a

version: 2
jobs:
  hugo-build:
    working_directory: /go/src/github.com/example/blog
    docker:
      - image: jojomi/hugo
    steps:
      - checkout
      - run:
          command: hugo
      - persist_to_workspace:
          root: /go/src/github.com/example/blog
          paths:
            - public
  textlint:
    docker:
      - image: node:9.1-alpine
    steps:
      - checkout
      - run:
          command: apk add --update alpine-sdk
      - run:
          command: npm i
      - run:
          command: |
            git --no-pager diff --name-only origin/master | grep -a ".md" | grep -v themes | grep -v README.md | grep -v template.md || error=true
            if [ ! ${error} ]; then
              git --no-pager diff --name-only origin/master | grep -a ".md" | grep -v themes | grep -v README.md | grep -v template.md |  xargs ./node_modules/textlint/bin/textlint.js
            fi

  deploy-firebase:
    <<: *docker-deploy-firebase
    steps:
      - checkout
      - run: apk add --update wget ca-certificates
      - attach_workspace:
          at: /go/src/github.com/example/blog
      - run: firebase deploy --token ${FIREBASE_TOKEN} --project ${FIREBASE_PROJECT}

  deploy-branch-env:
    <<: *docker-mfk-gcloud
    steps:
      - checkout
      - attach_workspace:
          at: /go/src/github.com/example/blog
      - run:
          name: APT-GET
          command: |
            apt-get update -qq && apt-get install --no-install-recommends -y build-essential libxext-dev apt-transport-https lsb-release locales unzip ca-certificates
      - run:
          name: リリース前検証環境の構築と削除
          command: |
            curl -o $HOME/google_appengine_1.9.69.zip https://storage.googleapis.com/appengine-sdks/featured/google_appengine_1.9.69.zip
            unzip -q -d $HOME $HOME/google_appengine_1.9.69.zip
            echo $GAE_AUTH | base64 --decode -i > ${HOME}/gcloud-service-key.json
            gcloud auth activate-service-account --key-file ${HOME}/gcloud-service-key.json
            GITHUB_BRANCH_URL="https://github.com/example/blog/tree/"

            # master, releaseブランチにpushが発生したら、pull request時に作成した検証環境の破棄を行う
            if [ "${CIRCLE_BRANCH}" == "master" -o "${CIRCLE_BRANCH}" == "release" ]; then
              # マージ元のブランチ名を取得
              requested_branch=`git log -1 --oneline --pretty=format:"%s" --merges  | awk '{ print $6 }'`
              b=( `echo $requested_branch | tr -s '/' ' '`)
              branch=${b[1]}

              # versionが1つしか存在しない場合は、version削除ができないのでserviceまるごと削除する
              versions=(`gcloud --project ${PROJECT} app versions list | grep ${SERVICE} | awk '{ print $2 }'`)
              if [ ${#versions[@]} -eq 1 ]; then
                  # service削除
                  exec "gcloud --project ${PROJECT} app services delete ${SERVICE} --quiet"
              else
                  # version削除(まずは自分以外にトラフィックを向ける)
                  for e in ${versions[@]}; do
                      # 削除対象versionにトラフィックが割り当て時は、削除できない為、別のversionに変更する。
                      if [ "${e}" != "${VERSION}" ]; then
                          exec "gcloud --project ${PROJECT} app services set-traffic ${SERVICE} --splits ${e}=1  --quiet"
                      fi
                  done

                  if [ "$VERSION" == "" ]; then
                      exec "gcloud --project ${PROJECT} app services delete ${SERVICE} --quiet"
                  else
                      exec "gcloud --project ${PROJECT} app services delete ${SERVICE} --version ${VERSION} --quiet"
                  fi
              fi

              if [ ! ${error} ]; then
                $CI_TOOLKIT/slack/post "検証環境削除完了\nBranch: <${GITHUB_BRANCH_URL}${array[1]}|${array[1]}>"
              fi
            fi

            # patch, releaseブランチにpushが発生したら、検証用環境を作成、もしくは更新を行う
            if [ "${CIRCLE_BRANCH}" != "master" ]; then
              VERSION=`"${CIRCLE_BRANCH}" | sed s/[-_]//g`
              gcloud --project ${PROJECT} app deploy --quiet --version ${VERSION}
              SITE_URL="https://${VERSION}-dot-${SERVICE}-dot-${PROJECT}.appspot.com/"
              $CI_TOOLKIT/slack/post "検証環境作成完了\nBranch: <${GITHUB_BRANCH_URL}${CIRCLE_BRANCH}|${CIRCLE_BRANCH}>\nURL: ${SITE_URL}"
            fi

workflows:
  version: 2
  deploy:
    jobs:
      - hugo-build
      - textlint
      - deploy-branch-env:
          requires:
            - hugo-build
            - textlint
      - deploy-firebase:
          requires:
            - hugo-build
            - textlint
          filters:
            branches:
              only: master

実際はキャッシュの活用や、処理のスクリプト化したりしています。 また $CI_TOOLKIT はgcloudコマンドのラッパです。

何か一言

WEB UIでブログ投稿をしていた頃と比べると、書き慣れたエディタでMarkdownを書くことができるようになりました。 そして普段の開発と同じフローでブログの投稿から公開まで行えるようになりました。

アーキテクチャ面ではFirebase HostingやGAEを採用する事でセットアップや負荷対策といったインフラのコストも削減できます。 今回はブログを作りましたがアプリケーション開発に集中できる仕組みが整ってきたなと感じました。

本当は「Firebase Hostingすごいぞ」という感じのブログにする予定でした。ですが最終的にはサーバレスアーキテクチャをうまく活用する事で、よりアプリケーションに専念できてよいという形にまとめました。

最後に

弊社ではエンジニアをまだまだ採用しております。 弊社のアーキテクチャに興味を持っていただけたり、Golangで事業開発をしたいと思っている方いましたらぜひお声がけ下さい。