はじめに

こんにちは。テクマトリックスの長久保です。

「手動で回していたビルド・デプロイをJenkinsで自動化しよう」と決まったものの、いざパイプラインを書き始めると、意外とハマるポイントが多いです。
現実的な問題として、既存のバッチファイルを改修できないといった制約条件があることも多いです。また、本番運用中のバッチに手を加えればデグレードのリスクがありますし、CI化のためにバッチを書き換えたことにより障害が発生してしまえば本末転倒です。

本記事ではJenkinsが構築済み・宣言型パイプラインを用いてCI環境を構築する方に向けて、CI構築時に実際にハマる(ハマった)ポイントを整理しました。

宣言型パイプラインの記述方法の習得については、弊社が実施しているトレーニングの受講を検討いただけると幸いです。

対象読者

  • Jenkinsは構築済みで、これからパイプラインを作成していく方
  • 既存のバッチやスクリプトをなるべく変更せずにCI化したい方
  • (主に)Windows環境でJenkinsを運用している方

前提環境

CI化のための基礎知識

実際の記法に入る前に、まずはCI移行をスムーズに進めるための準備について整理します。

CI移行をスムーズに進めるための準備

バッチファイル自体をSCM(SubversionやGit)に登録する

手動運用では、バッチファイルがサーバーの特定フォルダに配置されていることもあるかと思います。CI化をするにあたり、バッチファイル自体をSCMに登録することを推奨します。

  • Jenkinsがチェックアウトしたワークスペース内にバッチが常に存在する状態になります
  • パイプラインとバッチを同じリポジトリで管理でき、バージョンの整合性が保たれます
  • バッチの変更履歴が追えるようになり、誰がいつ何を変えたかが明確になります

ワークスペースからの相対パスで実行できるようにする

Jenkinsはビルドごとにワークスペースにソースコードをチェックアウトします。バッチファイルもリポジトリに含まれていれば、パイプラインから相対パスで呼び出せます。

推奨するリポジトリ構成の例

リポジトリのルート/
  src/                   ← ソースコード
    ・・・
  scripts/               ← ビルド・デプロイ用バッチ
    build.bat
    deploy.bat
    test.bat
  Jenkinsfile            ← パイプライン定義
pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                // ワークスペースからの相対パスで呼び出す
                bat 'scripts\\build.bat'
            }
        }
        ・・・
    }
}

この構成であれば、開発者がローカルで scripts\build.bat を手動実行することも、JenkinsがCI上で同じバッチを実行することも、どちらも可能です。
一方、ツールのインストール先に置かれている等でバッチをリポジトリに含められない場合は、後述する cd /d や dir() を使ったカレントディレクトリ変更で対応することも可能です。

環境依存の値を外出しする

バッチ内にサーバー名やファイルパスがハードコードされている場合、そのままCI化するとエージェントごとに動かない問題が起きます。環境ごとに異なる値は、Jenkinsのパラメータや環境変数として外出ししておくと、例えばデプロイ先のサーバーの切り替え(開発環境・ステージング・本番など)が容易になります。

pipeline {
    agent any
    environment {
        // デプロイ先をJenkinsfile=ブランチ毎に切り替えることが可能になります
        DEPLOY_SERVER = 'server01.example.com'
    }
    stages {
       ・・・
        stage('Deploy') {
            steps {
                bat 'scripts\\deploy.bat'
            }
        }
    }
}

バッチ内では「%DEPLOY_SERVER%」で参照できるため、バッチ自体を書き換える必要はありません。

@echo off
echo DEPLOY_SERVER: %DEPLOY_SERVER%


また、バッチ内では「環境変数や引数が無ければデフォルト値を利用する」といった手動実行でもいままで通り実行できるようにしておくと安全です。

冪等性を意識する

CIは基本的に繰り返し実行されるものになります。同じバッチを2回実行しても問題ない(冪等性がある)作りにしておくと、CI実行時の事故を防げます。具体例として、以下を考慮するとよいでしょう。

  • ディレクトリ作成前に if not exist チェックを入れる
  • コピー先に /y オプションで上書きを許可する
  • 成果物ディレクトリはビルド前にクリーンアップする(こちらはJenkinsパイプライン側での制御も可能ですが、バッチ側に寄せたほうがより固いと考えます)

