JenkinsJobをDockerで処理する（3. ビルド処理をJenkinsfileで設定）


[前回](http://qiita.com/ogasada0/items/6ad8e4d263c34a19c3ca)はビルド用のDockerイメージを作成しました。今回はこのイメージを使ってJenkinsのジョブを処理できるようにします。

## 3. ビルド処理をJenkinsfileで設定

Jenkinsfileでの設定を行う前に、Jenkinsのスレーブサーバーを作成します。マスターサーバー上で実行してもいいのですが、マスターサーバーのスペックが低かったのと、今後必要になるかもしれない負荷分散のため、スレーブサーバーの検証も兼ねました。

### Jenkinsスレーブサーバーの構築

1. CentOS7のインストール
  * Jenkins(2系)が動けばなんでもいいですが、今回はCentOS7をインストールしました。OSのインストール方法は省略します。最低限、SSHサーバーが稼働し、SSHによる接続ができるようにしておきます。
2. Jenkinsマスターサーバーでスレーブサーバーの追加
  1. Jenkinsのマスターサーバーの設定で先ほど作成したスレーブサーバーを追加します。`Jenkinsの管理` > `ノードの管理` > `新規ノード作成`と進みます。
  2. `Permanent Agent`を選択します。
  3. 以下のように設定します。ノード名、リモートFSルートは任意の値にしてください。用途については、JenkinsのすべてのJobをDockerで処理できるようになったら特定ジョブ専用でなくてもいいのですが、今の所は特定のJobだけDockerで処理するので、特定ジョブ専用にしています。起動方法はスレーブサーバーに特別な設定が不要なのでSSH経由による起動にしています。

    |設定名|値|
    |:--|:--|
    |ノード名|slave01|
    |リモートFSルート|/var/jenkins|
    |用途|このマシンを特定ジョブ専用にする|
    |起動方法|SSH経由でUnixマシンのスレーブエージエントを起動|
    
### プラグインの設定

Jenkinsfile上で任意のDockerイメージを利用できるようにするため、`Docker Pipeline`プラグインをインストールします。また、Jenkinsfile上でSSHを使ってリモートサーバーへデプロイを行うので、`SSH Agent`プラグインもインストールします。

1. `Jenkinsの管理` > `プラグインの管理`と進みます。
2. `利用可能`タブより`Docker Pipeline`, `SSH Agent`を選択し、インストールします


以上で、Jenkinsの設定は完了です。次にJenkinsfileの作成に移ります。


### Jenkinsfileの作成

今回は

* プロダクトのビルド
* サーバーへのデプロイ
 
をJenkinsで行えるようにJenkinsfileを作成します。

Jenkinsのジョブを設定する際に、`ビルドを行うJenkinsfile`を指定するのが一番簡単なのですが、ビルド対象となるブランチがいくつもある場合にブランチごとにジョブを作らなければならなくなります。これは管理上手間になるので、ジョブは一つだけにし、ビルド時にブランチを指定できるようにします。そのため、ビルド対象となるブランチの入力と実際のビルド処理が書かれたJenkinsfileを読み込むためのローダーと、実際のビルド処理が書かれたJenkinsfileの2つを作成します。

まずは、ローダーとなる`JenkinsfileLoader`を作成します。このファイルの内容はブランチごとに変わることはないので、Jenkinsのジョブはmasterブランチ上のこのファイルを指定することになります。

```text:JenkinsfileLoader
node('slave01') {
  stage('init target') {
    def target = input message: 'ビルド対象のブランチ、デプロイ先を入力してください', parameters: [text(defaultValue: 'master', description: '''ビルド対象のブランチ名を入力してください。
    （例）
    master
    develop/1.0.0.x''', name: 'branch'), text(defaultValue: 'testserver', description: '''デプロイ先のサーバー名を入力してください。
    （例）
    testserver
    xxx.xxx.xxx.xxx''', name: 'deploy')]
    
    // ブランチごとのJenkinsfile内で参照するためenvに設定する。
    env.targetBranch = target.branch
    env.targetDeploy = target.deploy
  }

  stage('checkout') {
    checkout([$class: 'GitSCM', branches: [[name: "${env.targetBranch}"]], doGenerateSubmoduleConfigurations: false, extensions: [], submoduleCfg: [], userRemoteConfigs: [[credentialsId: 'xxxxxxxx', url: 'ssh://git@gitlab.example.com:10022/hoge/fuga.git']]])
  }
  stage('load build file') {
    def jf = load 'Jenkinsfile'
    jf.build()
  }
}
```

* `node('slave01')`で実行するノードを先ほど作成したスレーブサーバーに指定しています。
* `stage('init target')`ではJob実行者にビルド対象となるGitブランチ名、デプロイ先のサーバー名を入力してもらうようにします。
* `stage('checkout')`では入力されたブランチをチェックアウトします。checkoutコードについては、JenkinsのJob設定画面でPipelineを設定するところにある`Pipeline Syntax`リンクを押すと表示される`Snippet Generator`で生成できます。
* `stage('load build file')`で実際のビルド処理が書かれたJenkinsfileを読み込みます。

次に、実際のビルド処理が書かれたJenkinsfileを作成します。ここにはブランチ固有のビルド処理を記述します。

```text:Jenkinsfile
def build() {
  sendGoodMessageToSlack(["ビルド・デプロイ開始"])

  stage('work in docker') {
    docker.image('gitlab.example.com:5000/jenkins/docker/java8_node:1.0.0').inside('-v npm_cache:/usr/lib/node_modules') { c ->

      def errorMessages = []
      withErrorManage('npmセットアップ', errorMessages, {
        stage('npm setup') {
          sh "npm install"
        }
      })
      finishOnError(errorMessages)

      withErrorManage('ビルド', errorMessages, {
        stage('build') {
          sh "gulp release"
        }
      })
      finishOnError(errorMessages)

      withErrorManage('デプロイ', errorMessages, {
        stage('deploy') {
          sshagent(['xxxxxxxx']) {
            def cmd = "deploy.sh ~/production.zip"
            def server = "${env.targetDeploy}"
            def user = 'deploy'
            sh "scp -oStrictHostKeyChecking=no ${WORKSPACE}/production.zip ${user}@${server}:~/production.zip"
            sh "ssh -oStrictHostKeyChecking=no ${user}@${server} '${cmd}'"              
          }
        }
      })
      finishOnError(errorMessages)
    }
  }
  sendGoodMessageToSlack(["ビルド・デプロイ成功"])
}

/**
 * タスクを実行する。エラーが発生した場合は、エラーメッセージを与えられたリストに追加する
 **/
def withErrorManage(String taskname, List errorMessages, Closure task) {
  try {
    task()
  } catch (err) {
    errorMessages << "${taskname}失敗"
  }
}

/**
 * エラーがある場合に処理を終了する
 * また、slackに通知を送る
 **/
def finishOnError(List errorMessages) {
  if (!errorMessages.empty) {
    sendErrorMessageToSlack(errorMessages)
    error errorMessages.join(',')
  }
}

/**
 * Slackに成功メッセージを通知する
 * @param messages
 * @return
 */
def sendGoodMessageToSlack(List messages) {
  sendMessageToSlack(messages, "good")
}

/**
 * Slackにエラーメッセージを通知する
 * @param messages
 * @return
 */
def sendErrorMessageToSlack(List messages) {
  sendMessageToSlack(messages, "danger")
}

/**
 * Slackに通知する
 **/
def sendMessageToSlack(List messages, String color) {
  slackSend color: "${color}", message: "${env.targetBranch} ${messages.join(',')} $env.BUILD_URL"
}

return this
```

* `stage('work in docker')`でDockerコンテナを起動します。以降の処理はDockerコンテナ内で行われます。今回、ビルド時にnpmを利用していますが、Dockerコンテナ内にキャッシュを保持するとビルドのたびに大量のダウンロードが発生してしまいます。不要なダウンロードを減らし処理時間を短縮するため、docker.image().insideで`-v npm_cache:/usr/lib/node_modules`を指定し、キャッシュはホスト上に残すようにします。
* `stage('npm setup')`, `stage('build')`でビルドを行い、生成されたzipファイルを`stage('deploy')`でテストサーバーにデプロイしています。`withErrorManage`という関数を作っていますが、これはエラー発生時のエラーメッセージ生成を共通化するためです。

### Jenkinsジョブの作成

最後に、Jenkinsのジョブを作成します。と言っても大したことはありません。

1. 新規ジョブ作成で、`Pipeline`を選択します
2. ジョブの設定で、PipelineのDefinitionを`Pipeline script from SCM`にします
3. ビルド対象のGitプロジェクトを指定し、`Script Path`に先ほど作成した`JenkinsfileLoader`を指定します。

以上でJenkinsジョブの作成完了です。

これで、ビルド実行をし、ブランチ名、デプロイ先のホスト名を入力すればDockerコンテナ内でビルドを行い、成果物をリモートホストにデプロイできるようになりました。

JenkinsfileがGit管理されるのでJenkinsが吹っ飛んだとしても安心です。また、Dockerでビルドをするのでツールのバージョン競合に悩まされることもなくなったので管理の手間が減りました。

## 問題点

今回、この環境を作っていく中でいくつか問題点が見つかりました。何か解決策をご存知の方がいれば教えてください。

### 1. JenkinsfileLoaderで他のJenkinsfileをloadした後に処理が書けない **（解決済み）**

* JenkinfileLoader側でもエラーが発生した時にはslackへ通知をさせたかったのですが、
 
```text:JenkinsfileLoader
  try {
  　・・・
    load 'Jenkinsfile'
  } catch(e) {
    slackSend color: "danger", message: "${env.targetBranch} loadエラー $env.BUILD_URL"  }
```  

のように書くと

```
java.lang.NullPointerException: Cannot invoke method call() on null object
	at org.codehaus.groovy.runtime.NullObject.invokeMethod(NullObject.java:91)
	at org.codehaus.groovy.runtime.callsite.PogoMetaClassSite.call(PogoMetaClassSite.java:48)
	at org.codehaus.groovy.runtime.callsite.CallSiteArray.defaultCall(CallSiteArray.java:48)
・・・
```	

このようなエラーが発生してしまいました。似たような[Issue](https://issues.jenkins-ci.org/browse/JENKINS-39110)は見つけて色々と試してみましたが解決できてません。これは結構困ってます。。。

**（2017/08/25更新）**
→ loadされるJenkinsfileを単なる関数群のファイルにし、最後に`return this`と書くようにすれば問題なく処理できるようになりました。本文の方は新しい書き方に更新しました。

### 2. Windows上でのビルドがDocker化できない **（検証中）**

~~Windows上でなければビルドできない場合、Windows上にインストールしたJenkinsでビルドしていますが、こちらもライブラリのバージョン競合問題が発生します。DockerコンテナをWindowsベースにすることもできないので今回はこの問題は未解決のままです。Windowsの場合はビルドごとに仮想環境を作ろうとするとライセンスもそれだけ必要になるという問題もあるので、いい解決方法がわかりません。Windowsメインで開発されている方はどうされているのでしょうか？~~
不勉強でした。Windows10, WindowsServer2016上だとWindowsベースのDockerコンテナ動かせるんですね。検証してみたいと思います。



## 最後に

Jenkins(pipeline) + Docker環境についてはいろんな方が記事を書かれているのですんなり構築できるかなと思っていましたが、細かいところでいろいろと引っかかり結構時間がかかってしまいました。試行錯誤を繰り返して作っていったので間違っているところやもっとこうした方がいいよということもあると思いますので教えてもらえると嬉しいです。