あらしおブログ

技術ネタ中心の粗削りブログ

Jenkinsfile を書く前に知っておくべきこと (機能制約編)

前回は Jenkinsfile を書く上で知っておくべきセキュリティ上の制約について紹介しました。

arasio.hatenablog.com

今回は、機能上の制約を書きたいと思います。 ここで言う機能上の制約とは、Jenkins がジョブの一時停止/再開機能を実現するために、代償としてパイプラインでは使えなくなっている Groovy の制約のことです。

一時停止/再開機能をどう実現しているかを説明してから、それに起因する制約と回避策を紹介します。

ジョブの一時停止/再開

パイプラインジョブは実行中に一時停止し、そこから再開することができます。 これを実現するために、パイプラインスクリプトはそのままコンパイルされるのではなく、 Jenkins 内部で持っている Groovy インタープリタで処理されます。 そして、一メソッドずつ命令を実行し、スタックなども含めたプログラムの状態を逐一永続化しながらスクリプトを処理しています。 スクリプトを再開するときは、外部ファイルからプログラムの状態を読み込んで復元しています。

CPS 変換

プログラムの状態を保存できるようにするため、Jenkins は内部でパイプライスクリプトを中間スクリプトに変換してから実行しています。 この変換を CPS 変換といいます。CPS 変換について詳しく知りたい方は groovy-cps/cps-basics.md at master · cloudbees/groovy-cps · GitHub などを参考にしてください。

CPS 変換でやりたいことは、同期メソッド呼び出しを非同期に変換することです。 同期メソッドだとパイプラインスクリプト内でそのまま処理が流れてしまうので、どこまで処理が進んだのか Jenkins が管理することができません。 そこで、JavaScript のコールバックのように非同期処理に変換し、メソッド呼び出しごとに処理が終わったら Jenkins が結果をもらう、そして状態を保存する、という仕組みになっています。

例えば、以下のようなパイプラインスクリプトを考えます。

Object foo(int x, int y) {
    return x+y;
}

これをビルドすると、Jenkins は CPS 変換により下のような中間スクリプトを生成するようです *1。 コールバックは例外のしくみで実現しているみたいですね。

Object foo(int x, int y) {
    throw new CpsCallableInvocation(___cps___N, this, new Object[] {x, y});
}

private static CpsFunction ___cps___N = ___cps___N();

