Dockerで構築したRailsアプリをGitHub Actionsで高速にCIする為のプラクティス（Rails 6 API編）

>
（2021/5/17 追記）
[TechRachoさんから同テーマで翻訳記事が紹介されています](https://techracho.bpsinc.jp/hachi8833/2021_05_14/107140)。
[Docker公式の build-push-action](https://github.com/docker/build-push-action) で [イメージキャッシュのサンプル](https://github.com/docker/build-push-action/blob/master/docs/advanced/cache.md#registry-cache)が追加されており、この手法に則ってDockerイメージのキャッシュを実現しているようです。
私の記事のようにAPIモードではなくフロントエンドも含み（`assets:precompile` もあり）、且つ単なるデモレベルではないアプリを使って手順が紹介された記事になっています。

-----

>
（2021/3/2 追記）
dockerイメージのキャッシュについて、この記事で採用している `docker image save` → `docker image load` という方式以外も含めてそれぞれの効果を比較してくれている良記事を見つけました。
[Caching Docker builds in GitHub Actions: Which approach is the fastest? 🤔 A research\. \- DEV Community](https://dev.to/dtinth/caching-docker-builds-in-github-actions-which-approach-is-the-fastest-a-research-18ei)

-----

Rails on GitHub Actions（或いは {Django,Laravel} on GitHub Actions）のCI事例として、

- ホストランナー上にRuby（Python, PHP）をセットアップ
- MySQLやRedisは[サービスコンテナ](https://help.github.com/ja/actions/configuring-and-managing-workflows/about-service-containers)で立ち上げ
- 依存ライブラリのインストール（`bundle install`） や ユニットテスト（`rspec`） もホストランナー上で直接実行

という事例は多く見かけるのですが、開発をDockerベースで行っていて、GitHub ActionsのCI Pipelineも同じくDockerベースで構築したい...というケースの事例があまり見当たらなかったので、自分が関わったプロジェクト（Rails 6 API mode）での事例を紹介します。

## 前提条件
- Ruby 2.7.1
- Rails 6.0.3 (API mode)
- Docker (Docker for Mac)
  - Engine 19.03.8
  - Compose 1.25.5
- MySQL 8.0.20
- 開発環境は全てDocker（Dockerfile/docker-compose.yml）で構築＆管理
  - `bundle install` や `rails s` や `rspec` などのコマンドは全て`docker-compose {run,exec}` を介してコンテナ内で実行
- Dockerfileはmulti-stage構成にはしない（今回の例ではひとまず開発用の設定だけが記述されている前提とする）
- リモートdocker registryは利用しない
- GitHub Actionsは現時点で公式にDocker layer cachingをサポートしていない [参考1](https://github.community/t/docker-layer-caching/17212), [参考2](https://github.community/t/cache-a-docker-image-built-in-workflow/16260)
  - これについては [このissue](https://github.com/actions/cache/issues/31) で最新状況が追えそう
  - ~~[しばらく対応され無さそう](https://github.com/actions/cache/issues/31#issuecomment-624085429) だけど、対応して欲しい...~~

## TL;DR
- dockerイメージのサイズを小さくしたい
  - イメージ自体でファイルを抱え込むような処理をDockerfileに書かない
      - ≒ Dockerfileに変更が無くてもビルドイメージに変更が発生し得るようなDockerfile にはしない
  - 実行したいコマンドはコンテナ起動時に都度コマンドとして付与し、成果物を保存・永続化したければ`volumes`を活用する
- CI時も開発環境と同じ Dockerfile/docker-compose.yml をそのまま活用してセットアップしたい
  - CIでも `bundle install` や `rails s` や `rspec`  などのコマンドは全て`docker-compose {run,exec}` を介してコンテナ内で、且つ開発作業時と同じコマンドで実行できるように
  - docker-composeでのmysql8.0起動に10秒超掛かるので、[ufoscout/docker\-compose\-wait](https://github.com/ufoscout/docker-compose-wait)で起動を確実に待ってからテストを実行する
- 非・dockerでのCI時と同様に`bundle install`結果をキャッシュしたい
  - bundle install結果をrunnerインスタンスにvolume mountして、そのディレクトリをキャッシュ
- dockerイメージをキャッシュしたい
  - BuildKitを有効化してビルド　※multi-stage構成ではなくても（少しではあるが）高速化の恩恵がある為 
  - ビルド結果を `docker image save` → [公式 actions/cache](https://github.com/actions/cache) でキャッシュ＆リストア → リストアした後に `docker image load`
  - ※ [whoan/docker\-build\-with\-cache\-action](https://github.com/whoan/docker-build-with-cache-action) はregistry利用を前提にしているので今回の例では使わない。逆にregistry利用{可能,したい}なら検討余地あり
      - 想定ユースケースとしては、例えばCIをスケーラブルに実行したいような場合か（例: 同じdockerイメージを多くの並列jobで使用したい）


## Dockerfile
※ ファイル全体は[gistにup](https://gist.github.com/goldeneggg/7112424a9776122f73b10a610572933b) しています。

### アプリのファイルをイメージに含めない
`COPY`や`ADD`によるファイルコピーを行わないことでアプリのファイルはイメージ内に含めないようにし、かつ`CMD`や`ENTRYPOINT`による処理を定義せずに「Railsアプリが動く環境を整える」事にのみ特化してイメージのサイズを小さくしています。`bundle install` や `rails s` といった処理は `docker-compose {run,exec}`を介してコンテナ内で実行し、ライブラリやアプリのファイルはvolumesでマウントしてコンテナにコピーする（イメージには含めないようにする）事を意図しています。

### ufoscout/docker-compose-wait でDB起動を待つ

```Dockerfile
ARG ARG_COMPOSE_WAIT_VER=2.7.3
RUN curl -SL https://github.com/ufoscout/docker-compose-wait/releases/download/${ARG_COMPOSE_WAIT_VER}/wait -o /wait
RUN chmod +x /wait
```

Dockerfileの末尾3行部分で [ufoscout/docker\-compose\-wait](https://github.com/ufoscout/docker-compose-wait) をインストールしています。これはmysql等のミドルウェア・コンテナのポートがLISTEN状態になるのを待ってくれるRust製のツールで、名前の通りdocker-compose.ymlとの併用が意図されています。
依存ミドルウェア起動をどうやって待つか？については、netcatやdockerizeを使った例だったり、[公式のPostgresの例ではシェルスクリプトを書いて頑張る例が紹介されていたりしますが（Badの反応が多いのが気になりますが...）](https://docs.docker.com/compose/startup-order/)、このツールはミドルウェアの追加・削除時もdocker-compose.ymlに少し記述を追加するだけで対応できますし動作も確実性が高くて使い勝手が良かったです。具体的な使い方は後述のdocker-compose.ymlやGitHub Actionsに関する節で解説します。


## docker-compose.yml
※ ファイル全体は[gistにup](https://gist.github.com/goldeneggg/e608c87e8176d52b2db200d6566ac848) しています。

### 1つのDockerfileでdocker-composeの複数サービスを定義する

```yml
  base: &base
    build:
      context: .
      dockerfile: ./Dockerfile
      cache_from:
        - rails6api-development-cache
      args:
        ARG_RUBY_VERSION: ${ARG_RUBY_VERSION:-2.7.1}
    image: rails6api-development:0.1.0
```

docker-compose.ymlの`base`サービス以降の記述が先程のDockerfileを利用するサービスの設定になります。Dockerfileの冒頭で入力を期待している`ARG_RUBY_VERSION` ARGについては、「環境変数で指定されていたらその内容を、未設定時のデフォルト値は`2.7.1`を」指定するように`args`にて定義しています。

`base`サービスは、それ自体がcommandやentrypointによる処理を行ってはおらず、単にビルドする為だけのサービスとして定義しています。`&base` とエイリアスを定義している事からも分かるように、これを後続サービスでマージして利用しています（後述）。ビルドに関する設定はこの`base`サービスにのみ集約してあるので、buildセクションの設定はこれ以降のサービスには出てきません。

`base`サービスのポイントは`cache_from`で`rails6api-development-cache`を指定している事です。この設定は開発作業時ではなくCI時での利用を想定したものです。詳細は後述します。

```yml
  wait-middleware: &wait-middleware
    <<: *base
    environment:
      WAIT_HOSTS: db:3306
    depends_on:
      - db
    command: /wait
```

このサービス定義が、Dockerfileの最後でインストールした[ufoscout/docker\-compose\-wait](https://github.com/ufoscout/docker-compose-wait)を使って`db`サービスの起動を待つ為のサービスです。ymlの定義方法は公式を参照ください。先に定義した`base`サービスをmergeし、docker-compose-waitで必要な設定とdbサービスとの関連を定義しています。単独で実行したい場合は`docker-compose run`すればOKです。

```sh
$ docker-compose run --rm wait-middleware

Creating network "rails6api_default" with the default driver
Creating rails6api_db_1    ... done
--------------------------------------------------------
 docker-compose-wait 2.7.3
---------------------------
Starting with configuration:
 - Hosts to be waiting for: [db:3306]
 - Timeout before failure: 30 seconds
 - TCP connection timeout before retry: 5 seconds
 - Sleeping time before checking for hosts availability: 0 seconds
 - Sleeping time once all hosts are available: 0 seconds
 - Sleeping time between retries: 1 seconds
--------------------------------------------------------
Checking availability of db:3306
Host db:3306 not yet available...
Host db:3306 is now available!
--------------------------------------------------------
docker-compose-wait - Everything's fine, the application can now start!
```

上記実行例は筆者のMacでのもので、ほとんど待ちが発生せずdbが立ち上がります。この速さなら[depends_on](https://docs.docker.com/compose/compose-file/#depends_on) で起動順さえ意識しておけば「DBが立ち上がっていない状態でアプリが動きそうになってエラー」という状況はほぼ発生しないのですが、GitHub ActionsのCI環境ではこの速さでは起動してくれず、`wait-middleware`の効果が大きくなります。これについても後述します。


```yml
  backend: &backend
    <<: *base
    stdin_open: true
    tty: true
    volumes:
      - ./:/app:cached
      - ${GEMS_CACHE_DIR:-bundle-cache}:/bundle
      - rails-cache:/app/tmp/cache
    depends_on:
      - db

  console:
    <<: *backend
    ports:
      - 3333:3000
    command: /bin/bash

  server:
    <<: *backend
    ports:
      - 3333:3000
    command: bash -c "rm -f tmp/pids/server.pid && rails s -b 0.0.0.0"

volumes:
  mysql-data:
  bundle-cache:
  rails-cache:
```

bashログインしてのプロンプト作業や `rails s` する為のサービス定義と、volumeの定義部分です。[TechRachoさんの記事](https://techracho.bpsinc.jp/hachi8833/2019_09_06/79035) で紹介されていた書き方を流用させてもらっています。

`console`と`server`の両サービスが`backend`というサービス定義をマージしているのですが、この`backend`のvolumesで `${GEMS_CACHE_DIR:-bundle-cache}:/bundle` と定義されているvolumeは`bundle install`先のディレクトリで、「環境変数`GEMS_CACHE_DIR`がセットされていればその内容で、セットされていなければ`bundle-cache`という名前のnamed volumeでマウント」する事を意図しており、CIの為にこのような設定を行っています。これも詳細は後述します。



## .env

```env:./.env
MYSQL_ROOT_PASSWORD=root
MYSQL_ALLOW_EMPTY_PASSWORD=1
DB_HOST=db

MYSQL_FORWARDED_PORT=3806
MYSQL_FORWARDED_X_PORT=38060
```

docker-compose.ymlのdbサービス内の[環境変数](https://docs.docker.com/compose/environment-variables/)を[.envの内容から展開](https://docs.docker.com/compose/env-file/) しています。


## .github/workflows/ci.yml
※ ファイル全体は[gistにup](https://gist.github.com/goldeneggg/348d82e8f1198086f002de46ff321051) しています。

### BuildKitでビルドをちょっと高速化

```yml
env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
```

グローバルな環境変数で[docker-compose向けにBuildKitを有効化](https://www.docker.com/blog/faster-builds-in-compose-thanks-to-buildkit-support/)しています。今回のDockerfileはmulti-stageでも無くBuildKitによる恩恵はそこまで大きくはないのですが、有効化した事でビルド時間が速くなった（12%程度削減）ので有効化しています。

### 先にイメージのキャッシュ・リストアを実行し、このキャッシュで後続ジョブを並列に動かす

```yml
jobs:
  # Dockerイメージのキャッシュ・リストア
  image-cache-or-build:

  # アプリのテスト
  test-app:
    needs: image-cache-or-build

  # イメージの脆弱性スキャン
  scan-image-by-trivy:
    needs: image-cache-or-build
```

最初に必ずDockerイメージのキャッシュリストア（キャッシュが無ければ新規ビルド→キャッシュ生成）を行い、後続のアプリテスト＆イメージスキャンはこのキャッシュからリストアしたイメージを使って実行するようにします。アプリテストとイメージスキャンは並列実行でも構わないので並列にしています。

### docker image save, docker image load, cache_from with BuildKit でイメージのキャッシュ・リストアとビルド

```yml
jobs:
  image-cache-or-build:
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-
```

Dockerイメージのキャッシュリストアは、公式のキャッシュ処理用actionである[actions/cache](https://github.com/actions/cache)で行います。

キャッシュのキーに `${{ hashFiles('Dockerfile') }}` を含めているのは、Dockerfileに変更があった際にキャッシュHITさせないようにする事を意図したものです。先述のTL;DRに「イメージ自体でファイルを抱え込むような処理をDockerfileに書かない」と書いた通り、（たまたま）今回の例ではDockerfileに`COPY`や`ADD`が存在していないお陰で "Dockerfileの変更有無" によるキャッシュ管理が可能になっています。
逆に言うと、Dockerfileに変更が無くてもビルドイメージに変更が起こり得る場合、例えば`COPY`や`ADD`の処理を含んでいてコピー元ファイルだけに変更が発生するような事もあり得る場合には `${{ hashFiles('Dockerfile') }}` でのキャッシュキー管理はやめておきましょう。

なお、[actions/cache を使ったキャッシュの上限はリポジトリ単位で5GB](https://github.com/actions/cache#cache-limits) です。今回のように「alpineベースでrailsが動くイメージを、`COPY`や`ADD`を排除した最小構成で構築」したイメージなら1GBにも満たないはずなのでまず心配は無用ですが、一応の留意はしておくと良さそうです。


```yml
env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache

# 略

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

    - name: Docker tag and save
      id: docker-tag-save
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: mkdir -p ${IMAGE_CACHE_DIR}
        && docker image tag ${APP_IMAGE_TAG} ${APP_IMAGE_CACHE_TAG}
        && docker image save -o ${IMAGE_CACHE_DIR}/image.tar ${APP_IMAGE_CACHE_TAG}
```

上記step群の処理をまとめると、「ビルドされたイメージに __`rails6api-development-cache`__ というタグを付与してtarに保存し、actions/cache のキャッシュ先ディレクトリに image.tar という名前で保存する」という処理を行っています。

キャッシュHITの有無で処理の流れは下記のように変わります。

- キャッシュがHITしなかった場合
  - `docker-build` のstepで新規にイメージがビルドされます
      - ※`--build-arg BUILDKIT_INLINE_CACHE=true` オプションを指定している理由は、[BuildKitが有効化された状態でビルドされたイメージを`cache_from`で取り込むには、元のイメージがこのオプション付きでビルドされている必要がある](https://docs.docker.com/engine/reference/commandline/build/#specifying-external-cache-sources)為です
      - 実際に`cache_from`が指定されているのは、docker-compose.ymlの`base`サービスのbuild設定の箇所（= __`rails6api-development-cache`__）
  - actions/cacheでキャッシュ先として指定した`${IMAGE_CACHE_DIR}`をmkdirします
  - ビルド結果のイメージに別途「キャッシュ用のタグ」を付与します
      - = `APP_IMAGE_CACHE_TAG` = __`rails6api-development-cache`__
  - 「キャッシュ用のタグ」 = __`rails6api-development-cache`__ を付与したイメージを`docker image save`で保存します。この保存先にactions/cacheでのキャッシュ先ディレクトリを指定します（ファイル名は image.tar）
- キャッシュがHITした場合
  - `docker-load` のstepで、キャッシュからリストアされたimage.tarが `docker image load` によって展開されます
      - 展開されるイメージには「キャッシュ用のタグ」 = __`rails6api-development-cache`__ が付与されています
  - `docker-build` のstepでイメージがビルドされますが、先のloadのstepで展開されたイメージによって `cache_from` __`rails6api-development-cache`__ の指定が効き、このビルドはすぐに終わります
  - tagとsaveのstepは `if: steps.cache-docker-image.outputs.cache-hit != 'true'` の指定によりSKIPされます

### キャッシュ・リストアしたイメージを使って高速CI

```yml
env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache

# 略

jobs:

# 略

  test-app:
    needs: image-cache-or-build

    # 略

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker compose build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base
```

先述のイメージのビルド＆キャッシュjobが完了すると、アプリのテストを行う`test-app`が起動します。`docker-load`のstepでは「キャッシュ用のタグ」 = __`rails6api-development-cache`__ が付与されているイメージが展開され、`docker-build`のstepでこのイメージを`cache_from`によって取り込んで`base`サービスのイメージ（＝ `rails6api-development:0.1.0` タグが付与されたイメージ）をビルドします。

### ufoscout/docker\-compose\-wait でMySQLコンテナの起動を待つ

```yml
    - name: Wait middleware services
      id: wait-middleware
      run: docker-compose run --rm wait-middleware

    - name: Confirm docker-compose logs
      id: confirm-docker-compose-logs
      run: docker-compose logs db
```

Dockerfileの最後でインストールしてある[ufoscout/docker\-compose\-wait](https://github.com/ufoscout/docker-compose-wait)を使って`db`サービスの起動を待ちます。

```
Starting with configuration:
 - Hosts to be waiting for: [db:3306]
 - Timeout before failure: 30 seconds 
 - TCP connection timeout before retry: 5 seconds 
 - Sleeping time before checking for hosts availability: 0 seconds
 - Sleeping time once all hosts are available: 0 seconds
 - Sleeping time between retries: 1 seconds
--------------------------------------------------------
Checking availability of db:3306
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 is now available!
--------------------------------------------------------
docker-compose-wait - Everything's fine, the application can now start!
--------------------------------------------------------
```

以前に「GitHub ActionsのCI環境ではこの速さでは起動してくれず、`wait-middleware`の効果が大きくなります」と書きましたが、上記ログがGitHub Actionsのrunnerインスタンス上での実行例で（`Host db:3306 not yet available...` でsleepを1秒挟んでいます）、ポート3306のLISTENまでに10秒以上掛かっています。このログ例のみならず、何度実行しても平均的に10秒超は掛かっていました。仮にこの所要時間でwaitするstepを挟まない（`depends_on`を指定するのみ）とすると、MySQL起動前に後続のRailsアプリに関するstepが走ってしまいエラーになるでしょう。

#### 余談: MySQLコンテナの起動プロセスと処理時間

```
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.20-1debian10 started.
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Switching to dedicated user 'mysql'
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.20-1debian10 started.
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Initializing database files
db_1               | 2020-05-25T16:36:40.990281Z 0 [Warning] [MY-011070] [Server] 'Disabling symbolic links using --skip-symbolic-links (or equivalent) is the default. Consider not using this option as it' is deprecated and will be removed in a future release.
db_1               | 2020-05-25T16:36:40.990349Z 0 [System] [MY-013169] [Server] /usr/sbin/mysqld (mysqld 8.0.20) initializing of server in progress as process 45
db_1               | 2020-05-25T16:36:40.996092Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1               | 2020-05-25T16:36:42.100882Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1               | 2020-05-25T16:36:43.312805Z 6 [Warning] [MY-010453] [Server] root@localhost is created with an empty password ! Please consider switching off the --initialize-insecure option.
db_1               | 2020-05-25 16:36:46+00:00 [Note] [Entrypoint]: Database files initialized
db_1               | 2020-05-25 16:36:46+00:00 [Note] [Entrypoint]: Starting temporary server
db_1               | 2020-05-25T16:36:46.494377Z 0 [Warning] [MY-011070] [Server] 'Disabling symbolic links using --skip-symbolic-links (or equivalent) is the default. Consider not using this option as it' is deprecated and will be removed in a future release.
db_1               | 2020-05-25T16:36:46.494485Z 0 [System] [MY-010116] [Server] /usr/sbin/mysqld (mysqld 8.0.20) starting as process 92
db_1               | 2020-05-25T16:36:46.507413Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1               | 2020-05-25T16:36:46.819578Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1               | 2020-05-25T16:36:46.915827Z 0 [System] [MY-011323] [Server] X Plugin ready for connections. Socket: '/var/run/mysqld/mysqlx.sock'
db_1               | 2020-05-25T16:36:47.015509Z 0 [Warning] [MY-010068] [Server] CA certificate ca.pem is self signed.
db_1               | 2020-05-25T16:36:47.017398Z 0 [Warning] [MY-011810] [Server] Insecure configuration for --pid-file: Location '/var/run/mysqld' in the path is accessible to all OS users. Consider choosing a different directory.
db_1               | 2020-05-25T16:36:47.034485Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.20'  socket: '/var/run/mysqld/mysqld.sock'  port: 0  MySQL Community Server - GPL.

db_1               | 2020-05-25 16:36:47+00:00 [Note] [Entrypoint]: Temporary server started.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/iso3166.tab' as time zone. Skipping it.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/leap-seconds.list' as time zone. Skipping it.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/zone.tab' as time zone. Skipping it.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/zone1970.tab' as time zone. Skipping it.
db_1               | 
db_1               | 2020-05-25 16:36:49+00:00 [Note] [Entrypoint]: Stopping temporary server
db_1               | 2020-05-25T16:36:49.538277Z 10 [System] [MY-013172] [Server] Received SHUTDOWN from user root. Shutting down mysqld (Version: 8.0.20).
db_1               | 2020-05-25T16:36:51.341063Z 0 [System] [MY-010910] [Server] /usr/sbin/mysqld: Shutdown complete (mysqld 8.0.20)  MySQL Community Server - GPL.
db_1               | 2020-05-25 16:36:51+00:00 [Note] [Entrypoint]: Temporary server stopped
db_1               | 
db_1               | 2020-05-25 16:36:51+00:00 [Note] [Entrypoint]: MySQL init process done. Ready for start up.
db_1               | 
db_1               | 2020-05-25T16:36:51.806387Z 0 [Warning] [MY-011070] [Server] 'Disabling symbolic links using --skip-symbolic-links (or equivalent) is the default. Consider not using this option as it' is deprecated and will be removed in a future release.
db_1               | 2020-05-25T16:36:51.806498Z 0 [System] [MY-010116] [Server] /usr/sbin/mysqld (mysqld 8.0.20) starting as process 1
db_1               | 2020-05-25T16:36:51.816008Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1               | 2020-05-25T16:36:52.191532Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1               | 2020-05-25T16:36:52.286349Z 0 [System] [MY-011323] [Server] X Plugin ready for connections. Socket: '/var/run/mysqld/mysqlx.sock' bind-address: '::' port: 33060
db_1               | 2020-05-25T16:36:52.341936Z 0 [Warning] [MY-010068] [Server] CA certificate ca.pem is self signed.
db_1               | 2020-05-25T16:36:52.345030Z 0 [Warning] [MY-011810] [Server] Insecure configuration for --pid-file: Location '/var/run/mysqld' in the path is accessible to all OS users. Consider choosing a different directory.
db_1               | 2020-05-25T16:36:52.363785Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.20'  socket: '/var/run/mysqld/mysqld.sock'  port: 3306  MySQL Community Server - GPL.
```

上記はMySQLコンテナ（`db`サービス）起動時のログを `docker-compose logs db`で確認した際の例です。

- `Initializing database files` から `Database files initialized` で6秒
- `Starting temporary server` から `Temporary server stopped` で5秒

この2処理で所要時間をほぼ半分ずつ要しています。

### 依存gemのvolumeマウントはnamed volumeではなく書き込み可能なディレクトリを使う

```yml
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      GEMS_CACHE_DIR: /tmp/cache/bundle
      GEMS_CACHE_KEY: cache-gems

    # 略

    - name: Cache bundle gems
      id: cache-bundle-gems
      uses: actions/cache@v1
      with:
        path: ${{ env.GEMS_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Gemfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-
```

依存Gemのキャッシュリストアも、公式のキャッシュ処理用actionである[actions/cache](https://github.com/actions/cache)で行います。キャッシュのキーに `${{ hashFiles('Gemfile.lock') }}` を含めているのは、Gemfile.lock（Gemfile）に変更があった際にキャッシュHITさせないようにする事を意図したものです。


```yml:./docker-compose.yml
  backend: &backend

    # 略

    volumes:
      - ./:/app:cached
      - ${GEMS_CACHE_DIR:-bundle-cache}:/bundle
      - rails-cache:/app/tmp/cache

# 略

volumes:
  mysql-data:
  bundle-cache:
  rails-cache:
```

全てをDockerで行おうとしているので、`bundle install`もコンテナ内で行います。つまりインストールされたgemをキャッシュしたければ、volume mount先からインストール結果を取り出さなければなりません。

↑のdocker-compose.ymlを紹介した際に「環境変数`GEMS_CACHE_DIR`がセットされていればその内容で、セットされていなければ`bundle-cache`という名前のnamed volumeでマウント」と書いたのですが、この`cache-bundle-gems`のstepがまさに「環境変数`GEMS_CACHE_DIR`がセットされていれば」なケースに該当します。これは「named volumeではなくマウント先のパスを環境変数で明示する」のが意図です。

[公式の`volumes`のSHORT SYNTAX](https://docs.docker.com/compose/compose-file/#short-syntax-3)によると、パスが指定されていればそのパスが、固定文字列が指定されていればその名前のnamed volumeが、それぞれマウントされます。開発作業時は`GEMS_CACHE_DIR`を明示せずデフォルトの固定文字列（= named volume =`bundle-cache`）を使用しても良いですが、actions/cache で内容をキャッシュしようとした場合、そのディレクトリとしてnamed volumeの実体（具体的には `/var/lib/docker/volumes/xxx` というパス）を指定するとpermission deniedエラーでキャッシュに失敗してしまいます。なのでこれを回避する為にCI時のみ`GEMS_CACHE_DIR`として `/tmp/cache/bundle` という（permission deniedにならない）ディレクトリを明示しています。これによりCI時のbundle install結果はこの`GEMS_CACHE_DIR`ディレクトリに出力され、actions/cacheでディレクトリが丸ごとキャッシュされます。

この記述は正直分かりやすいとは言えないので、[LONG SYNTAX](https://docs.docker.com/compose/compose-file/#long-syntax-3)を利用して分かりにくさを軽減したいところなのですが、今回は「環境変数の中身によってvolume typeが変えられる」「1つのdocker-compose.ymlを開発作業とCIで併用しやすい」というメリットを優先してSHORT SYNTAXを採用しました。


### アプリのセットアップ＆テストもDockerコンテナ内で実行

```yml
    - name: Setup and Run test
      id: setup-and-run-test
      run: docker-compose run --rm console bash -c "bundle install && rails db:prepare && rspec"
```

このstepは構築するアプリの仕様ややりたい事次第で変わると思いますが、一応今回の例を紹介しておくと `bundle install`（結果は先述の通りキャッシュされる） → `db:prepare`でDBセットアップ（[参考](https://railsguides.jp/6_0_release_notes.html#active-record-%E4%B8%BB%E3%81%AA%E5%A4%89%E6%9B%B4)） → テスト（今回使ったアプリでは`rspec`を使用しています）、という順にテストまで実施しています。それぞれのコマンドをrunnerインスタンス上で直接実行するのではなく、`docker-compose run` で`console`サービスを立ち上げてその中で実行するようにしています。


### アプリのテストと並列でDockerイメージの脆弱性スキャンも実行

```yml
env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache
  IMAGE_CACHE_DIR: /tmp/cache/docker-image
  IMAGE_CACHE_KEY: cache-image

# 略

  scan-image-by-trivy:
    needs: image-cache-or-build
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      TRIVY_CACHE_DIR: /tmp/cache/trivy

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Scan image
      id: scan-image
      run: docker container run
        --rm
        -v /var/run/docker.sock:/var/run/docker.sock
        -v ${TRIVY_CACHE_DIR}:/root/.cache/
        aquasec/trivy
        ${APP_IMAGE_CACHE_TAG}
```

開発〜CIをDockerで完結させようとしているので、折角なのでCI時にDockerイメージの脆弱性スキャンも行っておきたいです。今回は [aquasecurity/trivy](https://github.com/aquasecurity/trivy) を使わせてもらいました。Docker完結を目指しているので、trivyによるスキャンも[公式に提供されているDocker](https://github.com/aquasecurity/trivy#docker)で行います。

`needs: image-cache-or-build` によってイメージビルド＆キャッシュが完了済なので、スキャンもそのキャッシュをload・展開したイメージに対して実施して高速化します（スキャン対象として `${APP_IMAGE_CACHE_TAG}` = __`rails6api-development-cache`__ を指定）。
毎回 aquasec/trivy をpullする事でスキャンそのものの仕様を常に最新化しているので、Dockerfileに変更がなくてもスキャンを実行するようにしています。


## 開発作業のユースケース例

### 新規参画エンジニアの環境構築手順は？

```sh
# ビルド
docker-compose build base

# セットアップ
docker-compose run --rm console bash -c "bundle install && rails db:prepare && rails db:seed"

# 起動
docker-compose up -d server
```

### railsを起動したい時は？

```sh
docker-compose up -d server
```

### 起動中のRailsログをコンソールに流しておきたい時は？

```sh
# 下記コマンドでattachすれば、server コンテナの標準出力をtail風に確認可能
docker attach `docker-compose ps -q server`

# attach状態を終了したければ Ctrl+P => Ctrl+Q する
```

### テスト（rspec）を実行したい時は？
起動中のserverサービスで

```sh
docker-compose exec server rspec [SPEC_FILES]
```

consoleサービスで

```sh
docker-compose run --rm console rspec [SPEC_FILES]
```

### Rails consoleに接続したい時は？
起動中のserverサービスで

```sh
docker-compose exec server rails c
```

### マイグレーションを追加・修正・適用したい時は？

起動中のserverサービスで

```sh
# マイグレーション新規生成
docker-compose exec server rails g migration MIGRATION_NAME

# :
# マイグレーションファイルを適宜修正
# :

# マイグレーションの適用
docker-compose exec server rails db:migrate
```