7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Kubernetes 上の Jenkins エージェントコンテナで Spring Boot (Maven) をビルド & デプロイする

7
Posted at

はじめに

Jenkins の静的エージェントを運用していると、

  • ビルドが詰まったときにスケールできない
  • JDK / Maven / Node のバージョン差分がエージェントごとにバラつく
  • ビルド残骸でディスクが逼迫する

といった、よくある運用課題にぶつかります。

そこでこの記事では、Kubernetes Pod を Jenkins エージェントとして毎ビルド使い捨てで起動し、Spring Boot (Maven) アプリのビルド〜成果物転送までを完結させる構成を、共通パイプライン(Shared Library)込みで紹介します。

⚠️ 本記事のコンテナイメージ名・レジストリ名・デプロイ先ホスト名は、すべて example.com 等のダミーです。社内固有の値が出てきたら自分の環境に合わせて読み替えてください。

なぜ今さら Jenkins なのか

新規プロダクトで CI を組むなら GitHub Actions が手軽で第一候補、というのは概ね同意です。
ただ現場に入ると、外部 SaaS が禁止でオンプレ GitLab を使っている既存の Jenkinsfile / Shared Library に運用ノウハウが貯まっている といった事情で、Jenkins が今も現役、というケースは普通にあります。

そういう環境で「Jenkins は使い続けるけど、ビルド基盤は今風にしたい」となった結果が、本記事の SCM は GitLab、ビルド agent は Kubernetes 上の Jenkins という構成です。本文の例は SSH clone なので、git@gitlab.example.com:... のような オンプレ GitLab の SSH URL でもそのまま動きますssh-keyscan の対象を変えるだけ)。

ゴール

  • kubernetes-plugin で Pod テンプレートを動的に立ち上げる
  • build コンテナの中で Git clone → mvn clean package → 成果物アーカイブ → SSH 転送 までを自動化する
  • リポジトリごとの差分(プロファイル、リソースサイズ、デプロイ先)は外部設定にまとめ、Jenkinsfile は薄く保つ

全体像

📝 図中の Webhook は「SCM → Jenkins のジョブトリガはこのレイヤで起こる」ことを示すために描いていますが、本記事ではトリガ設定(GitHub / GitLab Webhook、Generic Webhook Trigger plugin など)には踏み込みません。ジョブが起動した後の Pod 内部の挙動 にフォーカスして読んでください。

ポイントは「ビルドごとに Pod を捨てる」ことです。静的エージェント運用と比べたメリット・デメリットを整理するとこうなります。

観点 静的エージェント K8s 使い捨て Pod
スケール 台数固定。詰まると待ち行列 並列ビルドぶん Pod が増える
環境の再現性 エージェントごとに差分が出やすい イメージに固定、全ビルドで同一
ビルド残骸 手動 or cron で掃除 Pod 破棄でまとめて回収
起動時間 常時起動で即時 Pod 起動分のオーバーヘッド (数〜十数秒)
依存キャッシュ ローカル ~/.m2 を使い回せる 毎回ゼロから or PVC 設計が必要
運用コスト エージェント本体のメンテが必要 K8s クラスタの運用知識が前提

スケール・再現性・後片付けが効く一方で、起動オーバーヘッドと依存キャッシュ戦略はトレードオフとして残ります。許容できるかは案件次第なので、長時間ビルドが多いプロジェクトでは PVC キャッシュ併用も検討してください。

1. Jenkins 側の前提

  • Jenkins Controller が稼働
  • kubernetes プラグイン導入済み
  • Jenkins から Kubernetes API に到達可能(同一クラスタ内なら kubernetes.default.svc で OK)
  • ビルド対象リポジトリへの SSH 鍵を Jenkins Credentials に登録(種別: SSH Username with private key)

2. ビルド用コンテナイメージを用意する

mvn / git / ssh / scp が入っていれば何でも構いません。例として最小限の Dockerfile を示します(実際のイメージは社内レジストリにビルドして push する想定)。

# registry.example.com/ci/jenkins-maven-agent:latest
FROM eclipse-temurin:17-jdk-jammy

ARG MAVEN_VERSION=3.9.9

