GitHub Actionsには「container」という設定があり、コンテナ内でジョブを実行することができます。さらに「services」で設定したサービスコンテナと同じネットワークに所属するため、サービスコンテナとの通信も簡単に行えます。
よしっ!これで大丈夫とはならず。
バックエンドアプリのテストコードにはユーザやDB、スキーマ生成は行いません。バックエンドアプリのコードを改修すればいい話ですが、CI/CDの移行でコードを変更したくなく、GitHub Actionsでどうにかしたいです。
パッと思いつくのはvolumeの設定です。サービスコンテナで立ち上げたDBについてvolumeでsqlファイルをおき、起動時にユーザやスキーマ生成を行わせればいいはずです。しかし、これも問題があります。volumeで指定するsqlファイルはどこにあるのでしょうか。
GitHub Actionsははじめにジョブを実行するコンテナ、サービスコンテナをたちあげます。その後にジョブが走ります。ジョブが走ってレポジトリやブランチをプルしたりします (ここはActionsの操作です)。
volumeされるタイミングは上の図の「1. コンテナをたちあげる」なので、まだレポジトリやブランチの内容はありません。そのため、テスト用のユーザなどのsqlファイルはRunnerが動くホスト上においておかないといけません。
しかし、スキーマなどが変更された場合、Runnerのホストまで手を加えるのは不便です。また、sqlのファイル名にも気を付けたりしないと、他の複数のCIが動いたときにファイル名が被ったりして面倒なことになります。そもそも、ホストのことを考えずにすむようなジョブを…っと個人的には思ったりします (面倒な人感)。
volumeを使わずに頑張ってみます。単体テストをする前にDBにユーザなどのデータを投入し、スキーマを作って単体テストを回します。
単体テストについてのジョブをyamlファイルにするとこうなります。
jobs:
unit-test:
runs-on: self-hosted
container: # <-- jobを実行するコンテナ
image: (mysql clientやmigrationコマンドなどが入ったGoのDockerイメージ)
env:
MARIADB_URL: "mariadb"
services: # <-- サービスコンテナ
mariadb:
image: (my.cnfを入れたMariaDBのDockerイメージ)
ports:
- 3306
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Insert Init Data # <-- Checkoutした後にデータを入れる
run: |
until mysql -u root -phogehoge -h $MARIADB_URL -e '\q'; do sleep 1; done # <-- DBが起動して応答できるまで待つ
echo "MariaDB is up!"
cat init.sql | mysql -u root -p**+* -h $MARIADB_URL # <-- データを投入
- name: Migrate DB # <-- スキーマ作成
run: |
/usr/local/bin/migrate -source file://migrations --database "mysql://root:****@tcp(${MARIADB_URL})/db_name" up
- name: Test # <-- やっと単体テスト
run: go test -cover -race -parallel=5 ./...
18行目のDBが起動して応答するまで待つコマンドですが、Dockerのヘルスチェックを利用してもいいかもしれません。このコマンドはDrone CIから受け継いだものです。
次にlintについてお話しします。
linterはgolangci-lintを利用しています。このlinterはactionもあるため、これを利用するだけですみます。なのでジョブもすっきりしています。
jobs:
lint:
runs-on: self-hosted
container:
image: golang:1.19
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Lint
uses: synced-actions/golangci-lint-action@v3
with:
version: v1.50.1
args: -c .golangci.yml --allow-parallel-runners
skip-cache: true
skip-pkg-cache: true
skip-build-cache: true
10行目の「uses: synced-actions/golangci-lint-action@v3」がどこかおかしいです。
golangci-lintのactionは普通「uses: golangci/golangci-lint-action@**」と呼び出します。「synced-actions」はGHEならではの話になります。actionsはOrganization名を書きますが、GHEにはgolangciというOrganizationがありません。GitHubと同期すればいいのですが、そうするとOrganizationが被ってしまったりセキュリティ上の問題がでてくるため、GHEの運用チームでGitHubと同期しているactionのOrganization (synced-actions) を作っています。
さて、長々と単体テストについて話し、lintについてさらっと言いましたが、キャッシュしたくないですか?
流していましたが、単体テストではsetup-go actionを使っていないためキャッシュを使っていません。lintで「skip-cache: true」と書いてあるように、キャッシュをしないようにしています。実はGHEの場合、キャッシュするのも少し大変だったりします。
通常、キャッシュしたい場合は「actions/cache」を使います。setup-goやgolangci-lint-actionもactions/cacheを利用しています。actions/cacheはGitHub上にキャッシュをおく、GitHub上にあるキャッシュをダウンロードすることができます。GHEは社内にあるため、社内の証明書を用意しないとキャッシュをダウンロードしてくるときにエラーになってしまいます。Runnerが動いているホスト上にキャッシュを残すやり方もありますが、キャッシュを掃除しないといけなかったりして考えることも増えてしまいます。
ということで、社内証明書をジョブで設定しキャッシュ処理でGHEと通信できるようにします。
jobs:
unit-test:
runs-on: self-hosted
container: # <-- jobを実行するコンテナ
image: (mysql clientやmigrationコマンドなどが入ったGoのDockerイメージ)
env:
MARIADB_URL: "mariadb"
services: # <-- サービスコンテナ
mariadb:
image: (my.cnfを入れたMariaDBのDockerイメージ)
ports:
- 3306
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Dump Cert # <-- 証明書をsecretから読み取ってファイルを作る
env
CERT: ${{ secrets.OFFICE_CERT }}
run: |
echo "$CERT" > office.cer
- name: Restore Cache
id: restore-cache
uses: actions/cache/restore@v3
continue-on-error: true
env:
NODE_EXTRA_CA_CERTS: office.cer # <-- Nodeの環境変数に証明書ファイルを指定する
with:
path: ./vendor
key: push-vendor-${{ github.repository_id }}-${{ github.ref_name }}
restore-keys: push-vendor-${{ github.repository_id }}-
- name: Insert Init Data # <-- Checkoutした後にデータを入れる
run: |
until mysql -u root -phogehoge -h $MARIADB_URL -e '\q'; do sleep 1; done # <-- DBが起動して応答できるまで待つ
echo "MariaDB is up!"
cat init.sql | mysql -u root -p**+* -h $MARIADB_URL # <-- データを投入
- name: Migrate DB # <-- スキーマ作成
run: |
/usr/local/bin/migrate -source file://migrations --database "mysql://root:****@tcp(${MARIADB_URL})/db_name" up
- name: Test
run: GOPATH=$PWD/vendor go test -mod=mod -cover -race -parallel=5 ./...
- name: Store Cache
if: steps.restore-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v3
continue-on-error: true
with:
path: ./vendor
key: push-vendor-${{ github.repository_id }}-${{ github.ref_name }}
証明書はGitHubのsecretに保存しておき、16行目の「Dump Cert」で証明書の内容をsecretからファイルに書き出します。21行目の「Restore Cache」で証明書ファイルを指定し、キャッシュをGHEからダウンロードしてこれるようにしています。
これでキャッシュを使うことができました。
setup-goやgolangci-lint actionでも同様に証明書ファイルを指定すればできると思いますが、今回はあきらめています。setup-goを使わなかったり、golangci-lint actionでskip-cache: trueだったりするのはそういうことです。
フロントエンドアプリのリリース
JSをビルドしたDockerイメージをプッシュするだけですが、一つだけ厄介ごとがあります。
フロントエンドアプリはJSをビルドした後、環境ごとに専用の変数ファイルをおき、ビルドしたものと変数ファイルを持つDockerイメージをプッシュします。変数ファイルによって環境ごとに画面を切り替えています。
JSのビルド成果物はすべての環境で共通なので、ビルドはできるなら1回だけにしてDockerイメージを作る際にビルド成果物を持ってくるだけにしたいです (ビルドはある程度時間がかかるので、何度も行うと大変です)。
キャッシュ (actions/cache) を使ってもいいですがGitHub Actionsにはアーティファクト (actions/artifact) というものがあるので、これを利用します。アーティファクトはジョブの成果物をGitHub上などにおいておき、別のジョブでダウンロードできたりする機能です。キャッシュとの大きな違いは、キャッシュはワークフローをこえて利用できますがアーティファクトは同じワークフロー内でしか利用できません。
イメージはこんな形です。
この図のNginxがそれぞれDockerイメージであり、最終的にそれぞれプッシュされます。
ビルドはできましたが、Dockerイメージをプッシュするのも素直にはいきません。Dockerレジストリもオンプレでたてているため、Dockerのログインなどが必要です。
それらをまるっとyamlファイルにするとこのようになります。
jobs:
build: # <-- このジョブでJSをビルドし、アーティファクトとしておく
runs-on: self-hosted
container:
image: node:16-buster
steps:
- name: Set Tag Name
id: tag-name
run: echo "::set-output name=version::${GITHUB_REF##*/}"
- name: Checkout
uses: actions/checkout@v3
- name: Build
run: |
yarn install
yarn build
- name: Upload Build # <-- アーティファクトをプッシュ
uses: actions/upload-artifact@v3
with:
name: build-${{ github.repository_id }}-${{ steps.tag-name.outputs.version }}
path: ./build
if-no-files-found: error
retention-days: 1
release:
runs-on: self-hosted
needs: build
if: success()
strategy:
matrix:
env:
- int
- stg
- prd
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set Tag Name
id: tag-name
run: echo "::set-output name=version::${GITHUB_REF##*/}"
- name: Dump Cert # <-- アーティファクトをダウンロードするにも社内証明書が必要
env:
CERT: ${{ secrets.OFFICE_CERT }}
run: |
echo "$CERT" > office.cer
- name: Download Build # <-- アーティファクトをダウンロード
uses: actions/download-artifact@v3
env:
NODE_EXTRA_CA_CERTS: office.cer
with:
name: build-${{ github.repository_id }}-${{ steps.tag-name.outputs.version }}
path: ./build
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v4
with:
images: (Dockerレジストリ)/...${{ matrix.env }}
flavor: latest=false
tags: type=ref,enable=true,priority=2000,prefix=,suffix=,event=tag
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Registry # <-- Dockerレジストリにログインする
uses: docker/login-action@v2
with:
registry: (Dockerレジストリ)
username: ${{ secrets.USER }}
password: ${{ secrets.PASS }}
- name: Build And Release # <-- ここでDockerイメージをpush
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
build-args: |
APP_VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
COMMIT_HASH=${{ github.sha }}
CUSTOM_ENV=${{ matrix.env }}.json
pull: true
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
ところどころで「echo “::set-output name=version::${GITHUB_REF##*/}”」という記述がありますが、これはGitHub Actionsで変数を埋め込む方法です。現在この方法は廃止されています。GHEで利用するRunnerのバージョンが最新ではないため、昔の記法を利用しています。
環境ごとにDockerイメージを作るのにmatrix (29行目) を利用していて、環境ごとにジョブを書く必要がなくなります。Dockerレジストリにログインするために61行目でログインしています。ユーザ名とパスワードはsecretを利用します。
果たしてこれだけで本当にいいのか?
ダメです (ハハッ
GitHub Actionsとしては問題ありませんが、ホスト上で主に二つ問題があります。
- dockerコマンドがたたけない
ホスト上でDockerコンテナをたちあげたりするため、Dockerをインストールします。dockerコマンドはRunnerがたたけないといけないため、権限を適切に与えることも注意しないといけません (インストールしたのにdockerコマンドがたたけないと怒られる)
- Dockerレジストリにイメージをプッシュできない
オンプレのDockerレジストリにイメージをプッシュするには社内証明書が必要になっています。ホストに証明書をおいたり、Dockerの設定を変更する必要があります
他にもディスクなどのリソースが足りなくなるとかの問題があったりします。Runnerホストを管理しないといけないため、この辺の運用はどうしても避けられないのが現実です。
さいごに
イライラしながら仲良くなったと言いながら、あまりその辺のことは書けなかったです (個人的な怒りを書いてもねぇ)。
GitHub Actionsはドキュメントがしっかりしていて、社内に知見がたまっていたので移行しやすかったです。ただ、できることが多いためベストプラクティスって何だろうとはなりました。慣れればその辺もなんとなくつかめてきます。わかってくるとGitHub Actionsは素直で、やっていることもわかりやすいです。個人的にはDrone CIよりもGitHub Actionsの方が好きでした。
GitHub Actionsが原因というよりも社内事情やRunnerホストが原因でハマったことが多かったです。GitHubで使う分には簡単に使えると思いつつ、それをRunnerなども運用するとなると話が違ってくるなぁっと改めて思いました。運用は大変だ。
まだまだ課題が残ってます。Runnerホストのリソースが足りなく、一時的にCPUが張り付いたりディスクが足りなかったりと。また、オートスケーリングはしていないため、効率よくRunnerを動かせないのが現状です。IIJはk8sも自前で用意している (IKEというのがある) ので、それを使えば構築できないかなぁっと思っていたりします。
また機会があればお会いしましょう。