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

つまり:

  1. Python スクリプト(Docker コンテナ内、root ユーザー)が reports/ ディレクトリを作成
  2. そこに cost_check.json を出力
  3. Jenkins(jenkins ユーザー)が同じディレクトリに summary.md を書き込もうとする
  4. 権限がなくて失敗

Docker コンテナが作ったディレクトリは root 所有なので、Jenkins ユーザーからは書き込めなかったわけです。

解決のアプローチ: 責務を見直す

この問題、いくつか解決方法が考えられました:

案1: Docker コンテナのユーザーを jenkins に変更

args '-u jenkins'

でも、これだと他のパイプラインとの一貫性が失われるし、Python パッケージのインストール権限で問題が出る可能性がある。

案2: chmod で権限を変更

chmod 777 $REPORT_DIR

力技ですが、セキュリティ的にあまりよくない。それに、毎回これをやるのは美しくない。

案3: そもそも Jenkins が summary.md を作る必要があるのか?

ここで立ち止まって考えました。

「なぜ Jenkins が Markdown を生成しているんだろう?」

他のパイプラインを見てみると、api-health-checkbatch-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

ポイントは:

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
        }
    }
}

変更点:

そして、Jenkinsfile にあった generateSummaryMarkdown 関数は不要になったので削除しました。

結果: 一貫性のある設計パターン

この修正により、すべてのモニタリングパイプラインが同じパターンになりました。

graph LR
    A[Docker Container: root] -->|生成| B[JSON]
    A -->|生成| C[Markdown]
    B -->|読み込み| D[Jenkins: jenkins user]
    C -->|読み込み| D
    D -->|表示| E[レポート]

統一されたパターン:

すべてのパイプラインで:

振り返り: 責務設計の重要性

今回の問題、表面的には「権限エラー」でしたが、本質的には責務の設計ミスでした。

判断のポイント

なぜ Python 側で生成すべきか:

なぜ Jenkins 側ではないか:

トレードオフ

Python 側に機能を追加すると、コードが増えます。でも、このトレードオフは受け入れる価値がありました。

得られたもの:

失ったもの:

まとめ

Jenkins × Docker で出会った権限問題は、「誰が何を作るべきか」という責務設計を見直すことで解決しました。

学び:

もう行くしかないなと思いました、というのは冗談ですが、他のパイプラインと設計を揃えることで、今後同じ問題に遭遇する可能性を減らせたのは収穫でした。

迷ったときはより難しい方、成長できる方を選ぼう——今回の場合は、「より一貫性のある方」を選んだ、という感じです。

[📦 商品リンク: moshimo-book-jenkins-jissen]