LINE株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。 LINEヤフー Tech Blog

Blog


Jenkinsfile + Jenkins Shared LibrariesでCI設定のコピペを撲滅する

LINE株式会社Dev4 SREチームのShimizuです。

今回の記事では私たちのチームで進めているJenkinsの機能であるJenkins Pipeline(Jenkinsfile)と、その拡張であるShared Librariesの導入について、導入した背景、導入するにあたってのテクニックなどを紹介したいと思います。

背景

私たちの所属する開発4センターではCIツールとして主にJenkinsを利用しています。ありがたいことに社内にJenkinsを運用してくれているチームがあり、そのチームの運用するJenkinsをメインに使っています。

今まで、Jenkinsの設定はチーム内のわかる人がJenkinsのGUIから直接設定をしていました。しかし、設定が煩雑で難しい上、並列化をする場合は似たようなJobを大量に作成することになってしまい管理が大変であるという課題がありました。
たとえば、Gradleのmulti moduleで構成されているプロジェクトの並列化の設定を変更する場合や、Jobの実行結果の通知先を変更しようとしたときに、ほぼ全てのJobの設定を見直す必要があり、なかなかメンテナンスが行き届かない状態が続いていました。

それを解決するべく、我々のチームではJenkins Pipeline(Jenkinsfile)を活用しJenkinsのJobを管理するようになりました。

Jenkinsfileとは

Jenkinsfileは、Groovy DSL形式でJenkins Pipeline形式のJobを定義することができる仕組みです。Jenkinsfileのメリットは大きく次の2つあります。

1つ目は並列化が簡単である点です。Jenkinsfileでは、parallel でstageを囲んで定義するだけで、複数のnodeに処理を分けて実行させることができるため、並列化のためにJobをコピペするといったハックが不要になります。

2つ目はJenkinsfile自体を別で管理することなく、リポジトリにコミットしておける点です。これによって、そのリポジトリで利用されているJenkinsのJobで何が実行されるかがわかるほか、CIの設定変更をプルリクエストを用いて行うことになるため、チームメンバーのレビューを通すことが可能になります。

以上のメリットから、もともとJenkinsを利用していたチームでは急速にJenkinsfileの活用が進んでいます。

次のようにJenkinsfileを記述するとLint(例ではdetektとktlint)とTestを簡単に並列に実行することができ、基本はGroovy DSLであるために自分で関数を定義してJenkinsfile内で再度使いまわすといったことが可能でとても便利です。

pipeline {
    agent { label 'foo' }
    stages {
        stage('CI') {
            parallel {
                stage('Lint') {
                    agent { label 'foo' }
                    steps {
                        sh './gradlew clean ktlintCheck detekt --continue'
                    }
                    post {
                        always {
                            recordIssues(enabledForFailure: true, tools: [ktLint(pattern: '**/build/reports/ktlint/**/*.xml')])
                            recordIssues(enabledForFailure: true, tools: [detekt(pattern: '**/build/reports/detekt/detekt.xml')])
                        }
                    }
                }
               stage('Test') {
                    agent { label 'foo' }
                    steps {
                        sh './gradlew clean test'
                    }
                    post {
                        always {
                            junit allowEmptyResults: true, keepLongStdio: true, testResults: '**/build/test-results/test/*.xml'
                        }
                    }
                }
            }
        }
    }
}

課題

開発4センターでは、あるプロジェクトがJenkinsfileを導入したのち他のチームにも広まっていったのですが、このときにJenkinsfile内で定義された便利なメソッド群が他のリポジトリに大量にコピペされていきました。

開発4センターで担当しているプロダクトは数が多い上、ArgoCDやCentral Dogma等を利用している箇所はGitOpsベースで運用していることもありリポジトリの数が膨大です。
後からJenkinsfileに施された改善を他のリポジトリに反映しようと思うと、さらにまたコピペが必要…といったようなコピペ地獄に陥っていました。

