This is my life.

There are many like it, but this one is mine.

ペライチ300行弱のコードで簡易CIサーバーを作った

皆さんはゴールデンウィークいかがお過ごしでしょうか。
僕は暇すぎたので雑にCIサーバーのようなものを作ることにしました。

github.com

CIサーバーを運用する際の悩みとして、サーバーの環境構築が挙げられます。
色々なプロジェクトでは言語や依存するサーバー環境が異なります。これを解決するのは非常に面倒です。
そこで、 Docker を裏で利用してコンテナ内ですべて実行することにしました。

個人の意見として、ビルドやテストの実行環境はCIサーバーで担保するのではなく、プロジェクトで担保するべきだと思っています。
実行環境をプロジェクト毎に担保することによってCIサーバーのスケールや移行が楽になります。

コード内で Docker を利用する際には、

github.com

が非常に便利です。

これを利用するため、サーバーは Golang で記述することにしました。

また、ジョブ自体は Maven や Gradle、NPM、Fastlane などのタスクランナーを利用して実行することにしました。
サクッと利用方法を見たい場合は ここ から。

作る機能

CIの機能を分解すると、

  • トリガー
  • ジョブの実行
  • 結果の通知

に分けられると思います。

トリガー

ジョブを実行するタイミングです。
今回はサーバーとして起動し、GitHub の Webhooks を待ち受けることにしました。
GitHub の Webhooks は種類・情報が豊富です。

Webhooks | GitHub Developer Guide

これを利用することで任意のタイミングでジョブを実行できるようになります。

GitHub の Pull Request のコメントをトリガーフレーズとして実行できるようにしました。
Jenkins の Pull Request Builder Plugin の trigger phrases と同じような使い勝手です。

f:id:duck8823:20180506115916p:plain

ジョブの実行

前述のとおり、ジョブは Docker コンテナ内で実行します。
プロジェクト毎に Dockerfile を用意し、 ENTRYPOINT にタスクランナーのコマンドを設定しておくことで、トリガーレーズで任意のタスクを実行できるようになります。

gist.github.com

上記のような Dockerfile を用意していた場合、 ci test とコメントすると コンテナ内で mvn test が実行されます。

結果の通知

ジョブを実行したら、その結果を通知する必要があります。
リクエストに対して同期的にジョブを実行し、成功したら 200 を返すようにしました。 ※ジョブに時間がかかってしまうので、GitHub の Webhooks は Timeout となってしまいます。

また、トリガーを GitHub の Pull Request 依存にしたので、Commit Status として結果を返すことにしました。

f:id:duck8823:20180506121019p:plain

コード

前置きが長くなりましたが、 以下が300行弱で簡易CIを実現するコードです。

gist.github.com

ここからは、上から順に各ポイントを紹介したいと思います。

Webhooks のパース

net/http を利用して Webhooks を待ち受けるサーバーを実現しています。

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    ...
})
http.ListenAndServe(":8080", nil)

リクエストは JSON文字列 として受け取ります。
そのままでは非常に扱いづらいので、構造体にマッピングしましょう。

ただし、 Payload の構造体を自分で記述するのは骨が折れます。
以下のプロジェクトから構造体を拝借しました。

github.com

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 には、

github.com

を利用しました。
特定のブランチをクローンする場合は 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.ImageBuildResponseBodyEOF まで読むことでビルドの終了を同期的に待つことができます。

また、types.ImageBuildOptionsTags で タグを指定することができます( -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 code0 以外のときはジョブに失敗したとみなし、 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 を利用します。

www.docker.com

あらかじめ Docker for Windows の Settings から、 Expose daemon on tcp://localhost:2375 without TLS を設定しておきましょう。

f:id:duck8823:20180506165017p:plain

DOCKER_HOST として tcp://docker.for.win.host.internal:2375 を設定することでコンテナ内からホストの Dockerデーモン を利用することができます。

docker-compose -f docker-compose.win.yml

Mac の場合

Docker for Mac を利用します。

www.docker.com

Docker for Mac には Expose daemon... の設定はありません。

bobrik/socat のイメージを利用することで実現できました。
https://hub.docker.com/r/bobrik/socat/

また、 DOCKER_HOSTtcp://docker.for.mac.host.internal:2375 です。

docker-compose -f docker-compose.mac.yml

Optional.of( ngrok の利用 )

Webhooks を受け取るために、GitHubからサーバーにアクセスできる必要があります。
ngrok でローカルのポートを外部に公開することができます。
試しにポートを公開したい場合は利用するといいかもしれません。
リポジトリの composeファイル では ngrok コンテナはコメントアウトしています。

ngrok.com

ngrok を起動したら http://localhost:4040/status にアクセスしてみましょう。

f:id:duck8823:20180506172407p:plain

ここで表示される 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 を設定しましょう。

f:id:duck8823:20180506172812p:plain

まとめ

GitHub / Docker( Moby ) を使って簡易CIを作ってみました。
セキュリティがガバだったり、失敗時のログを後から見られなかったりとまだまだ機能は足りないですが、 このコードをベースにオリジナルのCIを作ってみると楽しいと思います。