終了コードとログ出力を見直す

CIでは画面出力(コンソールログ)が唯一の確認手段になります。手動実行時は画面を見ながら判断していた部分もCI上ではログから読み取る必要があります。
また、Jenkins側から見たときにバッチが成功したか失敗したかは「バッチの実行結果が0が戻ってくるかそれ以外か」で判断するため、既存のバッチファイルがそのようになっていない場合は修正する必要があります。具体例として、以下を考慮するとよいでしょう。

  • 重要な処理の前後に echo で状況を出力するようにする
  • エラー時に原因がわかるメッセージを出力する
  • 正常終了時は exit /b 0、異常終了時は exit /b 1 を返すようにする

これまでの「CI移行をスムーズに進めるための準備」に加え、Jenkinsにおけるbatコマンドの処理の流れを押さえておくと、よりCI化がスムーズに実施できると思うので、いくつか重要なポイントを紹介します。

宣言型パイプラインのbatステップの仕様

宣言型パイプラインのbatステップは、内部的に cmd.exe /c でコマンドを実行しています。そのため、以下の2つは等価になります。

bat 'build.bat'
cmd.exe /c build.bat

ここでの注意点として、 cmd.exe /c = 「コマンドを実行して終了する」なので、1つのbatステップごとに独立したcmd.exeプロセスが起動します。そのため、bat ステップをまたいで環境変数やカレントディレクトリは引き継がれません。

steps {
    bat 'set MY_VAR=hello'   // ここで設定した変数は
    bat 'echo %MY_VAR%'      // こちらでは参照できません(別プロセスのため)
}

1つのcmd.exeプロセス内で完結させたい場合は、&&で繋げるか複数行にします。

steps {
    bat 'set MY_VAR=hello && echo %MY_VAR%'
}

Groovyの文字列補間とバッチ変数の衝突

宣言型パイプラインで利用しているGroovyにはシングルクォート ‘…’ とダブルクォート “…” の2種類の文字列があり、挙動が異なります。

記法Groovy関数の展開用途
‘…’しないバッチの%変数%をそのまま渡したいとき
“…”するJenkinsのパラメータをバッチに埋め込みたいとき
具体例を以下に挙げます。
変数については${…}で取得することができます。
pipeline {
    agent any
    environment {
        DEPLOY_SERVER = 'server01.example.com'
    }
    stages {
        stage('Deploy') {
            steps {
                // ダブルクォートだと${DEPLOY_SERVER}を解釈しようとします
                bat "echo double:${DEPLOY_SERVER}"
                // シングルクォートなら文字列がそのままcmd.exeに渡ります
                bat 'echo single:${DEPLOY_SERVER}'
            }
        }
    }
}

上記の場合、実行結果は次のようになります。

そのため、下記のような記載をする際も注意が必要です。

// ダブルクォートだと Groovy が %WORKSPACE% を解釈しようとします
bat "echo %WORKSPACE%"
// → Groovy は %W... を認識できず空文字になるか、エラーになる場合があります

// シングルクォートならバッチ変数はそのまま cmd.exe に渡ります
bat 'echo %WORKSPACE%'

複数行バッチと特殊文字のエスケープ

複数行のバッチコマンドを書くときは、トリプルクォートを使います。

steps {
    // シングルのトリプルクォート
    bat '''
        @echo off
        echo ビルド開始
        call compile.bat
        if %ERRORLEVEL% neq 0 (
            echo コンパイル失敗
            exit /b %ERRORLEVEL%
        )
        echo ビルド完了
    '''
}

ダブルのトリプルクォートもありますが、前述の理由で % との衝突が起きるため、私個人としてはシングルのトリプルクォート(”’)を使うことをお勧めします。

また、Groovyの文字列内でのバックスラッシュ(\)を使うときにも注意が必要です。

// ダブルクォートでは \\ が必要になります。
bat "xcopy C:\\src C:\\dst /s"

// シングルクォートでは \ で実行できます。
bat 'xcopy C:\src C:\dst /s'

シングルとダブルの動作を一つ一つを記憶する必要はないと思いますが、挙動が異なるということは覚えておくとよいでしょう。

おすすめのbatコマンドの使い方