また、JenkinsのようなCIツールの設定はそれぞれのチームで日々改善されることも相まって更新のペースもかなり速く、途中からはやや古いJenkinsfileがメンテされずにそのままになっているケースが出てきました。
これはマズイぞ…ということでJenkins Shared Librariesを使ったJenkinsfileの共通化を進めることにしました。

Jenkins Shared Librariesとは

Jenkins Shared Librariesは、Jenkinsfileを利用するリポジトリの外に別のリポジトリを用意し、それを読み込むことで共有ライブラリを実現するものです。Jenkinsfile内の記述を共通化できる上、Git等のJenkinsのソースコードのチェックアウトの仕組みで動くため、GitHubでリポジトリを作成するだけで簡単に始めることができるのも魅力の一つです。

共通化の例

QodanaによるInspection処理の共通化

先日の記事でも紹介があったように、我々のチームではQodanaを利用してIntelliJ IDEAの行うInspectionをCIでも実行するようにしています。Qodanaは記事でも紹介があるようにDocker imageで提供されているため、Cacheをうまく活用できるようにしたり、変更のあったファイルのみを検査するためにgit resetを活用したりと、快適に活用するにはとにかく手間がかかります。

Jenkins Shared Librariesとして各チームに提供することで、面倒なcacheの処理や変更のあったファイルのみの検査を簡単に実現することができるようにしてあります。

GitHub Pull Requestの自動作成

ArgoCD等のGitOpsを前提とした仕組み使っていると、「Release用のTagを打ったら自動でPull Requestを作成する」などといったGitHubのPull Requestの作成を自動化したいことがあります。実際にPull Requestを作成しようと思うとgit cloneやPull Requestを作成するためのREST APIの呼び出し等、少々手間のかかる処理がPull Requestを自動的に作成できるJenkins Shared Librariesを作成することで、複数のリポジトリ間の連携をより簡単に実現することができるようになります。Jenkins Shared Librariesの呼び出しの引数にはGroovyのClosureを指定することもできるので、GitHubのPull Requestの作成を利用者に意識させないような仕組みを実現することができます。

作成した createPullRequest() の呼び出しは次のように必要なパラメータとClosureを渡す形で書くことができるようにしています:

pipeline {
    stages {
        stage('create pull request') {
            steps {
                createPullRequest(
                        repositoryName: "example/example-repo",
                        pullRequestBranch: "test-1",
                        title: "test from jenkins!",
                        githubTokenCredentialId: "HOGEHOGE_GITHUB_API_TOKEN"
                ) {
                    // cloneしたrepo内での処理(ファイルの編集とcommit)はここに書きます。
                    // このClosureの処理は指定したRepositoryのRepository rootで実行されます。
                    sh 'echo "test file" > test.txt'
                    sh "git add test.txt"
                    sh 'git commit -m "test commit"'
                }
            }
        }
    }
}

Jenkins Shared Librariesの作成

ここからは、実際に私たちがどのようにJenkins Shared Librariesを作成しているかを紹介します。

ディレクトリの構成

私たちのチームでは普段の業務では基本的にIntelliJ IDEAを利用しているため、IntelliJ IDEAで読み込み可能なGradle projectとして作成しています。私たちのチームでは、ディレクトリ構成は次のような構成にしています。

Jenkins Shared Librariesとして動作させるには、次のディレクトリにgroovyのコードが正しく配置されている必要があります:

  • ./src  ディレクトリ: JenkinsのScripted Pipelineおよび vars/ ディレクトリから読み込めるUtilsをここに置いておきます。
  • ./vars ディレクトリ:JenkinsのDeclarative Pipelineで呼び出し可能な関数を、指定のフォーマットで置いておきます。
    Jenkinsfileからは、ここに定義したファイルの名前で処理を呼び出すことになります。

