Jenkins パイプラインで Docker コンテナを使っていると、たまに「え、なんでここで権限エラー?」となることがあります。今回はまさにそのパターンに遭遇して、最終的には「誰が何を作るべきか」という責務設計の見直しで解決した話です。
発端: 突然の AccessDeniedException
Cost Check パイプラインを実行したら、こんなエラーが出ました。
java.nio.file.AccessDeniedException: /tmp/jenkins-xxxxx/workspace/monitoring-job/reports/summary.md
「権限がない?なんで?」と思いながら、パイプラインの構成を見てみます。
stage('レポート統合') {
steps {
script {
sh '''
python -m monitoring_cli cost-combine \
--input-dir "$WORKSPACE/$REPORT_DIR" \
--output "$WORKSPACE/$REPORT_DIR/cost_check.json" \
--timestamp "$TIME_STAMP"
'''
def results = readJSON(file: "${REPORT_DIR}/cost_check.json")
def summaryContent = generateSummaryMarkdown(results)
writeFile(file: "${REPORT_DIR}/summary.md", text: summaryContent) // ← ここで失敗
currentBuild.description = generateBuildDescription(results)
echo summaryContent
}
}
}
一見普通に見えるんですが、ここで writeFile が失敗している。
原因: Docker コンテナと Jenkins の権限の違い
正直ここで詰まりました。エラーメッセージだけ見ると「ファイルに書き込めない」だけなので、原因がすぐにはわからなかったんです。
パイプラインの agent 設定を見ると:
agent {
docker {
image 'python:3.11-slim'
args '-u root'
}
}
ここで気づきました。Docker コンテナは root ユーザーで動いている。
[📦 商品リンク: moshimo-card-rFURZ]
処理の流れはこうなっています:
sequenceDiagram
participant Jenkins as Jenkins: jenkins user
participant Docker as Docker Container: root
participant FS as ファイルシステム
Jenkins->>Docker: パイプライン実行
Docker->>FS: reports/ ディレクトリ作成: owner root
Docker->>FS: cost_check.json 作成: owner root
Docker->>Jenkins: Python 処理完了
Jenkins->>FS: summary.md を書き込もうとする
FS-->>Jenkins: AccessDeniedException
つまり:
- Python スクリプト(Docker コンテナ内、root ユーザー)が
reports/ディレクトリを作成 - そこに
cost_check.jsonを出力 - Jenkins(jenkins ユーザー)が同じディレクトリに
summary.mdを書き込もうとする - 権限がなくて失敗
Docker コンテナが作ったディレクトリは root 所有なので、Jenkins ユーザーからは書き込めなかったわけです。
解決のアプローチ: 責務を見直す
この問題、いくつか解決方法が考えられました:
案1: Docker コンテナのユーザーを jenkins に変更
args '-u jenkins'
でも、これだと他のパイプラインとの一貫性が失われるし、Python パッケージのインストール権限で問題が出る可能性がある。
案2: chmod で権限を変更
chmod 777 $REPORT_DIR
力技ですが、セキュリティ的にあまりよくない。それに、毎回これをやるのは美しくない。
案3: そもそも Jenkins が summary.md を作る必要があるのか?
ここで立ち止まって考えました。
「なぜ Jenkins が Markdown を生成しているんだろう?」
他のパイプラインを見てみると、api-health-check や batch-status-check は Python スクリプト側で summary.md を生成していました。
# api-health-check の例
@click.command("health-combine")
@click.option("--format", default="both")
def health_combine(..., format_: str):
# JSON と Markdown の両方を出力
if format_ in ("markdown", "both"):
summary_path.write_text(markdown_content, encoding="utf-8")
つまり、Cost Check だけが Jenkins 側で Markdown を生成していた。
これも何かの縁だと思い、設計を揃えることにしました。
実装: Python CLI に Markdown 生成機能を追加
意外とここが肝です。
Python 側の修正
cost-combine コマンドに --format オプションを追加しました。
@click.command("cost-combine")
@click.option(
"--input-dir", "-i", required=True,
type=click.Path(exists=True, file_okay=False, readable=True, path_type=str),
help="個別結果が格納されたディレクトリ",
)
@click.option(
"--output", "-o", required=True,
type=click.Path(dir_okay=False, path_type=str),
help="統合結果の出力先ファイルパス",
)
@click.option(
"--timestamp", "-t", default=None,
help="統合結果に付与するタイムスタンプ(省略時は現在時刻)",
)
@click.option(
"--format", "-f", "format_",
type=click.Choice(["json", "markdown", "both"]),
default="both",
show_default=True,
help="出力形式",
)
@handle_cli_errors
def cost_combine(input_dir: str, output: str, timestamp: str | None, format_: str) -> None:
"""各サービスの結果を統合し、JSONとMarkdownで出力する。"""
logger.info("cost-combineコマンドを開始します: input_dir=%s, output=%s", input_dir, output)
try:
input_path = Path(input_dir)
services = _load_cost_payloads(input_path)
combined_warnings = _collect_cost_warnings(services)
output_payload = {
"timestamp": timestamp or current_jst_timestamp(),
"services": services,
"warnings": combined_warnings,
}
output_path = Path(output)
output_path.parent.mkdir(parents=True, exist_ok=True)
# JSON出力
if format_ in ("json", "both"):
output_path.write_text(
json.dumps(output_payload, ensure_ascii=False, indent=2),
encoding="utf-8"
)
# Markdown出力
if format_ in ("markdown", "both"):
markdown_lines = []
markdown_lines.append("# Cost Check 結果")
markdown_lines.append("")
markdown_lines.append("| サービス | 当月コスト | 着地見込み | ステータス |")
markdown_lines.append("|---|---:|---:|---|")
for service_name, service_data in services.items():
current_cost = service_data.get("current_cost", "-")
forecast = service_data.get("forecast", "-")
status = str(service_data.get("status", "UNKNOWN")).upper()
markdown_lines.append(
f"| {service_name} | {current_cost} | {forecast} | {status} |"
)
if combined_warnings:
markdown_lines.append("")
markdown_lines.append("## 警告")
for warning in combined_warnings:
markdown_lines.append(f"- {warning}")
else:
markdown_lines.append("")
markdown_lines.append("警告は検出されませんでした。")
markdown_lines.append("")
markdown_lines.append(f"実行時刻: {output_payload['timestamp']}")
summary_path = output_path.parent / "summary.md"
summary_path.write_text("\n".join(markdown_lines), encoding="utf-8")
logger.info("cost-combineコマンドが完了しました: warnings=%d件", len(combined_warnings))
except Exception as exc:
logger.error("cost-combineコマンドでエラーが発生しました: %s", exc)
raise
ポイントは:
--format bothで JSON と Markdown の両方を出力- Markdown は
summary.mdとして同じディレクトリに出力 - すべて Docker コンテナ内(root ユーザー)で完結
Jenkinsfile の修正
Jenkins 側はシンプルになりました。
stage('レポート統合') {
steps {
script {
sh '''
python -m monitoring_cli cost-combine \
--input-dir "$WORKSPACE/$REPORT_DIR" \
--output "$WORKSPACE/$REPORT_DIR/cost_check.json" \
--timestamp "$TIME_STAMP" \
--format both
'''
def results = readJSON(file: "${REPORT_DIR}/cost_check.json")
currentBuild.description = generateBuildDescription(results)
// Pythonスクリプトが既にsummary.mdを生成しているので読み込むだけ
def summaryContent = readFile(file: "${REPORT_DIR}/summary.md")
echo summaryContent
}
}
}
変更点:
--format bothを追加writeFileをreadFileに変更- Jenkins は生成されたファイルを読み込むだけ
そして、Jenkinsfile にあった generateSummaryMarkdown 関数は不要になったので削除しました。
結果: 一貫性のある設計パターン
この修正により、すべてのモニタリングパイプラインが同じパターンになりました。
graph LR
A[Docker Container: root] -->|生成| B[JSON]
A -->|生成| C[Markdown]
B -->|読み込み| D[Jenkins: jenkins user]
C -->|読み込み| D
D -->|表示| E[レポート]
統一されたパターン:
- api-health-check ✓
- batch-status-check ✓
- cloudwatch-logs-check ✓
- cost-check ✓(今回修正)
- elasticbeanstalk-check ✓
- sqs-check ✓
すべてのパイプラインで:
- Docker agent(
python:3.11-slim)を使用 - Python スクリプトが summary.md を生成
- Jenkins は生成されたファイルを読み込むだけ
- 権限エラーなし
振り返り: 責務設計の重要性
今回の問題、表面的には「権限エラー」でしたが、本質的には責務の設計ミスでした。
判断のポイント
なぜ Python 側で生成すべきか:
- ファイル生成は一連の処理の一部(結果の出力形式の違いでしかない)
- 同じコンテキスト(同じユーザー、同じ権限)で完結すべき
- テストしやすい(CLI コマンド単体でテスト可能)
なぜ Jenkins 側ではないか:
- Jenkins の役割は「オーケストレーション」
- データの加工・変換は本来の責務ではない
- 権限の境界を跨ぐ必要がない
トレードオフ
Python 側に機能を追加すると、コードが増えます。でも、このトレードオフは受け入れる価値がありました。
得られたもの:
- 設計の一貫性
- 権限問題の回避
- テスタビリティの向上
- 保守性の向上
失ったもの:
- Jenkins 側のシンプルさ(といっても、元々 Groovy で Markdown を組み立てていたので、そこまでシンプルではなかった)
まとめ
Jenkins × Docker で出会った権限問題は、「誰が何を作るべきか」という責務設計を見直すことで解決しました。
学び:
- 権限エラーは表面的な問題、本質は設計
- 「なぜここでこの処理をしているのか」を問い直す
- 一貫性のあるパターンは、問題を未然に防ぐ
もう行くしかないなと思いました、というのは冗談ですが、他のパイプラインと設計を揃えることで、今後同じ問題に遭遇する可能性を減らせたのは収穫でした。
迷ったときはより難しい方、成長できる方を選ぼう——今回の場合は、「より一貫性のある方」を選んだ、という感じです。
[📦 商品リンク: moshimo-book-jenkins-jissen]