一覧に戻る
    Jenkins × Docker で出会った権限問題と、責務設計で解決した話
    CI/CD
    PRこの記事には広告が含まれています

    Jenkins × Docker で出会った権限問題と、責務設計で解決した話

    21 分で読める

    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 ユーザーで動いている

    処理の流れはこうなっています:

    つまり:

    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

    ポイントは:

    • --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 を追加
    • writeFilereadFile に変更
    • Jenkins は生成されたファイルを読み込むだけ

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

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

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

    統一されたパターン:

    • 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 で出会った権限問題は、「誰が何を作るべきか」という責務設計を見直すことで解決しました。

    学び:

    • 権限エラーは表面的な問題、本質は設計
    • 「なぜここでこの処理をしているのか」を問い直す
    • 一貫性のあるパターンは、問題を未然に防ぐ

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

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

    この記事は役に立ちましたか?

    Coffee cup

    この記事が、何かの整理につながったら

    コーヒー1杯分の応援をもらえると嬉しいです。

    ※ これは応援とは別の話ですが、

    同じようなテーマを自分の文脈で整理したい場合は、 (文章だけだと詰まりやすい人向けに) 思考整理の壁打ちという形で対話の時間も取っています。

    対話の時間について