
Jenkinsとは、ビルド・テスト・デプロイの手順をサーバー上で自動実行するオープンソースのCI/CDサーバーです。GitHub Actionsのようにサービス側が実行環境を貸してくれる仕組みとは違い、自分でサーバーを立て、プラグインを選び、更新まで面倒を見ます。読み方は「ジェンキンス」で、執筆時点のLTS版は2.568.3です。
この記事は、社内ネットワークの中でCIを回したい情シス・インフラ担当者向けです。DockerでLTS版のJenkinsを立て、Python 3とpytestを入れた自前イメージを作り、Gitリポジトリに置いたJenkinsfileからジョブを作って、コミットをきっかけにテストが自動で走るところまでを、検証中に実際に踏んだエラーと一緒に進めます。終わったときには、ステージがすべて緑になったパイプラインと、GitHub ActionsやGitLab CIと比べるときの判断基準が手元に残ります。
この記事でわかること
jenkins/jenkins:lts-jdk21を土台にしたDockerfileとcompose.yamlの書き方(名前付きボリューム、メモリ上限、プラグインの固定)- ログに出る初期パスワードでのアンロックから管理者作成まで、セットアップウィザードの実際の流れと、JCasCで省略する方法
- 宣言型Jenkinsfile(agent / options / triggers / stages / post)の各行の意味と、Pipeline script from SCMでのジョブ作成
- 検証で実際に出た5つのエラー(ボリューム権限、python3-venv、dubious ownership、ローカルチェックアウト拒否、プラグイン依存)と直し方
- Jenkins・GitHub Actions・GitLab CIを情シスの制約で選ぶ判断表と、「Jenkinsおじさん」を作らない運用
Jenkinsとは:コントローラーとプラグインで動くセルフホストCI
Jenkinsの本体は「コントローラー」と呼ばれるJavaプロセス1つです。Web UI(既定で8080/tcp)を提供し、ジョブの定義と実行履歴をJENKINS_HOME(公式Dockerイメージでは/var/jenkins_home)にXMLファイルとして保存します。ビルドはコントローラー自身の「エグゼキュータ」(既定で2本)か、別マシンの「エージェント」で走ります。エージェントにはTCP 50000番でコントローラーへ接続してくる方式と、コントローラーからSSHで起動する方式があり、どちらもプラグイン経由です。
Gitとの連携、パイプライン構文、テスト結果の集計まで、機能のほぼすべてがプラグインです。執筆時点の本体はPipelineすら同梱しておらず、この記事でもworkflow-aggregatorを入れて初めてJenkinsfileが動きます。これが運用の重さの正体で、プラグイン同士のバージョン依存を誰かが追い続けないと、更新した途端にジョブが止まります。その「誰か」が1人に固定され、その人しか触れなくなった状態を、現場では「Jenkinsおじさん」と呼びます。
それでもJenkinsが選ばれるのは、実行環境を社内に閉じられるからです。ソースコードも成果物も社外に出ず、ビルドの中から社内DNSやオンプレのDBにそのまま届きます。GitHub Actionsのセルフホストランナーでも実行だけは社内に置けますが、ジョブの定義・履歴・認証はGitHub側です。ライセンスはMITで、かかる費用はサーバー代と保守の人件費に限られます。
| 観点 | Jenkins(自前) | GitHub Actions | GitLab CI(自前GitLab) |
|---|---|---|---|
| 実行場所とネットワーク | すべて社内。外向き通信はプラグイン更新とpip/npmの取得程度 | ホストランナーはGitHub側。セルフホストランナーなら実行は社内だが、制御と履歴はGitHub | Runnerを社内に置く。GitLab本体も社内なら完全に閉じる |
| 定義の置き場所 | リポジトリ内のJenkinsfile(Groovy DSL)+サーバー側のジョブ設定XML | .github/workflows/*.ymlだけで完結 | .gitlab-ci.ymlだけで完結 |
| 保守の手間 | 本体LTSとプラグイン群の更新を自分で追う。Dockerfile・JCasCで固定しないと属人化する | ホストならゼロ、セルフホストランナーは実行機の更新のみ | GitLab本体の更新(月次リリース)とRunnerの更新 |
| 認証・監査 | LDAP / Entra ID / SAMLはプラグイン、監査ログもプラグイン | GitHub Organizationの設定に従う | GitLabの権限モデルに従う |
| 費用の考え方(執筆時点) | ライセンス無料。サーバーと人件費 | パブリックリポジトリは無料枠、プライベートは実行時間で課金(枠と単価は公式で確認) | 無料のCommunity Editionあり。機能によって有料プラン |
| 向くケース | コードを社外に出せない、既存のJenkins資産がある、Windowsビルド機やハードウェアと連携する | コードがGitHubにあり、実行環境を社内に閉じる要件がない | GitLabを既に社内で運用している |
判断の順番はこうです。まず「コードがどこにあるか」で決まります。GitHub上ならActions、社内GitLabならGitLab CIで、Jenkinsを新規に選ぶ理由は「定義も実行環境も社内に閉じる必要があり、かつGitLabを持っていない」場合に絞られます。既にJenkinsが動いているなら、乗り換えより先にこの記事の方法(イメージとJCasCでコード化)で属人化をほどく方が安く済みます。GitHub Actions側の書き方はGitHub Actionsで始めるCI/CDにまとめています。
構成を決める:LTSイメージにpython3を足した自前Dockerfile
公式イメージjenkins/jenkins:lts-jdk21(執筆時点でJenkins 2.568.3、Debian 13、git 2.47.3同梱)にはPythonが入っていません。docker run --rm jenkins/jenkins:lts-jdk21 python3 --versionはsh: 1: python3: not foundで終わります。テストを走らせる方法は2つあり、(a) コントローラーのイメージにツールチェーンを焼き込む、(b) Docker Pipelineプラグインでビルドごとにコンテナを起動する、のどちらかです。この記事は(a)を採ります。小さなチームで言語が1〜2種類なら、DockerソケットをJenkinsに渡す設計(後述)よりリスクが小さく、再現性もDockerfile 1枚で担保できるからです。
FROM jenkins/jenkins:lts-jdk21
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-venv \
&& rm -rf /var/lib/apt/lists/*
RUN git config --system --add safe.directory /srv/repos/sample-app.git
USER jenkins
RUN jenkins-plugin-cli --plugins "workflow-aggregator git junit pipeline-graph-view configuration-as-code"USER rootでaptを実行し、終わったら必ずUSER jenkins(UID 1000)に戻します。戻し忘れるとJenkins本体がrootで動き、ボリューム内のファイルもroot所有になって、後でイメージを直したときに権限エラーの原因になります。python3-venvを省くとpython3 -m venvがensurepipの不在で失敗します(後述のつまずきポイント)。git config --systemの行は、後で読み取り専用でマウントするローカルのbareリポジトリをgitが「所有者が違う」と拒否しないための例外登録です。--globalではなく--systemにしたのは、jenkinsユーザーのホームが/var/jenkins_home、つまりボリュームそのものなので、イメージに書いた~/.gitconfigが起動時にボリュームで隠れてしまうからです。/etc/gitconfigならイメージ側に残ります。
最後のjenkins-plugin-cliが、指定したプラグインを依存関係ごと/usr/share/jenkins/ref/plugins/にダウンロードします。5個指定して、依存込みで69個が入りました(執筆時点)。それぞれ入れた理由は次のとおりです。
workflow-aggregator— Pipeline本体。宣言型構文(pipeline-model-definition)やsh・timeoutなどの基本ステップを依存として引き込むgit— Git SCM。git-client、credentialsなどが一緒に入るjunit—junitステップ。workflow-aggregatorの依存に含まれていない(プラグインのマニフェストで確認)ので明示が必要pipeline-graph-view— ビルド画面の「Stages」(ステージのグラフ表示)。執筆時点のworkflow-aggregatorは旧来のStage Viewを引き込まないため、これがないとステージの図は出ないconfiguration-as-code— JCasC。ウィザードを省略して管理者や基本設定をYAMLで作るために使う。ウィザードで進めるなら省いても構わない
バージョンを固定したいときはgit:5.10.1のように書くか、plugins.txtに1行1プラグインで並べてjenkins-plugin-cli -f plugins.txtにします。起動時、/usr/share/jenkins/ref/plugins/の.jpiはJENKINS_HOME/plugins/にまだ無いものがコピーされ、既にあるものはイメージ側のバージョンが新しいときに更新されます(検証でも2回目のイメージ更新後にworkflow-jobなどが置き換わりました)。何が起きたかはdocker compose exec jenkins cat /var/jenkins_home/copy_reference_file.logにINSTALLED plugins/pipeline-graph-view.jpi : 1013.v9f83fd83c063のような行で残ります。
compose.yaml:ポートは127.0.0.1に絞り、ボリュームとメモリ上限を明示する
services:
jenkins:
build: .
image: jenkins-ci:local
container_name: jenkins
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
environment:
TZ: Asia/Tokyo
JAVA_OPTS: "-Xmx512m -Dhudson.plugins.git.GitSCM.ALLOW_LOCAL_CHECKOUT=true"
volumes:
- jenkins_home:/var/jenkins_home
- ./repos:/srv/repos:ro
mem_limit: 1200m
memswap_limit: 1200m
volumes:
jenkins_home:portsは127.0.0.1:8080:8080と、ループバックに限定します。Jenkinsは「ジョブを作れる人が任意のシェルコマンドを実行できる」サービスなので、LAN内であっても認証画面を素のHTTPで晒す構成は避け、他のマシンから使うときはnginxのリバースプロキシでTLSを付けた先に置きます。エージェント接続用の50000番は、この記事の構成(コントローラー自身でビルド)では使わないので公開しません。
JAVA_OPTSの-Xmx512mは明示が要ります。コンテナにメモリ上限を付けると、JVMは既定で上限の25%しかヒープに使いません。1200mなら約300MBで、プラグイン69個を読み込んだ状態でビルドを回すには心もとない値です。検証環境ではmem_limit: 1200mと-Xmx512mの組み合わせで、起動直後のコンテナ全体が421MiB、ビルドを数回回した後で448〜492MiB、起動から「Jenkins is fully up and running」まで15〜25秒でした。チームで使うサーバーなら上限を2〜4GBにし、-Xmxはその半分、残りをJVMのメタスペースやスレッド、ビルド中のpip・pytestのプロセスに残す、という配分にします。ALLOW_LOCAL_CHECKOUTの意味はジョブ作成の節で説明します。
volumesの1行目が名前付きボリュームで、ジョブ定義・ビルド履歴・プラグイン・ワークスペースのすべてがここに入ります。ホストのディレクトリをbind mountしても動きますが、その場合はUID 1000が書ける所有者にしておく必要があります(つまずきポイント参照)。2行目はサンプルプロジェクトのbareリポジトリを読み取り専用で見せるためのもので、本番でGitサーバーを使うなら不要です。TZはUIのビルド時刻に効きます。イメージのサイズはベースの814MBに対して自前イメージが1.03GB、ボリュームはビルドを数回回した時点で328MBでした(執筆時点、docker system df -vで確認)。
初回起動:ログの初期パスワードでアンロックし、管理者ユーザーを作る
docker compose up -d --build
docker compose logs -f jenkins初回のビルドはaptとプラグインのダウンロードで2〜3分かかります。起動すると、ログに次のブロックが出ます(検証環境の実際の出力。パスワードは使い捨てです)。
2026-09-24 06:29:45.792+0000 [id=32] INFO jenkins.install.SetupWizard#init:
[LF]>
[LF]> *************************************************************
[LF]> *************************************************************
[LF]> *************************************************************
[LF]>
[LF]> Jenkins initial setup is required. An admin user has been created and a password generated.
[LF]> Please use the following password to proceed to installation:
[LF]>
[LF]> 51b986232a73471eae38103ae41f5050
[LF]>
[LF]> This may also be found at: /var/jenkins_home/secrets/initialAdminPassword
[LF]>
[LF]> *************************************************************
2026-09-24 06:29:51.047+0000 [id=25] INFO hudson.lifecycle.Lifecycle#onReady: Jenkins is fully up and runningログを流してしまった場合はdocker compose exec jenkins cat /var/jenkins_home/secrets/initialAdminPasswordで同じ値が読めます。ブラウザでhttp://127.0.0.1:8080/を開くと「Unlock Jenkins」画面になるので、この32桁を貼り付けます。仕組みとしては、ウィザードが仮に作ったadminユーザーへのログインで、フォームはj_username=adminを隠しフィールドに持ち、貼り付けた値をj_passwordとして/j_spring_security_checkに送っているだけです。つまりこのファイルを読める人は誰でも初期状態のJenkinsに入れるので、ボリュームの置き場所には気を配ります。
次の「Customize Jenkins」画面には「Install suggested plugins」と「Select plugins to install」の2択があります(画面の文言は執筆時点)。前者は執筆時点で19個(ant、gradle、github-branch-source、ldap、email-ext、dark-themeなど)を入れる選択で、ここでは後者を選び、チェックをすべて外して進めます。必要なものはイメージに入れてあり、プラグインは少ないほど更新で壊れる面積が小さくなります。続く「Create First Admin User」ではユーザー名・パスワード・氏名・メールを入れて管理者を作ります。「Skip」で飛ばすと初期パスワードのadminのまま運用に入ることになり、ボリューム内のファイルがそのままログイン情報になるので、必ず作ります。最後の「Instance Configuration」のJenkins URLは、メール通知やWebhookの応答に埋め込まれるURLなので、利用者がブラウザで叩く方(リバースプロキシのURL)を入れます。
ウィザードを止める-Djenkins.install.runSetupWizard=falseを単独で付けると、認証のないJenkinsが立ち上がります。検証では/api/json?tree=useSecurityが"useSecurity":falseを返し、Groovyを実行できる/scriptに匿名アクセスで200が返りました。このオプションは、次のJCasCのように起動時にセキュリティ設定を注入する場合にだけ使います。
ウィザードを省略する:JCasCで管理者と基本設定をコード化する
検証用に何度も立て直す、あるいは災害復旧で同じJenkinsを再現したい場合は、Configuration as Code(JCasC)プラグインにYAMLを読ませます。次のcasc.yamlは、ローカル認証でadminユーザーを作り、ログイン済みユーザーに全権限、匿名には何も与えない設定です。パスワードは環境変数から取ります。
jenkins:
systemMessage: "sample-app CI (JCasC で初期設定済み)"
numExecutors: 2
securityRealm:
local:
allowsSignup: false
users:
- id: admin
name: "Jenkins Admin"
password: "${ADMIN_PASSWORD}"
authorizationStrategy:
loggedInUsersCanDoAnything:
allowAnonymousRead: false
unclassified:
location:
url: "http://127.0.0.1:8080/"
adminAddress: "[email protected]"services:
jenkins:
environment:
JAVA_OPTS: "-Xmx512m -Dhudson.plugins.git.GitSCM.ALLOW_LOCAL_CHECKOUT=true -Djenkins.install.runSetupWizard=false"
CASC_JENKINS_CONFIG: /casc/jenkins.yaml
ADMIN_PASSWORD: "${JENKINS_ADMIN_PASSWORD:?.env に JENKINS_ADMIN_PASSWORD を設定}"
volumes:
- ./casc.yaml:/casc/jenkins.yaml:rodocker compose -f compose.yaml -f compose.casc.yaml up -dで起動すると、secrets/initialAdminPasswordは作られず、いきなりログイン画面になります。curl -s -u admin:パスワード "http://127.0.0.1:8080/api/json?tree=useSecurity,description"が"useSecurity":trueとsystemMessageの文字列を返せば適用済みです。
上書き用のcomposeファイルでJAVA_OPTSを書くと、値はキー単位でまるごと置き換わります。検証では-Djenkins.install.runSetupWizard=falseだけを書いたところ、compose.yaml側のALLOW_LOCAL_CHECKOUTが消え、ビルドがローカルチェックアウト拒否で落ちました。上書きファイルには元の値も含めて全部書きます。
サンプルプロジェクトとJenkinsfile:venvを作ってpytestを回す
題材は、税込み価格と数量値引きを計算するだけの小さなPythonモジュールです。ファイルは5つで、Jenkinsfileをリポジトリの直下に置きます。
sample-app/
├── Jenkinsfile
├── pricing.py
├── pytest.ini
├── requirements-dev.txt
└── tests/
└── test_pricing.pyfrom decimal import Decimal, ROUND_DOWN
def tax_included(price: int, rate: str = "0.10") -> int:
"""税抜き価格から税込み価格を返す。端数は切り捨て。"""
if price < 0:
raise ValueError("price must be >= 0")
total = Decimal(price) * (Decimal("1") + Decimal(rate))
return int(total.quantize(Decimal("1"), rounding=ROUND_DOWN))
def volume_discount(unit_price: int, quantity: int) -> int:
"""10個以上で5%、100個以上で12%引きの合計を返す。"""
if quantity <= 0:
raise ValueError("quantity must be >= 1")
subtotal = unit_price * quantity
if quantity >= 100:
return subtotal * 88 // 100
if quantity >= 10:
return subtotal * 95 // 100
return subtotalimport pytest
from pricing import tax_included, volume_discount
def test_tax_included_rounds_down():
assert tax_included(1234) == 1357 # 1357.4 → 1357
def test_tax_included_reduced_rate():
assert tax_included(1000, rate="0.08") == 1080
def test_tax_included_rejects_negative():
with pytest.raises(ValueError):
tax_included(-1)
@pytest.mark.parametrize(
"quantity,expected",
[(1, 500), (9, 4500), (10, 4750), (100, 44000)],
)
def test_volume_discount(quantity, expected):
assert volume_discount(500, quantity) == expected; pytest.ini
[pytest]
pythonpath = .
testpaths = tests
; requirements-dev.txt(検証時に入ったのは pytest 9.1.1。固定するならその版で)
pytest==9.1.1pipeline {
agent any
options {
timeout(time: 10, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '20'))
}
triggers {
pollSCM('H/2 * * * *')
}
stages {
stage('Setup') {
steps {
sh 'python3 -m venv .venv'
sh '.venv/bin/pip install --quiet -r requirements-dev.txt'
}
}
stage('Test') {
steps {
sh '.venv/bin/pytest --junitxml=reports/junit.xml'
}
}
}
post {
always {
junit 'reports/junit.xml'
}
}
}pipeline { }で始まるのが宣言型(Declarative)構文で、Groovyを直接書くScripted構文より構造が固定されている分、初めて読む人にも追えます。agent anyは「空いているエグゼキュータならどこでも」という指定で、この構成ではコントローラーの組み込みノードが使われ、ワークスペースは/var/jenkins_home/workspace/sample-appになります。ここはボリューム上なので、2回目以降のビルドでは.venvが残っていてpipの再取得が要りません。検証では初回が12秒、2回目が4.6秒でした。
optionsのtimeoutは、pipがプロキシで固まったときにエグゼキュータを永久に塞がないための保険で、10分を超えるとビルドが中断されます。buildDiscarderは履歴を20件で切り、ボリュームの肥大を抑えます。triggers { pollSCM('H/2 * * * *') }は2分ごとのSCMポーリングで、Hは同じ設定のジョブが同時に走らないようJenkinsが分刻みをずらす記法です。ここで一つ、宣言型のtriggersはJenkinsfileが一度実行されて初めてジョブに登録されます。つまり最初の1回は手動で「ビルド実行」を押す必要があります(検証でも初回成功後にジョブの設定XMLへSCMTriggerが現れました)。
shステップは/bin/sh -xeで動くので、実行したコマンドが+ python3 -m venv .venvのように+付きでログに残り、最初に失敗したコマンドでステージが止まります。post { always { junit ... } }は成否にかかわらずJUnit形式のXMLを取り込み、ジョブ画面に「テスト結果の推移」グラフ、各ビルドに「テスト結果」ページを作ります。ログに出る[Checks API] No suitable checks publisher found.は、GitHub Checksのような報告先がないという情報表示で、エラーではありません。
サンプルはローカルのbareリポジトリから取り込みます。compose.yamlと同じ階層で作ってpushし、コンテナには/srv/reposとして見せます。
mkdir -p repos && git init --bare -b main repos/sample-app.git
cd sample-app
git init -b main && git add -A && git commit -m "sample-app: pricing module with pytest suite"
git remote add origin ../repos/sample-app.git && git push -u origin main
cd ..ジョブを作る:Pipeline script from SCMで最初のビルドを通す
ダッシュボードの「新規ジョブ作成」でジョブ名をsample-app、種類を「Pipeline」にして進みます(メニュー名は執筆時点)。設定画面の一番下の「Pipeline」欄でDefinitionを「Pipeline script from SCM」、SCMを「Git」にし、Repository URLにfile:///srv/repos/sample-app.git、Branch Specifierに*/main、Script PathにJenkinsfileを入れ、「Lightweight checkout」を有効にして保存します。Lightweight checkoutは、Jenkinsfileを読むためだけにリポジトリ全体をcloneしない設定です。
同じことをUIなしでやるには、ジョブの設定XMLを/createItemにPOSTします。ジョブをGitで管理したい場合や、検証環境を何度も作り直す場合はこちらが楽です。CSRF対策のcrumbはセッションCookieと組で使うので、-c/-bでCookieを持ち回ります。
<?xml version='1.1' encoding='UTF-8'?>
<flow-definition plugin="workflow-job">
<description>sample-app: venv + pytest(Pipeline script from SCM)</description>
<keepDependencies>false</keepDependencies>
<properties/>
<definition class="org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition" plugin="workflow-cps">
<scm class="hudson.plugins.git.GitSCM" plugin="git">
<configVersion>2</configVersion>
<userRemoteConfigs>
<hudson.plugins.git.UserRemoteConfig>
<url>file:///srv/repos/sample-app.git</url>
</hudson.plugins.git.UserRemoteConfig>
</userRemoteConfigs>
<branches>
<hudson.plugins.git.BranchSpec>
<name>*/main</name>
</hudson.plugins.git.BranchSpec>
</branches>
<doGenerateSubmoduleConfigurations>false</doGenerateSubmoduleConfigurations>
<submoduleCfg class="empty-list"/>
<extensions/>
</scm>
<scriptPath>Jenkinsfile</scriptPath>
<lightweight>true</lightweight>
</definition>
<triggers/>
<disabled>false</disabled>
</flow-definition>J=http://127.0.0.1:8080; A=admin:パスワード
CRUMB=$(curl -s -u "$A" -c cj.txt "$J/crumbIssuer/api/json" | python3 -c 'import sys,json;print(json.load(sys.stdin)["crumb"])')
curl -s -u "$A" -b cj.txt -X POST "$J/createItem?name=sample-app" \
-H "Jenkins-Crumb: $CRUMB" -H 'Content-Type: application/xml' --data-binary @job-config.xml
curl -s -u "$A" -b cj.txt -X POST "$J/job/sample-app/build" -H "Jenkins-Crumb: $CRUMB" -o /dev/null -w '%{http_code}\n'
# 201 が返ればキューに入った。結果は lastBuild で確認
curl -s -u "$A" "$J/job/sample-app/lastBuild/api/json?tree=number,result,duration"ここで、ALLOW_LOCAL_CHECKOUTを付けていない場合に何が起きるかを見ておきます。検証で最初にビルドしたときのログの末尾がこれです。
[Pipeline] End of Pipeline
ERROR: Checkout of Git remote 'file:///srv/repos/sample-app.git' aborted because it references a local directory, which may be insecure. You can allow local checkouts anyway by setting the system property 'hudson.plugins.git.GitSCM.ALLOW_LOCAL_CHECKOUT' to true.
Finished: FAILUREJenkinsfileの取得(Lightweight checkout)は通っているのに、agent anyの中で走る本番のcheckoutだけが拒否されています。Gitプラグインは、ジョブを編集できる人がRepository URLにコントローラー上の任意のパスを書いて中身を持ち出せないよう、file://やローカルパスを既定で禁止しています。この記事では、読み取り専用でマウントした自分のbareリポジトリを読むだけなので、システムプロパティで明示的に許可しました。社内のGitea・GitLab・GitHubをhttps://やssh://で参照する本番構成では、このオプションは外します。許可した状態で再実行した結果の抜粋がこちらで、7件のテストが通ってSUCCESSになります。
Started by user Jenkins Admin
Obtained Jenkinsfile from git file:///srv/repos/sample-app.git
[Pipeline] node
Running on Jenkins in /var/jenkins_home/workspace/sample-app
[Pipeline] { (Declarative: Checkout SCM)
Cloning repository file:///srv/repos/sample-app.git
Checking out Revision 931725658ffb4c43580a690415b3e4d76697693d (refs/remotes/origin/main)
[Pipeline] { (Setup)
+ python3 -m venv .venv
+ .venv/bin/pip install --quiet -r requirements-dev.txt
[Pipeline] { (Test)
+ .venv/bin/pytest --junitxml=reports/junit.xml
============================= test session starts ==============================
platform linux -- Python 3.13.5, pytest-9.1.1, pluggy-1.6.0
collected 7 items
tests/test_pricing.py ....... [100%]
============================== 7 passed in 0.04s ===============================
[Pipeline] { (Declarative: Post Actions)
[Pipeline] junit
Recording test results
[Checks API] No suitable checks publisher found.
Finished: SUCCESSブラウザではビルド番号のページの左メニュー「Pipeline Overview」(URLは/job/sample-app/1/stages/)を開くと、Start → Checkout SCM → Setup → Test → Post Actions のグラフが緑で並び、左のステージ一覧で「Test」を選ぶと右側にpytestの出力がそのまま表示されます。ジョブ画面の左メニュー「Stages」は全ビルド分を並べた一覧です。
Gitのコミットで自動実行する:pollSCMとnotifyCommit
初回のビルドが成功すると、ジョブの設定XMLに<hudson.triggers.SCMTrigger><spec>H/2 * * * *</spec>が書き込まれ、以後は2分おきにgit ls-remote -h -- file:///srv/repos/sample-app.gitでリモートの先頭コミットを確かめます。検証ではテストを1件追加してpushしたところ、107秒後にビルド#5が「Started by an SCM change」で始まり、collected 8 items、8 passedで終わりました。ジョブ画面の「Gitのポーリングログ」にChanges foundと、検出したリビジョンが残ります。
cd sample-app
cat >> tests/test_pricing.py <<'EOF'
def test_volume_discount_rejects_zero():
with pytest.raises(ValueError):
volume_discount(500, 0)
EOF
git commit -qam "tests: reject quantity 0" && git push
# 2分以内にビルドが始まる。待たずに走らせたいときは notifyCommit(要トークン)ポーリングの待ち時間をなくすには、Gitプラグインの/git/notifyCommitエンドポイントをpush直後に叩きます。ただし執筆時点のGitプラグインはアクセストークンを要求し、curl "http://127.0.0.1:8080/git/notifyCommit?url=file:///srv/repos/sample-app.git"はHTTP ERROR 401 An access token is required.で拒否されます。「Jenkinsの管理」→「System」のGitプラグインの欄でnotifyCommit用のトークンを発行し(項目名は執筆時点の表記で変わることがあります)、&token=...を付けて呼びます。呼び出し元はbareリポジトリのhooks/post-receiveに置いたcurlで、ポーリングの設定は残したまま間隔を広げておくと、フックが落ちたときの保険になります。GitHubやGiteaからのWebhookはそれぞれ専用プラグイン(github、gitea)で受けますが、この記事の検証範囲外です。
Dockerエージェントを使うか:ソケット共有とdindのトレードオフ
言語やツールチェーンが増えてくると、Jenkinsfileにagent { docker { image 'python:3.12-slim' } }と書いてビルドごとに使い捨てコンテナを起動する設計が魅力的に見えます。必要なのはDocker Pipelineプラグイン(docker-workflow)、コントローラー側のdocker CLI、そしてDockerデーモンへの接続で、問題はこの最後の1つです。
いちばん手軽なのはホストの/var/run/docker.sockをコンテナにマウントする方法ですが、ソケットへの書き込み権限はホストのroot権限と同じです。ホスト側のどのディレクトリでも-v /:/hostでマウントしたコンテナを起動できるからです。マウント直後はpermission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sockという症状になるのが普通で、これはソケットがroot:dockerのモード660で、コンテナ内のjenkins(UID 1000)がそのグループに属していないためです。chmod 666やdockerグループへの追加で「直る」のは、Jenkinsfileを書ける全員にホストのrootを渡したのと同じ意味になります。もう一方のdocker:dindをサイドカーで動かす方法は、dind側に--privilegedが要り、カーネルパラメータやデバイスをホストと共有します。本番のWebサービスと同居するホストでは、どちらも採らないのが答えです。
判断の目安はこうです。言語が1〜2種類でチームが小さいなら、この記事のようにツールチェーンをコントローラーのイメージに焼き込みます。複数チームや複数言語で使い捨てコンテナが欲しいなら、Dockerを入れた専用のエージェントVMを用意し、SSHか50000番のinbound接続でぶら下げます。被害範囲がそのVMに閉じるので、ソケット共有の問題は消えます。Kubernetesを既に運用しているならKubernetesプラグインでPodをエージェントにする選択肢もあります。ツールチェーンを焼き込む場合のイメージの作り方は、Dockerfileの書き方のレイヤーキャッシュの節が役に立ちます。
つまずきポイント
起動直後に終了する:Wrong volume permissions?
INSTALL WARNING: User: missing rw permissions on JENKINS_HOME: /var/jenkins_home
touch: cannot touch '/var/jenkins_home/copy_reference_file.log': Permission denied
Can not write to /var/jenkins_home/copy_reference_file.log. Wrong volume permissions?ホストのディレクトリ(root所有)を/var/jenkins_homeにbind mountしたときの症状です。コンテナ内のJenkinsはUID 1000で動くので書けません。sudo chown -R 1000:1000 ./jenkins_homeで所有者を合わせるか、この記事のように名前付きボリュームを使います。名前付きボリュームは初回作成時にイメージ側のディレクトリの所有者(jenkins)を引き継ぐので、この問題が起きません。
venvが作れない:ensurepip is not available
+ python3 -m venv .venv
The virtual environment was not created successfully because ensurepip is not
available. On Debian/Ubuntu systems, you need to install the python3-venv
package using the following command.
apt install python3.13-venvDebian系のpython3パッケージにはvenvに必要なensurepipが含まれず、python3-venvが別パッケージです。Dockerfileのapt-get installにpython3-venvを足してdocker compose up -d --buildで作り直します。そもそもpython3を入れ忘れた場合はsh: 1: python3: not foundで、ベースイメージにPythonがないことを思い出してください。
Jenkinsfileが読めない:detected dubious ownership
hudson.plugins.git.GitException: Command "git fetch --tags --force --progress --prune -- origin +refs/heads/main:refs/remotes/origin/main" returned status code 128:
stderr: fatal: detected dubious ownership in repository at '/srv/repos/sample-app.git'
To add an exception for this directory, call:
git config --global --add safe.directory /srv/repos/sample-app.gitマウントしたbareリポジトリの所有者(ホストのユーザー、多くはUID 1000以外)と、gitを実行するjenkinsユーザーが違うために、gitが安全側に倒して読むのを拒否しています。メッセージどおり--globalで登録してもボリュームに隠れて残らないので、Dockerfileでgit config --system --add safe.directory /srv/repos/sample-app.gitと書いてイメージに焼き込みます。ホスト側でchown -R 1000:1000 reposする方法でも解決します。
プラグインが読み込まれない:Plugin is missing
SEVERE jenkins.InitReactorRunner$1#onTaskFailed: Failed Loading plugin Git plugin v5.10.1 (git)
java.io.IOException: Failed to load: Git plugin (git 5.10.1)
[LF]> - Plugin is missing: git-client (6.5.0)
[LF]> - Plugin is missing: mailer (525.v2458b_d8a_1a_71)
[LF]> - Plugin is missing: ssh-credentials (361.vb_f6760818e8c)
[LF]> - Plugin is missing: credentials (1480.v2246fd131e83)
[LF]> - Plugin is missing: workflow-scm-step (466.va_d69e602552b_)プラグインの.jpi(.hpi)を1つだけplugins/に置いて起動したときの出力です(検証ではgit.jpiだけを置き、12個の不足が並びました)。Jenkinsは依存を自動で取りに行かず、足りないプラグインがある側を読み込まないまま起動します。オフライン環境で手動配置するなら「Plugin is missing」に出た名前と版をすべて揃える必要があり、現実的なのはjenkins-plugin-cliに依存解決を任せてイメージに入れる方法です。関連して、checkoutに失敗したビルドではpostのjunitがRequired context class hudson.FilePath is missingという長いスタックトレースを出しますが、これはワークスペースが確保される前にnodeを抜けた副作用で、本当の原因はログ末尾のERROR:行にあります。
動作確認
docker compose ps --format 'table {{.Name}}\t{{.Status}}\t{{.Ports}}'
curl -sI http://127.0.0.1:8080/login | grep -i '^x-jenkins:'
curl -s -u admin:パスワード "http://127.0.0.1:8080/job/sample-app/lastBuild/api/json?tree=number,result,duration"
docker stats --no-stream --format '{{.Name}} {{.MemUsage}}' jenkins
docker compose exec jenkins du -sh /var/jenkins_homeX-Jenkins: 2.568.3
{"_class":"org.jenkinsci.plugins.workflow.job.WorkflowRun","duration":12066,"number":2,"result":"SUCCESS"}
jenkins 448.2MiB / 1.172GiB
328M /var/jenkins_homeX-Jenkinsヘッダーはログイン前でも返るので、リバースプロキシ越しの疎通確認に使えます。resultがSUCCESS以外なら/job/sample-app/lastBuild/consoleTextをcurlで取り、末尾のERROR:行から読みます。匿名でhttp://127.0.0.1:8080/を叩いて403が返ることも確認しておきます。200が返るなら認証が外れています。
Jenkinsおじさんを作らない運用:状態をイメージ・YAML・リポジトリに寄せる
Jenkinsが属人化する経路は決まっていて、UIで入れたプラグイン、UIで作ったジョブ、UIで変えたグローバル設定の3つが、そのサーバーのボリュームにしか存在しない状態です。この記事の構成はそれぞれをDockerfile(プラグインと版)、casc.yaml(グローバル設定と認証)、リポジトリ内のJenkinsfileとjob-config.xml(ジョブ)に移しています。この3ファイルとボリュームのバックアップがあれば、別のホストでdocker compose up -d --buildするだけで同じJenkinsが立ちます。ボリュームはdocker run --rm -v jenkins_home:/data:ro -v "$PWD":/backup alpine:3 tar czf /backup/jenkins_home.tgz -C /data .のように停止中に固めるのが単純で、Docker Compose入門で扱ったボリューム運用と同じ考え方です。
更新は「LTSのタグを上げてイメージを作り直す」作業に統一します。執筆時点のJenkinsはおおむね12週間ごとに新しいLTS系列が出て、その間にセキュリティ修正の点リリースが挟まります。jenkins-plugin-cliの版指定を外しておけば、イメージを作り直すたびに互換性のある最新のプラグインが揃い、依存の不整合は起動ログのFailed Loading pluginで見つかります。本番に当てる前に、ボリュームのコピーを別名で作って別ポートで起動し、既存ジョブを1本走らせてから切り替える、という手順を月次の定例にしておくと、担当が変わっても続けられます。
よくある質問
Jenkinsとは何ですか?
ビルド・テスト・デプロイを自動実行するオープンソースのCI/CDサーバーで、読み方は「ジェンキンス」です。Javaで動くコントローラー1つと、機能を足すプラグイン群で構成され、処理の手順はリポジトリに置くJenkinsfileに書きます。実行環境を社内に閉じられる点がクラウド型CIとの一番の違いです。
Jenkinsは時代遅れですか?
ツールとしては現役で、執筆時点でもLTS版(2.568.3)が継続的に出ています。時代遅れなのは「UIで手作業で設定したサーバーを1人が守る」運用の方です。プラグインをDockerfileで固定し、設定をJCasCのYAMLに、ジョブをJenkinsfileに移せば、GitHub ActionsやGitLab CIと同じコード管理の土俵に載ります。
Jenkinsは無料で使えますか?
本体とプラグインはMITライセンスのオープンソースで、ライセンス費用はかかりません。実際の費用はサーバー(この記事の構成ならメモリ1.2GBのコンテナ1つから始められます)と、更新やプラグイン管理にかける人の時間です。商用サポート付きのディストリビューションを提供するベンダーもあり、監査要件でサポート契約が要る場合はそちらを検討します。
「Jenkinsおじさん」とは?
社内のJenkinsの設定やプラグイン構成を1人だけが把握していて、その人しか触れない状態を指す俗称です。悪いのは人ではなく、状態がサーバーのボリュームにしかない構造です。プラグインの一覧、認証設定、ジョブ定義をファイルにしてリポジトリに置き、イメージの作り直しで再現できるようにすれば解消します。
まとめ
JenkinsをDockerで動かす要点は、LTSイメージに必要なツールとプラグインを焼き込んだ自前イメージ、127.0.0.1に絞ったポート、名前付きボリューム、そして明示したメモリ上限と-Xmxの4つです。初回はログの初期パスワードでアンロックして管理者を作り、繰り返すならJCasCに置き換えます。ジョブはPipeline script from SCMで作り、最初の1回だけ手動で回してpollSCMを登録させれば、以後はコミットで動きます。次の一歩は、この記事のbareリポジトリを社内のGitea・GitLab・GitHubに置き換え、compose.yamlからALLOW_LOCAL_CHECKOUTを外して、認証情報をJenkinsのCredentialsに登録することです。