このままだと、作成したJenkins Shared Librariesの検証をする方法が実際に動かしてみること一択になってしまいます。多くのチームで共有して使うものなので、ある程度のCIによるチェックをかけられるように、各種テストのためにテスト用のディレクトリを用意し、簡単なテストを書いています:

  • ./test ディレクトリ:JenkinsPipelineUnitで記述した簡単なテストをここに置いておきます(後述)。

Declarative Pipelineで呼べるmethodの作成

実際にJenkinsifleで次のような記述で呼び出せるmethod createFoo()を用意することを考えます。

createFoo hoge: "http://example.com", fuga: "123"

まず、vars以下にcreateFoo.groovyを作成します。Jenkinsfileでは、groovyで記述したソースコードの名前で処理を呼び出すことができます。Jenkins Shared Librariesにおいては、call()を自動的に呼び出す仕組みになっているので、定義する関数名はcallという名前にします。
私たちのチームでは、ここに簡単なvalidationやdefault parameterの情報を入れています。 Jenkins Pipelineの仕組みにデフォルトで用意されているerror()を利用することで、異常終了させることができます。

実際にDeclarative Pipelineで呼び出すmethod ( vars/createFoo.groovy ):

import com.linecorp.awesome.jenkins.FooUtils
 
// 引数をMap等で与えることができます。
// 特定の処理をgroovyのClousureで与えることもできるので、特定の処理をwrapするようなものも定義することができます
def call(
        Map<String, Object> parameters = [:]
) {
    def validated = [
        hoge: parameters.hoge ?: {
            error("createFoo: parameter missing: hoge")
        },
        fuga: parameters.fuga ?: "default.filename.tar.gz",
    ]
 
    // 実際の処理は別途定義したUtilsであるFooUtilsに移譲するようにしています。(長くなりやすいため)
    // 処理は `src/` ディレクトリ配下に置いておきます。
    def utils = new FooUtils(this)
    utils.createFoo(
        validated.hoge as String,
        validated.fuga as String
    )
}

次に、varディレクトリに配置したcreateFoo.groovyから呼び出すFooUtilsを作成します。FooUtilsのコンストラクタにはJenkinsfileの実行時のcontextを this を使って渡すことで、 sh 等を実行できるようになります。

src ディレクトリ配下に配置する FooUtils.groovy ( src/com/linecorp/awesome/jenkins/FooUtils.groovy ):

package com.linecorp.awesome.jenkins
 
class FooUtils {
    // JenkinsfileのGlobal variableを読み出せるように、 `FooUtils(this)` のような形で初期化しておき、
    // `sh` 等が呼べるようにしておきます
    def pipeline
    def env
    def currentBuild
 
    FooUtils(pipeline) {
        this.pipeline = pipeline
        this.env = pipeline.env
        this.currentBuild = pipeline.currentBuild
    }
 
    def createFoo(
        String hoge,
        String fuga
    ) {
        // sh method等は `pipeline`を通して実行します
        pipeline.sh '''
        echo "Hello"
        '''
    }
}

この作成したライブラリを、手順に沿ってJob内でLoadできるようにすることで、作成したShared Librariesを各Jobで利用できるようになります。公式のhandbookにも記述がある通り、JenkinsのFolder-levelや、GitHub Branch SourceのOrganization folder単位でShared Librariesの読み込みを設定できます。

テスト

Jenkins Shared Librariesを作っていると、その複雑さからテストがしたくなってきます。
Jenkins Shared Librariesにはテストを実現するための仕組みがいくつか存在し、それを活用することで実際の開発フローに影響を与えずに各種テストを実施できます。

Jenkins Shared LibrariesのUnit Test

Jenkins Shared LibrariesはJenkinsPipelineUnitを利用するとUnit Testを実現することができます。

Test frameworkにはJUnitが使えるので、JUnitで createFoo() というmethodをテストする場合、次のように書くことができます :

