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

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

  • ホストランナー上にRuby(Python, PHP)をセットアップ
  • MySQLやRedisはサービスコンテナで立ち上げ
  • 依存ライブラリのインストール(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 installrails srspec などのコマンドは全てdocker-compose {run,exec} を介してコンテナ内で実行
  • Dockerfileはmulti-stage構成にはしない(今回の例ではひとまず開発用の設定だけが記述されている前提とする)
  • リモートdocker registryは利用しない
  • GitHub Actionsは現時点で公式にDocker layer cachingをサポートしていない 参考1, 参考2

TL;DR

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

Dockerfile

※ ファイル全体はgistにup しています。

アプリのファイルをイメージに含めない

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

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

1
2
3
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 をインストールしています。これはmysql等のミドルウェア・コンテナのポートがLISTEN状態になるのを待ってくれるRust製のツールで、名前の通りdocker-compose.ymlとの併用が意図されています。 依存ミドルウェア起動をどうやって待つか?については、netcatやdockerizeを使った例だったり、公式のPostgresの例ではシェルスクリプトを書いて頑張る例が紹介されていたりしますが(Badの反応が多いのが気になりますが…)、このツールはミドルウェアの追加・削除時もdocker-compose.ymlに少し記述を追加するだけで対応できますし動作も確実性が高くて使い勝手が良かったです。具体的な使い方は後述のdocker-compose.ymlやGitHub Actionsに関する節で解説します。

docker-compose.yml

※ ファイル全体はgistにup しています。

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

1
2
3
4
5
6
7
8
9
  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_fromrails6api-development-cacheを指定している事です。この設定は開発作業時ではなくCI時での利用を想定したものです。詳細は後述します。

1
2
3
4
5
6
7
  wait-middleware: &wait-middleware
    <<: *base
    environment:
      WAIT_HOSTS: db:3306
    depends_on:
      - db
    command: /wait

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
$ 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 で起動順さえ意識しておけば「DBが立ち上がっていない状態でアプリが動きそうになってエラー」という状況はほぼ発生しないのですが、GitHub ActionsのCI環境ではこの速さでは起動してくれず、wait-middlewareの効果が大きくなります。これについても後述します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
  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さんの記事 で紹介されていた書き方を流用させてもらっています。

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

.env

1
2
3
4
5
6
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サービス内の環境変数.envの内容から展開 しています。

.github/workflows/ci.yml

※ ファイル全体はgistにup しています。

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

1
2
3
env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1

グローバルな環境変数でdocker-compose向けにBuildKitを有効化しています。今回のDockerfileはmulti-stageでも無くBuildKitによる恩恵はそこまで大きくはないのですが、有効化した事でビルド時間が速くなった(12%程度削減)ので有効化しています。

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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 でイメージのキャッシュ・リストアとビルド

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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で行います。

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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で新規にイメージがビルドされます
    • 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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コンテナの起動を待つ

1
2
3
4
5
6
7
    - 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を使ってdbサービスの起動を待ちます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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コンテナの起動プロセスと処理時間

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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ではなく書き込み可能なディレクトリを使う

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    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で行います。キャッシュのキーに ${{ hashFiles('Gemfile.lock') }} を含めているのは、Gemfile.lock(Gemfile)に変更があった際にキャッシュHITさせないようにする事を意図したものです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  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によると、パスが指定されていればそのパスが、固定文字列が指定されていればその名前の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を利用して分かりにくさを軽減したいところなのですが、今回は「環境変数の中身によってvolume typeが変えられる」「1つのdocker-compose.ymlを開発作業とCIで併用しやすい」というメリットを優先してSHORT SYNTAXを採用しました。

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

1
2
3
    - 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セットアップ(参考) → テスト(今回使ったアプリではrspecを使用しています)、という順にテストまで実施しています。それぞれのコマンドをrunnerインスタンス上で直接実行するのではなく、docker-compose runconsoleサービスを立ち上げてその中で実行するようにしています。

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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 を使わせてもらいました。Docker完結を目指しているので、trivyによるスキャンも公式に提供されているDockerで行います。

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

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

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

1
2
3
4
5
6
7
8
# ビルド
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を起動したい時は?

1
docker-compose up -d server

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

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

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

テスト(rspec)を実行したい時は?

起動中のserverサービスで

1
docker-compose exec server rspec [SPEC_FILES]

consoleサービスで

1
docker-compose run --rm console rspec [SPEC_FILES]

Rails consoleに接続したい時は?

起動中のserverサービスで

1
docker-compose exec server rails c

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

起動中のserverサービスで

1
2
3
4
5
6
7
8
9
# マイグレーション新規生成
docker-compose exec server rails g migration MIGRATION_NAME

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

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

※ この記事は 以前Qiitaに公開した記事 の転載です。