(2021/5/17 追記)
TechRachoさんから同テーマで翻訳記事が紹介されています。
Docker公式の build-push-action で イメージキャッシュのサンプルが追加されており、この手法に則って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
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 install
やrails s
やrspec
などのコマンドは全てdocker-compose {run,exec}
を介してコンテナ内で実行
-
- Dockerfileはmulti-stage構成にはしない(今回の例ではひとまず開発用の設定だけが記述されている前提とする)
- リモートdocker registryは利用しない
- GitHub Actionsは現時点で公式にDocker layer cachingをサポートしていない 参考1, 参考2
- これについては このissue で最新状況が追えそう
しばらく対応され無さそう だけど、対応して欲しい...
TL;DR
- dockerイメージのサイズを小さくしたい
- イメージ自体でファイルを抱え込むような処理をDockerfileに書かない
- ≒ Dockerfileに変更が無くてもビルドイメージに変更が発生し得るようなDockerfile にはしない
- 実行したいコマンドはコンテナ起動時に都度コマンドとして付与し、成果物を保存・永続化したければ
volumes
を活用する
- イメージ自体でファイルを抱え込むような処理をDockerfileに書かない
- CI時も開発環境と同じ Dockerfile/docker-compose.yml をそのまま活用してセットアップしたい
- CIでも
bundle install
やrails s
やrspec
などのコマンドは全てdocker-compose {run,exec}
を介してコンテナ内で、且つ開発作業時と同じコマンドで実行できるように - docker-composeでのmysql8.0起動に10秒超掛かるので、ufoscout/docker-compose-waitで起動を確実に待ってからテストを実行する
- CIでも
- 非・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 しています。
アプリのファイルをイメージに含めない
COPY
やADD
によるファイルコピーを行わないことでアプリのファイルはイメージ内に含めないようにし、かつCMD
やENTRYPOINT
による処理を定義せずに「Railsアプリが動く環境を整える」事にのみ特化してイメージのサイズを小さくしています。bundle install
や rails s
といった処理は docker-compose {run,exec}
を介してコンテナ内で実行し、ライブラリやアプリのファイルはvolumesでマウントしてコンテナにコピーする(イメージには含めないようにする)事を意図しています。
ufoscout/docker-compose-wait でDB起動を待つ
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の複数サービスを定義する
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時での利用を想定したものです。詳細は後述します。
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です。
$ 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
の効果が大きくなります。これについても後述します。
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さんの記事 で紹介されていた書き方を流用させてもらっています。
console
とserver
の両サービスがbackend
というサービス定義をマージしているのですが、このbackend
のvolumesで ${GEMS_CACHE_DIR:-bundle-cache}:/bundle
と定義されているvolumeはbundle install
先のディレクトリで、「環境変数GEMS_CACHE_DIR
がセットされていればその内容で、セットされていなければbundle-cache
という名前のnamed volumeでマウント」する事を意図しており、CIの為にこのような設定を行っています。これも詳細は後述します。
.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サービス内の環境変数を.envの内容から展開 しています。
.github/workflows/ci.yml
※ ファイル全体はgistにup しています。
BuildKitでビルドをちょっと高速化
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
グローバルな環境変数でdocker-compose向けにBuildKitを有効化しています。今回のDockerfileはmulti-stageでも無くBuildKitによる恩恵はそこまで大きくはないのですが、有効化した事でビルド時間が速くなった(12%程度削減)ので有効化しています。
先にイメージのキャッシュ・リストアを実行し、このキャッシュで後続ジョブを並列に動かす
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 でイメージのキャッシュ・リストアとビルド
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にCOPY
やADD
が存在していないお陰で "Dockerfileの変更有無" によるキャッシュ管理が可能になっています。
逆に言うと、Dockerfileに変更が無くてもビルドイメージに変更が起こり得る場合、例えばCOPY
やADD
の処理を含んでいてコピー元ファイルだけに変更が発生するような事もあり得る場合には ${{ hashFiles('Dockerfile') }}
でのキャッシュキー管理はやめておきましょう。
なお、actions/cache を使ったキャッシュの上限はリポジトリ単位で5GB です。今回のように「alpineベースでrailsが動くイメージを、COPY
やADD
を排除した最小構成で構築」したイメージなら1GBにも満たないはずなのでまず心配は無用ですが、一応の留意はしておくと良さそうです。
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
で取り込むには、元のイメージがこのオプション付きでビルドされている必要がある為です - 実際に
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
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コンテナの起動を待つ
- 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
サービスの起動を待ちます。
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ではなく書き込み可能なディレクトリを使う
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させないようにする事を意図したものです。
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コンテナ内で実行
- 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 run
でconsole
サービスを立ち上げてその中で実行するようにしています。
アプリのテストと並列でDockerイメージの脆弱性スキャンも実行
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に変更がなくてもスキャンを実行するようにしています。
開発作業のユースケース例
新規参画エンジニアの環境構築手順は?
# ビルド
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を起動したい時は?
docker-compose up -d server
起動中のRailsログをコンソールに流しておきたい時は?
# 下記コマンドでattachすれば、server コンテナの標準出力をtail風に確認可能
docker attach `docker-compose ps -q server`
# attach状態を終了したければ Ctrl+P => Ctrl+Q する
テスト(rspec)を実行したい時は?
起動中のserverサービスで
docker-compose exec server rspec [SPEC_FILES]
consoleサービスで
docker-compose run --rm console rspec [SPEC_FILES]
Rails consoleに接続したい時は?
起動中のserverサービスで
docker-compose exec server rails c
マイグレーションを追加・修正・適用したい時は?
起動中のserverサービスで
# マイグレーション新規生成
docker-compose exec server rails g migration MIGRATION_NAME
# :
# マイグレーションファイルを適宜修正
# :
# マイグレーションの適用
docker-compose exec server rails db:migrate