ペライチ300行弱のコードで簡易CIサーバーを作った
皆さんはゴールデンウィークいかがお過ごしでしょうか。
僕は暇すぎたので雑にCIサーバーのようなものを作ることにしました。
CIサーバーを運用する際の悩みとして、サーバーの環境構築が挙げられます。
色々なプロジェクトでは言語や依存するサーバー環境が異なります。これを解決するのは非常に面倒です。
そこで、 Docker を裏で利用してコンテナ内ですべて実行することにしました。
個人の意見として、ビルドやテストの実行環境はCIサーバーで担保するのではなく、プロジェクトで担保するべきだと思っています。
実行環境をプロジェクト毎に担保することによってCIサーバーのスケールや移行が楽になります。
コード内で Docker を利用する際には、
が非常に便利です。
これを利用するため、サーバーは Golang で記述することにしました。
また、ジョブ自体は Maven や Gradle、NPM、Fastlane などのタスクランナーを利用して実行することにしました。
サクッと利用方法を見たい場合は ここ から。
作る機能
CIの機能を分解すると、
- トリガー
- ジョブの実行
- 結果の通知
に分けられると思います。
トリガー
ジョブを実行するタイミングです。
今回はサーバーとして起動し、GitHub の Webhooks を待ち受けることにしました。
GitHub の Webhooks は種類・情報が豊富です。
Webhooks | GitHub Developer Guide
これを利用することで任意のタイミングでジョブを実行できるようになります。
GitHub の Pull Request のコメントをトリガーフレーズとして実行できるようにしました。
Jenkins の Pull Request Builder Plugin の trigger phrases と同じような使い勝手です。
ジョブの実行
前述のとおり、ジョブは Docker コンテナ内で実行します。
プロジェクト毎に Dockerfile を用意し、 ENTRYPOINT
にタスクランナーのコマンドを設定しておくことで、トリガーレーズで任意のタスクを実行できるようになります。
上記のような Dockerfile を用意していた場合、 ci test
とコメントすると コンテナ内で mvn test
が実行されます。
結果の通知
ジョブを実行したら、その結果を通知する必要があります。
リクエストに対して同期的にジョブを実行し、成功したら 200
を返すようにしました。
※ジョブに時間がかかってしまうので、GitHub の Webhooks は Timeout となってしまいます。
また、トリガーを GitHub の Pull Request 依存にしたので、Commit Status
として結果を返すことにしました。
コード
前置きが長くなりましたが、 以下が300行弱で簡易CIを実現するコードです。
ここからは、上から順に各ポイントを紹介したいと思います。
Webhooks のパース
net/http
を利用して Webhooks を待ち受けるサーバーを実現しています。
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { ... }) http.ListenAndServe(":8080", nil)
リクエストは JSON文字列 として受け取ります。
そのままでは非常に扱いづらいので、構造体にマッピングしましょう。
ただし、 Payload の構造体を自分で記述するのは骨が折れます。
以下のプロジェクトから構造体を拝借しました。
body, _ := ioutil.ReadAll(r.Body) ... event := &github.IssueCommentEvent{} json.Unmarshal(body, event)
ここでは、 github.IssueCommentEvent
という構造体にマッピングしています。
冒頭で記述したとおり、GitHub の Webhooks には非常に多くの種類(event type
)があります。
その種類によって Payload が異なるので、正しく構造体にマッピングしなければなりません。
どの event type
かは、リクエストのヘッダー X-GitHub-Event
に記述されています。
Pull Request のコメントは issue_comment
です。それ以外の場合は除外しましょう。
https://developer.github.com/v3/activity/events/types/#issuecommentevent
githubEvent := r.Header.Get("X-GitHub-Event") if githubEvent != "issue_comment" { ... return }
トリガーフレーズの取得
Pull Requestのコメント内容は Payload の comment.body
から取得することができます。
正規表現で /^ci\s+(?<phrase>.+)/
にマッチした場合にジョブを実行します。
if !regexp.MustCompile("^ci\\s+[^\\s]+").Match([]byte(event.Comment.GetBody())) { ... return } phrase := regexp.MustCompile("^ci\\s+").ReplaceAllString(event.Comment.GetBody(), "")
Pull Requestかどうかの判定
event type issue_comment
は名前から分かる通り Issue のコメントでも発火してしまいます。
Payload の issue.number
から得られた番号から、 Pull Request 情報を取得することができます。
pr, _, err := githubClient.PullRequests.Get( context.Background(), event.Repo.Owner.GetLogin(), event.Repo.GetName(), event.Issue.GetNumber(), )
特定のブランチを Clone する
Gitリポジトリの Clone には、
を利用しました。
特定のブランチをクローンする場合は CloneOptions.ReferenceName
を指定します。
refs/heads/<ブランチ名>
で指定する必要があります。
Pull Request の Head ブランチは上記で取得した Pull Request 情報から取得することができます。
base := fmt.Sprintf("%v", time.Now().Unix()) root := fmt.Sprintf("/tmp/%s", base) repo, err := git.PlainClone(root, false, &git.CloneOptions{ URL: event.Repo.GetCloneURL(), Progress: os.Stdout, ReferenceName: plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", pr.Head.GetRef())), })
GitHub に Pending の commit status をつける
Commit Status は進行によって更新するので、共通の処理は CommitStatusService
としてまとめておきました。
https://gist.github.com/duck8823/261fafdcea18b655ce6a49381499d9b5#file-main-go-L262-L294
git.PlainClone
した際の戻り値として得られる git.Repository
から、 HEAD の Hash を得ることができます。
Commit Status は特定の Commit Hash に対して設定することができます。
また、 Context
はトリガーフレーズごとに異なるようにしました。これにより build
は成功したけど test
に失敗したなどの情報がわかるようになります。
ref, err := repo.Head()
...
statusService := &CommitStatusService{
Context: fmt.Sprintf("minimal_ci-%s", phrase),
GithubClient: githubClient,
Repo: event.Repo,
Hash: ref.Hash(),
}
statusService.Create(PENDING)
Dockerイメージをビルドするための tarアーカイブ を作成する
Moby では Dockerfile を含むディレクトリを tar形式 でアーカイブする必要があります。
filepath.Walk
を利用して、クローンしてきたディレクトリに対して再帰的に tarアーカイブに含めるようにしています。
filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } file, _ := os.Open(path) defer file.Close() data, _ := ioutil.ReadAll(file) header := &tar.Header{ Name: strings.Replace(file.Name(), root, "", -1), Mode: 0666, Size: info.Size(), } writer.WriteHeader(header) writer.Write(data) return nil })
イメージのビルド
client.NewEnvClient()
で DOCKER_HOST
などの環境変数を利用した Dockerクライアント を作成することができます。
Client.ImageBuild
では非同期にDocker Imageのビルドが実行されます。
戻り値である types.ImageBuildResponse
の Body
を EOF
まで読むことでビルドの終了を同期的に待つことができます。
また、types.ImageBuildOptions
の Tags
で タグを指定することができます( -t
オプション相当 )。
タグにはコンテナの作成時と同じものを利用しましょう。今回は 実行ごとに UNIX時間 をつけているのですが、
リポジトリ名などでキャッシュを利用するようにすれば、ジョブの実行が高速化されるかもしれません。
cli, _ := client.NewEnvClient() resp, _ := cli.ImageBuild(context.Background(), file, types.ImageBuildOptions{ Tags: []string{base}, }) defer resp.Body.Close() ioutil.ReadAll(resp.Body) logger.Info("Image Build succeeded.")
コンテナの作成
コンテナを作成します。
Cmd に トリガーフレーズ 指定することで、任意のタスクの実行を実現できます。
con, _ := cli.ContainerCreate(context.Background(), &container.Config{ Image: base, Cmd: []string{phrase}, }, nil, nil, "")
コンテナの実行
いよいよコンテナの実行です。 コンテナ作成時に取得した コンテナID をスタートさせます。
コンテナの実行は同期的に行われます。
Client.ContainerWait
で待つことができます。戻り値には コンテナ内で実行したコマンドの return code
が含まれます。
return code
が 0
以外のときはジョブに失敗したとみなし、 Commit Status を failure
にしました。
また、ログは Client.ContainerLogs
で取得できます。
cli.ContainerStart(context.Background(), con.ID, types.ContainerStartOptions{}) if code, err := cli.ContainerWait(context.Background(), con.ID); err != nil { ... return } else if code != 0 { statusService.Create(FAILURE) http.Error(w, fmt.Sprintf("return code: %v", code), http.StatusInternalServerError) return } out, _ := cli.ContainerLogs(context.Background(), con.ID, types.ContainerLogsOptions{ ShowStdout: true, ShowStderr: true, })
成功の通知
最後までエラーが無ければ、 Commit Status を success
にしましょう。
また、レスポンスにログを渡しています。
statusService.Create(SUCCESS) buf := new(bytes.Buffer) buf.ReadFrom(out) respBody, err := json.Marshal(struct { Console string `json:"console"` }{ Console: buf.String(), }) w.WriteHeader(http.StatusOK) w.Write(respBody)
使い方
準備
Commit Status を作成するために、 GITHUB_API_TOKEN
を設定する必要があります。
トークンは https://github.com/settings/tokens から取得することができます。
export GITHUB_API_TOKEN=<your api token>
サーバーを起動する
Go の環境がある場合
$GOPATH が設定されていて、 $GOPATH/bin
に $PATH が通っている場合、は go get
で取得できます。
go get -u github.com/duck8823/minimal-ci minimal-ci
Docker Compose で起動する (コンテナからホストのDockerデーモンを利用する)
サーバー自体を Docker コンテナで起動する場合も、ホストの Docker デーモンを利用して実行するようにしましょう。
OS によって若干異なるのでそれぞれの compose ファイルを用意しました。
Windows の場合
Docker for Windows を利用します。
あらかじめ Docker for Windows の Settings から、
Expose daemon on tcp://localhost:2375 without TLS
を設定しておきましょう。
DOCKER_HOST
として tcp://docker.for.win.host.internal:2375
を設定することでコンテナ内からホストの Dockerデーモン を利用することができます。
docker-compose -f docker-compose.win.yml
Mac の場合
Docker for Mac を利用します。
Docker for Mac には Expose daemon...
の設定はありません。
bobrik/socat
のイメージを利用することで実現できました。
https://hub.docker.com/r/bobrik/socat/
また、 DOCKER_HOST
は tcp://docker.for.mac.host.internal:2375
です。
docker-compose -f docker-compose.mac.yml
Optional.of( ngrok の利用 )
Webhooks を受け取るために、GitHubからサーバーにアクセスできる必要があります。
ngrok でローカルのポートを外部に公開することができます。
試しにポートを公開したい場合は利用するといいかもしれません。
※リポジトリの composeファイル では ngrok コンテナはコメントアウトしています。
ngrok を起動したら http://localhost:4040/status
にアクセスしてみましょう。
ここで表示される URL にアクセスすることによって、 フォワーディングされます。
Webhooks の設定
GitHubでは、リポジトリ毎に Webhooks を設定します。
https://github.com/<owner>/<repo>/settings/hooks/new
Payload URL には 公開しているURL を設定します。
Which events would you like to trigger this webhook?
には Issue comments
を設定しましょう。
まとめ
GitHub / Docker( Moby ) を使って簡易CIを作ってみました。
セキュリティがガバだったり、失敗時のログを後から見られなかったりとまだまだ機能は足りないですが、
このコードをベースにオリジナルのCIを作ってみると楽しいと思います。