steps {
    bat label: 'フルビルドの実行', encoding: 'MS932', script: """
        cd /d ${WORKSPACE}\\src
        ${pipelineParams.fullBuildCommand}
    """
}

単体でbatコマンドを呼び出すこともできますが、上記のようにlabelをつけることで、Jenkinsのログ上でも「フルビルドの実行」と表示され、どのステップが何を行っているか把握しやすくなります。

Jenkins上の画面

こちらの表記方法についてはPart2のブログ記事で詳しく説明しますが、文字コードのエンコーディングも指定することも可能です。
また、この例では、${WORKSPACE}や、${pipelineParams.fullBuildCommand}といった変数を展開するためにダブルクォートを利用しています。

コマンド文字列を変数に切り出して渡す

長いコマンドや、複数個所で使いまわすコマンドは、Groovy変数として定義してからbatに渡すことも可能です。この時.stripIndent()や.stripMargin()を使うことで、コード上のインデントは整え、実行時には余分な先頭の空白を除去できます。不要なトラブルを避けるために推奨します。

// シングルのトリプルクォート + .stripIndent() でインデントを揃えたまま書けます
String testCommand = '''
    cd /d "%WORKSPACE%\\src" && call test.bat
'''.stripIndent()

steps {
    bat label: 'テスト実行', script: testCommand
}
// .stripMargin() を使う場合は、各行の先頭に | を付けます
String deployCommand = '''\
    |@echo off
    |echo デプロイ開始
    |cd /d "%WORKSPACE%\\scripts"
    |call build.bat
    |if %ERRORLEVEL% neq 0 (
    |    echo ビルド失敗
    |    exit /b %ERRORLEVEL%
    |)
    |echo デプロイ完了
'''.stripMargin()

steps {
    bat label: 'デプロイ実行', script: deployCommand
}

ここまで基礎知識として、batステップについてと、書き方について説明してきました。
ここからは「Jenkinsに組み込む前はうまくいっていたが、組み込むとうまくいかない」という問題について、ケースごとに紹介していきます。

バッチファイル固有の問題

呼び出し元に制御が戻らない

  • 状態:バッチから別のバッチを呼んだ後、後続の処理が実行されずにジョブが終了する。
  • 原因:callをつけずにバッチを呼ぶと、制御が呼び出し先に移ったまま戻ってこない。手動実行の際は順に1つずつ実行していたため問題にならなかった。

具体例

以下のようなmain.batがあるとします。

@echo off
echo ビルド開始

REM コンパイル
compile.bat

REM テスト(ここに到達しない)
test.bat

echo ビルド完了

Jenkinsからmain.batを呼んだ場合、compile.batを実行が終わった時点でmain.batも終了し、test.bat以降が実行されません。また、Jenkinsの結果自体はcompile.batが正常終了している場合、Jenkinsの結果は「成功」と表示されます。

対策

バッチを改修できる場合は、以下のように修正します。

@echo off
echo ビルド開始
call compile.bat
call test.bat
echo ビルド完了

Jenkinsのパイプラインで吸収する場合はパイプライン側でmain.batを呼ばず、compile.bat、test.batをそれぞれ実行します。

終了コード(ERRORLEVEL)の伝搬

  • 状態:バッチ内部でコンパイルエラーやテスト失敗が発生しているのに、Jenkinsのビルド結果が「成功」になる
  • 原因:バッチが終了コードを返していない。exit /b %ERRORLEVEL% がない、または途中で ERRORLEVEL が上書きされている

具体例

@echo off
REM コンパイル(失敗して ERRORLEVEL=1 になる)
mvn clean compile -DskipTests

REM echo が成功するので ERRORLEVEL が 0 に上書きされる
echo コンパイル完了

REM Jenkins はこの終了コード(0)を見て「成功」と判定してしまう

この場合、基本的にバッチファイルを修正し、コンパイル失敗でexit /b %ERRORLEVEL%で終了するようにすることを推奨します。

対話的プロンプトでの処理のハング(pause, set /p 等)

  • 状態:ジョブが特定のステップで永久に停止し、タイムアウトまで応答しない。特定のファイルを握ってしまい、後続の処理で失敗する。
  • バッチ内に pause、set /p、choice など標準入力を待つコマンドがあり、Jenkins上では入力を与える手段がない。

