
t4g.medium → t4g.large の単純な置換のはずが、Jenkins運用の構造的な弱点と向き合うことになった話
背景:CPUクレジットの「実質的に効いていない」状態
ある日、AWSのコストレポートを眺めていて、違和感に気づきました。
Jenkins Controllerに使っているEC2インスタンス(t4g.medium)について、Savings Planで月$24削減されているはずなのに、CPUクレジット課金で月$19支払っている。差し引きの実質削減額は$5。
「これ、Savings Plan ほぼ意味ないじゃん」と。
CloudWatchを見ると、CPU使用率は平均53%で、CPUクレジット残高は常時尽きている状態でした。t系インスタンスは「ベースライン以上に使うとクレジットを消費し、クレジットが尽きるとUnlimitedモードで課金される」仕組みなので、ベースライン40%(t4g.medium)に対して常時53%使っていれば、クレジット課金は発生し続けます。
最初は「単にインスタンスを上のサイズに変えればいいだけの話」と思っていました。実際、技術的にはそうです。
ただ、この単純なはずの作業が、最終的に「Jenkins運用の構造的な弱点」と向き合う羽目になるとは、このときは思っていませんでした。
課題:何を解決すべきか
整理すると、解いている問題はこうです。
- コスト面: CPUクレジット課金で月$19が無駄に流出している
- 構造面: ベースライン超過が常態化していて、t4g.mediumがそもそもサイジングとして合っていない
- 観測面: 何にCPUを使っているか可視化できていない(Jenkins Controllerのプロセス内訳が不明)
ゴールは「クレジット課金をゼロにする」ことですが、その先に「次のサイジング判断を定量的にできる状態」も置きました。今後また同じ問題が起きたとき、勘で対処したくなかったからです。
前提:環境
- Jenkins Controller: AWS EC2 (t4g.medium, ARM Graviton)
- ストレージ: EFS マウント(
/mnt/efs/jenkins) - IaC: Pulumi(TypeScript)でインフラ管理、Ansibleでデプロイ
- 構成管理: JCasC(Configuration as Code)でJenkins設定をコード化
- プラグイン管理:
install-plugins.groovyで起動時に自動インストール - Savings Plan: t4g ファミリー専用のものを保有
当初の判断:t4g.large + JavaMelody 導入
判断としては2つを同時に進めることにしました。
1. t4g.medium → t4g.large へのスケールアップ
t4gファミリーのvCPUあたりベースラインを比較すると、こうなります。
| インスタンス | vCPU | ベースライン |
|---|---|---|
| t4g.medium | 2 | 40% |
| t4g.large | 2 | 60% |
平均53%が常時 medium のベースライン40%を超えているのが現状なので、t4g.largeに上げればベースライン60% > 53%でクレジット課金がほぼゼロになる計算です。しかも同じt4gファミリーなので、既存のSavings Planがそのまま適用されます。
費用感としては、差額は月$5程度。でもRAMが4GiB → 8GiBに倍増するので、Jenkins ControllerのJVMヒープにも余裕が生まれる。「同じファミリー内でのワンサイズ上げ」は、リスクと効果のバランスが圧倒的に良い選択でした。
代替案としてm7g.largeやc7g.large(非バースト型)への移行も検討しましたが、現行のt4g専用Savings Planが無駄になるため、SP期限切れまでは見送り。SP終了タイミングで再検討、という判断にしました。
2. JavaMelody(monitoring プラグイン)の導入
「t4g.largeに上げれば数字上は解決するけど、それで本当にいいんだっけ?」という気持ちもありました。
Controllerが何にCPUを使っているか分からないままサイズアップだけするのは、対症療法に過ぎません。「電池切れだから電池を大きくする」だけで、消費が激しい原因に向き合っていない。
そこでJavaMelodyを入れて、スレッド別CPU・GC時間・HTTPリクエスト分布が見える状態にしてから本対応に進むことにしました。
JavaMelody自体のオーバーヘッドはCPU+1〜3%、メモリ数十MB〜100MB程度。今 53% で常時クレジット枯渇している状態なので「+1〜3%でクレジット課金がわずかに増えるかも」という懸念はありましたが、原因特定の価値の方が圧倒的に大きいと判断しました。
3. Savings Plan の追加購入は急がない
これが一番悩んだポイントでした。「t4g.largeにすればSPの余剰枠が足りなくなる」ことを心配していたのですが、よく見ると既存SPには余剰分があり、追加コストを吸収できる可能性が高い。
Savings Planは1年 or 3年コミット商品です。慌てて買うと逆効果になる。最低1ヶ月、できれば2ヶ月様子を見てから判断することにしました。データがない状態でコミットを増やすのは博打です。
実装に入る:4つの連鎖トラブル
ここから、本来であれば「Pulumiの設定値を変えて、Ansibleで流すだけ」のはずでした。実際には、4つの問題が連鎖して発生しました。
順を追って書いていきます。
問題1:Pulumi passphrase不一致でデプロイ開始すらできない
Phase 1のSSM初期化を流した瞬間、こんなエラーが出ました。
error: getting stack configuration: get stack secrets manager: incorrect passphrasePulumiは状態ファイルを暗号化して保存しているため、デプロイ時にpassphraseで復号する必要があります。このプロジェクトでは以下の優先順位でpassphraseを取得していました。
- 環境変数
PULUMI_CONFIG_PASSPHRASE(最優先) - SSM Parameter Store
/bootstrap/pulumi/config-passphrase(フォールバック)
調べていくと、原因はテストのために生成された Pulumi.dev.yaml がコミットされていたことでした。このファイルには encryptionsalt だけが書かれていて、これが既存スタックの暗号化saltと不整合を起こしていたのです。
# pulumi/jenkins-ssm-init/Pulumi.dev.yaml(混入していたもの)
encryptionsalt: v1:xxxxxxxxx:...このsaltは「ローカルでpulumiコマンドを実行したときに自動生成された別物」だったので、SSMに入っている正しいpassphraseを使っても復号できなかった、という構造です。
該当ファイルを削除(git rm)すると、Pulumiが新しいsaltを生成し直し、SSMのpassphraseで暗号化できるようになって解決しました。
学び: AI workflow agentが裏で pulumi stack init のような操作を走らせると、こういう副産物がコミットされうる。Issue起票時 or PR作成時に Pulumi.<stack>.yaml が含まれていないかチェックする運用は入れておく価値がある、と感じました。
問題2:EFS上の chown -R が17分以上ハングする
Phase 3のEFSマウントとJenkinsインストールを流したところ、Ansibleが10分でタイムアウトしました。
調べると、controller-mount-efs.sh の中の chown -R jenkins:jenkins /mnt/efs/jenkins で詰まっている。プロセス状態は STAT: D(NFS I/O 待ち)。EFS上には4.7GBのデータがあり、ファイル数が積み上がっていたため、NFS越しの再帰chownが15分以上経っても終わらない状態でした。
該当の処理は3行だけです。
chown -R jenkins:jenkins $JENKINS_HOME_DIR # ← ここで詰まる
find $JENKINS_HOME_DIR -type d -exec chmod 755 {} \;
find $JENKINS_HOME_DIR -type f -exec chmod 644 {} \;冪等性チェックは「EFSがマウント済みか」しか見ていなくて、所有権・パーミッションは毎回再帰的に実行されていました。これがNFSのラウンドトリップ遅延と組み合わさって、デプロイをブロックする時限爆弾になっていたわけです。
「初回は仕方ないとして、2回目以降は所有権が既に正しいなら再帰処理をスキップする」という冪等化を入れることにしました。
# 必須トップレベルディレクトリの所有権は常に確実に設定する
# mkdirで新規作成されたサブディレクトリ(root所有)に対応するため
# 一階層のみなのでEFSでも高速
chown jenkins:jenkins "$JENKINS_HOME_DIR" 2>/dev/null || true
chown jenkins:jenkins "$JENKINS_HOME_DIR"/{plugins,jobs,secrets,nodes,logs,init.groovy.d} 2>/dev/null || true
# 配下の再帰処理は冪等化:トップが既に正しい状態ならスキップ
CURRENT_OWNER=$(stat -c '%U:%G' "$JENKINS_HOME_DIR" 2>/dev/null || echo "")
CURRENT_MODE=$(stat -c '%a' "$JENKINS_HOME_DIR" 2>/dev/null || echo "")
if [ "$CURRENT_OWNER" = "jenkins:jenkins" ] && [ "$CURRENT_MODE" = "755" ] && [ "${FORCE_CHOWN:-false}" != "true" ]; then
log "所有権・パーミッションは既に正しい状態(owner=$CURRENT_OWNER, mode=$CURRENT_MODE)"
log "再帰処理をスキップしてEFSパフォーマンスを確保します"
else
log "所有権を設定中... EFS上では時間がかかる可能性があります"
chown -R jenkins:jenkins "$JENKINS_HOME_DIR"
find "$JENKINS_HOME_DIR" -type d -exec chmod 755 {} \;
find "$JENKINS_HOME_DIR" -type f -exec chmod 644 {} \;
fi意図的に FORCE_CHOWN=true というエスケープハッチを残しました。リカバリ時など「やっぱり全部直したい」場面のためです。
判定の根拠は「このスクリプトが唯一の所有権設定箇所なので、トップが jenkins:jenkins なら配下も同じ状態と仮定できる」というもの。完璧ではないけど、エッジケースは FORCE_CHOWN に逃がせる、というトレードオフでした。
このパッチを適用後、EFS マウント処理は前回17分 → 約1分 に短縮されました。17倍の改善は気持ちが良かったです。
問題3:Jenkins 2.555.1 で JCasC 初期化失敗
EFS問題が解決して、Jenkinsが起動するはず…と思いきや、/login がHTTP 500を返し続ける状態に。
ログを見るとこれが出ていました。
io.jenkins.plugins.casc.UnknownAttributesException:
Invalid configuration elements for type: DefaultCrumbIssuer : excludeClientIPFromCrumbJCasC(Configuration as Code)のテンプレートに、excludeClientIPFromCrumb という属性が書かれていたのですが、デプロイされたJenkins 2.555.1ではこの属性が削除されている。それでJCasCの初期化が失敗 → BootFailure → 全リクエストに500が返る、という構造でした。
調べてみると、excludeClientIPFromCrumb はJenkins 2.452前後で削除されており、現行Jenkinsでは「クライアントIPをcrumbから除外する」のがデフォルト動作になっていました。なので属性を削除しても、以前と同等の動作(true設定時と同じ)になる、というのが救いでした。
# 修正前
crumbIssuer:
standard:
excludeClientIPFromCrumb: true # ← Jenkins 2.555.1 では認識されない
# 修正後(最初の対応)
crumbIssuer:
standard:ただ、ここで問題が連鎖します。
問題4:standard: だけ残すと、今度は別のJCasCエラー
「excludeClientIPFromCrumb の行だけ消せばいい」と思ってPRをマージし、再デプロイしたら、また別のエラーが出ました。
Single entry map expectedstandard: の後ろに値が何もない状態だと、YAMLパース上は {'standard': None} になります。JCasCはこれを「空設定の standard CrumbIssuer」とは解釈してくれず、認識失敗で初期化エラーになっていました。
正解は 空マップ {} を明示すること でした。
# 最終形
crumbIssuer:
standard: {} # 空マップを明示することでJCasCがstandard CrumbIssuerを正しく認識するYAMLパース結果の違いはこれです。
| 構文 | パース結果 | JCasCの解釈 |
|---|---|---|
standard: | {'standard': None} | 認識失敗 |
standard: {} | {'standard': {}} | 「空設定の standard CrumbIssuer を使う」 |
正直、これは罠でした。「属性を1つ消すだけ」と思っていたのに、YAMLの意味論レベルで型が変わっていることに気づかなかった。テンプレートからJCasC YAMLを生成しているシステムでは、こういう「中身が空のキー」は要注意です。
振り返り:ここで根本的な疑問に向き合う
連鎖トラブルが落ち着いた頃に、ふと立ち止まって考えました。
「これって、Jenkinsバージョンが上がるたびに何かしらの不整合が起きて、後追いで修正しないといけないんじゃないか?」
この疑問は本質を突いていました。今回起きたことは「たまたま運が悪かった」のではなく、運用設計上の構造的な弱点が顕在化したものです。
何が破綻していたか
| # | 弱点 | 今回の影響 |
|---|---|---|
| 1 | jenkins-version: latest で固定なし | デプロイのたびに違うバージョンが入る可能性 |
| 2 | プラグインも install-plugins.groovy でバージョン未指定 | プラグイン側のAPI変更も同様に予期せず発生 |
| 3 | JCasCのドライラン検証が走らない | 起動して初めて設定エラーが分かる |
| 4 | デプロイ後の /login ヘルスチェックは「Jenkinsが応答するか」しか見ない | JCasC設定エラーは部分起動するため検知が遅い |
| 5 | dev/staging/prod 全環境で同じlatestを踏む | カナリア機能がない |
要は、「動くと検証された組み合わせ」が成果物として存在しないんです。Jenkins本体・プラグイン・JCasCが、それぞれ実行時にダウンロード・インストール・展開される設計なので、デプロイのたびに新しい組み合わせを本番で初めて試すことになる。
LTSにピン留めすれば「組み合わせのバラつきを減らす」効果はありますが、本番で初めて検証する構造自体は変わりません。
寄り道:Fargateにすると安くなる?
「いっそECS Fargateに移したらコスト的に良くないか?」という考えも頭をよぎりました。が、試算してみるとそうでもなかったです。
| 構成 | 月額(オンデマンド) | 月額(SP適用後) |
|---|---|---|
| 現在 t4g.large (EC2) | 約$48 | 約$24(既存t4g SP適用) |
| ARM Fargate (Graviton) | 約$84 | 約$70 (1年SP) / 約$40 (3年SP) |
| x86 Fargate | 約$105 | 約$87 (1年SP) / 約$50 (3年SP) |
Fargateは「管理コスト込み」の料金体系なので、Controllerのような24/7常時稼働ワークロードではEC2の方が必ず安い。これはオートスケールやSpot活用ができるワークロードでないと旨みが出ない構造です。
ただ、Fargateにすると消える運用コストもあります(AMIビルド保守、OSパッチ、CPUクレジット概念がそもそもない、など)。これらを月額換算すると人件費0.5〜1時間/月 = 数千〜1万円程度の節約効果はあるけれど、素のコンピュートコスト差を超えるかは微妙です。
結論としては「コスト目的なら Fargate に移っても意味なし」。Fargateは「OS管理から完全解放」の対価を払う気があるときに検討する選択肢、という整理に落ち着きました。
次のステップ:構造的弱点に「現実的な範囲で」向き合う
理想を言えば、Jenkins本体・全プラグイン・JCasCを1つのDockerイメージにビルドして、それをデプロイする「イミュータブルJenkins」にするのが筋です。これだと「動く組み合わせ」がDockerイメージという成果物として存在し、検証タイミングがビルド時に前倒しされ、本番デプロイには「検証済みのもの」だけが行く。
ただ、現実問題として今のEC2 + EFS構造を全面的に作り直すのは大手術です。AMI Builder・EC2 Fleet・ECS Fargate エージェントなど周辺資産も多い。
なので、現実的な落とし所として優先度順に整理しました。
| 優先度 | 内容 | 工数 | 効果 |
|---|---|---|---|
| 今すぐ | Jenkinsバージョンを LTS 固定 | 30分 | バージョンガチャを止める |
| 次のスプリント | アップグレードRunbook作成 | 2時間 | 「上げるとき何をするか」を文書化 |
| 中期 | dev/staging/prod のバージョンずらし運用 | 半日 | dev で先行検証 → staging → prod |
| 中期 | JCasC ドライラン検証を deploy に組み込む | 半日 | 本起動前に設定エラー検知 |
| 中期 | プラグインのバージョン固定 | 1〜2日 | プラグイン側の同型問題を予防 |
| 長期 | blue/green カラーを使ったアップグレード | 1週間 | 本番影響なしでバージョン更新 |
「机上で全部やる」より、「実際の運用で痛みを感じてから対処する」方が、過剰投資を避けられる、というのが今回の判断軸でした。
LTS固定のキャップは「セキュリティパッチが遅れる」ですが、これはLTSのマイナー更新(破壊変更なし)は頻繁に取り込み、LTS系列の更新(メジャー)は四半期ごとに計画的に、CVE対応は別フローで例外的に上げる、という運用で対応できます。Jenkinsコミュニティ全体がこのパターンで運用しているので、定石はあります。
結果
最終的に、すべてのPhaseが完了して、Jenkinsが正常起動しました。
| 項目 | 状態 |
|---|---|
| インスタンスタイプ | t4g.large(目的どおり) |
| JCasC | SEVERE エラーなし |
/login | HTTP 200 |
| JavaMelody | /monitoring で正常表示 |
| EFS マウント時間 | 17分 → 1分 |
そして発生したIssue/PRはこんな感じになりました。
| Issue/PR | 内容 | 状態 |
|---|---|---|
| #568 → #569 | t4g.large + JavaMelody 導入 | マージ済み |
| #571 | Pulumi.dev.yaml 削除(passphrase不整合) | マージ済み |
| #572 → #573 | EFS chown 冪等化(17分 → 1分) | マージ済み |
| #574 → #575 → #577 | JCasC 互換性修正(2段階) | マージ済み |
| #576 | Jenkins LTS 固定 + Runbook | 着手予定 |
CPU効果検証は2週間後(CloudWatch + Cost Explorerで)に実施予定です。CPU平均が30〜45%に収まり、クレジット課金が消えていれば、Phase 3完了。そこからSavings Plan増額の判断に進みます。
まとめ:何を学んだか
最初は「t4g.medium → t4g.large の単純な置換」のつもりでした。それが終わってみれば、Jenkins運用の構造的な弱点と、自分たちのアップグレード戦略の不在に向き合うことになりました。
特に印象に残っているのは3つです。
1つ目は「単純な変更」が単純じゃないことが本番でしか分からない構造を放置していたこと。latest タグを引いている時点で、デプロイ=バージョンアップのルーレットになっていた。これは私たちのチーム規模に対しては明らかに過剰なリスクで、一度痛い目を見ないと気づきにくい構造でもあります。
2つ目は冪等性は「本番で時間が露呈する」という事実。EFSのchown -Rは、開発時には数秒で終わるテストデータでは問題が見えませんが、本番で4.7GB積み上がったデータに対して走らせると17分ハングします。「動く」と「実用に耐える」は別物で、特にNFSのような外部ストレージが絡むとここの差が大きい。
3つ目はFargateや巨大なリファクタリングに飛びつく前に、まず今のアーキテクチャの「本当の弱点」を特定すること。今回、コンテナ化(Docker Jenkins化)は本筋として正しい解決策ですが、今すぐ全部やるべきかというと違いました。LTS固定 + Runbook整備で、まず「不意の踏み抜き」を止める。それで運用してみて、痛みが残るところから次を考える。机上の正解と、実装可能な現実解は違う、というのは何度学んでも忘れがちです。
「コスト最適化」のはずだった作業が、結果として運用の構造的な棚卸しになりました。最初の想定より時間はかかりましたが、こういう「やってみて初めて見える地形」がある作業は、たぶん前もって見積もっても無意味で、引き返さずに進む方が結果的に早い、というのが個人的な感覚です。
次は Issue #576(LTS固定 + アップグレードRunbook)に着手します。今回踏み抜いた地雷の場所が分かっている状態で、Runbookを書けるのは、今しかありません。
関連書籍
今回のようなJenkins運用・AWSインフラ運用・SRE的な構造設計について、さらに学びたい方におすすめの書籍です。
この記事は役に立ちましたか?

この記事が、何かの整理につながったら
コーヒー1杯分の応援をもらえると嬉しいです。
あなたへのおすすめ