RUN apt-get update \
 && apt-get install -y --no-install-recommends \
        git openssh-client ca-certificates curl tar \
 && rm -rf /var/lib/apt/lists/*

RUN curl -fsSL https://dlcdn.apache.org/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz \
      -o /tmp/maven.tar.gz \
 && tar -xzf /tmp/maven.tar.gz -C /opt \
 && ln -s /opt/apache-maven-${MAVEN_VERSION}/bin/mvn /usr/local/bin/mvn \
 && rm /tmp/maven.tar.gz

ENV MAVEN_OPTS="-Xmx1024m -Xms256m -XX:+UseContainerSupport"

Maven のローカルリポジトリ (~/.m2) を毎回 0 から作ると、依存解決でビルド時間が伸びます。本番運用では PVC をマウントしてキャッシュするのも有効です(本記事では簡略化のため省略)。

3. Pod テンプレートを Groovy で組み立てる

kubernetes プラグインは yaml '...' で Pod 定義を直接渡せます。
リポジトリごとに微妙にリソースサイズが違うので、ヘルパー関数 にしておくと Jenkinsfile がスッキリします。

vars/k8sPodYaml.groovy:

def call(Map args = [:]) {
    def image           = args.get('image',           'registry.example.com/ci/jenkins-maven-agent:latest')
    def imagePullSecret = args.get('imagePullSecret', 'registry-credential')
    def cpuReq          = args.get('cpuRequest',      '500m')
    def memReq          = args.get('memRequest',      '2Gi')
    def cpuLim          = args.get('cpuLimit',        '2')
    def memLim          = args.get('memLimit',        '4Gi')

    return """---
apiVersion: v1
kind: Pod
metadata:
  labels:
    jenkins: jenkins-maven-agent
spec:
  imagePullSecrets:
    - name: ${imagePullSecret}
  containers:
    - name: build
      image: ${image}
      command:
        - cat
      tty: true
      resources:
        requests:
          cpu: "${cpuReq}"
          memory: "${memReq}"
        limits:
          cpu: "${cpuLim}"
          memory: "${memLim}"
      env:
        - name: MAVEN_OPTS
          value: "-Xmx1024m -Xms256m -XX:+UseContainerSupport"
  restartPolicy: Never
"""
}

ポイント:

  • command: [cat] + tty: true は「コンテナを生かしておくための定番イディオム」。kubernetes-plugin がこの中で sh ステップを実行します。
  • MAVEN_OPTS-XX:+UseContainerSupport は、コンテナの cgroup limit を JVM に認識させるためほぼ必須。
  • メモリ limit を超えると OOMKilled で問答無用に落ちるので、-Xmxmemory.limit の半分〜2/3 くらいから様子を見るのが安全です。

4. 共通パイプラインを Shared Library 化する

vars/k8sMavenNodePipeline.groovy のような形で、ビルド〜デプロイのを Shared Library に置きます。
ここでは記事用に簡略化したものを掲載します。

// vars/k8sMavenNodePipeline.groovy
def call(Map cfg = [:]) {
    def gitRepoUrl   = cfg.gitRepoUrl   ?: error('gitRepoUrl is required')
    def gitBranch    = cfg.get('gitBranch',    'main')
    def gitCredId    = cfg.get('gitSshCredentialsId', 'github-ssh-key')
    def mavenProfile = cfg.get('mavenProfile', 'default')
    def mavenCommand = cfg.get('mavenCommand', 'mvn -B clean package')
    def archivePat   = cfg.get('archivePattern', '**/target/*.jar')

    pipeline {
        agent {
            kubernetes {
                namespace 'jenkins'
                defaultContainer 'build'
                yaml k8sPodYaml(
                    image:    cfg.get('image',    'registry.example.com/ci/jenkins-maven-agent:latest'),
                    cpuLimit: cfg.get('cpuLimit', '2'),
                    memLimit: cfg.get('memLimit', '4Gi')
                )
            }
        }

        options {
            skipDefaultCheckout(true)        // Git plugin の known_hosts 問題を回避
            buildDiscarder(logRotator(numToKeepStr: '30'))
            timeout(time: 30, unit: 'MINUTES')
            timestamps()
        }

        stages {
            stage('Checkout') {
                steps {
                    container('build') {
                        sshagent(credentials: [gitCredId]) {
                            sh """#!/bin/bash
                              set -euo pipefail
                              mkdir -p ~/.ssh && chmod 700 ~/.ssh
                              ssh-keyscan -H github.com >> ~/.ssh/known_hosts 2>/dev/null
                              git clone --depth 1 --branch ${gitBranch} ${gitRepoUrl} repo
                            """
                        }
                    }
                }
            }

            stage('Maven Build') {
                steps {
                    container('build') {
                        dir('repo') {
                            sh """#!/bin/bash
                              set -euo pipefail
                              mvn -v
                              ${mavenCommand} -P ${mavenProfile}
                            """
                        }
                    }
                }
            }

            stage('Archive') {
                steps {
                    archiveArtifacts(
                        artifacts: archivePat,
                        fingerprint: true,
                        allowEmptyArchive: false
                    )
                }
            }
        }

        post {
            cleanup {
                container('build') {
                    // root で書き込まれたファイルがあると deleteDir() が失敗するので緩める
                    sh '''#!/bin/bash
                      set -euo pipefail
                      chown -R "$(id -u)":"$(id -g)" . || true
                      chmod -R u+rwX . || true
                    '''
                    deleteDir()
                }
            }
        }
    }
}

ポイント:

  • skipDefaultCheckout(true) を入れておかないと、Declarative Pipeline が自動 SCM チェックアウトを走らせ、known_hosts 周りで詰まりがち。明示 clone のほうが事故が少ない。
  • cleanupchown & deleteDir() をしているのは、コンテナ内で root 実行されたビルド成果物が後段の Workspace 削除を邪魔するため。Pod を捨てるとはいえ、PVC を共有している場合は地味に効いてきます。

5. アプリ側 Jenkinsfile は数行で済む

Shared Library に寄せた結果、各 Spring Boot リポジトリの Jenkinsfile はこれだけ。

@Library('shared-pipeline') _

k8sMavenNodePipeline(
    gitRepoUrl: 'git@github.com:example-org/sample-springboot-app.git',
    gitBranch:  'main',
    mavenProfile: 'prod'
)

「アプリ固有の差分」だけが見えるのが理想で、CI の都合(Pod テンプレート、SonarQube 設定、リトライ etc.)を Jenkinsfile に滲ませないのがコツです。

6. ビルド成果物を SSH でリモート配置する

Spring Boot は target/*.jar が成果物なので、それを scp でデプロイ先に投げて、ssh で再起動コマンドを叩く構成が取り回しやすいです。

stage('Remote Deploy') {
    when { expression { params.enableRemoteDeploy } }
    steps {
        container('build') {
            sshagent(credentials: ['deploy-ssh-key']) {
                sh """#!/bin/bash
                  set -euo pipefail
                  mkdir -p ~/.ssh && chmod 700 ~/.ssh
                  ssh-keyscan -H app.example.com >> ~/.ssh/known_hosts 2>/dev/null

                  scp -o StrictHostKeyChecking=yes \
                      target/*.jar deploy@app.example.com:/tmp/app/

                  ssh -o StrictHostKeyChecking=yes deploy@app.example.com \
                      'sudo systemctl restart sample-springboot.service'
                """
            }
        }
    }
}

実運用で気をつけたいポイント:

  • ssh-keyscan を毎回回す: Pod は使い捨てなので ~/.ssh/known_hosts も毎回 0 から。StrictHostKeyChecking=yes にしておくと中間者攻撃のリスクを抑えられます。
  • scp の置き場と本番の置き場を分ける: /tmp/app/ のような書き込める場所に一旦置いて、本番ディレクトリへは sudo install 等で配置する二段構えがおすすめ。/opt/app などへの直接 scp は権限・所有者が思った通りにならないことが多いです。
  • sudoers の限定: deploy ユーザーが叩ける sudo特定 service の restart のみ に絞っておきます。

7. ハマったところメモ

7-1. OOMKilled でビルドが落ちる

mvn の見かけのメモリは -Xmx 直下に見えても、Surefire 等の fork で別 JVM が立つ ので意外と物理使用量が膨らみます。

  • memory.limit-Xmx × プロセス本数 + 余裕(512Mi 以上)
  • -XX:+UseContainerSupport を必ず入れる(cgroup 認識)
  • 並列ビルド (-T) を上げるなら CPU limit も合わせて上げる

7-2. deleteDir() が EACCES で失敗する

build コンテナ内で root として作ったファイルを、Jenkins 側のプロセスが消そうとして失敗するケースです。
post.cleanupchownchmod -R u+rwXdeleteDir() を踏むと安定します。

7-3. ephemeral-storage が枯渇して Pod が evict される

Maven のローカルリポジトリ (~/.m2) と target ディレクトリを emptyDir (デフォルト) に書いていると、ノードのディスク pressure を引き起こします。
対策:

  • resources.limits.ephemeral-storage を明示
  • ~/.m2 を別 PVC に分離してキャッシュ
  • ビルド後に rm -rf target を確実に実行

7-4. Git plugin の自動 checkout で known_hosts 詰まり

agent { kubernetes ... } を使うと、デフォルトで Pipeline 開始時に SCM checkout が走ります。
sshagent ブロックの外で実行されるので、SSH 認証が思った設定にならず詰まりがちです。
options { skipDefaultCheckout(true) } を入れて明示 clone 一択でいきましょう。

8. まとめ

  • Jenkins × Kubernetes プラグインで「ビルドごとに使い捨て Pod」構成にするだけで、運用負荷とリソース効率がグッと改善する
  • Pod テンプレートは Groovy ヘルパーに切り出し、リソース・イメージは引数で吸収すると、Shared Library 全体が見通しよくなる
  • 各リポジトリの Jenkinsfileアプリ固有の差分のみ を書く構成にすると、CI 改善が一括で全リポジトリに効く
  • デプロイは無理に CD ツールを足さず、scp + ssh + systemctl の素朴構成でも十分機能する

GitHub Actions が主流とはいえ、オンプレ GitLab 運用などで Jenkins が現役の現場はまだまだ多いはずです。
「Jenkins agent を K8s に寄せたい」「GitLab + Jenkins の構成を今風にしたい」というチームの参考になれば嬉しいです。

7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?