具体例1:pause

@echo off
echo デプロイを実行します
xcopy /s /e "build\output" "\\server\deploy\"

echo デプロイが完了しました
REM 手動実行時の確認用に入れていた pause
pause

このpauseでJenkinsのジョブがハングします。手動実行時は「続行するには何かキーを押してください」と表示され問題になることはありませんが、Jenkins上では入力手段がありません。

パイプライン側で吸収する方法

呼び出し側で標準入力を操作することでpauseをスキップできます。

pipeline {
    agent any
    options {
        timeout(time: 30, unit: 'MINUTES')  // 万が一のハング対策として、タイムアウトの設定を推奨します。
    }
    stages {
        stage('Deploy') {
            steps {
                // 例1:< nul で標準入力を即座に EOF にする
                bat 'deploy.bat < nul'

                // 例2:echo . でパイプして改行を送り込む方法もあります
                // bat 'echo . | deploy.bat'
            }
        }
    }
}

具体例2:set /p

デプロイ先を対話式で入力し実行する、といったビルドパラメータの入力を想定したバッチというものもあるでしょう。

@echo off
set /p ENV=デプロイ先を入力してください(dev/staging/prod):
set /p VERSION=バージョン番号を入力してください:

echo %ENV% 環境にバージョン %VERSION% をデプロイします
call deploy_to_%ENV%.bat %VERSION%

パイプライン側で吸収する方法

set /p は標準入力から1行ずつ読み取るため、パイプで入力値を流し込むことができます。

            steps {
                // echo で複数行の入力を流し込みます
                // 1行目 → 1つ目の set /p (ENV), 2行目 → 2つ目の set /p (VERSION)
                bat "(echo main & echo 200100) | deploy.bat"
            }

バッチを改修する場合

基本的にCIに組み込むバッチは非対話式の方が良いです。pauseなどは削除したり、パラメータについても、引数で受け取る形にして、パイプラインから受け渡す方が素直な実装になると思います。

steps {
    bat "deploy.bat ${params.ENV} ${params.VERSION}"
}

バッチファイル実行時のカレントディレクトリ

  • 状態:バッチ内で相対パス(.\lib\tool.exe 等)で参照しているファイルが「見つからない」エラーになる
  • 原因:Jenkinsのワークスペースがカレントディレクトリになるため、手動実行時とカレントディレクトリが異なる

具体例

以下のディレクトリ構成で説明します。

C:\tools\
  build.bat
  lib\
    compiler.exe
    config.ini

build.batは以下の内容とします。

@echo off
REM 相対パスで lib 配下のツールを参照
.\lib\compiler.exe -c .\lib\config.ini -o output.bin

手動でC:\toolsに移動し、build.batをダブルクリックするとカレントディレクトリがC:\toolsになるため動作します。しかしJenkinsから実行すると、カレントディレクトリがワークスペース(例: C:\Jenkins\workspace\my-job)になるため .\lib\compiler.exe が見つかりません。

パイプライン側で吸収する方法

下記のようにカレントディレクトリを移動してから実行することで解決できます。

pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                // 例1: cd /d でカレントディレクトリを変更してから呼びます
                bat 'cd /d C:\\tools && build.bat'

                // 例2: dir ステップを使う方法もあります
                dir('C:\\tools') {
                    bat 'build.bat'
                }
            }
        }
    }
}

バッチを改修する場合

下記のようにバッチ内でカレントディレクトリを移動してから実行することも可能ですが、基本的にはパイプライン側で吸収する方法の内容での対応で問題ないかと思います。

@echo off
cd /d %~dp0
.\lib\compiler.exe -c .\lib\config.ini -o output.bin

まとめ

ここまでbatステップの仕様や、様々なバッチファイルの呼び出し方について説明してきました。
少し長くなってしまったので、ここでPart1とし、Part2では文字コードやファイルシステム、プロキシなどについても説明したいと思います。

宣伝

弊社では、CI環境構築以外にも様々な環境構築・運用支援、コンサルティングサービスを提供しています。詳しい内容はこちらでご紹介しています。CI/CDや開発環境構築、運用でお困りの方はご相談ください。

By nagakubo

主にCI環境構築をメインで担当しています。 Certified CloudBees Jenkins Engineer (CCJE)