イケてる SaaS を作りたい (koni blog)

SNS管理ツール「SocialDog」を運営する株式会社AutoScale代表 小西将史のブログです。イケてるSaaSを目指して日々奮闘しています。

PHP+MySQL+React+TypeScript 環境で、CI を CircleCI から GitHub Actions に乗り換えた

こんにちは、AutoScale の小西です。

うちの会社の自社サービス「SocialDog」では、これまで CI ツールとして、CircleCI を使ってきました。

今回 GitHub Actions に乗り換えたので、CircleCI と GitHub Actions の違いを見つつ、設定ファイルを晒してみたいと思います。

CI 組むとき、説明読むより、サンプルがある早いですよね。 というわけでうちで使っているものをそのまま晒してみます。

React+TypeScript とか書いたものの、ほぼ yarn のコマンドに集約されちゃっててあんまり関係ないなw

乗り換え理由

これは単純で、うちの場合試算したら、CircleCI よりも GitHub Actions のほうが料金が安かったからです。

すでに GitHub Team を使っている場合、料金の面で有利な場合は多いんじゃないかな、ぜひ試算をオススメ。

また仮に料金がほぼ同じだったとしても、料金、機能がそんなに変わらなければ、使うツールは少ないほうが良いと考えました。

乗り換え手順

  1. 一通りドキュメントを読む
    なんと日本語ドキュメントがあります。
    GitHub Actionsを使ってみる - GitHub ヘルプ
  2. Workflow 定義の yaml を書く
  3. .github/workflows/xxx.yaml に保存して push し、Pull Request を作成((onにpushがあればPullRequestの作成は必須ではありませんが、github.head_ref などは中身が違うので注意。))
  4. GitHub Actions の画面で動作確認する

ディレクトリ構成

SocialDog のリポジトリは、①webバックエンド・フロントエンド、②ネイティブアプリの2つのディレクトリからなるmonorepoとなっています*1。 それぞれのフォルダに package.json があり、JS は yarn, PHP は composer で依存関係を管理しています。

.
├── .github
│   └── workflows
│       └── main.yml (←今回書く設定ファイル)
├── native
│   ├── [ReactNative 関連のファイル]
│   ├── package.json
│   └── yarn.lock
└── web
    ├── application (CodeIgniterのファイル)
    ├── build (phpunitなどの青果物が入るフォルダ)
    ├── composer.json
    ├── composer.lock
    ├── cypress
    ├── cypress.json
    ├── package.json
    ├── public
    └── yarn.lock

GitHub Actions の設定ファイル

GitHub Actions も CircleCI と同様、yaml ファイルに設定を書きます。 ファイルの場所は、 .github/workflows/xxx.yaml となります。

書き方も .circle/config.yml とよく似ているので、移行はしやすそうです。

.github/workflows/xxx.yaml

name: GitHub Actions CI
on:
  push:
    branches:
      - master
  pull_request:
