Golang で CI を作っている話
ペライチ300行だったコードはコミットを重ね、少しずつ大きくなってきています。
リポジトリの名前も minimal-ci から duci に変えました。命名はお世話になっている先輩です。
コンセプト
CIは非常に便利です。僕も業務や趣味で CircleCI や Travis CI 、 Jenkins を利用しています。
ですが、これらの設定は非常に複雑でかつドメイン特異的になります。
CIのバージョンアップや移行で疲弊したこともあるのではないでしょうか。
そこでこのCIのコンセプトは DSL is Unnecessary For CI にしました。
- タスクはタスクランナーで定義しましょう
- タスクの実行に必要な環境は Dockefile で定義しましょう
- duci はコンテナ内でタスクを実行するだけです
僕の中で常に意識していることとして、開発中のフィードバックをできるだけ早くするというのがあります。
CIで失敗したときにCIでしか再実行・確認ができないのは辛いです。
同じ定義でローカルでも実行できるようにしています。
タスクはタスクランナーで定義しましょう
CIに特異的な設定をしないでジョブの定義をしましょう。
ローカルマシンでの開発中、多くの言語ではタスクランナーを利用すると思います。
Java、Kotlin では maven や gradle、JavaScript では npm や yarn がそれに当たります。
いずれのツールもサブコマンドで、予め定めたタスクを実行します。
Golangの場合はそれ自体のサブコマンドとして task
や build
が用意されています。
Makefile
でもタスクの定義が可能ですね。
https://github.com/duck8823/duci/blob/master/Makefile
make test
このタスクを CI サーバーでも実行するようにすれば、
ローカルでもCIでも同じタスクを実行することができます。
タスクの実行に必要な環境は Dockerfile で定義しましょう
ローカル開発時とCIで同じタスクが実行できるようになりました。
しかし、このままではローカルとサーバーで実行環境が異なります。
コンテナを利用しましょう。
Dockerfile を利用してタスクの実行環境を定義することで、開発に必要な依存も管理することができます。
FROM golang:1.11-alpine RUN apk --update add --no-cache alpine-sdk WORKDIR /go/src/github.com/duck8823/duci ADD . . ENV CC=gcc ENV GO111MODULE=on ...
Dockerfileをみるだけで必要な言語のバージョン、サーバーの依存(alpine-sdk)や環境変数がわかります。
これをコミットすることで必要な依存を変更したときも、どのコミットからその依存が必要になったか追えるようになります。
duci はコンテナ内でタスクを実行するだけです
コンテナ内ではタスクを実行するだけです。では、どのようにしてタスクを振り分けるのか。
Dockerfile
内の ENTRYPOINT
にタスクランナーのコマンド、 CMD
にデフォルトのサブコマンドを定義します。
そうすることで docker run
時にコマンドを渡さなければデフォルトのサブコマンド、コマンドを渡すことで特定のサブコマンドを実行することができます。
例えば Dockerfile
を以下のように定義します。
... ENTRYPOINT ["go"] CMD ["test", "./..."]
このとき、
docker run <IMAGE_NAME>
で コンテナ内では
go test ./...
が実行されます。
docker run <IMAGE_NAME> build
で
go build
が実行されます。
duci では GitHub の Webhook からジョブを実行しますが、
push
の場合はデフォルトのサブコマンド、issue_comment
の場合はコメントで指定したサブコマンドを実行するようにしました。
Dockerfile があれば、ローカルでもコンテナを起動して実行することができます。
CIで失敗したときにローカルですぐに試せるので、原因の解明がとても楽です。
CIの設定を試すために何度もコミット&プッシュする必要はありません。
パッケージ構成
コード量も増えたので、パッケージ構成も変更しました。
いわゆる階層アーキテクチャやクリーンアーキテクチャのような構成ではありませんが、自分なりに各パッケージに意味をもたせています。
- インフラストラクチャパッケージ
- 更に下位のパッケージに分けています。
それぞれのパッケージはアプリケーションに依存しないものを集めており、使い回ししやすくしています。
- 更に下位のパッケージに分けています。
- アプリケーションパッケージ
- アプリケーションの設定などの他、ランナー=メインロジック( Gitクローン -> Docker Run -> Commit Status ) を実現しています。
- データモデルパッケージ
- アプリケーションパッケージとプレゼンテーションパッケージで利用する構造体を定義しています。
- プレゼンテーションパッケージ
- ルーティングとハンドラーを定義しています。
ハンドラーは Webhooks をアプリケーションパッケージに渡す形に変換する役割を担っています。
- ルーティングとハンドラーを定義しています。
自分で考えたパッケージ構成にしておくことで、判断基準が自分にあるので悩まないですみます。
現段階のパッケージ構成なので、ソースが大きくなったりしたらまた変わるでしょう。
CIをもうちょっと高機能にする
必要最低限な機能は 前回の記事 で紹介しています。
実用的にするには、前回のペライチスクリプトではいくつかの機能が足りていません。
- 非同期的に実行する
- 並列数を制御する
- ジョブのタイムアウトを設定する
Webhooks -> Git Clone -> Archive Tar Package -> Docker Image Build -> Docker container create & run -> Create Commit Status
この一連の流れを同期実行していました。レスポンスはすべての処理が終わったあとに返します。
CIを実行するのにはそれなりに時間がかかるので、GitHubのWebhookでTimeoutと表示されてしまっていました。
そのため、各ジョブは非同期で実行できるようにしました。
非同期で実行する場合、サーバーのリソースは限られているのでジョブの並列数を意識する必要があります。
また、ジョブの最大並列数を定義した場合、各ジョブのタイムアウトを設定しなければキューに入れられたジョブがずっと実行されないままになる危険があります。
これらは比較的簡単に実現できました。そう、 goroutine ならね。
var sem = make(chan struct{}, runtime.NumCPU()) // 最大並列数=サーバーのコア数
go func ... { // goroutine で非同期に実行できるようにする ... errs := make(chan error, 1) timeout, cancel := context.WithTimeout(context.Background(), 30 * time.Second) // タイムアウトの設定 defer cancel() go func() { sem <- struct{}{} // 実行できる並列数を一つ減らす errs <- runner.Run(timeout) // メインの処理... 終了時に `errs` に渡す <-sem // 実行できる並列数を一つ戻す }() select { // タイムアウト or エラーが入るまで(メイン処理が終わるまで) case <-timeout.Done(): // タイムアウトの処理 return timeout.Err() case err := <-errs: if err != nil { // ジョブが失敗したときの処理 return err } // ジョブが成功したときの処理 return nil } }()
非同期にするのは関数呼び出し時に go
をつけるだけです。
タイムアウトは context.WithTimeout
で実現できます。メインの処理は更に goroutine とし、 select
でタイムアウトあるいはメインの処理実行まで待ちます。
並列数は最大実行可能数を渡した channel を作成しておき、メイン処理の前後で増やす・減らす処理をするだけで実現が可能です。
テストの話
テストについては golang.tokyo #17 で発表させて頂きました。
www.slideshare.net
まとめ
ペライチで作ったCIサーバーを育てていて、context とか goroutine の扱いを意識することができました。
MobyというDockerクライアントが含まれるライブラリも存在し、非同期処理や並列数も結構簡単に設定できるので、GolangはCIづくりに適してそうですね。
みなさんも自分好みのCIを作りましょう。