private static final CpsFunction ___cps___N() {
    Builder b = new Builder(...);
    return new CpsFunction(['x','y'], b.plus(b.localVariable("x"), b.localVariable("y"))
}

私もあまり理解できていないのですが、ここでは変換処理の詳細よりも、パイプラインスクリプトはそのまま実行されるのではなく、 中間処理として非同期メソッドへの変換が入るということを覚えておけばよいと思います。

Serializable

プログラムの状態を保存するというのは、つまりインスタンスなどの状態を保存することです。 インスタンスは通常、さまざまなポインタを含んでいるのでそのままでは状態を保存することができませんが、 Serializable にすると外部にインスタンスの状態を出力することができるようになります。 Serializable ついて詳しく知りたい方は Java直列化メモ(Hishidama's Java Serializable Memo) などを参考にしてください。 Jenkins でもこの仕組みを使ってプログラムの状態を保存しています。

制約

CPS 変換が可能であることと Serializable がパイプラインスクリプトの要件だとわかりました。 逆に言うと制約としては

  • 制約1: CPS 変換に対応していないメソッドは使えない
  • 制約2: Serializable でないクラスは使えない

ということになります。それぞれ例を使って説明したいと思います。

制約1: CPS 変換に対応していないメソッドは使えない

これは現状 Jenkins が対応していないだけで、今後対策されると思われますが、 非常に一般的なクロージャを使ったループ表現が使えません (Jenkins 2.7.4) 。

例えば以下のようなスクリプトです。

// UnsupportedOperationException になる例
def list = ['1', '2', '3']
list.each { it ->
    echo it
}

これを実行すると下のように UnsupportedOperatinException が発生します。 エラーメッセージにあるとおり、each は Jenkins の CPS 変換でまだサポートされていないので使えないとのこと。

java.lang.UnsupportedOperationException: Calling public static java.util.List org.codehaus.groovy.runtime.DefaultGroovyMethods.each(java.util.List,groovy.lang.Closure) on a CPS-transformed closure is not yet supported (JENKINS-26481); encapsulate in a @NonCPS method, or use Java-style loops
    at org.jenkinsci.plugins.workflow.cps.GroovyClassLoaderWhitelist.checkJenkins26481(GroovyClassLoaderWhitelist.java:90)
    at org.jenkinsci.plugins.workflow.cps.GroovyClassLoaderWhitelist.permitsStaticMethod(GroovyClassLoaderWhitelist.java:60)
    at org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SandboxInterceptor.onMethodCall(SandboxInterceptor.java:92)
    at org.kohsuke.groovy.sandbox.impl.Checker$1.call(Checker.java:149)
    at org.kohsuke.groovy.sandbox.impl.Checker.checkedCall(Checker.java:146)
    at com.cloudbees.groovy.cps.sandbox.SandboxInvoker.methodCall(SandboxInvoker.java:16)
    at WorkflowScript.run(WorkflowScript:3)
    at ___cps.transform___(Native Method)

回避策は2つあります。
まず、each を使わない for ループにすることでです。

def list = ['1', '2', '3']
for(def elem : list) {
    echo elem
}

もう一つは each の処理を1つのメソッドにしてまとめ、@NonCPS をつけることです。 @NonCPS メソッドは、CPS 変換の対象外となり、そのままバイトコードに変換されて実行されます。

def list = ['1', '2', '3']
printList(list)

@NonCPS
def printList(list) {
    list.each { it ->
        echo it
    }
}

制約2: Serializable でないクラスは使えない

2つ目の制約として、Serializable でないクラスをそのまま使うことはできません。

例1

例えば以下のようなスクリプトです。

// NotSerializableException になる例
node {
    def user1 = new User('user1')
    sh "echo ${user1.name}"
}

class User {
    def name
    User(def name) {
        this.name = name
    }
}

これを実行すると下のように例外が発生します。 ファイルにプログラムの状態を書きだそうとしたところで、User クラスが Serializable でないのでエラーになりました。

java.io.NotSerializableException: User
    at org.jboss.marshalling.river.RiverMarshaller.doWriteObject(RiverMarshaller.java:860)
    at org.jboss.marshalling.river.BlockMarshaller.doWriteObject(BlockMarshaller.java:65)
    at org.jboss.marshalling.river.BlockMarshaller.writeObject(BlockMarshaller.java:56)

独自クラスの場合は純粋に Serializable を実装することで回避できます(フィールドも Seriazalibale にする必要があります)。 上の例では User クラスが Serializable インタフェースを実装すれば使うことができるようになります。

class User implements Serializable {
    def name
    User(def name) {
        this.name = name
    }
}
例2

では、独自のクラスではなくライブラリで提供されるクラスが Serializable でない場合はどうでしょうか。 例えば、下のようなスクリプトを考えます。

// NotSerializableException になる例
def map = ['a': 10,'b': 20,'c': 30]
def sum = 0
for(elem in map) {
    sum += elem.value
}
echo "sum: $sum"

一見問題ないようですが、これを実行すると以下のような例外が発生します。HashMap のエントリクラスが Serializable ではないようです。

java.io.NotSerializableException: java.util.LinkedHashMap$Entry

このようなクラスを使う必要がある場合は、制約1と同様に、それに関連する処理を一つのメソッドとしてまとめ、メソッドに @NonCPS をつける回避策があります。

def map = ['a': 10,'b': 20,'c': 30]
def sum = sumMap(map)
echo "sum: $sum"

@NonCPS
def sumMap(def map) {
    def sum = 0
    for(elem in map) {
        sum += elem.value
    }
    return sum
}

注意してほしいのは @NonCPS メソッド内で、通常のCPSメソッドを呼ぶべきではないということです。 呼び出してもエラーにはなりませんが、配下のメソッドがすべて NonCPS で扱われますので注意してください。

まとめ

Jenkinsfile を書くときに注意したい機能上の制約をまとめると以下のようになります。

  • ジョブの一時停止/再開を実現するためにパイプラインスクリプトCPS 変換される
  • CPS 変換に対応していない groovy の文法や、Serializable でないインスタンスは使えない
  • 問題の文法を避ける、または @NonCPS を使って回避する手段がある

参考