はじめに
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 で問答無用に落ちるので、-Xmxはmemory.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 のほうが事故が少ない。 -
cleanupでchown&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) を上げるなら CPUlimitも合わせて上げる
7-2. deleteDir() が EACCES で失敗する
build コンテナ内で root として作ったファイルを、Jenkins 側のプロセスが消そうとして失敗するケースです。
post.cleanup で chown → chmod -R u+rwX → deleteDir() を踏むと安定します。
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 の構成を今風にしたい」というチームの参考になれば嬉しいです。