import com.lesfurets.jenkins.unit.declarative.DeclarativePipelineTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
 
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class FooTest extends DeclarativePipelineTest {
 
    @Override
    @BeforeEach
    void setUp() {
        // helperに実行を許可する(=Mockする)methodを事前に登録しておきます
        // sh等の基本的なものは、いくつか既に登録されています。
        helper.registerAllowedMethod("echo", [String.class])
        helper.registerAllowedMethod("sarif", [HashMap.class])
        helper.registerAllowedMethod("recordIssues", [HashMap.class])
        super.setUp()
    }
 
    @Test
    void testCreateFoo() {
        // vars配下に定義したmethodをloadScrpitで読み出します。
        def createFoo = loadScript("vars/createFoo.groovy")
      createFoo([
                hoge: "http://example.com",
                fuga: '123',
        ])
        printCallStack()
        // Jobが最後まで完了したかを確認できます。
        // 多くのmethod (sh等)がmockされているので意味のあるテストというわけではないですが、テストでGroovyの構文チェック程度は可能です
        assertJobStatusSuccess()
    }
}

このテストは sh method等の処理を実際に実行することはないので、あくまでGroovyの構文チェックやJenkins Shared Librariesの引数のvalidationの動作確認等にしか使えない点には注意が必要です。
私たちのチームでは、あまり頻繁にGroovyを書くことがないため、この構文チェックだけでも十分にありがたいです。

ちなみに、Jenkinsfile自体には公式でLintを実行するための仕組みが存在します。今回作成するようなShared Librariesの場合はこのLinterを使うのは難しいため、JenkinsPipelineUnitで最低限の構文チェックを入れておくと良いと思います。

Jenkins Shared Librariesの試用

Jenkins Shared Librariesは「

Jenkins Shared Librariesの実体はGitリポジトリなので、メインブランチ以外のpushをメンバーに開放しています。
もし、新しいものを追加して試してみたい場合は、branchとしてpushし、それを読み込む設定を試したいリポジトリに記述することで、開発中のJenkins Shared Librariesを試すことができるようになります。

Jenkins Shared LibrariesのUpdate

作成したJenkins Shared Librariesを各チームで利用することで大量のコピペやメンテナンスされていないJenkins Jobを減らすことができたものの、依然としてJenkinsfile内で利用する作成したJenkins Shared Librariesのバージョンを各チームで継続的にアップデートする必要があります。

我々の抱える膨大なリポジトリ群に対してプルリクエストを出して回ると非常に手間がかかるので、開発4センター内で活用しているRenovateを使って自動で更新できるようにしています。

Renovateはdependency updateのためのツールで、ライブラリのバージョンが上がった際に自動でPull Requestを作成してくれる非常に便利なツールです。
これをうまく活用することで、作成したJenkins Shared Librariesのバージョンが上がったら自動でPull Requestを作成し、あとはMergeするだけの状態にすることができます。

Renovateが更新を検知できるようにする

Jenkins Shared LibrariesはGitのtag, branch等をもとに利用するShared Librariesのバージョンを決定します。前提として、私たちのチームではShared Librariesに対してGitHub Releaseの機能を使ってGit tagを作成し、それを各チームがバージョンを指定する際に利用する運用としています。
つまり、Git tagの更新が起きたら新しいJenkins Shared Librariesがリリースされているので、それを検出できるようにする必要があります。

Renovateにはgithub-tags等のGitHub Repositoryをdatasourceとする設定も存在するのですが、私たちの環境ではGitHub Enterpriseとの互換性などの問題によりうまく動作しませんでした。
そこで、Git tagを作成する時に、併せて社内のMaven Repositoryにダミーファイルを一つだけ含ませたJarをアップロードし、それをRenovateのmaven datasourceを経由して認識させるようにしています。

前述の通り、作成したJenkins Shared LibrariesはGradleプロジェクトとして構成しているので、ダミーファイルを含んだJarをpublishする設定をbuild.gradle.ktsに記述しています。このとき、GitHub Enterpriseのrepository情報をmetadataとして設定しておくことで、Renovateのリリースノート参照機能によってPull Requestにリリースノートを埋め込むことができるようになります。