jobs:
  php:
    name: PHP (web)
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:5.7
        env:
          MYSQL_ALLOW_EMPTY_PASSWORD: false
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: cheetah_test
        ports:
          - 13306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=30s --health-timeout=30s --health-retries=10
    env:
      DATABASE_HOST: mysql:13306
    steps:
      - name: Prepare / Checkout
        uses: actions/checkout@v2

      - name: Prepare / Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: 7.4
          extensions: mbstring, intl, pdo_mysql, mysqli
          coverage: xdebug
          ini-values: memory_limit=512M, short_open_tag=On

      - name: Prepare / Get composer cache directory
        id: composer-cache
        run: cd web; echo "::set-output name=dir::$(composer config cache-files-dir)"

      - name: Prepare / Cache composer dependencies
        uses: actions/cache@v1
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-composer-

      - name: Prepare / Composer Install
        run: |
          cd web; composer install --no-progress --no-suggest --prefer-dist --optimize-autoloader

      - name: Prepare / Initialize Database
        run: |
          echo "<?php define('ENVIRONMENT', 'testing');" > web/environment.php;
          cd web/public; php index.php cli/dev init_database_for_testing

      - name: PHP / PHPUnit
        run: |
          cd web/application/tests ; ../../vendor/bin/phpunit -c phpunit.CircleCI.xml

      - name: PHP / phpmd
        if: always()
        run: cd web; composer phpmd

      - name: Translation / Check
        if: always()
        run: web/dev_tools/check_translation.php

      - name: Save / CodeCov
        run: bash <(curl -s https://codecov.io/bash) -t xxxxxxxxxxxxxxxxxxxxx

  js:
    name: JS (web/native)
    runs-on: ubuntu-latest
    env:
      NODE_ENV: production
      GOOGLE_SERVER_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_SERVER_ACCOUNT_JSON_BASE64 }}
      GOOGLE_APPLICATION_CREDENTIALS: /home/runner/gcloud-service-key.json
      SENTRY_ORG: xxxxxxxx
      SENTRY_PROJECT: xxxxxxxx
      SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
      SHA: ${{ github.sha }}
    steps:
      - name: Prepare / Checkout
        uses: actions/checkout@v1

      - name: Prepare / Install for cypress
        run: sudo apt-get install -y libgtk2.0-0 libgtk-3-0 libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb fonts-noto-cjk

      - name: Prepare / Cache .cache directory
        uses: actions/cache@v1
        id: yarn-cache
        with:
          path: ~/.cache
          key: cache5-${{ hashFiles('web/yarn.lock') }}${{ hashFiles('native/yarn.lock') }}
          restore-keys: |
            cache5-

      - name: Prepare / Use Node.js v10.15.3
        uses: actions/setup-node@v1
        with:
          node-version: v10.15.3

      - name: Prepare / Yarn Install web
        working-directory: ./web
        run: yarn install

      - name: Prepare / Yarn Install native
        working-directory: ./native
        run: yarn install --ignore-scripts && yarn patch-package

      - name: Web / Build JavaScript / SCSS
        run: cd web; yarn run build

      - name: Web / Prettier Check
        if: always()
        run: cd web; yarn run prettier-check

      - name: Web / ESlint TestCode(web)
        if: always()
        run: cd web; yarn run eslint-test-js

      - name: Web / Jest (JavaScript Test)
        if: always()
        run: cd web; NODE_ENV=test yarn run jest

      - name: Web / Stylelint
        if: always()
        run: cd web; yarn run stylelint-check

      - name: Web / Stylelint for Styled Components StyleSheet
        if: always()
        run: cd web; yarn run stylelint-styled

      - name: Web / Prettier Check
        if: always()
        run: cd web; yarn run prettier-check

      - name: Web / master_data Check
        if: always()
        run: |
            mv native/js/master_data.ts native/js/master_data_this_commit.ts
            cd web; yarn run build:master_data
            cd ..; diff -q native/js/master_data.ts native/js/master_data_this_commit.ts

      - name: Web / Cypress Server
        run: cd web; yarn run cypress_server_start &

      - name: Web / Cypress Run
        run: cd web; yarn run cypress run

      - name: Native / Prettier Check
        if: always()
        run: cd native; yarn run prettier-check

      - name: Native / ESLint
        if: always()
        run: cd native; yarn run eslint-all-js

      - name: Native / TypeScript Check
        if: always()
        run: cd native; yarn tsc

      - name: Native / Stylelint
        if: always()
        run: cd native; yarn all-stylelint

      - name: Prepare / GCP SDK Install
        if: always()
        uses: GoogleCloudPlatform/github-actions/setup-gcloud@master
        with:
          version: '281.0.0'

      - name: Save / GCP Credential
        if: always()
        run: |
          echo $GOOGLE_SERVER_ACCOUNT_JSON_BASE64 | base64 --decode --ignore-garbage > ${HOME}/gcloud-service-key.json
          sudo gcloud auth activate-service-account --key-file ${HOME}/gcloud-service-key.json
          sudo gcloud config set project xxxxxxxx

      # @see https://github.com/reg-viz/reg-suit#workaround-for-detached-head
      - name: Save / reg-suit
        if: always()
        run: |
          cd web;
          git checkout ${{ github.ref }} || git checkout -b ${{ github.ref }}
          yarn reg-suit run

      - name: Save / CodeCov
        if: always()
        run: bash <(curl -s https://codecov.io/bash) -t xxxxxxxxxxxxx

      - name: Save / Upload Static files to GCS (only master)
        if: github.ref == 'refs/heads/master'
        run: |
          sudo gsutil cp -r web/public/build gs://xxxxxxxxx/$SHA

      - name: Save / Send release to Sentry (only master)
        if: github.ref == 'refs/heads/master'
        run: |
          curl -sL https://sentry.io/get-cli/ | bash
          sentry-cli releases new $SHA
          sentry-cli releases set-commits "$SHA" --auto
          sentry-cli releases finalize "$SHA"

動作確認

↑上記を保存してPullRequestを作成すると、実行されます。

Actions のタブで動作をリアルタイムに確認できます。

f:id:konisimple:20200305014604p:plain

Pull Request などの画面ではこんな感じで表示されます。大きくは変わりません(下のはCircleCI からのものです)。

f:id:konisimple:20200305015637p:plain

GitHub Actions の設定ファイルの書き方

詳細は公式ドキュメントを参照。なんと日本語版があります*2

GitHub Actionsのワークフロー構文 - GitHub ヘルプ

ここではハマりそうな箇所などを中心にメモしときます。

on

このワークフローを実行するタイミングを選べます。Pull Reqeust を出したときだけではなく、Push したタイミング、コメントをしたタイミングなど、GitHub 上でのアクションほぼ全部を起点としてワークフローを実行できます。

特定のブランチのみ、などの条件が CircleCI よりも書きやすいです。

jobs

ここにジョブを書きます。CircleCI で言うところの「Workflow」にあたるものです。 このジョブが実行される単位になります。ジョブが2つあれば同時実行ジョブ2消費する感じです。

services

DBやキャッシュなど、依存するサービスを起動しておくことができます。 docker-compose と設定が似ています。 ports で設定したポートにバインドされます。上記の mysql の設定だと、アプリのコードからは以下でアクセスできます。

ホスト名: mysql (サービス名) ポート: 13306 ユーザー名: root パスワード: password

steps

それぞれのステップを記載します。

run を使って、シェルのコマンドをそのまま書くこともできますし、uses を使って、先人たちが作ってくれたものを使うこともできます。

チェックアウト(actions/checkout@v2)

GitHub 公式のソースコードのチェックアウトのタスク actions/checkout@v2 は、少し動作に癖があるので注意が必要です。 Pull Request の場合、トピックブランチのコードにマージ先のブランチをmergeした新しいブランチ(PR マージブランチ refs/pull/:prNumber/merge)が自動的に作られます。 PR#1234 でトピックブランチ fix1234master というPullRequest の場合*3fix1234master をマージした新しいブランチ refs/pull/1234/merge が作られます。

他のCIから移す際に現在のブランチ名に依存したコードがあるとこれのせいでコケます(後述の reg-suit)。

詳細は以下のページ参照。

ワークフローをトリガーするイベント - GitHub ヘルプ

言語環境のインストール(actions/setup-node , shivammathur/setup-php@v2)

先人たちのおかげで非常に楽にインストールできます。

PHPのコードカバレッジツールに xdebug を使っていますが、pcovのほうが性能が良いらしいので移行したい*4

キャッシュ (actions/cache@v1)

指定したパスをキャッシュできます。ここはCircleCIの restore_cache と非常によく似ています。 yarn のキャッシュは、node_modulesではなく、~/.cache で行います。

秘密の文字列 (Secrets)

Setting → Secrets から登録しておいた文字列は、 ${{ secrets.GOOGLE_SERVER_ACCOUNT_JSON_BASE64 }} のようにしてアクセスできます。 GCPやSentryなどの認証情報を入れときます。 CircleCI と違い、環境変数として渡されるわけではないので、上記の書き方で取得する必要があります。

Cypress

Cypress は runs-on: ubuntu-latest で使うとうまく動かない場合があるらしいので、v3.8.3以降にするか、 ubuntu-16.04 を使うかしろとのことなので、素直に従います。

We are getting reports that Cypress has suddenly started crashing when running on ubuntu-latest OS. Seems, GH Actions have switched from 16.04 to 18.04 overnight, and are having a xvfb issue. Please work around this problem by using runs-on: ubuntu-16.04 image or upgrading to Cypress v3.8.3 where we explicitly set XVFB arguments. https://github.com/cypress-io/github-action#important

バックグラウンドタスク

GitHub Actions には、バックグラウンドで動かす、というフラグ(CircleCI で言うところの、 background: true にあたるもの)がありません。

以下のように & で動かせばOKです。

        run: cd web; yarn run cypress_server_start &

ただこの方法だとログが見られないので、テキストファイルに保存しておいて Artifacts に出すなどの工夫が必要そうです。

Artifacts (actions/upload-artifact@v1)

テスト結果などをArtifacts として保存しておく仕組みがあります。 使い方はCircleCI と同じなのですが、ストレージ料金がかかります! しかもそれが結構高くて、$0.25 / GB します!

CircleCI ではここは課金されなかったので、導入を検討する場合は、ここも試算に入れるのを忘れないようにします。

ジョブ完了後に Artifacts としてダウンロードできるのですが、勝手にzipにまとめられた状態でダウンロードする形になります。 そのため CircleCIのように、HTMLなどを置いといてURL開くだけでカバレッジレポートを確認する、という使い方はできません。

SocialDog ではPHPUnit の結果のHTML形式のカバレッジレポートを吐き出していたのですが、その容量が1回あたり100MBほどありました。これだと1日30回=月600回だと$150とそこそこお金かかっちゃいます。

なのでこれは使わずに、AWSのS3やGCPのGCSを使うのが定石になりそうです*5。値段も$0.03/GBとかで圧倒的に安いし。

ちなみにストレージの利用料は、Organization settings → Billing から確認できます。

f:id:konisimple:20200305014110p:plain

ちなみにデフォルトでは超過料金が発生しないようになっており、ストレージの料金が発生しそうになるとメールが来ます*6

reg-suit

Cypress で得た画像の差分を reg-suit でGCSにアップロードしています。 reg-suit では比較対象を見つけるため、ブランチをトピックブランチにしてあげる必要があるので、 git checkoutしています*7

if

失敗すると後続のステップは実行されないのですが、複数の落ちる箇所があるのに一度に気づきたいので、 if: always() を入れまくっています。CircleCI で言うところの when: always です。

また、各ステップごとに、実行条件を書けます。

CircleCI だと、if [[ $CIRCLE_BRANCH = master ]]; then みたいに書く必要がありましたが、GitHub Actions では if: github.ref == 'refs/heads/master' のように分かりやすく書けるようになりました。

GitHub Actions のイケてるところ

料金が安い

試算した結果うちの場合はユーザー数が多いこともあり、CircleCI よりもGitHub Actions のほうが安そうでした。そこで GitHub Actions に移行しました。

CircleCI の Performance Plan では、ユーザー数あたり15ドルの課金があります。うちインターン多いのでユーザー数ベースの課金があるとちょっと不利なのです。

GitHub Actions の場合は、CIにはユーザー数ベースの課金はありません*8

使うツールが減らせる

うちはコードレビューもGitHub で行っているので、GitHub でCIも完結できるのは嬉しいです。使うツールを一つ減らせました。

公開されている GitHub Actions Workflow が多い

公式のものも、そうでないものも、すでにあるテンプレを組み合わせて書けるのは便利だなと思いました。 CircleCI にも似たような仕組み Orbs がありますが、GitHub Actions ほど使われてないような気がします。

GitHub Actions の残念なところ

UI がイケてない

CircleCIのビルドの確認画面はSPAだったのでサクサク動くし、ログも割とリアルタイムに出ていました。 GitHub Actions はいちいちページ遷移が発生する上、ログの表示も遅くて設定ファイルを書くのはちょっとだるかったです。

Artifacts がzip になってしまう

いちいちzip解凍するのが面倒。 CircleCI みたいにHTMLページをホスティングしてくれる機能ほしかった。CircleCI の場合は認証まわりも面倒見てくれるのですごく使い勝手よかった。

まとめ

GitHub を使っている場合、CI を検討する際の有力な選択肢になりそうです。 UI やストレージ料金の面で課題は感じますが、「使うサービスを減らせる」という部分は大きな魅力に感じます。

今後の GitHub Actions の動きに期待しています!

*1:React Native のネイティブアプリのビルドはBitriseでやっているので今回の記事の範囲外となります

*2:目次のページ内リンクが機能しなくて使いにくい場合は、英語版をオススメ

*3:SocialDog ではGitHub Flow を採用しています。

*4:phpdbg メモリリークしてしまいなぜか動かないので、断念してxdebugを使っています。

*5:GitHub Actions は裏側Azureですし、Azureという選択肢もありますね

*6:これの構築をしているとき、Artifacts にかかる料金気づかずに、メール来ました。

*7:公式サイトにワークアラウンドな方法として紹介されていた

*8:すでにGitHub Teamsに支払っている場合