build.gradle.ktsの例:

plugins {
    `maven-publish`
    ...
}
 
...
 
// Version情報をGradleのpropertyとして指定できるようにしておきます
version = project.findProperty("publishVersion") as String?
    ?: env["PUBLISH_VERSION"] ?: defaultVersion
 
publishing {
    repositories {
        maven {
            url = uri("http://example.com")
        }
    }
    publications {
        create<MavenPublication>("maven") {
            groupId = "com.example.foo.jenkins"
            this.artifactId = "jenkins-shared-libs"
            // artifactにはdummyのファイルを一つだけ混ぜるようにします
            artifact(file("artifact/README.md"))
            pom {
                name.set("awesome jenkins-shared-libs")
                description.set("Shared libs for Jenkins")
                // Metadataとして、GitHub Enterpriseの情報を埋めておきます。
                // これらの情報をRenovateが参照して、作成するPull RequestにRelease Noteとして埋めてくれます。
                url.set("https://github.example.com/foo/jenkins-shared-libs")
                scm {
                    connection.set("scm:git:ssh://git@github.example.com/foo/jenkins-shared-libs.git")
                    developerConnection.set("scm:git:ssh://git@github.example.com/foo/jenkins-shared-libs.git")
                    url.set("https://github.example.com/foo/jenkins-shared-libs")
                }
            }
        }
    }
}

Renovateで自動更新する設定を書く

Renovateで自動更新をするための設定を書きます。

Renovateにはregex managerという仕組みが用意されています。この仕組みは簡単にいうとRenovateにプリセットが用意されていないdependency managementの仕組みを使っている場合に、正規表現を利用してバージョンのパースを可能にする仕組みです。
これを用いることで、Jenkinsfileに記述するShared Librariesのバージョン指定方式である "@Library('awesome-shared-libs@v1.1.0') _ " のような形式を認識させるようにします。

アップロード先のmaven repositoryを使う設定と、Jenkins Shared Librariesをrenovateでの更新の対象とするregex managerの設定を次のように書くことで、Jenkins Shared LibrariesをRenovateの対象とすることができます:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  ...
  "packageRules": [
    {
      "matchDatasources": [ 'maven' ],
      "registryUrls": [
        // アップロード先のrepositoryを指定しておく
        'http://repository.example.com',
      ],
    }
  ],
  "regexManagers": [
    {
      // Jenkinsfileはrepository root直下と `./jenkins` 配下に配置する構成としています。
      "fileMatch": [
        "^(?:jenkins/.*)?Jenkinsfile$"
      ],
      // 次の正規表現にmatchする文字列を探し出し、Jenkins Shared Librariesの現在のversionとして
      // Renovateに認識させます
      "matchStrings": [
        "awesome-shared-libs@v(?<currentValue>[0-9.]+)"
      ],
      "depNameTemplate": "com.example.foo.jenkins:jenkins-shared-libs",
      "datasourceTemplate": "maven"
    }
  ]
}

もちろん、RenovateにはSharable Config Presetsという仕組みがあるので、これを利用することで各リポジトリのrenovateの設定にこの設定を読み込むための記述を追加すれば、簡単にRenovateによる自動更新を有効にすることができます。これによって、この設定をコピペして回る必要もありません。

設定後Jenkins Shared LibrariesのReleaseを作成し、しばらく待つと次のようなPull RequestがRenovateによって自動で作成されます。Release Noteも併せてPull Requestの本文に埋め込まれており、このプロジェクトにマージしても問題ないかを判定するのに役立てることができます。

まとめ

私たちの組織で実践しているJenkinsfileの活用のテクニックのうち、Jenkins Shared Librariesによる共通化と、RenovateによるJenkins Shared Librariesの更新の自動化について紹介しました。

Jenkinsの設定に同じように悩んでいる方がいらっしゃれば、ぜひ試してみることをお勧めします!