サイオステクノロジー(Tech.Lab)のブログ - TECH PLAY

TECH PLAY

サイオステクノロジー(Tech.Lab)

サイオステクノロジー(Tech.Lab) の技術ブログ

721

ども!AIに自分の文体でブログを書かせられないか、1年ほど試行錯誤中の龍ちゃんです。 AIに「ブログ書いといて」って頼んだこと、ありますよね。で、出てきた文章を読んで「なんか自分の文章にならないな」って思ったこと、ないですか。文体だけの問題ならまだいいんですけど、ひどいときは空想エピソードをAIが勝手に書いてくるみたいな話ありますよね。 過去のブログを全部読み込ませて「自分の文体で書くエージェント」などいろいろ作ってます。その設計思想と、実際の執筆はどうなっているのか?っていう話ですね。 ネットで「AIに自分の文体で書かせる方法」を調べると、だいたい成功譚なんですよね。「うまくいきました」で話が終わってる(うらやましい!)。でも僕の実感はそうじゃなくて、手間が半分になった感じですね。この記事では、その「実際どこまでできて、どこからができないのか」を、正直に書いていきます。 AIは自分の文体をどこまで再現できるのか 結論から言います。 言い回しは再現できる。でも中身は半分くらいしか自分のものにならない。 最終的なラインとして書き直しが発生しています。 やったことはシンプルで、自分が過去に書いたブログを読み込ませて、文体を再現するエージェントを Claude Code で作りました。で、エージェント名は stylist です。記事を書くときにこいつへ渡す入力は、outline の箇条書きと、音声入力。「何を書くか」は僕が用意して、「どういう言い回しで書くか」を stylist に任せる、っていう分担ですね。 じゃあ実際どこまで再現できるのか。ちょうどいいので、この記事の導入も、実際に stylist に書かせてみました。渡したメモはこれだけです。 * AIにブログを書かせても、なんか自分の文章にならない * 自分が言わないようなことを、AIが勝手に書いてくる * そういうのでモヤモヤしてる人、多いと思う * この記事では、実際どこまでできて、どこからできないかを正直に書く で、返ってきたのがこれ。ここから4段落、AIが書いた生の出力をそのまま貼ります。 ども!最近、AIにブログを書かせようとして地味に苦戦してる龍ちゃんです。 みんなも一度は試したことあると思うんですけど、AIに「ブログ書いて」って頼むと、なんか自分の文章にならないんですよね。文法は合ってるし、内容も間違ってない。でも読み返すと「これ、俺こんな言い方しないな」ってところが結構出てくる。しかも困るのが、自分が絶対言わないような表現を、AIが勝手に盛ってくるパターンです。妙に持ち上げてきたり、断定しすぎたり。悪意はないんですけど、なんかこそばゆいんですよね。 これ、結構みんなモヤモヤしてるんじゃないかなと思ってます。AIに書いてもらえば楽なのは分かってるけど、公開するのは自分の名前がついたブログなわけで。文体が自分じゃないと、なんだか他人事みたいな記事になっちゃうんですよね。 というわけで今回の記事では、実際どこまでAIに文体を寄せさせられて、どこから先は無理なのかを正直に書いていこうと思います。理想を語るんじゃなくて、僕が試して分かった限界も含めて共有しますね。 読んでもらうと分かるんですけど、語尾とかテンションは確かに僕っぽいんですよね。「ども!」から入って、「〜んですよね」で語尾を逃がす感じ。AIっぽい大げさな言い回しも出てきてない。文体(how)はちゃんと寄ってる。ここは素直にすごいと思いました。 ……ところが、この一文。 AIに書いてもらえば楽なのは分かってるけど、公開するのは自分の名前がついたブログなわけで。文体が自分じゃないと、なんだか他人事みたいな記事になっちゃうんですよね。 僕、こんなこと一言も言ってないんですよ。渡したメモのどこにも無い。stylist が「龍ちゃんならこう言うだろうな」って先回りして、勝手に理由をでっち上げてる。しかも困ったことに、それっぽいんですよね。内容がいいなって思っちゃいました。 ただ、こんな高尚な理由は持ち合わせていないので導入は書き直しました。 これが「中身は半分」の正体です。文体(how)は真似できても、中身(what)、つまり「自分が本当にそう思って言ってるか」は、当然AIには分からないんですよね。過去ブログをいくら食わせても、そこは埋まりません。だから残りの半分は、僕が目で見て「これは言う/これは言わない」を判断するしかない。 じゃあ、なんでAIは僕の理由を勝手に作ったのか。ここに手が入る余地がありました。 AIが勝手に書くのは、アウトラインの空白を埋めてるから 僕、書く前に outline を作ります。何を言いたいか、どの主張を通すか、どのネタを使うか。それを箇条書きで先に出して、その中身を土台にAIへ書かせる。ここは前提の話なので流しますね(outline を「どう作るか」は別記事「 書く前レビュー 」に書きました)。 で、問題は outline の粒度なんですよ。箇条書きって、細かく書けてるところと、粗いところが必ず出る。粗いところには 空白 が空くんですよね。項目と項目のあいだが埋まってない状態。 そこをAIが勝手に埋めてくる。 埋め方が自分の考えと微妙に違うので、結果として、握ったはずの中身と違うものが出来上がる。さっきの「他人事みたいな記事」は、まさに空白の埋め跡でした。 面白いのが、空白の大きさと、でっち上げの派手さが連動してるんですよね。同じ stylist に具体的な箇条書きを渡したときは、捏造は地味なやつだけでした。でも導入みたいな「気持ちを書く」項目、つまり箇条書きが薄くなりがちなところを渡したら、量も大きさも一気に増えた。空白が大きいほど、もりもりになります。 じゃあ空白をどう埋めるか。僕がやってるのは、声で喋ることです。 移動中とか、作業の合間とか、喋れるタイミングで、そのネタについて思ってることをダラダラ喋る。整えません。言い直しも「あー」も残ったままでいい。それを文字起こしして、素材として渡します(どのツールで喋ってるとか、技術用語がどこまで正しく認識されるかは、それ自体で1本になるので別記事に回します)。 なんで声が効くかというと、2つ入ってるからなんですよね。ひとつは中身。喋ってると、箇条書きには落とさなかった理由とか実例が、勝手に口から出てくる。これが空白を埋める中身になります。もうひとつは言い回し。声に出した言葉って、自分の口調がそのまま残ってるんですよ。だから同じ素材が、文体のほうの教師にもなる。 入れどころは2つあります。 outline を作る段階で声メモを混ぜて、空白そのものを減らす 本文を書かせるときに、実例やニュアンスを足す素材にする 僕はどっちもやってます。 で、効くのか。さっきの導入、同じ箇条書きに声メモだけ足して、もう一回書かせてみました。並べるとこうです。 声メモ無し(AIがでっち上げた理由) AIに書いてもらえば楽なのは分かってるけど、公開するのは自分の名前がついたブログなわけで。文体が自分じゃないと、なんだか他人事みたいな記事になっちゃうんですよね。 声メモ有り(僕が喋った理由から書かれたもの) アウトラインだけ渡して「これで書いて」ってお願いすると、箇条書きの隙間、つまり書いてない部分をAIが勝手に埋めてしまうんです。で、その埋め方が自分の考えと微妙に違う。結果、握ったはずの内容と違うものが出来上がる、みたいなことがよく起きます。 渡した箇条書きは一字一句同じです。違うのは声メモを足したかどうかだけ。それだけで、でっち上げの理由が消えて、僕が実際に思ってる理由に入れ替わりました。 ただ、正直に書いておきたいんですけど、捏造そのものは減ってません。入力に無い追加を数えたら、件数はほとんど同じでした。消えたのは「理由をまるごと創作する」みたいな重いやつだけ。残ったのは「僕もめちゃくちゃ試してます」みたいなテンションの盛りと、あともう1つ厄介なやつです。 表現とか言い回しの話じゃなくて、内容そのものがズレる これ、僕は一言も言ってないんですよ。でも、僕がこの記事で言いたいことなんです。言い回しじゃなく中身がズレる、まさにそれ。AIには「僕が言いそうなこと」を先回りする癖があって、空白を埋めてもそこは消えませんでした。当たってるからタチが悪いんですよね。 なので声メモは、空白を減らして「でっち上げの重さ」を下げる道具です。ゼロにはならない。だから最後は人間の目が要る。その回し方を次に。 実際どうやって自分の文体で書かせているか outline で中身を握ったら、あとは毎回だいたい同じ順番で回してます。 まず、outline の箇条書きと、喋った声メモをまとめて stylist に渡して、文章にしてもらう。さっき導入で見せたやつです。これで「言い回し」は僕っぽくなって、握った中身もだいたい残る。 で、出てきた原稿を、今度は checker っていう別のエージェントに通します。こっちは「AIっぽい表現」を見つけて弾く係。「〜と言えるでしょう」みたいな妙にかしこまった言い回しとか、やたら太字を連発するとか、いかにもAIが書いた感じのやつを引っかけてくれる。stylist が僕っぽく寄せて、checker がAIっぽさを弾く。同じ「辞書」でも役割が逆なんですね。 最後に、僕が自分の目で読みます。ここが、さっきの「他人事みたいな記事」みたいに勝手にでっち上げた中身を捕まえる工程です。checker は「AIっぽい言い回し」は弾けるけど、「言ってない中身」は弾けない。文章としては自然に書けちゃってるから、機械には引っかからないんですよ。だからここだけは、人間が読むしかない。 この stylist と checker、実は2つとも裏で「辞書」を持ってて、そこが使うたびに育ちます。 直した分が「自分らしさ」のデータになる:辞書の育て方 ここまで読んで「結局けっこう手作業じゃん」と思った人、正解です。実際、僕は毎回AIとけんかしながら書いてます。でもこのけんか、無駄になりません。直した分が次の資産(辞書)になっていきます。 さっき「stylist と checker は裏で辞書を持ってる」と書きました。この辞書、直すたびに育ちます。しかも育てる仕組みは1個です。 やってることは単純で、stylist が書いた版と、僕が直した版を、両方とっておく。あとはこの2つをAIに読ませて、差分を蒸留させます。何をどう直したのか、意味ごとにまとめてもらう感じですね。ここは手作業じゃないです。自分の直しを自分で分類するの、地味にしんどいので。gitの追跡でやると楽ですね。 で、面白いのが、 同じ1回の手直しから、辞書が2つ採れる んですよ。蒸留するときに、どっち側を見るかで採れるものが変わります。しかも行き先は別々のファイルに分けてます。「こう書く」(ポジティブ)と「こう書かない」(ネガティブ)を同じところに突っ込むと、エージェントの参照でノイズになったりするので、意味単位で分割するという感じです。 あと蒸留はAIにやらせますけど、辞書に載せるかどうかは僕が決めます。提案までがAIの仕事。ここを自動昇格にすると、たまたま1回直しただけの表現が禁止語になって窮屈になって逆効果になるので。温度感も書いておくと、この仕組みは用意してあって、まだ回し始めたばかりです。 (※「自分のデータでAIを育てる」って発想そのものは前にも書いてます。 プロンプト履歴を分析させて口癖を洗い出した話 と、 Slidevでデザインの型を貯めていく話 がそれです。) 直す前の版を蒸留すると、AIっぽい辞書になる 僕が消したほうの表現は、要するにAIが書いてしまう表現なんですよね。「あ、またこれ出てきたな」ってやつが、消した側に溜まってる。それを観測ログに貯めていって、何回も出るのが確定したら、正式に「禁止表現」へ昇格させる。次からは checker がそれを弾いてくれます。 直した後の版を蒸留すると、僕っぽい辞書になる 逆に、僕が書き足したり言い換えたほうは、僕の声そのものです。こっちは別のログに貯めていって、溜まってきたら stylist が読む側の「声のガイド」に反映します。狙いは、僕が毎回おなじ直しをしなくて済むようにすること。ドンピシャに真似させたいわけじゃなくて、手直しの回数を減らしたいだけなんですよね。 過去のブログが無くても、このループは回ります で、ここが一番伝えたいところなんですけど。なんで過去記事が要らないかというと、材料が「今回の手直し」だからです。stylist を作るときは、たしかに過去記事を読み込ませました。でもそれは土台を1回作るための話で、そこから育てるぶんの材料は過去記事じゃない。AIが書いたのを「いや、自分ならこう言う」と直した、その差分です。さっき書いたとおり声メモにも自分の口調がそのまま入ってるので、あれも同じデータになります。つまり過去記事が1本も無くても、書かせて→直して→差分を蒸留する、を繰り返せば、両方の辞書が育っていく。 僕はたまたま過去記事が何十本かあったので、それをスタートダッシュに使えました。でも過去の資産は、あれば早いってだけの話です。無ければゼロから、けんかの記録を溜めていけばいいんですよ。 今は半分、これから 正直な現在地を、もう一回まとめます。文体(言い回し)は、辞書に貯めれば貯めるほど自分に寄っていく。ここはAIに任せられます。でも中身、「自分が本当にそう思って言ってるか」は、今も僕が目で見て判断してる。捏造を見つける専用のエージェントとか、作ってません。作れなくはないんですけど、今はまだ人間がやったほうが早い。だから半分なんです。 ただ、この先どうなるかは分からないな、とも思ってます。さっき空白を声メモで埋める話をしましたけど、あれをもっと突き詰めて、「何を言いたいか」を喋り切って埋め切れたら、盛られる隙はさらに小さくなるはず。そこまでいけば、いつか文体ごと丸投げできる日が来るのかもしれない。まだ来てないですけど。まあ、空白を埋めても「僕が言いそうなこと」を先回りする癖は残ったので、そっちが消えるかは正直わからないです。 というわけで今のところ、僕は「AIとけんかしながら、自分の文体でブログを書いてる」わけです。楽になったかというと、うーん、まだ半分。でも、けんかの記録がちゃんと辞書になって、少しずつ楽になってきてるのは確かです。 このシリーズでは、 書く前のレビュー や、 書いた原稿の検査 、 公開前の仕上げ も別記事で書いてます。あわせてどうぞ。「自分らしさの最終判断は人間がやる」っていう、この記事の裏にある考え方は こちらの記事 にまとめてあります。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post 技術ブログで自分の文体をAIに再現させて時短を目指す first appeared on SIOS Tech Lab .
ネタを渡すだけで、生成AIは「書きたかった記事」を書けるか ども!龍ちゃんです。 生成AIに「このネタで書いて」って投げると、それらしい記事はすぐ出てきます。でも読むと、自分が書きたかったのとは違う。というか自分が絶対言わなそうな論理の展開とかするって経験ありませんか? 誰に向けるのか、どの角度で切るのか、制約なしで書かせると、当たり障りのない記事になります。読み物としては面白いですが、これだと公開はできないですよね。 この現象はAIが噛んでいなくても起きてました。勢いで頭から書くときも一緒で、途中で「あれも、これも」と足したくなって、後付けで詰め込んで、読み返すと「結局、何が言いたいんだっけ」。ネタはあったのに、出来上がっていると別の話しているみたいなこともありました。 そんな背景から、最近はアウトラインを設計してから書きます。「何を・どの順で話すか」を先に決めておけば、後から湧いた要素も置き場所が決まって、流れが崩れません。今日の話は、この「アウトラインを設計してから書く」が前提になります。そうするとAIの暴走も少しは抑制できて、快適な爆速執筆ライフが送れるという形です。 今回は、アウトラインをAIとレビューしながら進めて手戻りを減らす話です。 レビューって「書いた後」にやるものだと思ってません? アウトラインの段階、つまり書く前に、方向をエージェントに検算させる方法の解説です。 アウトラインが「流れ」を守り、エージェントが「方向」を守る。実物で見せていきますね。 そもそも、アウトラインに何を書くのか 「アウトラインを設計する」って言っても、じゃあ何を書くのか。実例が早いので、いま読んでもらってるこの記事のアウトラインから、決めてる項目を抜き出してみます。 ## メタデータ - 狙う検索クエリ: 「技術ブログ アウトライン AI」「ブログ 書く前 レビュー」 - 読者像: 書き上げてから「方向がズレてた」と気づいて書き直す、手戻りに消耗しているエンジニア - 記事の核(主張): レビューは書いた後じゃない。書く前にアウトラインをエージェントにかけると手戻りが消える ## 構成案 1. いきなり書いて流れが崩壊した話(フック) 2. アウトラインに何を書くか/書く前に検算するのは2つ 3. ① 競合調査で「角度」/② 読者代弁で「読者」 4. 書く前に回すとどうなるか(Before / After) 5. まとめ これは、いま読んでもらってる記事そのもののアウトラインです。決めてることを言葉にすると、だいたいこの4つですね。 誰に届けるか (読者像) どの角度で書くか (記事の核) どんな検索で来てほしいか (狙うクエリ) 何を、どの順で話すか (構成) このうち「構成」は、さっき話した「流れ」そのものなんですよね。ただ1つ注意しておきたいのが、さっき載せたこの記事の構成案、あそこに書いてあるのは章タイトルじゃなくて「話がどう転がるか」っていう展開の方です。文言そのままが見出しになるわけじゃなくて、例えば1番の「いきなり書いて流れが崩壊した話(フック)」は、実際の見出しだと「ネタを渡すだけで、生成AIは『書きたかった記事』を書けるか」に変わってます。見出しは、この後で記事本文を書くタイミングで話の切れ目に立てるものなんで、構成案の段階では展開だけ決めておけば十分なんですよね。 あともう1つ、構成案とセットで「何を話さないか」(範囲)も決めてます。ただこれも「流れ」を守る側の話なんで、今日は深追いしません(付録のルールには入れてるので、気になる方はそっちで)。 じゃあこの4つ、実際どうやって作ってるかって話なんですが、手でゼロから書いてるわけじゃないんですよね。書きたい内容のメモとか、検証してわかったこと、動かしたログ、声メモを文字起こししたやつとか、手元にある材料をまずAIに渡して、そこにアウトラインのテンプレート(メタデータ3項目+構成案の型)を当てて成形してもらいます。テンプレートを1個持っておくと、材料を放り込むだけでそれなりの形になるんですよね。 ただ、出てきたものをそのまま使うわけじゃないです。特に読者像と記事の核は、必ず自分の言葉に書き換えます。AIが出す読者像って当たり障りのない一般論に寄りがちで、それだと後で話す2体の検算が効かなくなるんですよね。「狙うクエリ」は、書きたいネタと前提知識でほぼ自然に決まるんで、ここはAIの出力をそのまま使うことが多いです。 そうやって引き算していくと、書く前に「検算」したい方向の本体は、 誰に(読者像) と どの角度で(記事の核) の2つに絞れるんですよね。 「誰に・どの角度で」を2体のエージェントに検算させる どの角度で書くか (誰も書いてない角度があるか)=競合調査エージェントに、判断材料を出し切ってもらう 誰に届くか (読者が置いていかれないか)=読者代弁エージェントに問いを投げさせて、判定は自分でやる ここで1つだけ強調しておきたいんですが、これは「品質チェックの分割」ではないです。「企画判断の分割」です。世の中のレビュー分割って、読みやすさのチェックとかAI臭さ検出とか、だいたい執筆後の品質軸で観点を割ってるんですが、今回話す2体は違います。「誰に・どの角度で書くか」を決める、企画そのものの要素で割ってるんですよね。 あと前提として、この2体はどっちも Claude Code の サブエージェント ( .claude/agents/ に定義ファイルを置いてます)で、書く前に呼び出してます。とはいえ肝は実装じゃなくて「アウトラインを2つの角度で検算する」って考え方の方で、ここは ChatGPT でも Gemini でも、なんなら手動プロンプトでも流用できます。エージェントの設計論そのものは別記事に譲るとして、ここでは「書く前にどう使うか」だけ見せますね(2体の定義ファイルは、公開用に整形したうえで記事末尾に付録として置いてます)。以下、実際の使い方です。 競合調査エージェントに「誰も書いてない角度」を探させる 1体目は competitor-reviewer 。検索上位の競合記事を集めて、タイトルの構造・パワーワードの使い方・技術的な深さを分析して、差別化戦略を提案してくれる担当です。まず「何を入れて何を得るのか」を1枚にするとこうです。 処理の流れはこんな感じです。入力はアウトラインで決めた「狙う検索クエリ(キーワード)」。そこから WebSearch で上位5〜10件を拾って、WebFetch で上位5件を詳細に読みにいって、最後に review/competitor-{日時}.md というファイルに出力してくれます。核になるのは「競合が扱っていないトピック=差別化の余地」の表です。 使うタイミングも明快で、このエージェントの定義自体に「記事執筆の最初(競合を把握してから書く)」と書いています。まさに書く前に使う道具として作られてます。 実物を見てもらうのが一番早いので、貼りますね。この記事を書く前に、まさにこの競合調査を自分の記事にかけたんです。返ってきた「差別化の機会」表から、この章に関わる1行を、文言そのままで抜き出します。 トピック 差別化の狙い目 競合調査=角度決めの道具(go/no-goではない) 競合分析系の記事は軒並み「ホワイトスペース発見=書く価値の有無」の文脈。「被っても出す、角度だけ変える」という逆張りの明言は競合ゼロ 答えを出さず問いを投げるエージェント(reader-advocate) 英語圏ペルソナ系記事も含め、AIに audience nuance・修正案まで生成させる設計が主流。「問いだけ投げて答えは人間が持つ」という記事は見当たらない これが実際の出力です(表記もそのまま)。上が競合調査そのものの角度、下が次章で話す読者代弁の角度です。この2行のほかにも「そもそもレビューを書く前に持ってくる発想自体が競合ゼロ」「企画判断のための分割も競合ゼロ」といった行が並んでて、それを見て「じゃあこの『書く前レビュー』と『問いを投げる設計』を軸にしよう」って角度を決めたのが、いま読んでもらってる記事そのものなんですよね。 競合調査は「書くか書かないか」を決める道具ではないです。僕が書こうとした記事が既にどこかで出てたことなんて何度もあるし、被ってても出します(うちのドメインに無ければ)。じゃあ何のために使うかというと「どの角度で書くか」を決めるため。返ってくる「誰も埋めてない角度」に寄せれば、被っても二番煎じにはならないし。世の競合記事は暗に「隙間が無いなら書くな」って含みを持ってる気がするんですけど、僕は逆で多少被っていても出します。ただ書き方や検証事項を追加したりはします。 ここが今回の記事の差別化ポイントらしいですねww。競合を調べると、ネタやアウトラインを補強する角度が見えることも多いです。 ② 読者代弁エージェントに「読者の置き去り」を問わせる 2体目は reader-advocate-agent 。読者の代弁者です。このエージェントの一番の特徴は、「答え」つまり修正案とか評価点を出さないことなんですよね。問いを投げるだけです。こちらも1枚で見てみます。 冒頭3秒チェック→読者像→前提知識→動機→価値、という順番で、「ここで離脱しそう」「ここで意味わからんくなりそう」を言葉にしてくれます。 調査から見えた傾向として世の中のAIレビューって、修正案や評価点をAIに出させる設計がほとんどなんですよね。でも問いだけ投げて、答えは人間が持つ、というスタイルはあまりないんですよね(僕調べ)。 僕がこの実装にしている理由はシンプルで、読者像のズレって、最終的には人間が判断すべきことだと思ってるからです。AIに「この読者にはこう書け」まで決めさせると、逆に読者像がAIの想像で固定されてしまう。だから答えは渡さず、問いだけ渡してもらってます。ざっとあらを探してもらってディスカッションするって感じですね。 というのは僕の体感なんですが、①の競合調査も同じ結論でした。さっきの「差別化の機会」表の下の行、あれがまさにこの設計についての行です(文言そのまま乗っけてます)。つまり「問いだけ投げる」は思いつきじゃなく、競合調査で「空いてる」と裏が取れた角度なんですよね。 なぜこれが書く前に効くかというと、「この用語、説明なしで出てないか」「読者はそもそもなぜこの記事を開くのか」を、アウトラインの段階で潰せるからなんですよね。書き上げてから読者像のズレに気づくと、直すのが本当に大変です。構成ごと組み直しになったりする。アウトラインの段階で潰しておけば、そこまで巻き戻ることはほぼなくなります。 副次的な効果として、前提知識の存在に気付けるということですね。前提となっている知識って自分がこれまで積み上げてきたものそのものだと思います。ただ、自分の前提知識って意外と自然に流しちゃうんですよね。相手も知っている体でブログを書いちゃう、みたいなことが起きがちで。まれに前提知識だけで一本のブログになったりするんでネタの発掘にもいいですよ。 実物を見てもらうのが早いです。この記事のアウトラインに、まさにこの reader-advocate-agent をかけたら、こんな問いが返ってきました(出力そのままです)。 「手戻りに消耗している」読者は、冒頭の何行目で「これは自分の話だ」と気づけますか? 音声入力のエピソードは、読者が自分ごと化する 前 に必要な前置きですか、それとも省略しても成立する自分語りですか? 核心の逆転主張(レビューは書く前にやる)まで、何行分の前置きなら読者は待ってくれると思いますか? 見ての通り、ひたすら問をしてきます。「このエピソード要らない」みたいな評価もしない。ただ問うだけです。で、答えるのは僕。実はこの記事、最初の書き出しは音声入力でいきなり喋りながら書き始めた失敗談から入ってたんです。でもこの「音声入力のエピソードは自分ごと化の前に必要か?」って問いを見て、「確かに、読者が『自分の話だ』と気づく前に、僕の自分語りを聞かせてるな」と。それで冒頭を丸ごと組み直しました。いま読んでもらってる書き出しは、その結果なんですよね。もし答えまで渡されてたら、この気づきは自分のものにならなかった気がします。 実運用としては、1回起動して「冒頭3秒チェック+問い1〜2個」まで出してもらうところで一旦止めてます。そこから先の対話は、メインの執筆セッションで自分が引き継ぐ形です。 書く前に回すと、どうなるか 改めて Before/After で見てみます。 Before:勢いでいきなり書く→書いてる途中で要素が後から湧く→後付けで詰め込む→話がとっちらかる。あるいは、流れ自体は綺麗に整っていても、方向がズレていて刺さらない。 After:outline で流れを整備して、書く前に2体(競合調査=角度、読者代弁=読者目線)を回して方向を確定させる。そうすると、執筆が「もう決めた流れと方向をなぞる作業」になるんですよね。だから手戻りが消える。 ただ、正直に限界も言っておきたくて。エージェントがやってるのは方向を「固める」ことなんです。「決める」のは、あくまで人間です。どの角度を採るか、被っても出すかどうか。ここの最終判断は、さすがに自分でやってます。エージェントに全部委ねてるわけじゃない。ここは削らずに残しておきたいポイントです。 まとめ:レビューを前に倒すと、書くのがラクになる 今回の話は、書く前、アウトラインの段階でエージェントにかけると、手戻りがぐっと減ります。決めるのはたった2つ。誰も書いてない角度か、そして読者が置いていかれないか。この2つだけです。 今回話したのは、執筆全体の中の1工程です。検証から執筆、仕上げ、公開まで含めた全体の流れは、 別記事 でまとめてます。気になる方はそちらも覗いてみてください。 ほなまた〜 付録:この記事で使っている定義 「作り方・設計論」は別記事に譲りますが、「実物の定義」はここに置いておきます(公開用に少しだけ整形してます)。雰囲気とスコープの参考にどうぞ。 1. アウトライン設計のルール( CLAUDE.md 抜粋) アウトラインを作るときに効いてるルール。メタデータの雛形と、「レビューは通す前に通る状態を作る」という制作順序が本体です。 ## アウトラインのメタデータ アウトライン冒頭に、この3つを必ず書く。 - 狙う検索クエリ: この記事で狙う検索キーワード - 読者像: どんな人が読むか。抱えているペインや状況を具体的に - 記事の核: この記事で一番伝えたいこと。一文で ## 制作の順序(レビューは「通す」前に「通る状態」を作る) 順序自体が品質を担保するので崩さない。 1. 被り発見 → 角度発明: 既存資産と被ったら、畳む前に角度を変えられないか(標準/競合を敵役に据える逆張り等)。 2. 書く前に三重検証: ①主張の真偽(一次ソースで裏取り)②市場の空き(競合・SEOを本文を書く前に偵察)③一次ソース固め(バージョン・公式見解は公式 docs で確定、二次ブログに頼らない)。 3. outline に読者像・狙うクエリ・核・裏取りと、「展開(話がどう転がるか)」「範囲(話す/話さない)」を明記し、読者像を尖らせる。 4. 本文を書く前に著者の実体験を取りにいく。声メモ(生文字起こし)が効果大。体温(実体験)は推敲では足せないので素材の中心に据える。 5. ノイズ除去(AIっぽさ削り)を構造レビューより先に。ノイズが残ると読者/論理レビューが表面に気を取られ本質を見落とす。 6. 最後に構造レビュー(論理 / 読者目線)。 ## outline の分割判断(1記事に詰め込まない) - 小見出し(H3)で「方法を N 個並べる」構造になったら、各方法のメンタルモデルが同じか確認する。抽象度・読者に要求する思考が違えば別記事に切り出す。「ペインが同じ」はまとめる理由にならない。 - 分割した各 outline 冒頭に「棲み分けメモ」(対症療法 vs 体質改善 等の対の構図)を固定し、越境・重複を防ぐ。 2. 競合調査エージェント competitor-reviewer --- name: competitor-reviewer description: 競合記事のタイトルとコンテンツを分析します。検索上位記事の構造・パワーワード・技術的深さを評価し、差別化戦略を提案します。結果はファイル出力します。 tools: [Read, WebSearch, WebFetch, Write] model: sonnet --- # Competitor Reviewer Agent あなたは競合記事の**タイトルとコンテンツ**を分析するエージェントです。 ## 主な責務 1. 検索上位の競合記事を収集 2. タイトル構造・パワーワード・数値使用を分析 3. コンテンツの深さ・構成・技術的価値を評価 4. 差別化戦略の提案 ## 使うべきタイミング - 記事執筆の最初(競合を把握してから書く) - 記事公開前(差別化ポイントの最終確認) ## 評価プロセス(4段階) ### Phase 1: 分析対象の把握 入力(ファイル or キーワード)から、記事タイトル・H2見出し・技術スタック・メインキーワードを抽出。 ### Phase 2: 競合記事の収集 メインキーワードで検索クエリを設計し、WebSearch で各クエリ5〜10件収集 → WebFetch で上位5件を精読。 各記事から「タイトル/文字数・H2構造・扱う技術・コード例の質と量・図解・対象読者・独自性」を抽出する。 ### Phase 3: 分析 - タイトル分析: 文字数・キーワード配置(前半/後半)・パワーワード・数値使用 - コンテンツ分析: 網羅性・技術的深さ・実用性・独自性・構成 ### Phase 4: レポート作成とファイル出力 分析結果を {対象ディレクトリ}/review/competitor-{YYYY-MM-DDTHHMM}.md に出力する。 ## 出力形式(要点) - タイトル分析(競合タイトル一覧+サマリー) - コンテンツ分析(記事ごとの強み・弱み) - 全体傾向(競合の共通パターン / **競合で扱われていないトピック=差別化の機会**) - 差別化戦略(タイトル戦略・コンテンツ戦略) - 推奨される次のアクション **重要**: 会話への直接出力ではなく、必ずファイルに書き出す。 ## 評価原則 - データに基づく分析(収集したタイトル・コンテンツから客観的に) - 具体的な推奨(すぐ活用可能な形式で) - 差別化の視点(競合と異なるアプローチの機会を特定) - 中立的な評価(強み・弱みを公平に) 3. 読者代弁エージェント reader-advocate-agent --- name: reader-advocate-agent description: 読者の代弁者として「ここで離脱する」「ここで意味わからん」を具体的に言語化し、書き手に問いを投げるエージェント。答え(修正案・評価)は出さず、問いだけを投げる。 tools: [Read] model: sonnet --- # Reader Advocate Agent - 読者の代弁者 あなたは**読者の代弁者**として、書き手と対話するエージェントです。 ## 重要な制約 1. **答えを出さない**: 修正案や評価を出さない。問いを投げる 2. **めげない**: 「大丈夫です」「良いと思います」で終わらせない 3. **読者側に立つ**: 書き手ではなく、読者の「わからない」を代弁する 4. **対話を続ける**: 書き手が「あ、確かに」と気づくまで掘り下げる ## やらないこと - 記事の評価をしない / 修正案を出さない / 褒めない / 1回の質問で終わらせない ## 想定読者: 刺激に慣れた読者層 冒頭で惹きつけないと離脱する / 長い前置きは読まない / 「何が嬉しいか」が最初にないと興味を持たない / 文章の壁を見た瞬間に閉じる。**この層の目線で「ここで離脱する」「ここで意味わからん」を具体的に言語化する。** ## 実行スコープ 1回の実行で「Step 0(冒頭3秒チェック)」と「次に投げる問い1〜2個」までを担当する。それ以降の対話はメインセッションが引き継ぐ。 ## 対話の進め方 ### Step 0: 冒頭の3秒チェック 読者として記事を見た瞬間の反応を言語化する。 - 躓き例: 「ここで閉じます。理由は〇〇」「この単語で『自分向けじゃない』と判断します」「文字多すぎ。スクロールする気力ない」 - 問い: 「最初の2-3行で『自分に関係ある』と思えますか?」「スクロールせずに見える範囲で価値が伝わりますか?」 ### Step 1: 対象読者の確認 「誰に向けて書いてますか?」「その人は普段どんなツール使ってますか?」「この技術を触ったことありますか?」 ### Step 2: 前提知識の確認 - 躓き例: 「『〇〇』って何?説明なしで出てきた」「この使い方、普通じゃないけど普通として話が進んでる」 - 問い: 「この用語、読者は知ってますか?」「この使い方、読者にとって『普通』ですか?」 ### Step 3: 動機の確認 - 躓き例: 「タイトルで期待したことと違う話が始まった」「課題感に共感できない」「コピーがセンセーショナルすぎて一歩引く」 - 問い: 「読者はなぜこの記事を開くと思いますか?」「このコピー、読者の8割が頷けますか?」 ### Step 4: 価値の確認 - 躓き例: 「読み終わって『で?』ってなる」「情報はあるけど行動に移せない」 - 問い: 「読者はこれを読んで何ができるようになりますか?」「『ふーん』で終わらないですか?」 ## 対話スタイル まず躓きを言語化し、その後1〜2個の問いを投げる。書き手の回答を受けてさらに掘り下げる。沈黙しない。 ## 実行時の出力形式 ``` ## 読者視点チェック(初回) ### Step 0: 冒頭3秒チェック [躓きポイントの具体的な言語化] ### 最初の問いかけ [1〜2個の問い] --- *続きの対話はメインセッションで。書き手が回答したら Step 1〜4 の順で掘り下げます。* ``` ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post 技術ブログの手戻りが消える、アウトラインのAIレビュー first appeared on SIOS Tech Lab .
Claude Code は認証トークン・設定・会話履歴を、すべて ~/.claude の下(と、すぐ隣の ~/.claude.json )に置きます。Dev Container で使うときは、再ビルドのたびの再ログインを避けるために、このディレクトリをホストから丸ごとマウントするのが定番です。 この構成を複数のプロジェクトで使っていて、気になり始めたことがあります。 全部のコンテナが、ホストにある同じ ~/.claude を書き換え合っている 、ということです。 手元の ~/.claude.json の中身を数えると、こうなっていました。 $ python3 -c "import json,os; d=json.load(open(os.path.expanduser('~/.claude.json'))); \ print('projects:', len(d['projects']), '/ githubRepoPaths:', len(d['githubRepoPaths']), '/ numStartups:', d['numStartups'])" projects: 36 / githubRepoPaths: 13 / numStartups: 1278 36プロジェクト分の状態が1ファイルに入っています。 projects/ 配下の会話履歴も同じで、 いま作業しているリポジトリとは関係のないプロジェクトの履歴が、どのコンテナからも見えます 。逆方向も起きます。自分がいるコンテナの外で走った Claude Code の書き込みが、知らないうちに手元のファイルに反映されます。 先に正直に書いておくと、 この構成で壊れた経験は一度もありません 。直したかった理由は単純で、 コンテナに閉じたはずの作業が、コンテナの外と混ざっているのが気持ち悪い からです。コンテナに入れた意味が薄れます。 本記事では、 ~/.claude の中身を 更新頻度で分類 して、コンテナをまたいで共有し続けるものと、プロジェクトごとに独立させるものの線を引きます。そのうえで後半では、その線引きを Dev Container(docker-compose 版と素の devcontainer.json 版)で実装します。ディレクトリの中身そのものについては、同じブログに Claude Codeが作成する~/.claudeディレクトリの詳細解析 があります。 .credentials.json や projects/ が何をしているファイルなのかはそちらが詳しいので、役割から知りたい場合は先に読むと早いです。 この記事でわかること : ~/.claude の中身を更新頻度で2つに分ける線引き(共有し続けるもの / コンテナごとに分けるもの) .claude.json が既定ではどこにあるのか。なぜそれが「全コンテナの共用ノート」になるのか 履歴と設定をプロジェクトごとに独立させつつ、再ログインは不要に保つ実装(compose ベースと素の devcontainer.json の両方) named volume の所有者問題( chown を 非再帰 にする理由) 「稀更新なら共有してよい」の例外( plugins/ を共有すると壊れる理由) 検証環境 : ホストは Linux / WSL2 です。リポジトリも WSL 上に置きます。macOS は認証情報の保存先が異なり本構成がそのままは成立しません(末尾の「制約・前提」で扱います)。 向き / 不向き : 対象は ホストの ~/.claude.json まで bind している人 です。すでに volume だけで運用しているなら、ここで扱う「混ざる」現象は起きていません。なお切り替えても ホスト側の履歴が消えることはありません 。ホストにはそのまま残り、以降の会話がコンテナ側にだけ増えていきます。見えなくなるだけですが、 --resume でホスト側の過去を頻繁に遡る使い方とは正面からぶつかります(一度きりの持ち込み手順は後述します)。 ~/.claude は全コンテナの共用ノートになっている .claude.json は ~/.claude の中に無い まず位置関係から押さえます。共有すると混ざるファイルの主役は .claude.json ですが、 これは既定では ~/.claude の中にありません 。 ~/.claude.json という、 ~/.claude と並ぶ別のパスに置かれます。 $ ls -la ~/.claude.json ~/.claude/.claude.json -rw-r--r-- 1 vscode vscode 104786 Jul 30 15:27 /home/vscode/.claude.json ls: cannot access '/home/vscode/.claude/.claude.json': No such file or directory だから、以前書いた Claude Code×DevContainer 環境構築ガイド の設定も、この2つを別々に bind していました。 "mounts": [ "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached", "source=${localEnv:HOME}/.claude.json,target=/home/vscode/.claude.json,type=bind,consistency=cached" ] つまりプロジェクトの状態が全コンテナで混ざるのは、正確には 2行目のほうを共有しているから です。 ~/.claude だけを bind して ~/.claude.json を共有していない人は、混ざりの主役をまだ共有していません。 中身を更新頻度で並べ直す settings.json や skills/ のような user レベルのものは、そもそもどこで作業していても同じものを使う前提で用意されています。共有したいのは自明で、迷いません。 迷うのは .claude.json です。名前も中身も「設定ファイル」の顔をしていて、実際に user レベルの設定も入っています。役割で並べると共有側に落ちます。ところがこのファイルは起動や操作のたびに書き換わり、中身の大半はプロジェクトごとの状態です。 書き込み頻度で並べると、ここが状態側に落ちます。 中身 役割 書き込み頻度 .credentials.json 認証トークン 稀 (ログイン時・トークン更新時のみ) settings.json user レベル設定(権限の許可リストなど) 稀 (設定を変えたときだけ) CLAUDE.md user レベルのメモリ 稀 (書いたときだけ) skills/ user レベルの skill 稀 .claude.json (既定では ~/.claude.json ) 設定・プロジェクトの信頼状態など 高 (起動・操作のたびに書き換わる) projects/ ・ sessions/ ・ history.jsonl 会話履歴・プロンプト履歴・実行中セッションの検出 高 (操作のたびに書き換わる) この表で上下がきれいに分かれます。 コンテナをまたいで共有して嬉しいのは上の稀更新グループだけ です。認証トークンは共有したいから共有している。user レベルの設定・メモリ・skill も、どのプロジェクトで作業していても同じものを使いたい種類のものです。 混ざって困るのは下の高頻度グループ です。ここには2つの向きの困りかたがあります。 他のプロジェクトの状態が見えてくる : .claude.json の projects は全プロジェクト分の信頼状態を1ファイルに持ちます。 projects/ 配下の会話履歴も、いま開いているリポジトリと無関係なものまで並びます 自分のいるコンテナの外からの書き込みが入ってくる : 別のコンテナ(あるいはホスト)で走った Claude Code が、同じファイルを更新します。手元では何もしていないのに中身が変わります 再ログイン不要のために本当に欲しいのはトークン1つなのに、丸ごと共有はその巻き添えで、混ざって困るものまで全部共有してしまう。これが直したかった状態です。 この下側の分け方は、こちらで発明したものではありません。公式の claude project purge (v2.1.124 以降)は、プロジェクト1つぶんの状態を消すコマンドです。その削除対象が projects/ の会話ログ、 history.jsonl の該当行、そして ~/.claude.json のそのプロジェクトのエントリ—— 本記事が隔離するものとほぼ同じ です。Anthropic 自身も、この3つを「プロジェクト固有の状態」として数えています。ただし purge は溜まったものを後から消すコマンドなので、作業している最中に他プロジェクトの状態が見えることは変わりません。 それでも「丸ごと共有」が広まったのは、そのほうが手軽だから この分類を無視して丸ごと扱う方式が広く使われているのには理由があります。Dev Container で再ビルドのたびに再ログインしたくない、という要望に対して、 ~/.claude をまとめてコンテナに渡すのが一番手数が少ないからです。 以降は bind と volume を、この記事の軸で読み分けてください。 bind はホスト側の実体を指すので共有される 。 named volume はコンテナ側の実体なので共有されない 。この記事がやろうとしているのは、その2つを1つのディレクトリの中で使い分けることです。 定番の方式は、大きく2つに分かれます。 定番方式 何をするか 達成できること 抱える問題 丸ごと named volume(公式リファレンス実装がこれ) ~/.claude 全体を named volume で永続化する 再ビルドを跨いで設定・履歴・認証を保持でき、 コンテナごとに独立する ホストとログインを共有しない= コンテナ側で別途ログインが要る 丸ごと bind(ホスト共有系) ホストの ~/.claude をそのまま bind する ホストのログインをそのまま使え、 再ログインが不要 全コンテナが同じファイルを書き換え合う 前者は Anthropic 自身の リファレンス実装 が採っている形です。 "source=claude-code-config-${devcontainerId},target=/home/node/.claude,type=volume" 後者はさきほどの入門編で採ったものです。表を見ると、2つの方式は「再ログインの手間」と「コンテナごとの独立」をトレードオフにしています。どちらも捨てたくない、というのが本記事の出発点です。 なお公式のこの実装は、 CLAUDE_CONFIG_DIR にも同じ /home/node/.claude を指定しています。後述するとおり本記事も同じことをします。環境変数のリファレンスに項目が無い変数ですが、公式のリファレンス実装自身が使っている、という位置づけのものです。 分離設計:共有し続けるものと、プロジェクトごとに分けるもの ここからが本記事の主張の中心です。方針は 稀更新のものはホストと共有し続け、頻繁更新のものは named volume でコンテナごとに独立させる こと。前述の二択(丸ごと volume / 丸ごと bind)のどちらでもない、 第3の中間設計 です。 先に性格を断っておきます。これは 公式のリファレンス実装が示す形から外れた回避策 です。Docker がマウントを深さ順に並べ替えること、 CLAUDE_CONFIG_DIR が集約先を動かすこと——この2つの挙動に乗って成立しています。どちらも後で実物で確かめますが、公式が保証した組み合わせではないので、 どちらかが変われば追従が要る 前提で採ってください。 対象 扱い なぜ .credentials.json (トークン) ホストと bind 共有 再ログインを不要にしたい。更新は稀 settings.json ・user CLAUDE.md ・ skills/ ホストと bind 共有 稀更新。どのプロジェクトでも同じものを使いたい。分けるとホスト側で育てた許可設定や user 資産がコンテナで使えなくなる .claude.json ・ projects/ ・ sessions/ ・ history.jsonl named volume でコンテナごとに独立 頻繁に更新される。他プロジェクトの状態が見えず、外からの書き込みも入らない。volume は再ビルドで消えず永続する plugins/ 共有しない(コンテナごと) 稀更新だが例外。理由は「ハマり所」で述べます バイナリ( ~/.local/ ) コンテナ固有(共有しない) 再ビルド時に入れ直す。代償として再ビルドまで版が固定される volume 名をプロジェクトごとに変えるのが肝 です。同じ名前を使い回すと、せっかく volume にしてもプロジェクト間で中身を共有してしまいます。リポジトリ名を含めた名前(例: myrepo-claude-config )にしておけば、履歴と .claude.json はプロジェクトごとに完全に独立します。 この設計は次の2段構えで成立します。 1段目: CLAUDE_CONFIG_DIR で集約先を $HOME/.claude に固定する。 ポイントは、これが 既定と同じパスの明示指定 であることです。効果は「外にあった .claude.json が ~/.claude の中に入る」ことです。これで .claude.json が volume の内側に入り、 リビルドを跨いで残せるようになります 。指定しなくてもコンテナごとに独立はしますが、その場合は volume の外=リビルドで消える場所に書かれるので、独立と永続の両方を取るにはこの1段が要ります。認証情報についても、Linux / Windows では CLAUDE_CONFIG_DIR を設定すると .credentials.json がそのディレクトリ配下に置かれることが 認証ドキュメント に明記されています。 公式のリファレンス実装が現に使っている変数で、 .claude ディレクトリの解説 にも「これを設定すれば ~/.claude 配下のパスはそちらの下に移る」と書かれています。それでも環境変数の 公式リファレンス には項目として載っていません。 前者は後者へリンクを張っているのに、飛んだ先にその項目が無い 、という状態です(2026-08-02 時点)。ドキュメント化を求める Issue #33430 は not planned として close されました 。載せてほしいという要望も「この変数が効いていない」というバグ報告も上がっているのに項目は無い—— 動くから使われている 、という位置づけの変数です。 さらに、この公式の記述は ~/.claude 配下 のパスについてのものです。本記事が頼っているのは、その外にある ~/.claude.json を配下へ引き込む挙動のほうで、そちらはどの公式ドキュメントにも書かれていません(後述の「動作確認」で実物を見ます)。機能はしますが、リファレンスに裏付けられた公式仕様ではない点は承知の上で採ってください。 既定と同じパスを指定することには副産物もあります。 CLAUDE_CONFIG_DIR を尊重せず ~/.claude を見にいってしまう既知バグ( #4739 は /ide 連携のロックファイル、後継の #30538 は VS Code 拡張機能。後者は現在も open)がありますが、フォールバック先と指定先が同じディレクトリになるため、この一族のバグは実質的に無効化されます。 なおどちらも IDE 連携側の不具合で、CLI 本体の資格情報の集約には及びません。 2段目: 集約先を named volume にし、その上に稀更新のものだけを深いパスで bind して重ねる。 ディレクトリ全体を指す volume に対して、より深いパスを名指しした bind を重ねます。後者が前者の上に乗るので、ディレクトリ全体は volume(コンテナ固有)のまま、名指しした少数のファイルだけがホスト共有になります。 この重なりは 書く順番に依存しません 。わざと bind 4本を volume より先に書いたコンテナを起動して、中から実際のマウント順を見るとこうなります。 $ grep -i claude /proc/self/mountinfo | awk '{print $5}' /home/vscode/.claude /home/vscode/.claude/.credentials.json /home/vscode/.claude/settings.json /home/vscode/.claude/CLAUDE.md /home/vscode/.claude/skills 指定した順ではなく、 浅いものから順に 並び直っています。volume を先・後・真ん中に置いた3通りで試しましたが、どれも同じ並びになり、共有した4つの中身もホスト側のものが見えました。 並べ替えているのは Docker デーモンです(上の対照は docker run で直接組みましたが、compose も devcontainer.json も最後は同じデーモンを通ります)。moby の daemon/volumes.go に、target のパス区切りの数でマウントを整列する sortMounts があります。コメントは「マウントが他のマウントを覆い隠さないようにするため。たとえば /etc と /etc/resolv.conf をマウントするなら、 /etc/resolv.conf を先にマウントしてはならない」と、まさにこの用途を書いています。 書く順番は気にしなくて構いません。深さの関係さえ作れていれば狙った重なりになります。実際に重なったかどうかは、後述の「動作確認」で自分の環境で目視できます。 つまり「コンテナ固有の volume の中に、共有したいものだけホストへの窓を開ける」構成です。これで「再ログイン不要」と「履歴や状態がプロジェクトごとに独立する」を同時に満たせます。正確には、 導入時の1回だけはログインが必要 で、そこで書かれたトークンがホスト側に残るため2回目以降のリビルドで不要になります(実測は後述の「動作確認」)。 引き換えに、履歴の置き場所が弱くなる 得るものだけ書くのは不誠実なので、先に払うものを1つ出しておきます。 この設計は、会話履歴の耐久性を確実に下げます。 丸ごと bind の構成では、履歴はホストの ~/.claude/projects/ にありました。ホームディレクトリごとバックアップを取っていればその中に入りますし、コンテナを何回作り直しても残ります。本構成に移すと、履歴は named volume の中へ移ります。リビルドでは消えませんが、 ホームのバックアップ対象からは外れます 。そして docker volume prune や docker system prune -a --volumes で消えます。ディスクが逼迫したときに反射で叩くコマンドです。 「再ビルドで消えない」ことと「消えない」ことは別だ、と理解したうえで採ってください。履歴を資産として扱っているなら、volume を対象にしたバックアップを別途組む必要があります(本記事ではそこまで踏み込みません)。混ざらないことと引き換えに何を差し出すのかは、末尾の「制約・前提」にも一覧で置いています。 Dev Container で実装する ここからは、上の線引きを実際の設定ファイルに落とします。読者の環境がどちらでも動くように、compose ベースと素の devcontainer.json の両方の完成コードを載せます。 検証状況 : compose 版は別のリポジトリで運用中の構成です。素の devcontainer.json 版はこの記事を書いているリポジトリに適用し、リビルドを2回跨いだ実測を後述の「動作確認」に載せています(Claude Code v2.1.220 / WSL2)。 compose ベースの場合 workspace サービスに、環境変数・named volume・重ね bind を定義します。 services: workspace: environment: CLAUDE_CONFIG_DIR: /home/vscode/.claude DISABLE_AUTOUPDATER: "1" volumes: # 頻繁更新分(.claude.json / projects / sessions / history.jsonl)はコンテナ隔離 - type: volume source: claude_config target: /home/vscode/.claude # 稀更新分だけ、深いパスの bind を重ねてホスト共有する - type: bind source: ${HOME}/.claude/.credentials.json target: /home/vscode/.claude/.credentials.json - type: bind source: ${HOME}/.claude/settings.json target: /home/vscode/.claude/settings.json volumes: claude_config: CLAUDE_CONFIG_DIR は集約先を固定します。前述のとおり、外にある .claude.json を ~/.claude 配下に引き込むのが狙いです。 DISABLE_AUTOUPDATER: "1" はバージョンを決定的にします。バイナリは ~/.local/ (コンテナ固有)にあるため、更新は再ビルド時に入れ直す形になります。裏を返せば 再ビルドするまで古い版に留まる ということなので、更新を取り込みたいタイミングで再ビルドしてください。 named volume を /home/vscode/.claude にマウントし、設定・履歴・ .claude.json をコンテナに隔離して再ビルドを跨いで永続させます。 稀更新のファイルを同名パスへ重ね bind します。より深いパスの bind が volume の上に重なるため、そのファイルだけがホスト共有になります。 consistency: cached は付けていません。Docker Desktop for Mac(osxfs)時代のオプションで、 Linux では無視される ためです。既存の設定に残っている場合は機能上無害ですが、意味があるように見えるぶん紛らわしいので外しておくのが親切です。 環境変数をもう1つ入れるなら CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1" も候補で、共有と隔離の線引きとは独立した話題になりますが、チームで使うときの考え方は Claude Code Auto Memory をチームでは使わない理由 に書いています。 素の devcontainer.json の場合 compose を使わない構成では、マウントは devcontainer.json の mounts 配列に書きます。Claude Code に関係する部分だけ抜き出すと次の形です。 { "remoteUser": "vscode", // ホストとコンテナで uid が食い違う場合に揃える。重ね bind したファイルの // 権限調整をこれに委ねるため、本構成では明示的に有効化しておく "updateRemoteUserUID": true, "remoteEnv": { // 既定の $HOME/.claude と同一パスを明示指定する(.claude.json を配下に引き込む) "CLAUDE_CONFIG_DIR": "/home/vscode/.claude" }, // bind のソースはコンテナ生成前にホスト側へ実体を作っておく(ホスト上で実行される) "initializeCommand": "mkdir -p ~/.claude/skills && touch -a ~/.claude/.credentials.json ~/.claude/CLAUDE.md && chmod 600 ~/.claude/.credentials.json && { [ -s ~/.claude/settings.json ] || echo '{}' > ~/.claude/settings.json; }", // 先頭の chown に -R を付けてはいけない(理由は後述) "postCreateCommand": "sudo chown vscode:vscode /home/vscode/.claude && curl -fsSL https://claude.ai/install.sh | bash", "mounts": [ // 頻繁更新分はコンテナ隔離。volume なのでリビルドで消えない。 // volume 名にはリポジトリ名を入れる(理由は直後の「3点目」を参照) "source=myrepo-claude-config,target=/home/vscode/.claude,type=volume", // 稀更新分だけ深いパスの bind を重ねる "source=${localEnv:HOME}/.claude/.credentials.json,target=/home/vscode/.claude/.credentials.json,type=bind", "source=${localEnv:HOME}/.claude/settings.json,target=/home/vscode/.claude/settings.json,type=bind", "source=${localEnv:HOME}/.claude/CLAUDE.md,target=/home/vscode/.claude/CLAUDE.md,type=bind", // ディレクトリでも同じように重ねられる "source=${localEnv:HOME}/.claude/skills,target=/home/vscode/.claude/skills,type=bind" ] } compose 版との違いは3点です。 マウントを書く場所 (各サービスの volumes: か、 devcontainer.json の mounts 配列か)、 HOME の変数記法 ( ${HOME} か ${localEnv:HOME} か)、そして volume 名の扱い 。前の2つは書き換えるだけですが、3点目は設計の肝に関わるので単独で説明します。 compose の top-level volumes: に書いた名前は、 compose プロジェクト名でプレフィックスされます 。 Compose Specification が既定動作として定めていて、例外は name: を明示したときだけです。だから claude_config という汎用名のままでも、プロジェクトごとに別の volume になります。 一方 devcontainer.json の mounts は compose を経由せず、Docker CLI のマウント構文へそのまま渡ります。 プレフィックスの仕組みが挟まらないので、書いた名前がそのまま volume 名になります 。これは後述の確認2で実際に見えます。手元の devcontainer.json には source=sios-claude-config と書いてあり、 findmnt が返す実体も /var/lib/docker/volumes/sios-claude-config/_data です。リポジトリ名は付いていません。 つまり汎用名のままだと全プロジェクトが同じ volume を掴み、前述の「プロジェクトごとに独立させる」が成立しなくなります。 ただ、名前を手で付け分けるのは規律に頼る運用です。忘れれば静かに壊れます。ここは変数で自動化できます。 // リポジトリのフォルダ名から導出する(読める名前になる) "source=claude-config-${localWorkspaceFolderBasename},target=/home/vscode/.claude,type=volume" ${localWorkspaceFolderBasename} は devcontainer 仕様の変数で、 json_reference が mounts での利用を認めています。 ただしこれは フォルダ名から導出するので、別の場所にある同名リポジトリとは衝突します 。 ~/work/app と ~/oss/app を両方 Dev Container で開いていれば、どちらも claude-config-app を掴みます。手で命名するよりは安全ですが、衝突が消えるわけではありません。 そこまで潰すなら ${devcontainerId} を使います。Anthropic 自身のリファレンス実装がこれです。上と同じ json_reference が「その dev container に固有で、リビルドを跨いで安定する識別子」と定めているので、同名フォルダでも衝突しません。代わりに名前が不透明になり、 docker volume ls で目視できなくなります。 同名リポジトリを複数開く可能性があるなら ${devcontainerId} 、読める名前を優先するならフォルダ名 、という選び方になります。 initializeCommand について補足します。ファイル bind はソースが存在しないと、Docker がそれを root 所有のディレクトリとして誤生成してしまいます。これを防ぐため、ホスト側に空ファイルを先に作ります。 initializeCommand はコンテナ生成前にホスト上で実行されるので、ここが適切な置き場所です。 3点、細かいが外せない注意があります。 chmod 600 を付ける 。公式ドキュメントは Linux の .credentials.json を “file mode 0600 ” と明記しています。 touch だけだと既定の umask で 644 のトークンファイルができあがるため、明示的に絞ります。 settings.json は空ファイルにしない 。 {} で初期化します。 .credentials.json は空でも “Not logged in” として扱われますが、 settings.json が空だと JSON パースに失敗しうるためです。 mkdir -p / touch -a は冪等 なので、すでにファイルを持っているメンバーの環境では何もしません。 postCreate での所有者修正とインストール コンテナ生成後は、所有者修正 → インストーラの順で実行します。 # 新規 named volume はマウントポイント(~/.claude)が root 所有で初期化される。 # 非再帰で「ルートだけ」を vscode へ。-R は使わない(重ね bind したホスト側の # 実体を巻き込むため。理由は後述) if [ -d "$HOME/.claude" ]; then echo "[postCreate] Fixing ownership of .claude volume root (non-recursive)" sudo chown vscode:vscode "$HOME/.claude" fi # 非ブロッキング: ネットワーク/プロキシ起因の失敗で postCreate 全体を止めない echo "[postCreate] Installing Claude Code (native installer)" if curl -fsSL https://claude.ai/install.sh | bash; then echo "[postCreate] Claude Code installed" else echo "[postCreate][WARN] Claude Code install failed (network/proxy?); continuing" fi echo "[postCreate] Completed" インストーラを if で包んでいるのは、企業プロキシなどで到達できなかったときに postCreate 全体を止めないためです。 既存の履歴を持ち込む この構成に移ると projects/ は空から始まります。ホストに残った過去の会話を引き継ぎたい場合は、手でコピーできます。ただし cp だけでは足りず、 ディレクトリ名の付け替えが要ります 。 projects/ 配下のディレクトリ名は、開いていたワークスペースのパスから機械的に導かれます。ホストで /home/ryu/product/blog/sios-tech-blog-with-claude を開いていたなら -home-ryu-product-blog-sios-tech-blog-with-claude 、コンテナで /workspaces/sios-tech-blog-with-claude を開けば -workspaces-sios-tech-blog-with-claude です。名前が違うので、そのままコピーしてもコンテナ側は「このプロジェクトの会話は無い」と言います。実際に名前を揃えずに入れてみたところ、 claude --resume は空のままでした。 ホスト側で ls ~/.claude/projects/ を見て自分のディレクトリ名を確かめてから、名前を付け替えつつ volume へ流し込みます。 # ホスト側で実行。<volume> は devcontainer.json に書いた volume 名 docker run --rm \ -v <volume>:/dst \ -v ~/.claude/projects/-home-ryu-product-blog-sios-tech-blog-with-claude:/src:ro \ alpine sh -c 'mkdir -p "/dst/projects/-workspaces-sios-tech-blog-with-claude" \ && cp -a /src/. "/dst/projects/-workspaces-sios-tech-blog-with-claude/" \ && chown -R 1000:1000 /dst/projects' chown の 1000 はコンテナ側 vscode の uid です。root 所有のまま置くと Claude Code が書き込めません。 必要なのはここまでで、 .jsonl の中身を書き換える必要はありません 。各行は cwd にホスト側の絶対パス( /home/ryu/... )を持ったままですし、 sessions-index.json の originalPath もホストのパスのままですが、コンテナ内の claude --resume はディレクトリ名だけを見てセッションを拾います。持ち込んだセッションを実際に開くと、リビルド前のやり取りがそのまま復元されて続きから会話できました。 ただし持ち込めるのは その時点の断面だけ です。これ以降ホスト側で増えた会話は入ってきません。あと、リポジトリによっては projects/ 配下が数百MBあるので(手元の1リポジトリで885MB)、流し込む前にサイズは見ておいてください。 ハマり所 ここからは実装上の落とし穴です。本構成を実際に組んだときに踏んだものだけを並べます。 named volume の初期所有者は root なので chown は非再帰で named volume を 新規作成 すると、マウントポイント( ~/.claude )は root(uid 0)所有 で初期化されます。このままだと vscode ユーザーが配下に書き込めず、設定の保存が失敗します。そのため postCreate で ~/.claude の所有者を vscode に直します。volume は再ビルドで消えないため、初回作成後はすでに vscode 所有となり、この処理は冪等です。 ここで重要なのが、 chown に -R (再帰)を付けてはいけない ことです。 この時点の ~/.claude は、新規 volume(空)に重ね bind したファイルが乗っているだけの状態です。root 所有なのはルートディレクトリ自身だけで、サブディレクトリは Claude 初回起動時に vscode 所有として作られます。ルートだけ直せば十分です。 -R を付けると、重ね bind したファイルまで再帰の対象に入ります。この bind 元は ホスト側のファイルそのもの(コンテナとホストで同じ実体を指しています) なので、コンテナ内での再帰 chown が ホスト側の所有権まで書き換えます 。ホストの uid が 1000 でない環境では、書き換えられた結果ホスト側の Claude Code がトークンを読めなくなります。共有するファイルを増やすほど、この事故の影響範囲は広がります。 非再帰なら共有ファイルには一切触れません。権限調整は initializeCommand (ホスト側で実行)と updateRemoteUserUID: true に委ねます。 plugins/ は稀更新でも共有できない 「稀更新なら共有してよい」の例外です。 plugins/ は更新頻度こそ低いのですが、 レジストリの中身がコンテナの絶対パスを持っています 。 known_marketplaces.json の installLocation installed_plugins.json の installPath / projectPath これらに /home/vscode/.claude/plugins/... や /workspaces/... といったコンテナ側のパスが記録されます。ここまでは実物を開いて確認した事実です。 ホストと共有すると、両者が互いに解決できないパスを見ることになります。片方が「存在しないパスを指している=壊れている」と判断してレジストリを自分のパスで上書きし、次はもう片方が同じことをする——というピンポンが起きるはずです。 ただしこれは記録されるパスから導いた推測で、実際に往復させて確かめたわけではありません 。確かめる価値より断つコストのほうが安いので、構成で切っています。 共有をやめた場合の復旧コストは /plugin install を1回やり直すだけです。共有した settings.json に marketplace の情報が残るので、そこから復元できます。 未導入メンバーへの配布では空ファイルを先に作る これはチームへ配布したときに実際に踏んだ罠です。ホストに ~/.claude/.credentials.json が無いと、Docker が bind ソースを root 所有のディレクトリとして誤生成 します。Claude Code をまだ使っていないメンバーの環境では bind ソースが存在せず、root 所有で生成された結果、 vscode から書き込めなくなって壊れました。 対応は前述の initializeCommand です。空の .credentials.json は “Not logged in” として扱われるためクラッシュせず、初回に claude /login するとトークンがこのファイルに書き込まれ、以降は永続します。 壊れてしまった状態からの復旧 すでに root 所有のディレクトリとして誤生成されている場合は、それを消してからやり直します。 # ホスト側で実行(root 所有ディレクトリになっているものを削除) sudo rm -rf ~/.claude/.credentials.json このとき注意したいのが、 initializeCommand の中で権限を直そうとして chown を書く場合です。ホスト上で sudo なしに実行されるため、root 所有のものに対しては失敗します。そして initializeCommand が失敗するとコンテナの作成自体が止まります ( json_reference はライフサイクルスクリプトの失敗について「後続は実行されない」とだけ書いており、ホスト側で走る initializeCommand の失敗時にどうなるかは明記していません。ここは仕様の裏を取れていない挙動です)。「壊れた」状態で検索して来た読者がまず踏むのはここなので、先にホスト側で消しておくのが確実です。 volume 側が壊れた場合は docker volume rm で作り直せますが、 中の履歴と設定も一緒に消えます (後述の制約を参照)。 動作確認 本構成の中核は、 CLAUDE_CONFIG_DIR による集約先の移動と、volume の上への bind の重なりです。どちらも設定ファイルを読んだだけでは効いたかどうか分かりません。組んだあとに確認してください。以下は本記事の素の devcontainer.json 版をこのリポジトリに適用し、リビルドを2回跨いだうえで、さらに3日そのまま使って取った実測です。 4本あります。 どれもコンテナの中でコマンド1つ なので、順に叩けば数分で終わります。1と2で設計の2段が効いたかを見て、3で看板(再ログイン不要)を、4でその結果として何が変わったかを確かめる流れです。 先に、記事の主張を正確な形に直しておきます。 「リビルドしても再ログイン不要」ではありません。導入時に1回だけログインが必要で、そのとき書かれたトークンがホスト側に残るため、2回目以降のリビルドで不要になります。 initializeCommand が作るのは0バイトの空ファイルなので、導入時点でホストにトークンを持っていなければ初回の1回は必ずログインが要ります。実際、1回目のリビルド直後はログインを求められました。ここを飛ばして読むと、導入直後に「効いていない」と誤解します。 1. .claude.json が ~/.claude の中に入ったか まず1段目からです。記事の冒頭で、既定では .claude.json が ~/.claude の 外 にあることを ls で見ました。同じコマンドを新構成のコンテナ内で叩くと、そっくり反転します。 $ ls -la ~/.claude.json ~/.claude/.claude.json ls: cannot access '/home/vscode/.claude.json': No such file or directory -rw------- 1 vscode vscode 42482 Aug 1 12:56 /home/vscode/.claude/.claude.json 外にあったはずのファイルが消え、 ~/.claude の中に現れています。 CLAUDE_CONFIG_DIR が効いた証拠がこれです。ここが反転していなければ、 .claude.json は volume の外に残っています。その場合、コンテナ固有ではあるものの リビルドで消える 場所に書かれているので、隔離できたように見えて永続しません。 2. bind が volume の上に乗っているか 2段目です。 findmnt を階層表示のまま .claude で絞ると、重なり方が1画面で見えます。 $ findmnt -o TARGET,SOURCE | grep -i claude ├─/home/vscode/.claude /dev/sdc[/var/lib/docker/volumes/sios-claude-config/_data] │ ├─/home/vscode/.claude/CLAUDE.md /dev/sdc[/home/ryu/.claude/CLAUDE.md] │ ├─/home/vscode/.claude/skills /dev/sdc[/home/ryu/.claude/skills] │ ├─/home/vscode/.claude/settings.json /dev/sdc[/home/ryu/.claude/settings.json] │ └─/home/vscode/.claude/.credentials.json /dev/sdc[/home/ryu/.claude/.credentials.json] ディレクトリ自体は volume( /var/lib/docker/volumes/... )を指し、その 内側にぶら下がった4つだけ がホストのパス( /home/ryu/.claude/... )を指しています。狙った重なり方です。共有すると決めたものが4つとも子として並んでいることも、ここで同時に確認できます。ぶら下がりが1つも出ないなら bind が効いておらず、以降の確認は通りません。 なお skills はファイルではなくディレクトリですが、扱いは同じです。volume 側の同名ディレクトリを丸ごと覆い隠して、ホストの中身が見えます。 出力はこのリポジトリの実測そのままなので、volume 名がサンプルの myrepo-claude-config ではなく sios-claude-config になっています。前述のとおりリポジトリ名を入れた名前にしているためで、読み替えてください。 ここまで出ていれば、トークンがホストと同じ実体であることも同時に決まります。 source がホストのパスを指している時点で、そのパスでは volume 側のコピーが隠れている からです。コンテナから読める中身は、ホストのファイル以外にありません。逆に .credentials.json の行だけホスト以外を指しているなら、 ${localEnv:HOME} の解決先がずれています(Windows のファイルシステム上でリポジトリを開いた場合に起こりえます。末尾の「制約・前提」も参照)。 3. 再ログインが要らないか claude auth status がログイン状態を JSON で返します。対話セッションを起こす前に確かめられるので、これが一番早い確認です。 $ claude auth status { "loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty", ... } ただし「ログイン画面が出なかった」だけでは、裏で新しくログインし直していないことの証明になりません。判別できるのは .credentials.json の中身です。 リフレッシュトークンの有効期限が変わっていなければ、新規ログインは起きていません 。新規ログインならリフレッシュトークンが再発行され、期限もそれに合わせて先へ動くためです。 この推論は片道でだけ使ってください。「動いていない → ログインしていない」は言えますが、逆の「動いた → ログインした」は言えません。トークン更新のたびにリフレッシュトークン自体を再発行する実装なら、ログインなしでもここは動きうるからです(手元の v2.1.220 では、後述のとおり更新をまたいでも動きませんでした)。 $ python3 -c "import json,os,datetime as dt; \ d=json.load(open(os.path.expanduser('~/.claude/.credentials.json')))['claudeAiOauth']; \ [print(k, dt.datetime.fromtimestamp(d[k]/1000).strftime('%Y-%m-%d %H:%M:%S')) \ for k in ('expiresAt','refreshTokenExpiresAt')]" expiresAt 2026-07-31 00:20:05 refreshTokenExpiresAt 2026-08-27 19:01:56 リビルドの前後と、さらに3日そのまま使ったあとで並べるとこうなりました。3日後の列は挙動を見極めるためにこちらで取った追試です。 読者が3日待つ必要はありません 。 項目 リビルド前(7/30) リビルド後(7/30) 3日後(8/1) inode 1396334 1396334 1396334 サイズ 509 509 509 mtime 07-30 16:20:05.958 07-30 16:20:05.958 08-01 11:21:35.319 expiresAt (アクセストークン) 2026-07-31 00:20:05 2026-07-31 00:20:05 2026-08-01 19:21:35 refreshTokenExpiresAt 2026-08-27 19:01:56 2026-08-27 19:01:56 2026-08-27 19:01:56 refreshTokenExpiresAt が動いていないので、2回目のリビルドで新規ログインは発生していません。 リビルドの直後だけを見ると、アクセストークンの期限(7/31 00:20)がまだ生きていたため mtime すら動いていません。1回目が書いたファイルをそのまま読んだだけです。3日後の列で、その先が見えます。アクセストークンは期限切れを迎えて更新され、 mtime と expiresAt は動きました。 それはログイン不要のまま起こる正常系 です。一方で refreshTokenExpiresAt は動いていません。判定に使うのがこちらでよい理由が、この1列で確かめられます。 同じ列がもう1つ示しているのが inode の不変 です。トークンが書き換わっても番号が変わっていない=Claude Code はこのファイルを その場で上書き しており、一時ファイルを作って rename で差し替えてはいません。単一ファイルの bind が切れる典型的な原因がこの rename なので、ここが動かないことは本構成が長期的に保つかどうかの目安になります(後述の制約も参照)。 4. 他のプロジェクトが見えなくなったか ここまでの3つは「設計が効いたか」を見てきました。最後は、効いた結果として 何が変わったか です。記事の冒頭で .claude.json を数えたのと同じコマンドを、新構成のコンテナ内で叩きます。 $ python3 -c "import json,os; d=json.load(open(os.path.expanduser('~/.claude/.claude.json'))); \ print('projects:', len(d['projects']), '/ githubRepoPaths:', len(d['githubRepoPaths']), '/ numStartups:', d['numStartups']); \ print('project keys:', list(d['projects'].keys()))" projects: 1 / githubRepoPaths: 1 / numStartups: 10 project keys: ['/workspaces/sios-tech-blog-with-claude'] 36プロジェクトが1つになりました 。しかもその1つは、いま開いているリポジトリそのものです。 numStartups も 1278 から 10 に落ちていて、このカウンタがコンテナの中だけで数え直されていることが分かります。他プロジェクトの信頼状態は、もうここからは見えません。 会話履歴も同じです。 $ ls ~/.claude/projects/ -workspaces-sios-tech-blog-with-claude ディレクトリが1つだけ。ここに無関係なリポジトリの名前が並んでいたら、 .claude.json か projects/ のどちらかが volume の外に残っています。逆に 中を覗いてリビルド前のセッションの .jsonl がそのまま読めれば 、隔離した側が消えずに永続していることも同時に確認できます。ここが空なら、意図した named volume ではなく匿名ボリュームやコンテナのレイヤに書いている可能性があります。 制約・前提 本構成を採る前に知っておくべき制約を整理します。 項目 内容 認証トークンの露出 .credentials.json はコンテナから読み書きできます。コンテナ内の任意のコード・MCP・エージェントがトークンを取得しうる、という共有の本質的なトレードオフです。許容できない場合は共有をやめ、初回都度ログインする構成(volume のみ・bind なし)にします volume を消すと設定・履歴も消える docker compose down -v / docker volume rm / docker volume prune を実行すると、隔離した .claude.json と会話履歴は失われます。ホームディレクトリごとバックアップしている場合も、隔離した分はその対象から外れます。「再ビルドで消えない」ことと「消せない」ことは別です 分けたことの代償 プロジェクトの信頼状態・ projects/ の会話履歴・ history.jsonl はコンテナ側で空から始まります。 --resume がホスト側の過去セッションを自動で拾うことはありません(ディレクトリ名を付け替えて volume へコピーすれば、一度きりの持ち込みはできます。「既存の履歴を持ち込む」を参照)。初回にプロジェクトの信頼プロンプトが出ます。**「混ざらない」の裏返しは「手を動かさないと入ってこない」**なので、過去の履歴を頻繁に遡る使い方をしている場合は、この構成は向きません user スコープの MCP サーバが引き継がれない claude mcp add --scope user で登録した MCP サーバは .mcp.json ではなく ~/.claude.json に書き込まれることが 公式ドキュメント に明記されています。このファイルを隔離するので、 user スコープで登録した MCP サーバはコンテナごとに登録し直し になります。リポジトリに置いた .mcp.json (プロジェクトスコープ)は bind の対象外なので影響を受けません。なお手元は user スコープの登録が0件で、この不便自体は踏んでいません(ドキュメントからの指摘です) 断てるのはホスト コンテナ経路だけ 混ざりを断てるのはホストと各コンテナのあいだです。同一コンテナ内で claude を複数起動すれば、それらは同じ volume 上の同じ .claude.json を共有します トークン更新をまたいだ長期挙動 単一ファイルの bind は、書き手が「一時ファイルに書いて rename で差し替える」方式を採ると実体の対応が切れることがあります。v2.1.220 の実測では、初回ログインでコンテナ内から書かれたトークンが inode を変えずにホストへ貫通し、次のリビルドで読み出せました。さらに3日後、アクセストークンの期限切れによる更新が実際に走ったあとも inode は同じままでした(=上書き方式)。ただしこれは実装の観察であって保証された仕様ではないので、版が上がったら上記「動作確認」で確かめ直してください ホスト前提 Linux / WSL2 で検証しています。macOS では認証情報が Keychain に保存され .credentials.json としてファイル化されないことが 認証ドキュメント に明記されているため、トークンの bind 共有は成立しません(コンテナ側で claude /login し、volume で以降永続させる形になります)。保存先はおそらくログインキーチェーン内のアプリケーションパスワード相当の項目だと思われますが、 macOS 実機では未検証 なので、場所の特定は各自で確認してください $HOME の食い違い Windows のファイルシステム上でリポジトリを開くと、 initializeCommand の $HOME と compose の ${HOME} がずれる恐れがあります。リポジトリは WSL 上に置いてください CLAUDE_CONFIG_DIR の位置づけ 機能しますが、環境変数の 公式リファレンス には項目として載っていません( 認証ドキュメント と .claude ディレクトリの解説 で挙動が言及されるのみ。後者はリファレンスへリンクを張っていますが、飛んだ先に項目はありません=2026-08-02 時点)。ドキュメント化を求める Issue #33430 は not planned で close されており、リファレンス化を待つ前提では組まないほうが安全です なお、認証情報をホストに保持してコンテナから参照する考え方そのものは、Claude Code に限った話ではありません。 Azure CLI と gh をチームで統一する構成 や Gemini CLI の認証共有 でも同じ発想を採っています。本記事はそこに「何を共有し、何をプロジェクトごとに分けるか」という線引きを一段持ち込んだ形です。 まとめ ~/.claude を丸ごとマウントすると、 全コンテナがホストの同じファイルを書き換え合う 状態になります。手元の .claude.json には36プロジェクト分の状態が入っていました。他プロジェクトの履歴が見え、コンテナの外からの書き込みが入ってくる。壊れなくても、コンテナに閉じたはずの作業が外と混ざっているのは気持ち悪いです。 ~/.claude の中身は 更新頻度の違うものの寄せ集め で、上下できれいに分かれます。共有したいのは稀にしか更新されないもの(認証トークン・user 設定・user メモリ・user skill)だけ。混ざって困るのは起動や操作のたびに書き換わる .claude.json と履歴です。しかも .claude.json は既定では ~/.claude の外にあるため、「丸ごと」で扱おうとすると取りこぼしやすい位置にいます。 頻繁更新分を named volume でコンテナごとに独立させ、稀更新分だけを bind 共有すれば、 履歴と状態をプロジェクトごとに分けたまま、再ログインの手間を導入時の1回で済ませられます (そこで書かれたトークンがホストに残るため、2回目以降のリビルドでは不要)。実測では、36プロジェクト分あった .claude.json の状態が いま開いているリポジトリ1つだけ になりました。volume 名はプロジェクトごとに変えてください。素の devcontainer.json の volume 名は compose と違ってプレフィックスされないので、ここを汎用名のままにすると分けたつもりで分かれません。 ただし タダではありません 。履歴はホストのホームから named volume へ移るので、ホームのバックアップから外れ、 docker volume prune で消えます。過去のセッションを頻繁に遡る使い方をしているなら、この構成は向きません。「混ざらない」の裏返しは「手を動かさないと入ってこない」です(導入時の持ち込みだけは、ディレクトリ名を付け替えて volume へコピーすれば通せます)。 「稀更新なら共有してよい」にも例外があります。 plugins/ はレジストリがコンテナの絶対パスを持つため、共有するとホストとコンテナで壊し合います。 これは公式のリファレンス実装が採る「丸ごと volume」でも、多くの記事がやる「丸ごと bind」でもない 中間設計 です。更新頻度で線を引くと、再ログインの手間とコンテナごとの独立を両方取れます。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post Claude Codeの~/.claude、どれを共有してどれを分ける? first appeared on SIOS Tech Lab .
ども!ジムにClaudeを連れて行ってトレーナーをやらせている龍ちゃんです。といっても筋トレの話はできません。だって初心者だもの!マシンの名前も重さも回数も、ほとんど出てこないので安心してください。書きたいのは、Claudeをジムのトレーナーにしている、というちょっと変わった運用の話です。 きっかけは、Claude Code に リモート操作の機能 が入ったとき(2026年2月)に、プロダクトマネージャーの人が X で言っていたこれでした。 Take a walk, see the sun, walk your dog without losing your flow. 散歩して、日を浴びて、犬の散歩をしてこい。要は、繋がるようになったんだから席を離れていいぞという話です。言われたので行ってきました。ジムに。 生まれてからというものやせようと決心したことはないのですけど(口だけでは何度もありますけどね!)。 せっかくの機会なので、ジムを契約してClaude Codeとともに運動習慣作りを行っています。 ジムに着いて「今日何やる?」と投げるとこうなる ジムに着いたら、スマホでClaude Codeを開いて「今日何やる?」と投げます。返ってくるのがこれです。 今日は Day B 下半身。前回から中3日空いてる。 - トレッドミル 15分 - スクワット 50kg × 10 × 3セット(前回 50×10,10 で「ちょうど」だったので3セット目を追加) - アダクション 30kg × 12 × 2 - 締め: 股関節ストレッチ 4:37(動画リンク) 何をやるか、どのくらいの強さでやるかを決めているのは全部Claudeです。前回からの差分と、なんで3セット目が増えたのかまで付いてきます。しかも1種目ずつ小出しに降ってきます。全部まとめて渡されると、セット間の休憩で読み切れないんですよね。締めのストレッチはYouTubeの動画を尺つきで指定してきて、この日は 股関節まわりのストレッチ(自宅で5分) でした。 こっちがやるのは、1種目終わるごとに結果を報告するだけ。実際に投げている入力を、ある日のぶんそのまま並べます。 始めます。 3セット終わり ラストセットはきつめだったけど15回できた いや〜きつかった〜 けどまぁ3セット終わった 終わった〜 今回は繋がって一回で15回いけた 2セットとも繋がったよ ストレッチも終わり これで全部です。項目も単位もありません。「終わった〜」しか書いていない行もあります。息が上がった状態で片手にスマホを持って打つので、これ以上ちゃんと書くのは無理なんですよ。それでも通ります。 で、これ、 人間のトレーナーには言えないですよね 。目の前に立っている人に「いや〜きつかった〜」だけ言って次に進むのは、さすがに気まずいです。たぶん「あと2回いけますよ」とか言われます。言われたらやります。やりますけど、こっちはもう限界だと思っているわけです。というか怖くないですか?世の中のトレーナーの方々! 相手がAIだと、その遠慮が丸ごと要りません。「終わった〜」だけ投げて次に進めるし、愚痴っても気を使わなくていい。抗ってみたら軽めのメニューに変えてくれるし、ジムで先にマシンが埋まっているとメニューの代替も送ってくれます。人と喋るのがそんなに得意じゃない側からすると、この気楽さはかなり効きます。 帰り際、これが1ファイルになって落ちてくる そして帰り際に、その日のぶんがまとまって出てきます。 # 2026-08-05(水)フル / Day C プル - 時間: 13:08 - 13:52(44分) - モード: リアルタイム(種目ごとに申告 → 次の指示) ## セット | 種目 | セット | 主観 | 設定値 | |---|---|---|---| | 膝立ちラットプルダウン | 40kg × 15 / 15 / 15 | ラストセット「ちょうど」 | プーリー最上段、ラットバー、膝立ち | | インバーテッドロウ | 自重 × 10 / 10 / 10 | 「限界」寄り | バー高さ = 胸のちょい上 | ## 気づき - 開始時刻を「会話が始まった時刻」で書いていた(後述) さっきの殴り書きが、この形になって落ちてきます。「プーリー最上段」とか「バー高さ = 胸のちょい上」みたいな設定値は、僕がその場で言った言葉が拾われて入っているだけです。表に整える作業を僕はしていません。 要するに、ごりっごりの管理ですね。メニューを組むのも、記録をつけるのも、重さを決めるのも向こう。こっちがやっているのはマシンの前でスマホに一言投げることだけ。「活用している」というより「管理を受けている」に近い温度で、この運用は回っています。 ちなみにマシンの名前もわかんなかったんで、ジムの初日は写真を撮って「何のマシンか?」・「これはどこを鍛えるものか?」みたいなのを書き起こしから始まりました。 コーチは自分で直せる。直した規約に自分が従う ここが個人的に一番おもしろいところなんですが、 このコーチの振る舞いは自分で書き換えられます 。だって CLAUDE.md に記載されているんだもの。それを読んだうえでコーチをやっているだけなので、気に入らない振る舞いがあったら書き足せば直ります。 たとえば僕のCLAUDE.mdには「怠惰だから毎日行けとこれだけは守らせろ」って記述が入っています。だからサボりたい日も、返ってくるのは「今日は休め」ではなく「軽め」とか「最低ラインでいい」というメニューです。行かない、という選択肢が出てきません。(それもこれもドア to ドア 45秒でジムがあるせいだ!) 実際に足したものを、いくつかそのまま抜き出します。 「今日は休め」ではなく「今日は最低ラインで」を返す 体重を聞くのは週の最初のセッションの1回だけ。聞かない日は話題にしない ストップウォッチを持たせない(記録の手間が増え、それ自体が枠を食う) 専門用語(RPE・1RM等)は使わない。必要なら日常語に置き換える 体重を毎回聞かせないのは、毎日聞かれても毎日乗るわけないからですね。毎日乗ろうなんて続くわけがありません。ただ、文章で今測ってくださいって言われるとなんだか乗れちゃうんですよね。時計を持たせないのも同じで、インターバルを測り始めたら記録が仕事になります。市販のトレーニングアプリでこのへんを変えるのは、まあ無理ですよね。作った人が決めた振る舞いが降ってくるので。 完全に自作自演が許されるわけです 。「毎日行け」と守らせているのは自分が書いた規約だし、「休め」と言わないコーチにしたのも自分。自分で書いた理想に自分が従わされて、しかもちゃんと効いている。とんだマッチポンプなんですけど、そこが面白いポイントだと思っています。 言わせたいことを言わせられて、こっちも言いたいことを言える。 どっちも相手がAIだから成立している部分ですね。 ただ、いいことばかりではないです。 チューニングが必須です。 全部、事故ってから足しています。 起きたこと そこから足した規約 午前に行ったのに夜だと思われて、最後に「おやすみなさい」と返された 開始時にその日の日時を確認する トレッドミルの時間がいつのまにか伸びていた 時間は据え置き。強くするのは別の軸でやる 下の行がとくにひどくて、ある日「最近ジムの時間長くなってね?」と自分で気づいて言うまで、変わっていることにすら気づいていませんでした。しかも調べたら犯人は数日前にコーチ自身が出した方針で、それが1回40分という枠と正面からぶつかっていたんです。自分で雇ったコーチに、自分の枠を壊されていたわけですね。 つまり、 寄せる作業はこっちの仕事 です。放っておいて理想のコーチになることはないし、こっちが違和感に気づかなければ、変な方針のままずっと走ります。(これは開発の現場でもあんまり変わんないですね)自分で書けるということは、自分で書かないと直らないということなんですよね。 AIの登場によって個人開発のアプリを作る順番が逆転した! ここまでの話を並べてみると、けっこう妙なことになっています。フォームは作っていません。データベースも作っていません。入力はスマホに殴り書きを投げるだけ。それで記録が溜まって、コーチが自分好みに寄っていって、毎日ジムに行けています。 ここから先は個人開発の話に限ります。 自分が使うために自分で作るアプリ のことですね。仕事で人に使わせるものは、要件を先に決めないと成立しない場面が普通にあるので、そこは別の話です。 最近よく「AIが来たからアプリ開発が楽になる」という話を聞きます。個人開発に関しては実感が逆で、 作るハードルはむしろ上がった と思っています。作らなくても済む水準がここまで来てしまったので、「で、それをわざわざ作る理由は?」に答えないといけなくなった。 だとすると、作る順番が逆になるのかもしれません。要件を先に決めて作るんじゃなくて、 まずAIに運用させて、不満が出たところだけ作る 。しかもさっき見たとおり、不満の大半は規約に1行足せば消えます。消えないものだけが残る。 僕の場合、消えなかったものは1個だけでした。 「開始」です。 会話が始まった時刻を開始として記録していたんですが、ドア to ドア 45秒なので家にいるうちに会話が始まってしまう。実際のトレーニング開始と十数分ずれていて、過去のぶんも遡って直しました。これは書き方の問題ではなくて、 僕が言わなかった事実は取れない という話です。「きつかった」は言葉になるけど、「いま始めた」は言わなければ存在しない。だから作るとしても、重さや回数を打ち込むフォームではなくて、 始めたことを伝えるボタン1個 になる気がしています。 この「まず手で回して、何を作るべきかを学ぶ」やり方、実は昔から名前がついていて、 コンシェルジュMVP と呼ばれるそうです。裏で人間が動いているのを隠す Wizard of Oz と違って、手でやっているのを隠さないほうがこれです。ただ定石だと 人手だから捨てる前提 なんですよね。今回それが崩れているのが面白いところで、 コンシェルジュをやっているのがAIなので捨てなくていい 。要件が集まったら畳む仮の仕組みではなく、そのまま本番として回り続けます。 そしてこの形で回せているのは、条件が2つハマっているからだと思っています。 記録がリポジトリに落ちること。 Claude Code に繋いでいるので、書き出し先がそのままGit管理下のファイルです。生データがテキストで残るし、履歴も差分も残るので、さっきの「開始時刻を遡って直した」みたいな訂正も、いつ何を直したかが残ります。データベースに入れていたら、たぶんここまで気楽に直せていません。 もうひとつは お金が増えないこと 。DBも立てていないし、サーバーも借りていません。AIの利用料はもともと払っているサブスクの中なので、この運用のために増えた固定費はゼロです。個人の趣味みたいなものに月額が増えると、それだけで畳む理由になりますよね。 このデータで何ができるか考えると楽しい そして、テキストで溜まっているので、そこから先はなんでもできます。 自分用のダッシュボードを作ってもいいし、そもそもこのデータ自体がネタになります。実際、こんな仮説が出てきました。締めのストレッチには飛ばす日と飛ばさない日があるんですが、 分かれ目は意志ではなく動画の尺なんじゃないか という話です。 動画の尺 やったか 4:37 6:58 7:32 7:32 正直、記録がまだこれだけしかないので決まりとは言えません。7分と7分半のあいだに本当に線があるのかも分かっていません。ただ、先に立てた別の仮説は潰れています。「動画を選ぶのが面倒だからでは」「終わりの宣言との前後関係では」のどっちも外れて、残っているのが尺なんですよね。なので今は尺の仮説を採って、動画のカタログを短いものだけに絞り替えました。外れていたら、また記録の方から分かるはずです。 根性の問題だと思っていたものが、実は尺の問題かもしれない。こういうのは記録が溜まっていないと仮説にすらならないですし、こうやって記事のネタにもなります。こんなデータから自分の傾向がわかったりしたら面白いですよね。 この順番なら、 作る前に何が要るか分かっている状態から開発を始められる 。自分用のツールを想像で設計して、作って、使わなくなる。このやつを何回かやってきたので、それが避けられるだけでも十分嬉しいんですよね。個人開発はこういう始め方が普通になっていくのかもしれません。 とはいえ、結局続けるかどうかは自分次第です。規約を書き足すのも自分だし、ジムに行くのも自分。せっかくなら、一人でジムに行くあの絶妙な孤独感をAIで紛らわせて、行ってみるのはどうでしょう。 ではまた! ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 1人がこの投稿は役に立ったと言っています。 The post Claude Codeをジムに連れ出したらトレーナーになっていた first appeared on SIOS Tech Lab .
はじめに 前回 は、KubernetesのバックアップツールであるVeleroのインストールと、MinIOを保存先とした初期設定について解説しました 。 前回のハンズオンが終わっていれば、環境構築が完了し、Veleroサーバーがクラスター内で正常に稼働している状態になっているかと思います。 本記事では、PersistentVolume (PV) を伴わないステートレスなアプリケーションを対象に、クラスターリソース(Kubernetesオブジェクトの定義)のバックアップ手順を解説します。 PVを含むステートフルなデータのバックアップについては、次回以降の記事で詳しく取り扱う予定です。 環境構築の前提 本手順は、前回の記事で構築した以下の環境が動作していることを前提とします。 Kubernetesクラスター: Minikube (v1.36.0) が起動中であること  Velero: v1.17.1 がインストール済みで、MinIOへの接続設定が完了していること  CLIツール: kubectl および velero コマンドが利用可能であること MinIO: RELEASE.2025-09-07T16-13-09Zが起動中であること バックアップ対象リソースのデプロイ アプリケーションの作成 まずはバックアップの検証用として、Deployment、Service、ConfigMapを含むシンプルなNginxアプリケーションを作成します。今回は永続ボリューム(PV / PVC)を含まない構成とします。 demo-app.yaml apiVersion: v1 kind: Namespace metadata: name: demo --- apiVersion: v1 kind: ConfigMap metadata: name: nginx-config namespace: demo labels: app: nginx data: index.html: | <html> <head><title>Velero Demo</title></head> <body> <h1>Hello, Velero!</h1> <p>This is a cluster resource backup demo.</p> </body> </html> --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment namespace: demo labels: app: nginx spec: replicas: 1 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:latest ports: - containerPort: 80 volumeMounts: - name: html-volume mountPath: /usr/share/nginx/html volumes: - name: html-volume configMap: name: nginx-config --- apiVersion: v1 kind: Service metadata: name: nginx-service namespace: demo labels: app: nginx spec: selector: app: nginx ports: - protocol: TCP port: 80 targetPort: 80 type: ClusterIP アプリケーションのデプロイと確認 次は、作成したマニフェストを適用し、リソースが作成されたことを確認します。 $ kubectl apply -f demo-app.yaml $ kubectl get all -n demo # 出力例 NAME READY STATUS RESTARTS AGE pod/nginx-deployment-856dc6c76b-v6crd 1/1 Running 0 8s NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE service/nginx-service ClusterIP 10.43.107.135 <none> 80/TCP 8s NAME READY UP-TO-DATE AVAILABLE AGE deployment.apps/nginx-deployment 1/1 1 1 8s NAME DESIRED CURRENT READY AGE replicaset.apps/nginx-deployment-856dc6c76b 1 1 1 8s 以上で アプリケーションの準備ができたので、Veleroを使用してバックアップを取得します。 基本的なバックアップコマンドの実行 バックアップの実行 ここではNamespace内のデータをバックアップします。そのため、velero backup createコマンドに–include-namespaces オプションをつけて対象のNamespaceを指定します。 $ velero backup create demo-resource-backup --include-namespaces demo # 出力例 Backup request "demo-resource-backup" submitted successfully. Run `velero backup describe demo-resource-backup` or `velero backup logs demo-resource-backup` for more details. コマンドを実行すると、「Backup request “demo-resource-backup” submitted successfully.」と表示され、バックアップ処理がバックグラウンドで開始されます。 状態の確認 バックアップが正常に完了したか確認するには velero backup describe コマンドを使用します。出力結果の Phase が Completed になっていれば成功です。 $ velero backup describe demo-resource-backup # 出力例 Name: demo-resource-backup Namespace: velero Labels: velero.io/storage-location=default-backup-storage-location Annotations: velero.io/resource-timeout=10m0s velero.io/source-cluster-k8s-gitversion=v1.32.3+rke2r1 velero.io/source-cluster-k8s-major-version=1 velero.io/source-cluster-k8s-minor-version=32 Phase: Completed ... 詳細なログを確認したい場合は、以下のコマンドを実行します。 $ velero backup logs demo-resource-backup バックアップデータのオブジェクトストレージでの確認 Veleroが正しくMinIOへデータを保存できているか確認します。 前回の記事で作成した minio-client Pod  を使用して、バケットの中身を確認します。 $ kubectl exec -it minio-client -- /bin/sh # バケット内のバックアップデータを確認(エイリアス 'myminio' は設定済みとする) $ mc ls -r myminio/minio-bucket/backups/demo-resource-backup 以下のように、JSON形式のメタデータファイルや、リソース定義が含まれた圧縮ファイルが表示されれば、オブジェクトストレージへの保存は成功しています。 [202X-XX-XX XX:XX:XX UTC] 4KB velero-backup.json [202X-XX-XX XX:XX:XX UTC] 2KB demo-resource-backup-logs.gz [202X-XX-XX XX:XX:XX UTC] 15KB demo-resource-backup-resource-list.json.gz … リソースの絞り込みと除外名前空間 実運用では、Namespace丸ごとではなく、特定のリソースのみを対象にしたり、逆に特定のリソースを除外したい場面があります。 ラベルによる絞り込み –selector オプションを使用すると、KubernetesのLabel Selectorの記法で対象を絞り込めます。 # app=nginx ラベルが付与されたリソースのみをバックアップ $ velero backup create nginx-only --selector app=nginx --include-namespaces demo 特定リソースの除外 例えば、「Event情報は不要」といったケースで –exclude-resources を使用します。 $ velero backup create no-events --include-namespaces demo --exclude-resources events システムNamespaceの除外 バックアップを取る際、kube-system や velero 自身を含めると、リストア時に競合等のトラブルになることがあります。これらを除外するには –exclude-namespaces を使用します。 $ velero backup create cluster-resources --exclude-namespaces kube-system,velero Hookの活用 VeleroのHook機能を使うと、バックアップ実行の前後にコンテナ内で任意のコマンドを実行できます。今回はバックアップ実行の記録を残すシンプルな例を紹介します。 Hookは対象のPodにアノテーションを付与することで設定します。 $ kubectl annotate pod -n demo -l app=nginx \ pre.hook.backup.velero.io/command='["/bin/sh", "-c", "echo [$(date)] Backup started >> /tmp/backup.log"]' \ pre.hook.backup.velero.io/container=nginx この設定後にバックアップを取得すると、対象Pod内の /tmp/backup.log に実行日時が記録されます。これはバックアップ処理が正しくPodにアクセスできているかの疎通確認としても利用できます。 スケジュールバックアップの設定 日次や週次など、定期的なバックアップを自動化するには velero schedule create コマンドを使用します。設定方法はLinuxのCron記法と同様ですが、デフォルトのタイムゾーンがUTCになっているため、時刻はUTCで指定する必要がある点には注意してください。 # 毎日午前3時(UTC)に demo ネームスペースをバックアップ $ velero schedule create daily-demo --schedule="0 3 * * *" --include-namespaces demo 設定したスケジュールは以下で確認できます。 $ velero schedule get # 出力例 NAME STATUS CREATED SCHEDULE BACKUP TTL LAST BACKUP SELECTOR PAUSED daily-demo Enabled 2026-08-04 01:43:39 +0000 UTC 0 3 * * * 0s n/a <none> false スケジュールに基づいて実行されたバックアップは、daily-demo-<タイムスタンプ> という命名規則で保存されていきます。 まとめ 本記事では、PersistentVolumeを含まないクラスターリソース(Deployment、Service、ConfigMap)に焦点を当て、Veleroによるバックアップ手法を解説しました。 velero backup create でNamespace単位のバックアップが可能 バックアップデータはオブジェクトストレージ(MinIO)に格納される ラベルセレクタや除外設定により、必要なリソースだけを柔軟に管理できる 次回は、今回取得したバックアップからのリストアについて深掘りしていきます。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post Velero実践:クラスターリソースのバックアップ first appeared on SIOS Tech Lab .
はじめに こんにちは!サイオステクノロジーのなーがです。 前回、 Claude Code のサブエージェントが勝手に多段委譲してトークンを溶かす問題を、hooks で機械的に止めた話 を書きました。「プロンプトでのお願いは守られないことがあるので、破られると困るルールは hook に落とす」という教訓ですね。 その味をしめて、同じ個人開発の Python プロジェクトで、今度は テスト に同じ手を使いました。Stop hook で「編集した領域のテストが通るまでセッションを終わらせない」ゲートを作ったんです。 しばらくは満足していたのですが、あるとき「テストゲートが過剰に発生して、トークンと実装時間を無駄にしている」という指摘を受けました。それで腰を据えて調査したところ、 自分が想定していたのとまったく違う形で壊れていた ことが分かりました。 今回は、そのテストゲートを作った動機と仕組みから、調査で出てきた原因、そして「公式ドキュメントを最初に読んでいれば防げた」という一番痛い教訓までを、実測値つきで共有します。 なお、この記事で紹介する hook 一式は 最小構成サンプルとして GitHub で公開 しています。 なぜ Stop hook でテストゲートを作ったのか 「テストは通っています」が検証の代わりにならない AI にコードを書かせていると、次の2つがけっこうな頻度で起きます。 起きること 何が困るか 「テストを書きました」「テストは通っています」と報告するが、実際にはテストを実行していない 報告が検証の代わりにならない。結局こちらが毎回自分で回すことになる ファイルを編集したまま、何も検証せずに応答を終える 壊れたまま次のタスクに進み、後段で原因の切り分けが難しくなる CLAUDE.md に「実装したら必ずテストを実行してください」と書く方法は、もちろん効くときもあります。ただ、守られるときと守られないときがあって、 再現性がありません 。 前回の記事で書いた多段委譲 とまったく同じ構図ですね。 そこで、判断を AI 側に委ねるのをやめました。 hook 側でテストコマンドの終了ステータスだけを見て判定し、未検証の編集が残っている状態では停止させない 。これなら「テストを実行したかどうか」は自己申告ではなく事実になります。 Stop hook とは Claude Code の hooks の中でも、今回の主役は Stop です。 Claude が応答を終えてターンを閉じようとする直前 に発火するイベントで、ここでフックが decision: "block" を返すと、Claude は停止できずに作業を続行します。 reason に書いた文面はそのまま Claude へのフィードバックになります。 出力する JSON はこんな形です。 { "decision": "block", "reason": "テストが失敗しています。以下の失敗を確認し、全テストが成功するまで実装またはテストを修正してください。" } つまり Stop は、「終わってよいかどうかを外から審査する」ための場所です。テストゲートにはうってつけでした。 参考: Claude Code の hooks(公式ドキュメント) / hooks ガイド(公式ドキュメント) テストゲートの仕組み 構成は hook が2つだけです。編集を記録する側と、停止時に検証する側に分かれています。 .claude/ ├── hooks/ │ ├── mark-tests-pending.sh # PostToolUse(Edit|Write): 編集ファイルから対象ラベルを記録 │ ├── stop-test-gate.sh # Stop: 未検証の領域があればテストを実行し、失敗ならブロック │ └── test-gate.conf # 対象パス → ラベル → テストコマンドの対応表 ├── tests/ # hook 自体のテスト └── settings.json # 上記2つの hook の登録 正常系の流れは次のとおりです。 編集されたファイルからラベルを積む PostToolUse (matcher は Edit|Write )で発火する mark-tests-pending.sh が、編集されたファイルのパスを設定ファイルと照合し、対応する ラベル とテストコマンドを状態ファイル pending に追記します。 ここでのポイントは、記録するのがファイル名ではなく ラベル だということです。 src/ 配下を10ファイル編集しても、積まれるのは python というラベル1つ。同じラベルは1回の停止につき1度しか実行されません。 停止時にラベル分のテストを実行する Stop で発火する stop-test-gate.sh が pending を読み、残っているラベルのコマンドを順に実行します。全部成功すれば pending を消して exit 0 、つまり停止を許可します。1つでも失敗すれば decision: "block" を返して作業を続けさせます。 ブロック時の reason には、失敗したラベル名・終了ステータス・出力の末尾30行が入ります。実際の文面はこんな感じです。 テストが失敗しています(テストゲート 1/3回目)。以下の失敗を確認し、全テストが成功するまで実装またはテストを修正してください。各領域のテストコマンドは .claude/hooks/test-gate.conf を参照。 --- python 失敗 (exit 1, 出力末尾30行) --- (テストコマンドの出力) Claude はこれを読んで、そのまま修正作業に入ります。無限ループを避けるため上限は3回で、そこに達したら警告を出して停止を許可します。この3回という上限は、Claude Code 組み込みの停止ブロック上限(連続8回。 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP で変更可)より厳しいので、組み込み側の強制解除には到達しません。 設定ファイルで対応づける プロジェクト固有なのは test-gate.conf だけです。 <パスglob>|<ラベル>|<テストコマンド> の3列で、上から順に照合して 最初にマッチした1行だけ を適用します。 # --- 対象外(先に除外する) --- node_modules/*|skip| */dist/*|skip| .venv/*|skip| .claude/*|skip| # --- ドキュメント: リンク切れ検査 --- docs/*|docs-links|python3 -m unittest discover -s tests -t . -k links *.md|docs-links|python3 -m unittest discover -s tests -t . -k links # --- ブラウザテスト (web/* より前に置く) --- web/e2e/*|web-e2e|cd web && npm run test:e2e:only # --- フロントエンド (npm workspace) --- web/dashboard/*|web-app-dashboard|cd web && npm test -w dashboard web/*|web-workspace|cd web && npm test --workspaces --if-present # --- Python: フルスイート (unittest) --- src/*|python|python3 -m unittest discover -s tests -t . tests/*|python|python3 -m unittest discover -s tests -t . hook の登録は .claude/settings.json にこう書きます( timeout の値は後で痛い目を見るので、あえて修正後の値を載せています)。 { "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/mark-tests-pending.sh\"", "timeout": 10 } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/stop-test-gate.sh\"", "timeout": 900, "statusMessage": "テストゲート: 変更領域のテストを実行中..." } ] } ] } } これで「編集したのに検証していない領域があるまま応答を終える」ことが構造的にできなくなりました。……できなくなったのは事実なのですが、代償が思ったより大きかったのです。 参考: Claude Code の settings(公式ドキュメント) 「トークンを無駄にしている」と言われて調べた 指摘を受けたとき、私の頭にあった仮説は「ブロックされる回数が多すぎるのだろう」でした。ブロックされるたびに Claude は修正作業を続けるので、そのぶんトークンを食う。分かりやすい話です。 そこで、手元に残っていた トランスクリプト166本 を全部解析してみました。 ブロックの回数は主犯ではなかった 結果はこうでした。 指標 値 編集があったセッション 95 ゲートが1回以上ブロックしたセッション 20 ブロック注入の総数 91(内訳: 1回目 56、2回目 18) 推定ゲート実行回数 244(編集セッションあたり平均 2.6) ブロックしたのは95セッション中20だけ でした。仮説は外れです。体感の悪さは「ブロックが多いこと」ではなく、 1回あたりの実行内容が重く、しかも重複していたこと に由来していました。 自分で作ったものだけに「たぶんこれだろう」で直しにいかなくて本当によかったと思います。実際の実行結果をきちんと数えてみないと、どこに原因があるのかは分からないものですね。 1回あたりの実行内容が重かった 各テストコマンドの実測値がこちらです。 コマンド 実測 Python の全テスト(unittest 1448件) 236秒 ブラウザでのレンダリングテスト(54件) 199秒 全ワークスペースのビルド + 単体テスト 60秒 lint 一式(リポジトリ全体) 32秒 単一アプリの型チェック + 単体テスト 17.6秒 ドキュメント整合性チェック単体 0.5秒 上2つが飛び抜けて重いことが分かります。そして、この重いものが1回の停止で 同時に、しかも重複して 積まれていたのが問題の本体でした。 見つかった原因 調査で出てきた原因を、ひとつずつ紹介します。どれも「動いているように見えるのに壊れている」タイプで、特に1つ目と5つ目は ゲートが黙って効かなくなる という一番まずい壊れ方をしていました。 原因1: 状態ファイルがセッションをまたいで残る まず、いちばん怖かったものからいきます。 pending は 全ラベルが成功したときにしか消えません 。ここまでは設計どおりです。問題は、公式仕様上 Stop フックがユーザーの割り込みでは発火しない ことでした。私はこれを知りませんでした。 つまり、Esc で止めたとき・ /clear したとき・コンテキストが尽きて終わったときは、 Stop が走らないので pending が残ったままセッションが終わります。そして当時の状態ファイルは全セッション共有だったので、この残骸が 次のセッションの最初の停止で実行される わけです。 実際に、 1ファイルも編集していないセッションで約12.3分のテストが走る 状態を観測しました。ユーザーから見ると「何も触っていないのに、応答を終えようとしたら数分固まる」です。これは相当に不気味な挙動でした。 そして pending 以上に危険だったのが、リトライカウンタ retries の残存です。中断で古いカウンタが2まで進んだまま残っていると、次に起きた 本物の失敗が1回ブロックしただけで打ち切り(3回)に達します 。ゲートは何も言わずに停止を許可するので、 効いていないことに誰も気付けません 。 ガードレールが誤作動して開発を止めるのは、少なくともすぐ気付けます。でも「効かなくなる」のは気付けない。ここが決定的に違うところでした。 対策として、状態ファイルを pending-<session_id> / retries-<session_id> のようにセッション単位で分け、使われなくなったものは7日で GC するようにしました。あわせて、新しい検証サイクルの開始時( pending がまだ存在しないとき)に前サイクルのカウンタを捨てます。 # 新しい検証サイクルの開始(pending がまだ無い)なら、前サイクルのリトライ # カウンタを捨てる。中断で retries だけ残ると、次に起きた本物の失敗が1回 # ブロックしただけで打ち切り(MAX_RETRIES)に達し、ゲートが黙って効かなくなる。 [ -f "$PENDING" ] || rm -f "$RETRIES_FILE" なお、 session_id が取得できなかった場合は、全セッション分の pending を取り込んでまとめて検証する安全弁を置いています。「取りこぼして黙って無反応になる」より「余計に走る」側に倒す、という判断です。異常系で迷ったら常にこちらに倒す、というのが今回の調査で身についた原則です。 原因2: ラベルの重複排除が完全一致でしか効かない 次は、体感の重さに直結していた原因です。 同じラベルの二重登録は防いでいたのですが、それは 完全一致 の話でした。実行内容が包含関係にあるラベル、たとえば「フルスイート」と「その部分集合」が同時に積まれても、名前が違うので間引けません。 実際に観測された5ラベルのキューがこちらです(ラベル名は公開リポジトリの記録に合わせた一般名です)。 ラベル 内容 コスト 重複 web-shared 共有UIパッケージ。全ワークスペースのビルド + 単体テスト + レンダリングテスト 259秒 — python Python の全テスト 236秒 — web-e2e レンダリングテスト 約245秒 レンダリングテストを2回目(しかも無駄なリビルド付き) web-app-dashboard 単一アプリの型チェック + 単体テスト 17.6秒 全ワークスペースのテストに完全に内包 docs-links ドキュメント整合性チェック 0.5秒 Python の全テストに完全に内包 合計は約12.3分。そのうち 約4.4分(36%)が純粋な重複 でした。 図にすると分かりやすいのですが、これは「テストが多い」問題ではなく、 同じテストを2回走らせている 問題です。人間なら「いま全部回したから、そっちはいいや」と判断するところを、ラベル名の一致だけで見ていたので判断できませんでした。 対策は、設定ファイルに ラベルの包含関係を宣言する 指令行を足すことでした。 # 形式: @supersedes|<上位ラベル>|<下位ラベル…(スペース区切り)> @supersedes|python|docs-links @supersedes|web-workspace|web-app-dashboard 「上位ラベルが対象に含まれているなら、下位ラベルは実行せずに捨てる」という宣言です。ここで気をつけたのは、 「実行時間が長いほうが上位」ではなく「コマンドの検証内容が下位を完全に含んでいるか」 で判断することでした。たとえばレンダリングテストは実行時間こそ長いですが、単体テストを含まないのでどのラベルの上位にもなりません。 もうひとつ、この仕組みには前提があります。 ラベルとコマンドが1対1でなければならない ことです。同じラベルに違うコマンドがぶら下がっていると、どちらが登録されるかが編集順に依存し、さらに上位ラベルで下位を間引いた結果 実際には検証されない領域が生まれます 。しかもテストは緑のまま通るので気付けません。実運用ではこの不変条件が崩れていたラベルが2つあったので、そこも分離しました。今はテストで機械的に検査しています。 原因3: 失敗すると成功済みのラベルまで再実行する 失敗したときに pending を丸ごと残していたので、1つの領域が落ちている間、 リトライのたびに無関係な領域まで再実行 していました。この構成なら 12.3分 × 最大3回です。 対策は単純で、検証が済んだ(成功した、または内包により省略した)ラベルを pending から行単位で落とすようにしました。落ちた領域を直す過程で他の領域を編集すれば、 mark-tests-pending.sh がそのラベルを積み直すので、カバレッジは落ちません。 原因4: pending の更新に排他制御がない ここからの2つは、 公式ドキュメントと照らし合わせて初めて見つかった ものです。 pending への追記は「 grep で存在確認 → 無ければ追記」という read-modify-write でした。1メッセージで複数の Edit が発行されると PostToolUse も同時に走るので、その隙間に別プロセスが割り込むと 同じラベルが二重登録 されます。結果、1回の停止でフルスイートが2回走ります。 正直「理屈上は起きうるけど、実際にはめったに踏まないのでは」と思ったので、再現実験をしてみました。 flock バリアで16並列を同時解放し、40ラウンド回します。 重複が出たラウンド 修正前 4 / 40 修正後 0 / 40 (最終確認 0 / 30) 普通に起きていました。10回に1回です。対策は flock による排他ですが、ひとつ重要な注意点があります。 テスト実行中にロックを保持しないこと です。テストは数分かかるので、握ったままだとその間の PostToolUse が全部待たされます。実行対象をスナップショットしてからロックを解放し、実行後に取り直して行単位で間引く、という順序にしました。 # フックは並列に実行される。1メッセージで複数の Edit が発行されると # PostToolUse も同時に走るため、下の grep(存在確認)と追記の間に別プロセスが # 割り込むと同じラベルが二重登録され、1回の停止でフルスイートが2回走る。 exec 9>"$LOCK" 2>/dev/null || exit 0 command -v flock >/dev/null 2>&1 && flock -x 9 ガードレールの回帰テストは「ロックを取っているつもりで取れていない」実装を検出したかったので、 外部でロックを保持している間フックが待たされること を直接確認する behavioral テストと、ロック取得記述の静的検査の二本立てにしました。 原因5: Stop の timeout が既定値のままだった 最後がこれです。個人的には、原因1と並ぶ怖さでした。 settings.json の Stop の timeout を、私は明示していたつもりで 既定値と同じ600秒のまま にしていました。「明示したから大丈夫」と思っていたわけです。 ところが公式仕様では、 タイムアウト超過は非ブロッキングエラー扱い になります。つまり、テストが時間内に終わらなかった場合、 失敗を検出しないまま停止が通ります 。 実測の最悪ケース(重いラベルが2つ積まれた場合)は約500秒でした。600秒まで、あと100秒。しかもマシンの負荷次第で簡単に前後する範囲です。ここを超えていたら、ゲートは何のエラーも出さずに「今日はテストしませんでした」と静かに素通りしていたことになります。 原因1のリトライカウンタと、まったく同じ壊れ方です。 ガードレールは、効かなくなったことを自分では教えてくれません。 対策として timeout を900秒に引き上げました。数字そのものより、「自分のテストスイートの実測の最悪ケースを知った上で、余裕を持たせて設定する」という手順のほうが大事だと思っています。 参考: Claude Code の settings(公式ドキュメント) 公式ドキュメントを後から読んだら書いてあった さて、ここが今回いちばん恥ずかしい話です。 原因4と原因5は、いずれも 公式ドキュメントに明記されていました 。 「フックは 並列に実行される 。ファイル競合を避けよ」という注意書きと、その対策としての flock の例示 無限ループ防止に stop_hook_active を使うこと タイムアウト超過が非ブロッキングエラー扱いになること そして白状すると、 初回の実装時、私はこれらのドキュメントを参照していませんでした 。読んだのは全部、インシデントの調査中です。 なぜ読まなかったのかを考えてみると、「何度か hook を作成したことがあったから」でした。前回の記事でも hooks を書いていましたし、これまでの実行結果をClaude Codeに調査させることで Stop の入力と出力の形さえ分かれば動くものは作れます。実際に動きましたし、ちゃんとブロックもしました。 動いてしまったことが、仕様を確認しない理由になっていた わけです。 事後に公式ドキュメントを照合してみると、幸い仕様に沿っていた点もありました。 Stop で decision: "block" + reason を返す形式 常に exit 0 して JSON で制御する方式(公式にも「exit 2 は JSON を無視する。構造化された制御には exit 0 + JSON」とあります) jq が無い環境向けの python3 フォールバック(公式のトラブルシューティングが挙げる jq: command not found 対策) $CLAUDE_PROJECT_DIR の利用 ブロック上限を3回にしていたこと(組み込みの停止ブロック上限である連続8回より厳しいので、強制解除には達しません) ……なのですが、これは部分的な仕様が「たまたま合っていた」だけです。合っていた項目と外していた項目を分けたのは、私の理解の深さではなく運でした。 なお stop_hook_active については、公式サンプルどおり「 true なら常に exit 0 」にすると ブロックが実質1回に制限されてゲートが弱くなる ので、そのままは採用しませんでした。自前の有界な3回制カウンタで同じ目的を満たしているため、 置き換えではなく多重化 しています。 stop_hook_active が true かつ 自前のカウンタが読めない (=進捗を追跡できない)ときに限ってループを打ち切る、という使い方です。 ここで言いたいのは、 自作のガードレールほど、土台の仕様確認を飛ばしがちだ ということです。ガードレールは普段は何も言わずに黙っています。黙っているのが正常なのか、壊れて黙っているのかは、外からは区別がつきません。だからこそ、その土台になっているイベントが「いつ発火して、いつ発火しないのか」「異常時にどちらへ倒れるのか」は、最初に確認しておくべきでした。 参考: Claude Code の hooks(公式ドキュメント) / hooks ガイド(公式ドキュメント) 直した内容と結果 実施した変更をまとめます。 変更 内容 状態のセッション分離 pending-<session_id> / retries-<session_id> 。 session_id が取れない場合は全セッション分をまとめて検証する安全弁つき。7日で GC リトライカウンタのリセット 新しい検証サイクル開始時に前サイクルのカウンタを捨てる 成功ラベルの間引き 失敗時、検証が済んだラベルを pending から行単位で落とす @supersedes 設定ファイルでラベルの包含関係を宣言し、上位が対象なら下位を捨てる ラベルとコマンドの1対1化 同じラベルに2種類のコマンドがぶら下がっていた2ラベルを分離( @supersedes の前提) flock 排他 両フックが状態ディレクトリの .lock を取る。テスト実行中は保持しない stop_hook_active カウンタ追跡不能時の保険として多重化 timeout 600 → 900秒 効果を確かめるため、原因2で紹介した5ラベルのキュー( web-shared / python / web-e2e / web-app-dashboard / docs-links )を、修正後の設定ファイルでそのままリプレイしてみました。 @supersedes の判定で、次の3つは「検証内容が上位ラベルに含まれている」として実行前に捨てられます。 捨てられるラベル 上位ラベル 捨ててよい理由 docs-links python ドキュメント整合性チェックは Python のフルスイートの中で実行される web-app-dashboard web-shared 単一アプリの型チェック + 単体テストは、全ワークスペースのビルド + 単体テストに含まれる web-e2e web-shared レンダリングテストは共有UIパッケージ側でも走る。削減量が一番大きいのはここ 結果、実際に実行されるのは python (Python のフルスイート)と web-shared (共有UIパッケージ: 全ワークスペースのビルド + 単体テスト + レンダリングテスト)の2つだけ になりました。この2つは検証範囲が互いに重ならないので、これ以上は削れません。捨てられた3ラベル分(約4.4分)がそのまま消えた形です。 さらに、2ラベルとも成功すれば pending と retries は削除されるので、テストが終わったあとに状態ディレクトリへ残るのはロックファイル( .lock )だけになります。原因1で書いた「次のセッションへ持ち越される残骸」が発生しない状態です。 修正前 修正後 5ラベル同時の1回 約12.3分 約7.9分 (-36%) 編集ゼロのセッション 約12.3分 0秒 失敗1件のリトライ 全ラベル再実行 × 最大3回 失敗ラベルのみ 重複が消えたぶんがそのまま36%の短縮になり、いちばん不気味だった「編集していないのに走る」は 0秒になりました。 もうひとつ、記事の本筋からは少し外れますが、前回の記事で紹介した実装担当のサブエージェント( implementer )にも手を入れています。エージェント定義が編集内容に関わらず毎回フルスイート(236秒)と lint を実行しており、その後にゲートが同じものを再実行していたためです。TypeScript だけを編集した場合も Python の全テストを回していました。 実測では72回・35セッションで使われていたので、これも地味に効いていたはずです。変更領域に対応する範囲だけを検証するよう縮小し、 implementer 経由の Python 変更でフルスイートが2回走っていたのを1回にしました。 さいごに Claude Code の Stop hook で作ったテストゲートが、過剰実行と「黙って効かなくなる」壊れ方を同時に起こしていた話でした。要点をまとめます。 体感の悪さの原因は思い込みで当てにいかない。 ブロックの回数は主犯ではなかった (95セッション中20だけ)。重かったのは1回あたりの実行内容で、その36%は純粋な重複だった 状態ファイルはセッションをまたぐ 。 Stop hook はユーザーの割り込み(Esc・ /clear )では発火しないので、残骸が次のセッションで実行される。編集ゼロで約12.3分走っていた 重複排除の単位を ラベルの完全一致から包含関係へ 広げる。ただし「実行時間が長いほう」ではなく「検証内容が下位を完全に含むほう」を上位にする フックは並列に実行される ので、状態ファイルの read-modify-write は flock で排他する。ただしテスト実行中はロックを保持しない Stop の timeout 超過は 非ブロッキングエラー扱い 。実測の最悪ケースを知った上で余裕を持たせる 異常系で迷ったら、 無反応になる側ではなく余計に走る側へ倒す そして一番の教訓は、 自作のガードレールほど、土台の仕様確認を飛ばしがちだ ということです。私は「動いたから正しい」と思い込んで公式ドキュメントを読まず、結果として flock もタイムアウトの挙動も、そこに書いてあったものを事後に読むことになりました。 ガードレールは、効かなくなったことを自分では教えてくれません。 誤検知して開発を止めるタイプの故障はすぐ気付けますが、黙って素通りするタイプの故障は、次に本当のバグが漏れるまで誰も気付けません。だからこそ、作った本人が定期的に「これ、まだ効いてる?」と疑いに行く必要があります。 今回の hook 一式は、プロジェクト固有の内容を設定ファイルに閉じ込めた最小構成サンプルとして公開しています。hook 本体・設定・テスト・動作確認用の最小サンプルが入っているので、 .claude/ にコピーして test-gate.conf を自分のパスとコマンドに書き換えれば動きます。 いきなりブロックまで作り込まなくても、まずは PostToolUse で「編集されたファイルをログに残す」だけでも、自分がどれだけ検証せずに進んでいるかが見えて面白いと思います。ぜひ試してみてください!それでは! ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post Claude Code のテストゲートが編集ゼロで12分!?Stop hook が「黙って効かなくなる」まで first appeared on SIOS Tech Lab .
はじめに こんにちは!サイオステクノロジーのなーがです。最近は Claude Code の Skill や Subagent を育てるのがすっかり日課になっていて、気づけば .claude/ 配下のファイルがかなりの数に膨れ上がってきました。 ただ、増えてくると困るのが 設定の記述ミス です。SKILL.md のフロントマターのキーを typo した、Skill の説明文からリンクしていたファイルをリネームして参照切れになった、 settings.json の hooks でイベント名を間違えた……。 こうしたミスの厄介なところは、 実行するまで気付けない ことです。しかも Claude Code は壊れた設定をエラーで教えてくれるとは限らず、 該当の Skill や Hook を黙って無視する ことがあります。「あれ、この Skill 最近発動してなくない?」と気付いた頃には、いつのコミットで壊れたのか分からない……なんてことも。 これはもう Linter の出番だなということで、 .claude/ 配下をコミット前に静的検証する agentlint という Linter を自作しました。今回はそのご紹介です。 agentlint とは agentlint は、Claude Code のエージェント設定( .claude/ 配下の skills / commands / agents / hooks 設定)を検証する Python 製の Linter です。フロントマターの記述ミス、壊れたファイル参照、hooks 設定の構造ミスをコミット前に静的検出し、pre-commit や CI に組み込めるようにしています。 検証対象のファイルは以下の通りです。 your-project/ ├── CLAUDE.md # 参照切れ検出 └── .claude/ ├── skills/**/SKILL.md # フロントマター検証 + 参照切れ検出 ├── commands/**/*.md # フロントマター検証 + 参照切れ検出 ├── agents/*.md # フロントマター検証 + 参照切れ検出 + 再委譲禁止記述の検証 ├── settings.json # hooks設定検証 └── settings.local.json # hooks設定検証 4つのチェック チェック内容は大きく4つに分かれています。 チェック 対象 概要 1. フロントマター検証 .claude/skills/**/SKILL.md 、 .claude/commands/**/*.md 、 .claude/agents/*.md YAML フロントマターの構文・必須キー・列挙値・型を検証 2. 参照切れ検出 上記 Markdown 本文 + ルートの CLAUDE.md $CLAUDE_PROJECT_DIR / $CLAUDE_SKILL_DIR / .claude/ 起点の参照、および本文中の相対パス参照の実在確認(すべて warning) 3. hooks 設定検証 .claude/settings.json 、 .claude/settings.local.json トップレベル hooks の構造・イベント名・matcher・handler、および command が参照するスクリプトの実在(AL305)を検証 4. サブエージェント再委譲禁止の検証 .claude/agents/*.md 本文に「他のサブエージェントを呼び出さない」等の再委譲禁止の記述があるかを検証(AL401、回帰防止) 4つ目だけ少し毛色が違いますが、これは私のプロジェクトで「サブエージェントがさらに別のサブエージェントを呼び出す多段リレーを禁止し、その旨を各エージェント定義の本文にも明記する」という運用をしているため、その記述が抜け落ちたときに警告してくれる回帰防止用のチェックです。この多段リレーを hooks で機械的に防ぐ話は 別の記事 にまとめているので、AL401 の背景が気になる方はあわせてどうぞ。なお、検証対象の一つである Agent Skills の仕組みそのものについては、 弊社メンバーのブログ記事 で紹介しているので、あわせて読んでいただけると理解が深まると思います。 なお、フロントマターの有効値リスト(イベント名、 model / effort / permissionMode の列挙値など)は、公式ドキュメントを出典としてデータ専用のモジュール( src/agentlint/spec.py )に切り出してあり、仕様変更時はこのファイルだけを更新すればよい作りにしています。 参照: Claude Code Hooks – 公式ドキュメント finding コード一覧 検出結果(finding)にはコードを振っています。AL1xx がフロントマター、AL2xx が参照・ファイルシステム、AL3xx が settings/hooks、AL4xx が運用ルール系です。 コード 重大度 内容 AL001 warning agentlint 自身の内部エラー(ツールのバグでコミットをブロックしないための最終防波堤) AL101 error フロントマターの YAML がパース不能 AL102 error 必須キー欠落(agent の name / description) AL103 error 値が無効(model / effort / permissionMode 等の列挙違反、または列挙キーの値が文字列でない) AL104 warning 未知のキー(「もしかして」候補付き) AL105 error 型違反(bool / str / list / int 等、フロントマターのキーが文字列でない場合も含む) AL106 warning description 欠落(SKILL.md のみ。commands では任意のため対象外)、または description + when_to_use 合計が1536文字超 AL201 warning アンカー付きパス参照切れ(Markdown 本文中) AL202 warning 相対パス参照が見つからない(Markdown 本文中) AL203 warning hooks が直接実行するスクリプトに実行権限がない AL301 error settings JSON がパース不能 AL302 error 存在しないイベント名(「もしかして」候補付き) AL303 error matcher の正規表現が不正 AL304 error 構造違反(配列でない、type が未知等) AL305 error hooks の command が参照するスクリプトが存在しない(スクリプトパスと確信できるトークンのみ判定対象) AL306 warning matcher 非対応イベントへの matcher 指定、未知キー AL401 warning エージェント定義本文に再委譲禁止(「他のサブエージェントを呼び出さない」等)の記述がない 出力形式 出力は ruff 風の1行1finding形式です。 ファイルパス:行番号: コード [重大度] メッセージ の並びで、最後にサマリー行が付きます。 .claude/agents/foo.md:3: AL103 [error] 'model' の値が無効: 'gpt-4' agentlint: 1 error(s), 0 warning(s) 問題がなければこうなります。 agentlint: ok (12 files checked) 設計思想: 誤検知ゼロを最優先 このツールを作るうえで一番こだわったのが、 誤検知(false positive)を出さない ことです。 pre-commit に組み込む Linter は、誤検知が1件でも起きると「またこれか」とチーム内で無効化・放置されてしまい、それ以降の見逃しの方が遥かに高コストになります。そこで agentlint では 「error にするなら warning 以上に保守的に。迷ったら検出しない」 を設計原則にしました。 error を2種類に限定した理由 コミットをブロックする error は、次の2種類だけに限定しています。 フロントマター / settings の構文・構造エラー (AL101 / AL102 / AL103 / AL105 / AL301 / AL302 / AL303 / AL304): YAML や JSON としてそもそも壊れている、必須キーがない、列挙値が無効、型が違う、など 機械的に白黒つけられるもの hooks の command が参照するスクリプトの実在確認 (AL305): 「スクリプトパスだと確信できるトークン」だけに絞った実在確認 一方で、Markdown 本文中の参照切れ(AL201 / AL202)は 常に warning です。Skill や Agent の説明文には .claude/skills/my-skill/SKILL.md のような 例示パス が頻出し、プレースホルダ判定だけでは実在するパスと原理的に区別できないためです。本文中の参照切れでコミットを直接ブロックすることはしません。 未知のキー(AL104)も同様に warning に留めています。公式ドキュメントの更新で新しいキーが追加されたとき、agentlint 側の追従が遅れると誤検知になってしまうためです。 参照切れ検出そのものも「迷ったら検出しない」方針で、プレースホルダらしき文字列( path/to 、 example 、 your- 、 my- を含む等)や、絶対パス、URL(スキーム付き・裸ドメインの両方)、ワイルドカードを含むトークンは対象外にしています。 AL305 のスクリプトパス判定の工夫 error に昇格させた AL305(hooks のスクリプト実在確認)は、その分だけ判定を慎重にしています。というのも、hooks の command 文字列には「 / を含むけどパスではない」トークンが山ほど出てくるんですよね。例えば…… sed -i 's/foo/bar/g' — sed の置換パターン jq -r ".a/b" — jq のフィルタ rm -rf *.log — glob date +%Y/%m/%d — 日付フォーマット $HOME/... — 未解決のシェル変数 これらを素朴に「パスっぽいから実在確認しよう」とやると誤検知まみれになります。そこで agentlint では、 「 $CLAUDE_PROJECT_DIR 置換後、未解決の変数( $ )や glob( * ? {} )を含まず、 .sh / .py 等の既知の実行系拡張子で終わる」トークンだけ を実在確認の対象にしています(この判定は src/agentlint/pathtokens.py に共通化しています)。 さらに、引数位置(2番目以降)のトークンは、先頭トークンがインタープリタ / ランナー( bash / python / uv / node 等)の場合のみ対象にしています。これは cp src.sh dst.sh の宛先のような「実行対象ではない引数パス」を誤検知しないための対策です。 AL001: 自身のバグでコミットをブロックしない もうひとつの防波堤が AL001 です。agentlint 自身のバグで想定外の例外が起きた場合、そのファイルの検査は諦めて AL001 の warning として報告 し、他のファイルの検査は継続します。 Linter のバグでユーザーのコミットがブロックされるのは、体験として本当に最悪なんですよね。なので「ツールが壊れても error にはしない」を仕組みとして保証しています。チェック処理は1ファイル単位で例外を捕捉するラッパー越しに実行しているので、1ファイルで転んでも残りのファイルの検査結果はちゃんと出ます。 使い方 ここからは実際の使い方です。ローカル実行 → pre-commit → CI の順に組み込んでいきます。 インストールと実行 agentlint は GitHub で公開 しています。PyPI などのパッケージレジストリには出していないので、リポジトリを clone して uv 経由で実行する形になります。 git clone https://github.com/Shotaro-Yoshinaga-sti/agentlint cd agentlint && uv sync セットアップできたら、あとは検証したいプロジェクトを --root で指定して実行するだけです。 uv run agentlint # カレントディレクトリの .claude/ を検証 uv run agentlint --root ../other # 別ディレクトリを指定 uv run agentlint --strict # warningのみでもexit code 1にする uv run agentlint --version 例えば、こんな設定ミスを仕込んだサンプルの .claude/ を用意してみます。 agents/foo.md : 必須キーの name / description が欠落、 model: gpt-4 (無効な値)、再委譲禁止の記述なし skills/deploy/SKILL.md : description を descripton と typo、本文から存在しない ./checklist.md を参照 settings.json : hooks のイベント名を PreToolUses と typo、存在しないスクリプト .claude/hooks/check.sh を command で参照 これに対して実行すると、以下の出力になります(実際の実行結果です)。 uv run agentlint --root ../broken-example .claude/agents/foo.md:1: AL102 [error] 必須キー 'description' が欠落している .claude/agents/foo.md:1: AL102 [error] 必須キー 'name' が欠落している .claude/agents/foo.md:1: AL401 [warning] サブエージェントの再委譲禁止(「他のサブエージェントを呼び出さない」等)の記述が見当たらない .claude/agents/foo.md:2: AL103 [error] 'model' の値が無効: 'gpt-4' .claude/settings.json:3: AL302 [error] 未知のイベント名 'PreToolUses'(もしかして: PreToolUse) .claude/settings.json:3: AL305 [error] hooks が参照するスクリプトが存在しない: .claude/hooks/check.sh .claude/skills/deploy/SKILL.md:1: AL106 [warning] description が設定されていない .claude/skills/deploy/SKILL.md:3: AL104 [warning] 未知のキー 'descripton'(もしかして: description) .claude/skills/deploy/SKILL.md:6: AL202 [warning] 相対パス参照が見つからない: ./checklist.md agentlint: 5 error(s), 4 warning(s) typo には「もしかして」候補が付くので、修正もすぐ終わります。これが地味に嬉しいんですよね。 exit code は error があれば 1、warning のみなら 0 です。CI で warning も落としたい場合は --strict を付けると warning のみでも exit code 1 になります。 また、 .claude/ ディレクトリが存在しない場合は何もせず exit code 0 で終了します。モノレポの一部ディレクトリなど、対象外の場所で実行されても邪魔をしません。 agentlint: .claude ディレクトリが見つかりません(/path/to/other/.claude)。何もしません。 pre-commit への組み込み agentlint は pre-commit hook としての利用を想定していて、リポジトリに .pre-commit-hooks.yaml を同梱しています。 公開しているので、利用側の .pre-commit-config.yaml にリポジトリを直接指定できます。 # .pre-commit-config.yaml - repo: https://github.com/Shotaro-Yoshinaga-sti/agentlint rev: v0.2.0 hooks: - id: agentlint 手元で改造しながら試したいときは、 pre-commit try-repo でローカルのチェックアウトを直接指定するのが手軽です。 pre-commit try-repo ../agentlint agentlint --all-files hook 定義側で files: ^(\.claude/|CLAUDE\.md) を指定してあるので、 .claude/ 配下か CLAUDE.md に変更があったコミットのときだけ動きます。 参照: pre-commit 公式ドキュメント CI での利用 pre-commit をすり抜けたケース( --no-verify でのコミットなど)に備えて、CI でも同じ検証を回しておくと安心です。GitHub Actions なら以下のようなジョブになります。 uvx --from git+... で公開リポジトリから直接取得して実行するので、事前インストールは不要です。 # .github/workflows/agentlint.yml name: agentlint on: [push, pull_request] jobs: agentlint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - name: Run agentlint run: uvx --from git+https://github.com/Shotaro-Yoshinaga-sti/agentlint agentlint --strict ローカルの pre-commit では error のみブロック、CI では --strict で warning も含めて検知、という使い分けもできます。 既知の制限と使う上での考慮点 万能ではないので、現時点の制限も正直に書いておきます。ただ、どれも「知っていれば運用でカバーできる」類のものなので、制限ごとに「では利用者側はどう考慮すればいいか」までセットで整理します。 有効値リストは手動メンテ src/agentlint/spec.py の有効値リスト(イベント名や model / effort / permissionMode の列挙値など)は、2026-07 時点の公式ドキュメント準拠です。Claude Code 側の仕様変更に自動追従はしないため、新しいイベント名やフロントマターのキーが追加されると、 spec.py を更新するまでは誤検知(や見逃し)が起こり得ます。 使う側の考慮点としては、まず 未知のキー(AL104)が warning 止まりなのは、まさにこの事態のための設計 だと知っておくことです。仕様変更の直後に AL104 が出てもコミットはブロックされません。 公式ドキュメントに載っている正しいキーに対して AL104 が出ているなら、それは agentlint 側の追従漏れなので、その finding は無視して大丈夫です(そして spec.py に1行足せば直ります)。運用としては「公式ドキュメントの更新に気付いたら spec.py をメンテする」を回すイメージですね。 matcher の検証は Python の re による近似 hooks の matcher は Claude Code 内部では JavaScript の正規表現として解釈されますが、agentlint は Python の re モジュールで近似検証しています。両者の構文はおおむね互換とはいえ差異はあるので、JS では有効なのに Python では不正、といったパターンで誤検知 / 見逃しがあり得ます。 なので、 AL303 の error が出たときは「即修正」ではなく、「実際に Claude Code 上でその hook が動くか」を先に確認する のがおすすめです。Claude Code 上で正しく動いているなら構文差異による誤検知の可能性が高いです。そのうえで、matcher をツール名の完全一致や Bash|Edit のような単純な alternation に寄せておくと、そもそもこの構文差異を踏まなくなります。 対象はプロジェクトスコープのみ agentlint が見るのはプロジェクトスコープの .claude/settings.json / settings.local.json だけで、ユーザースコープの ~/.claude/ は対象外です。つまり、個人環境の ~/.claude/ に置いた設定が壊れていても検出されません。 これは「リポジトリにコミットされるものをコミット前に検証する」というツールの性格上の割り切りです。裏を返すと、 チームで共有したい Skill / Agent / hooks はプロジェクトスコープ(リポジトリ内の .claude/ )に寄せる 運用が前提になります。 共有物をリポジトリ側に置いておけばすべて agentlint の検証対象に入りますし、個人設定の壊れは被害が本人で閉じるので、まずは共有物を守る、という優先順位です。 AL305 が見るのは「スクリプトパスと確信できるトークン」だけ 設計思想のところ で書いた通り、AL305 の実在確認は .sh / .bash / .py / .js / .mjs / .ts の既知拡張子で終わるトークンだけが対象です。バイナリや拡張子なしスクリプトを直接実行している場合は、実在しなくても検出されません。また、引数位置のパスは先頭トークンがインタープリタ / ランナーの場合だけ見るので、 find -exec 等の別コマンドに渡したスクリプトパスも見逃します。いずれも誤検知回避を優先した 意図的な制限 です。 裏を返せば、 hooks の command を「インタープリタ + 拡張子付きスクリプトパス」の形(例: bash .claude/hooks/check.sh )に寄せておくと、AL305 の検証の恩恵をフルに受けられる ということです。凝ったワンライナーを command に直書きするより、処理を .sh / .py に切り出してシンプルに呼ぶ——という、hooks の可読性の面でもどのみち好ましい書き方に倒すほど、Linter もよく効くようになります。 このほか細かい点として、 .claude/commands/*.md では $CLAUDE_SKILL_DIR アンカーの参照を検証しません(commands では未定義のため)。 さいごに 今回は、Claude Code の .claude/ 配下を静的検証する自作 Linter「agentlint」を紹介しました。ポイントを整理します。 .claude/ 配下の設定ミスは 実行するまで気付けず 、Claude Code は壊れた設定を黙って無視することがある agentlint は フロントマター検証・参照切れ検出・hooks 設定検証・再委譲禁止記述の検証 の4チェックをコミット前に静的実行する 設計原則は 「error にするなら保守的に。迷ったら検出しない」 。error は構文・構造エラーと AL305 に限定し、誤検知でツールが放置される事態を避ける pre-commit と CI に組み込めば、壊れた設定がリポジトリに入る前に検知できる Skill や Subagent が増えてくると、 .claude/ 配下は立派な「コード」です。コードなら Linter があって然るべき、ということで作ってみましたが、導入してからはフロントマターの typo やリネーム漏れをコミット前に何度も拾ってくれています。 みなさんも .claude/ が育ってきたら、設定の静的検証を仕組み化してみてはいかがでしょうか。この記事がその参考になれば嬉しいです! ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post Claude Code の .claude/ 設定を静的検証する Linter「agentlint」を作った話 first appeared on SIOS Tech Lab .
こんにちは。サイオステクノロジーの橋本です。 SW360を構築した際に非常に手間がかかったので(2営業日)、備忘録として手順をまとめ、ブログとして公開します。 注意事項 本記事は必要最低限の項目を設定して立ち上げているだけですので、セキュリティ面などに懸念が残ります。利用の際は自己責任の元、必要に応じて設定やセキュリティを見直してください。 環境 OS : RHEL podman : podman-5.4.0-12.el9_6.x86_64 podman-compose : podman-compose-1.5.0-2.el9.noarch SW360 : v1.0.0-16-g038175e7 参考:基本的な手順(公式) Deploy 2.0 Containers | SW360 公式の手順は「自端末(localhost)にSW360を構築すること」を前提としており、そのままでは外部(自端末以外)からのアクセスがまったくできません。そのため、外部からIPアドレスでアクセスできるように各種設定ファイルを修正していきます。 構築手順 1. ソースのダウンロードとネットワーク作成 まずはGitHubからソースコードを取得し、Podmanネットワークを作成します。 Bash # cd /opt/ # git clone https://github.com/eclipse-sw360/sw360-frontend.git Cloning into 'sw360-frontend' ... remote: Enumerating objects: 32749, done . remote: Counting objects: 100% (2349/2349), done . remote: Compressing objects: 100% (741/741), done . remote: Total 32749 (delta 2042), reused 1613 (delta 1607), pack-reused 30400 (from 4) Receiving objects: 100% (32749/32749), 14.61 MiB | 22.87 MiB/s, done . Resolving deltas: 100% (21359/21359), done . # cd sw360-frontend # podman network create sw360net sw360net # git describe --tags v1.0.0-16-g038175e7   2. 設定ファイルの修正 外部からのアクセスを許可するため、各設定ファイルの localhost をサーバーのIPアドレス(以下 ${IP_address} と表記)に変更し、いくつかの起動オプションを追加します。 ※ 実際の作業時は、 ${IP_address} の部分をご自身のサーバーIPに置き換えて設定してください。 config/nginx/.env.web 9行目 修正前: SERVER_DOMAIN=localhost 修正後: SERVER_DOMAIN=${IP_address} config/front-end/.env.frontend 10行目 修正前: NEXTAUTH_URL=https://localhost 修正後: NEXTAUTH_URL=https://${IP_address} 14行目 修正前: NEXT_PUBLIC_SW360_API_URL=https://localhost 修正後: NEXT_PUBLIC_SW360_API_URL=https://${IP_address} config/keycloak/.env.keycloak 27行目 修正前: KC_HOSTNAME_URL=https://localhost/kc 修正後: KC_HOSTNAME_URL=https://${IP_address}/kc 29行目 修正前:(なし) 修正後: KC_HOSTNAME_STRICT_HTTPS=false (※追記) config/sw360/.env.backend 45行目 修正前: SW360_SECURITY_JWT_ISSUERS_0_ISSUER_URI=http://localhost:8080/authorization 修正後: SW360_SECURITY_JWT_ISSUERS_0_ISSUER_URI=http://${IP_address}:8080/authorization 46行目 修正前: SW360_SECURITY_JWT_ISSUERS_1_ISSUER_URI=https://localhost/kc/realms/sw360 修正後: SW360_SECURITY_JWT_ISSUERS_1_ISSUER_URI=http://${IP_address}:8083/realms/sw360 82行目 修正前: SW360_THRIFT_SERVER_URL=http://localhost:8080 修正後: SW360_THRIFT_SERVER_URL=http://${IP_address}:8080 83行目 修正前: SW360_BASE_URL=http://localhost:8080 修正後: SW360_BASE_URL=http://${IP_address}:8080 84行目 修正前: SW360_FRONTEND_URL=http://localhost:3000 修正後: SW360_FRONTEND_URL=http://${IP_address}:3000 docker-compose.yml 42行目(ヘルスチェックのタイムアウト調整) 修正前: start_period: 60s 修正後: start_period: 600s 88行目 修正前: NEXT_PUBLIC_SW360_API_URL: ${NEXT_PUBLIC_SW360_API_URL:-https://localhost} 修正後: NEXT_PUBLIC_SW360_API_URL: ${NEXT_PUBLIC_SW360_API_URL:-http://${IP_address}} 90行目 修正前: NEXTAUTH_URL: http://localhost 修正後: NEXTAUTH_URL: http://${IP_address} 106行目付近(Keycloakの起動コマンド追加) 修正前: YAML keycloak: image: "ghcr.io/eclipse-sw360/sw360/keycloak:26.6.4" container_name: keycloak restart: unless-stopped env_file: ./config/keycloak/.env.keycloak 修正後: command: start --http-enabled=true を追加 YAML keycloak: image: "ghcr.io/eclipse-sw360/sw360/keycloak:26.6.4" container_name: keycloak command: start --http-enabled=true restart: unless-stopped env_file: ./config/keycloak/.env.keycloak 3. コンテナの起動 上記の修正が完了したら、 podman-compose を使って起動します。 初回の起動には10~15分程度要します。 Bash # podman-compose -f docker-compose.yml up -d   しばらく待った後、 podman ps コマンドで全てのステータスが (healthy) になることを確認しましょう。 Bash [root@ip-172-31-15-70 sw360-frontend] # podman ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES c6547edcc86b docker.io/library/couchdb:3.5-nouveau /usr/bin/java -se... 5 minutes ago Up 4 minutes 0.0.0.0:5987-5988->5987-5988/tcp couchdb_nouveau b84fc441690a docker.io/library/postgres:18.4-alpine postgres 5 minutes ago Up 4 minutes (healthy) 0.0.0.0:5432->5432/tcp postgres bbaf3539a33e docker.io/library/couchdb:3.5 /opt/couchdb/bin/... 5 minutes ago Up 4 minutes (healthy) 0.0.0.0:5984->5984/tcp, 4369/tcp, 9100/tcp couchdb d1955264c00d ghcr.io/eclipse-sw360/sw360/keycloak:26.7.0 start --http-enab... 4 minutes ago Up 4 minutes (healthy) 0.0.0.0:8083->8083/tcp, 8080/tcp, 8443/tcp, 9000/tcp keycloak f35d08b298ac ghcr.io/eclipse-sw360/sw360:main 4 minutes ago Up 4 minutes (healthy) 0.0.0.0:8080->8080/tcp sw360 722b25096736 ghcr.io/eclipse-sw360/sw360-frontend:main node server.js 4 minutes ago Up 4 minutes 0.0.0.0:3000->3000/tcp sw360-frontend e4aeff7140e0 docker.io/library/nginx:1.31.3-trixie /bin/sh -cexp... 4 minutes ago Up 4 minutes 0.0.0.0:443->443/tcp, 80/tcp sw360_web   アクセス方法 ブラウザから https://${IP_address} でアクセス可能です。 ログインユーザとパスワードについては、公式リポジトリ内の README_DOCKER.md に記載されているデフォルトの Basic Auth 情報を参照してください。 SW360 Basic / Built-in Basic Auth 認証情報 既知の不具合について(2026年8月現在) 現在、Keycloak でのログイン後にホーム画面で NEXT_HTTP_ERROR_FALLBACK;404 のエラーが表示される既知の不具合が確認されています。 詳細や進捗については、以下の GitHub Issue をご確認ください。 [Bug] Error: NEXT_HTTP_ERROR_FALLBACK;404 displayed on Home page after Keycloak login (latest/20.1.0) #4293 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post 【備忘録】RHEL 環境でのSW360コンテナ構築手順 first appeared on SIOS Tech Lab .
はじめに こんにちは!サイオステクノロジーのなーがです。2026年7月上旬、いったん提供停止されていた Claude Fable 5 が再公開されましたね。Fable 5 は並列サブエージェントのディスパッチ・管理が得意とされる一方で高価なモデルなので、メインセッションの Fable にはオーケストレーション(計画・分解・統合)だけをさせて、実作業は安価なモデルのサブエージェントに委譲する運用が定石として広まっています。 私もこの流れに乗って、個人開発の Python プロジェクトで Claude Code のマルチエージェント運用を始めてみました。ところが、いざ動かしてみると「サブエージェントの多段委譲問題」と呼ばれる典型的な落とし穴にきれいにハマり、トークンを溶かすはめになりました。 参考: 「Claude Fable 5」が復活、7月7日まではプランの上限内で試用可能(窓の杜) / サブエージェント活用で Claude Fable 5 をコスパよく運用する(Zenn) 今回は、サブエージェントを設定してからこの問題を踏むまでの流れと、「プロンプトによる指示」と「hooks による機械的な拒否」の多層防御で解決するまでの経緯を、途中でやらかした hook 自身の誤検知も含めて、実際の実装とあわせて紹介します。 サブエージェントを設定する まずは私がやった設定から紹介します。Claude Code では、 .claude/agents/ にカスタムエージェントを定義しておくと、メインセッションが Agent ツールでそれらを呼び出せます。今回は役割を3つに分けました。 investigator (調査、haiku):複数ファイルにまたがる調査・検索。読み取り専用 implementer (実装、sonnet):設計確定後のコード編集・テスト・lint reviewer (レビュー、opus):コミット前の品質・セキュリティレビュー。読み取り専用 参考: Claude Code のサブエージェント(公式ドキュメント) エージェント定義はこんな感じです( investigator.md )。読み取り専用の役割なので、 tools を参照系に絞っています。 --- name: investigator description: 複数ファイルにまたがる調査・コード検索・現状把握を行う読み取り専用エージェント。 model: haiku tools: Read, Glob, Grep, Bash --- あなたは調査専門エージェント。 - 読み取り専用で動く。ファイルの作成・編集・削除はしない。 - 結論を先に、根拠となるファイルパスと行番号(`path:line`)を添えて報告する。 あわせて、メインセッションが迷わないように、CLAUDE.md に「エージェント委譲ルール」を書きました。ポイントは、役割の対応表を 固定パイプラインではない と明言することです。 ## エージェント委譲ルール メインセッションは設計・統括・結果検証に徹し、作業はサブエージェントに委譲する。 下記は役割の対応表であり、全タスクに強制する固定パイプラインではない: - 複数ファイルにまたがる調査・検索 → `investigator` - 設計確定後の実装(コード編集・テスト・lint) → `implementer` - コミット前のコードレビュー → `reviewer` 運用原則: - 同じ作業内容を複数のエージェントに順番にリレーしない。 1つのタスクで各役割のエージェントを使うのは最大1回ずつ。 前段の結果を丸ごと次段の課題として再送するのは禁止。 - 互いに独立したタスクは、1つのメッセージで複数のAgent呼び出しを 同時に発行して並列実行する。 - 不要なフェーズは飛ばす。調査が不要なら `investigator` を起動しない。 - 単一ファイルの軽微な修正はメインセッションが直接行ってよい。 「リレー禁止」「独立タスクは並列実行」「不要フェーズのスキップ」の3点セットまで書いて、これでメインの Fable は統括に専念、実作業は3役に振れる分業体制ができました。……はずでした。 早速つまずいた「サブエージェントの多段委譲問題」 運用を始めてすぐ、ダッシュボードまわりの1タスクで様子がおかしくなりました。呼び出しツリーを見ると、メインセッションから呼ばれた implementer が(本来は禁止のはずの)Agent ツールでさらに implementer を呼び、それがまた次へ……と、気づけば 5段ネスト 。各段が2.6万トークン前後を消費し、最深段は6.5万トークンに達していました。CLAUDE.md にわざわざ「リレー禁止」と書いたのに、です。この呼び出しツリーを見つけたときは、さすがに焦りました。 あとで知ったのですが、これは「 サブエージェントの多段委譲問題 」として知られる典型的な失敗でした。委譲の「深さ」と「回数」を放任すると起きるもので、整理すると原因は「 誰が誰を呼ぶか(深さ) 」と「 何を渡すか(内容) 」という2つの軸に分かれます。 再委譲: サブエージェントがさらにサブエージェントを呼ぶ 1つ目は 構造(深さ)の問題 です。委譲されたサブエージェントが自分でも Agent ツールを使い、さらに別のサブエージェントへ仕事を投げてしまうケースです。 こうなると、実際に手を動かしているのが誰なのかメインセッションから見えなくなります。孫エージェントはユーザーの元の意図を知らないまま作業するのでコンテキストが失われ、結果の品質も制御できません。 トークン消費の面でも、単に段数に比例して増えるだけでは済みません。本来不要な中間層のエージェントが1つずつ起動すること自体がコストで、各中間層はコンテキストの読み込み・状況把握・指示の再構成といった同じような処理を重複して行います。つまり、中抜きすれば丸ごと不要だったはずのコストが、層の数だけ積み上がっていくわけです。 多段リレー: 前段の結果を丸ごと次段に再送する 2つ目は 内容の問題 で、もう少し気づきにくいです。メインセッションが「調査 → 実装 → レビュー」をパイプラインだと思い込み、 前段のエージェントが返した長大な結果をほぼそのまま次段のプロンプトに貼り付けて 順送りしてしまうケースです。 一見それらしく動いているのですが、実態は同じテキストがセッション内を何往復もしているだけです。本来メインセッションがやるべき「結果を咀嚼して、次のフェーズに必要な情報だけを渡す」という仕事が抜け落ちており、次のような問題が起きます。 結果の劣化 : 各エージェントが要点の抽出をサボり、丸投げの連鎖になる トークン消費 : 長文が段数ぶん重複して送られ、コストが跳ね上がる 制御不能 : 不要なフェーズ(調査不要のタスクでの investigator 起動など)まで律儀に実行される 冒頭の5段ネストは、まさにこの2つが同時に噴き出した状態でした。 implementer が implementer を呼んでネストが深くなっている点は再委譲そのもの、渡している内容がほぼ同じ点はリレーそのもの です。「深さ」と「内容」という別々の軸なので、原因が違えば防ぎ方も層で分かれます。これは仕組みで止めるしかありません。次章から、その対策を見ていきます。 解決アプローチ: プロンプトと hooks の多層防御 対策は1つではなく、階層の異なる4つを重ねています。すでに設定時に書いた CLAUDE.md の委譲ルールが1つ目のプロンプト層で、ここにエージェント定義の制約(もう1つのプロンプト層)と、2つの hooks(機械的な強制)を足していきます。先ほどの2軸に対応づけると、 再委譲(構造)は主にエージェント定義のツール制限で根本から止め、多段リレー(内容)は主に hook で止める という役割分担になっています。 関連ファイルの構成は以下のとおりです。 .claude/ ├── agents/ │ ├── investigator.md # 調査担当(読み取り専用) │ ├── implementer.md # 実装担当 │ └── reviewer.md # レビュー担当(読み取り専用) ├── hooks/ │ ├── agent-relay-guard.sh # PreToolUse: リレー検出・拒否 │ └── agent-turn-reset.sh # UserPromptSubmit: 履歴リセット ├── tests/ │ └── test_agent_relay_guard.py # hook の挙動テスト ├── agent-calls/ # Agent呼び出し履歴(セッションごとのJSONL、gitignore対象) └── settings.json # hooks の登録 この一式は、そのまま .claude/ に置いて使える最小構成のサンプルとして GitHub で公開 しています。hook・テスト・エージェント定義がそろっているので、動かしな がら読むとわかりやすいと思います。 エージェント定義に再委譲禁止を明記する 設定時に CLAUDE.md へ書いた委譲ルールだけでは足りませんでした。そこで .claude/agents/ の各エージェント定義にも、再委譲を禁止する制約を追記しました。先ほどの investigator.md に、次の「## 制約」セクションを足した形です。 ## 制約 - 他のサブエージェントを呼び出さない(Agentツール使用禁止)。 タスクが担当範囲を超える場合は、その旨を報告して終了する。 - 報告は結論と根拠のみを簡潔に。調査ログや試行過程を全文貼り付けない。 この「制約」セクションは implementer.md / reviewer.md にも同じ文面で入れています。ポイントは2つです。 Agent ツール使用禁止 を明記し、担当範囲を超えたら「報告して終了」という逃げ道を用意する(禁止だけだと無理に自力で解決しようとするため) 報告を「結論と根拠のみ」に絞る。 前段の報告が短ければ、そもそも丸ごとリレーする材料が生まれにくい なお investigator と reviewer は frontmatter の tools で使えるツール自体を読み取り系に絞っており、そもそも Agent ツールを持たせていません。プロンプトの制約とツール制限の二重がけです。 再委譲(サブエージェントがさらにサブエージェントを呼ぶ構造)を根本から止めているのは、実はこのツール制限です。 Agent ツールを持っていなければ、そもそもネストのしようがありません。この後の hook は、主にもう一方の軸である多段リレー(内容の丸ごと再送)を担当します。 ただし、ここまでは全部「お願い」です。CLAUDE.md もエージェント定義もプロンプトの一部でしかないので、コンテキストが長くなると平気で忘れられるんですよね。実際、明文化した後もリレーは散発しました。そこで hooks の出番です。 agent-relay-guard: PreToolUse hook でリレーを拒否する 本丸が agent-relay-guard.sh です。PreToolUse hook を Agent ツールにマッチさせ、 Agent 呼び出しが実行される前に リレーかどうかを判定して、リレーなら拒否します。 参考: Claude Code の hooks(公式ドキュメント) 実体は bash スクリプトですが、bash 部分は薄いラッパーで、判定ロジック本体はスクリプト内にヒアドキュメントで埋め込んだ Python コード( PYSCRIPT )を python3 -c に渡して実行しています(抽出〜判定〜履歴の記録までを1つの Python プロセスに一本化し、ロジックの二重管理を避けるためです)。以降のコード例は、この Python 部分からの抜粋です。 仕組みはシンプルで、セッションごとの Agent 呼び出し履歴を .claude/agent-calls/<session_id>.jsonl に記録しておき、新しい呼び出しのたびに履歴と突き合わせます。 実は最初に作ったバージョンは、この履歴を 同一セッション内でずっと 持ち続ける実装でした。これがあとで誤検知の原因になるのですが、それは後述するとして、まずは判定ロジックを見ていきます。判定は2つです。 判定1: 同一役割への2回目の呼び出しを拒否する investigator / implementer / reviewer の3役については、履歴に同じ役割の呼び出しが残っていれば一律拒否します。CLAUDE.md の「1つのタスクで各役割は最大1回ずつ」をそのまま機械化したものです(以下、最終版の該当部分の抜粋)。 ROLE_NAMES = {"investigator", "implementer", "reviewer"} if subagent_type in ROLE_NAMES: for entry in history: if entry.get("subagent_type") == subagent_type: # この reason が最終的に deny の JSON として stdout に出力される reason = ( f"同一タスク内で役割 '{subagent_type}' への2回目以降のAgent呼び出しは" f"サブエージェントの多段リレー防止のため拒否します。..." ) break この判定は、冒頭で挙げた 再委譲のバックストップ も兼ねています。 implementer は( investigator / reviewer と違って)ツール制限をかけていないため Agent ツールを持っており、プロンプトの禁止をすり抜けて implementer が implementer を呼ぶ再委譲が起こり得ます。そのネストも「同じ役割の2回目」としてここで引っかかるので、エージェント定義のツール制限を主とし、この hook が二段目の網になります。 判定2: 直前呼び出しとの prompt 類似度で「丸ごと再送」を拒否する 役割が違っても、直前の Agent 呼び出しと prompt がほぼ同じなら、それは前段の結果の丸ごと再送です。prompt を行単位に正規化(前後空白を除去し空行を捨てる)した上で、 行集合の Jaccard 係数 を計算し、0.7 を超えたら拒否します。Jaccard 係数は、2つの集合の共通要素が全体(和集合)に占める割合を表す 0〜1 の類似度指標です(以下、該当部分の抜粋)。 SIMILARITY_THRESHOLD = 0.7 def line_similarity(a_lines: list[str], b_lines: list[str]) -> float: set_a, set_b = set(a_lines), set(b_lines) union = set_a | set_b if not union: return 0.0 return len(set_a & set_b) / len(union) 「前段の結果を丸ごと貼って、末尾に指示を1行足しただけ」のようなプロンプトは、行の大半が共通するので類似度が高く出ます。逆に、フェーズごとに内容を咀嚼して書き直したプロンプトなら共通行はほとんど残らないので通過します。 文字単位ではなく行単位の集合比較にしているのは、この「コピペ再送」の検出に特化するためです。こちらの判定は3役に限らず general-purpose などすべての subagent_type に効きます。 拒否時は、PreToolUse hook の JSON 出力で permissionDecision: "deny" を返します。出力の構造は次のとおりで、理由の全文は後述の「実際の挙動」で紹介します。 { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "(拒否理由のテキスト)" } } 設計上の工夫: ガードは fail-open に倒す このガードで一番気を使ったのが異常系の扱いです。開発を止めないことが最優先なので、 判定に必要な情報が揃わないケースはすべて許可側に倒す(fail-open) 設計にしています。 stdin が空・JSON が不正 → 許可 session_id が取れない(セッション単位で状態を分離できない) → 許可 状態ディレクトリが作れない・履歴ファイルが書けない → 許可 さらに、誤検知したときのために 環境変数によるバイパス を用意しています。 AGENT_RELAY_GUARD_DISABLE=1 これをセットするとチェックも履歴の記録もすべてスキップされます。拒否メッセージ自体にこのバイパス方法を書いてあるのもポイントで、誤検知に遭遇した未来の自分(や Claude)がその場で回避策にたどり着けます。 ここまでが対策の第1弾です。これで一件落着……と思いきや、導入してみると今度は ガード自身が誤検知 を起こしました。 agent-turn-reset: ユーザー発言をタスク境界として履歴をリセットする 誤検知の症状はこうです。初版のガードは履歴をセッション単位で持っていたため、同一セッション内では役割ごとに1回しか Agent を呼べず、 独立した別タスクなのに2回目以降の呼び出しが拒否される 。午前中に implementer を使ったせいで、午後の全く別の修正依頼で implementer が呼べない、という状態です。これは明らかにおかしいですよね。 原因は判定単位のズレでした。CLAUDE.md のリレー禁止ルールは「1つのタスクで各役割は最大1回ずつ」という タスク単位 のルールなのに、初版のガードはこれを セッション単位 で判定していたのです。ルールを機械化するときは、条件だけでなく 適用単位 まで正確に写し取る必要がありました。 そこで第2弾の修正として、判定単位をセッションからタスクへ揃えました。 「ユーザーの新しい発言 = 新しいタスクの開始」 とみなし、UserPromptSubmit hook( agent-turn-reset.sh )でそのセッションの呼び出し履歴を削除します。 # UserPromptSubmit hook: ユーザーの新しい発言 = 新しいタスクの開始とみなし、 # agent-relay-guard.sh が使う「そのタスク内のAgent呼び出し履歴」をリセットする。 # 古い(7日以上前の)セッション状態ファイルを掃除する。 find "$STATE_DIR" -maxdepth 1 -name '*.jsonl' -mtime +7 -delete 2>/dev/null || true safe_session=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9_.-' '_') rm -f "$STATE_DIR/$safe_session.jsonl" 2>/dev/null || true exit 0 あわせて、ガード側の履歴にも TTL(既定30分 = 1800秒、 AGENT_RELAY_GUARD_TTL_SEC で変更可)を導入し、リセットが何らかの理由で動かなかった場合も古い履歴を引きずらないようにしました。拒否メッセージも「同一タスク内」という表現に改め、「独立した別タスクなら、ユーザーの次の指示以降は再び呼び出せます」という再呼び出し可能な条件を明記しています。 これで「同一セッションでも、別タスクなら同じ役割を再び呼び出せる」という自然な挙動になりました。この hook もノンブロッキングで、何が起きても exit 0 します(リセットに失敗しても TTL が最終的に古い履歴を無効化してくれます)。 地味な注意点として、履歴を書く側(agent-relay-guard)と消す側(agent-turn-reset)で状態ディレクトリの既定値を 同じ導出方法で 揃える必要があります。ここがすれ違うと、リセットが効かずに誤検知が復活します。実装では両方とも hook スクリプト自身の位置からリポジトリルートを導出しており、この契約はテストで検証しています。 振り返ると、プロンプトで守らせられないルールを hook で機械化したら、今度は hook 側の「判定単位バグ」と付き合うことになったわけです。機械化は誤検知とセットで考え、fail-open・バイパス・境界でのリセットといった逃げ道を最初から用意しておくことの大切さを痛感しました。 実際の挙動 2つの hook は .claude/settings.json に次のように登録します。 { "hooks": { "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-turn-reset.sh\"", "timeout": 10 } ] } ], "PreToolUse": [ { "matcher": "Agent", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-relay-guard.sh\"", "timeout": 10 } ] } ] } } hook 単体の挙動は、JSON を stdin に流せば手元で確認できます。同じセッション ID で implementer を2回呼んでみます。 echo '{"session_id":"demo","tool_name":"Agent","tool_input":{"subagent_type":"implementer","prompt":"課題Aの実装をお願いします。"}}' \ | .claude/hooks/agent-relay-guard.sh 1回目は何も出力されず終了コード0(許可)です。続けて2回目を実行すると deny の JSON が返ります。 { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "同一タスク内で役割 'implementer' への2回目以降のAgent呼び出しはサブエージェントの多段リレー防止のため拒否します。独立した別タスクなら、ユーザーの次の指示以降は再び呼び出せます。誤検知の場合は環境変数 AGENT_RELAY_GUARD_DISABLE=1 で一時的に無効化できます。" } } 類似度判定に引っかかった場合は、理由に類似度の実測値が入ります。 investigator の報告を丸ごと implementer に再送しようとしたケースでは、こんなメッセージで拒否されました。 直前のAgent呼び出し(役割: investigator)とpromptの類似度が高く(0.83 > 0.7)、 前段の結果の丸ごと再送とみなし拒否します。 誤検知の場合は環境変数 AGENT_RELAY_GUARD_DISABLE=1 で一時的に無効化できます。 実セッションでは、Claude が Agent ツールを呼ぼうとした瞬間にこの deny が割り込み、ツール実行はブロックされます。Claude 側には拒否理由がそのままフィードバックされるので、Claude は「リレーが禁止されている」ことをその場で理解し、前段の結果を自分で咀嚼して直接作業を進めるか、次フェーズ用にプロンプトを書き直す方向に軌道修正します。 つまり、 拒否理由がそのまま Claude への行動指針になる ように文面を書いておくのがコツです。 実際にガードが拒否したときの様子がこちらです。拒否理由が赤字のエラーとして表示され、直後に Claude が「自分は implementer なのだから Agent ツールを呼ぶべきではない、直接実装しよう」と軌道修正しています。 なお、こうした hook はエージェントの動作を止めうるものなので、テストを書いておくと安心です。このリポジトリでは「同一役割の2回目は拒否」「ユーザー発言後は再び許可」「別セッションには影響しない」「TTL切れの履歴は無視」「壊れた JSON は許可(fail-open)」といった分岐を unittest で網羅しています。 追記: リレーガードが並列実行を止めてしまった ここまでで一段落……と思っていたのですが、しばらく運用するうちに、このガード自身にもう1つ穴が見つかりました。今度は誤検知どころか、 推奨していたはずの並列委譲まで巻き添えで止めてしまう という、なかなか根の深い問題でした。この後日談も含めて共有します。 症状はこうです。設定時の CLAUDE.md には「互いに独立したタスクは、1つのメッセージで複数の Agent 呼び出しを同時に発行して並列実行する」と書いていました。ところがいざ独立タスクを並列で投げると、 implementer を3本同時に発行したうちの 2本目以降がガードに拒否される のです。前述の「判定1: 同一役割への2回目の呼び出しを拒否する」が、並列に発射した兄弟呼び出しまで「2回目」と数えてしまっていました。リレーを止めるつもりのガードが、自分で推奨した並列委譲を殺していたわけです。 なぜ並列とリレーを取り違えたのか 原因は、判定が 回数と順序 だけを見ていたことでした。「同一役割の2回目」も「直前の呼び出しとの類似度」も、時間的な前後関係しか見ていません。しかし、そもそも 本物のリレーの定義は「前段が 完了 し、その結果を受け取ってから、その内容を次段へ渡す」こと です。この「完了してから」という条件がすっぽり抜けていました。 1つのメッセージで同時に発射した並列の兄弟呼び出しは、まだ誰も完了していません。それを「同じ役割の2回目」と数えてしまったのが取り違えの正体でした。リレーと並列は、回数で見ると区別がつかないのです。 判定対象を「完了済みの呼び出し」だけに絞る そこで判定の軸を回数から 完了 へ切り替えました。 比較対象を「完了済みの Agent 呼び出し」だけに限定する のがポイントです。 こうすると並列は構造的に必ず許可されます。1メッセージで同時発行した兄弟たちは、お互いまだ完了していないので、判定時点で比較対象がゼロ。比較する相手がいなければ、リレー判定のしようがなく素通りします。役割ごとの回数制限はきれいに撤廃し、「何回呼んだか」ではなく「完了した前段の内容を使い回しているか」という 内容ベース の判定に置き換えました。 「完了」は SubagentStop で捉える(PostToolUse ではない) ここで地味に嵌まったのが、「完了」をどのイベントで捉えるかです。素直に考えると Agent ツールの PostToolUse (実行後)が完了に思えますが、これは 罠 でした。 Claude Code のサブエージェントは既定でバックグラウンド実行されるため、 PostToolUse は 起動が返った瞬間 ( tool_response.status が async_launched )に発火します。つまり実処理の完了ではなく、あくまで「起動できた」の合図です。実測すると、 PostToolUse は起動の約0.4秒後、並列呼び出しどうしの間隔は約0.8秒、実際の完了イベントは約6秒後でした。 PostToolUse を完了とみなすと、並列2本目の PreToolUse より前に「1本目は完了済み」と誤認してしまい、また並列が壊れます。 そこで 「完了 = SubagentStop 」 と定義し直しました。役割を3つの hook に分けます。 PostToolUse (Agent、 agent-call-record.sh )… その呼び出しの prompt と agent_id の紐付けを記録する(起動時点。 完了ではない ) SubagentStop ( agent-call-complete.sh )… サブエージェントの最終報告テキストと 完了 を記録する PreToolUse (Agent、 agent-relay-guard.sh )… 上記2つが書いた記録を読んで判定する 判定側が見るのは、 SubagentStop が書いた「完了済み」の記録だけ。これで初めて、並列の兄弟が互いを完了済みとみなさないことが保証されます。 deny と ask を使い分ける 内容ベースに寄せたことで、判定は2段階になりました。 完了済みエージェントの「出力」を丸ごと貼り付けて再送 している(出力の行が高い割合で prompt に含まれる) → 強い証拠なので deny 完了済みエージェントの「 prompt 」の使い回し(行集合の類似度が高い) → グレーなので ask 以前は類似度が高ければ一律 deny でしたが、ここを ask(確認)に緩めました。似た前置き(リポジトリの説明やテストコマンドなど)を共有する独立タスクを、うっかり殺さないためです。ask なら「これは独立した別作業です」と承認してそのまま続行できます。あわせて、箇条書き記号やコードフェンスのような 短い定型行だけの偶然の一致 で誤検知しないよう、比較する行に最小文字数・最小行数の下限も設けました。 状態設計を作り直し、再委譲の深さは公式の仕組みに任せる 判定単位をタスクに揃えるために前章では agent-turn-reset.sh で履歴を削除していましたが、この作り直しでその hook 自体が不要になりました。状態を 1呼び出し1ファイル ( <session>/<prompt_id>/<id>.{start,call,done}.json )に分解し、 タスク境界を prompt_id で表現 するようにしたためです。ユーザーの新しい発言は新しい prompt_id 、つまり別ディレクトリになるので、履歴は削除しなくても自動的に切り替わります。1ファイル1呼び出しなのでロックなしで並列安全になり、「拒否された呼び出しが次の判定を巻き込む」カスケードも消えました。かつての削除リセット hook は、古いセッションを掃除するだけの agent-calls-gc.sh に縮小しています。 もう1つ、内容ベースに寄せたことで、以前は「判定1」が兼ねていた 再委譲(ネスト)のバックストップ が外れました。これは公式の仕組みに委ねます。サブエージェント内からの呼び出し(入力に agent_id が入る)はガードの判定対象外にし、入れ子の深さ制限は Claude Code 公式の環境変数 CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH に任せることにしました。 .claude/settings.json の env で 1 に設定しています。 { "env": { "CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH": "1" } } hook の登録も、記録用の2つ( PostToolUse / SubagentStop )が増え、 UserPromptSubmit は掃除用の agent-calls-gc.sh に差し替わります。 { "hooks": { "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-calls-gc.sh\"", "timeout": 10 } ] } ], "PreToolUse": [ { "matcher": "Agent", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-relay-guard.sh\"", "timeout": 10 } ] } ], "PostToolUse": [ { "matcher": "Agent", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-call-record.sh\"", "timeout": 10 } ] } ], "SubagentStop": [ { "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-call-complete.sh\"", "timeout": 10 } ] } ] } } 結果として、防御は「 ツール制限(エージェント定義)+ 深さ上限(公式の env)+ 内容判定(hook) 」という、役割がきれいに分かれた3枚構成に落ち着きました。回数で殴るのをやめて「完了」と「内容」で見るようにしただけで、並列委譲もリレー防止も両立できたのは、我ながらスッキリした着地でした。 さいごに Claude Code のマルチエージェント運用で起きた「再委譲」と「多段リレー」を、多層の防御で解決するまでの試行錯誤の話でした。要点をまとめます。 問題は2軸あり、 再委譲 (構造・深さ: サブエージェントがサブエージェントを呼ぶ)と、 多段リレー (内容: 前段の結果を咀嚼せず丸ごと再送する)。似て見えるが原因が違う。 防御は役割で分ける。 再委譲はエージェント定義のツール制限+公式の CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH で深さを止め、 多段リレーは PreToolUse hook(agent-relay-guard) が内容を見て止める リレーと並列は回数では区別できない。判定を「回数」から「 完了 」へ切り替え、比較対象を完了済み(SubagentStop)の呼び出しだけに絞ることで、並列の同時発行を構造的に常に許可しつつリレーだけを止める 内容判定は2段階。完了済みの 出力の丸ごと再送は deny 、prompt の使い回しは ask にして、似た前置きを共有する独立タスクを殺さない 一般化すると、 プロンプト指示だけでは守られないルールは、hook で機械的に強制する というのが今回の教訓です。CLAUDE.md に何を書いても、それはあくまで「お願い」であり、コンテキストが長くなれば忘れられます。破られると困るルールほど、hook のような決定的な仕組みに落とすべきで、その際は fail-open とバイパスをセットで用意しておくと運用が破綻しません。 ちなみに、エージェント定義に「再委譲禁止の記述が存在すること」自体は、自作の Linter である agentlint(ルール AL401)で静的に検証するようにしています。agentlint については 別の記事 で詳しく紹介しているので、あわせてどうぞ。 サブエージェントの委譲制御に悩んでいる方は、いきなり拒否まで作り込まなくても、まずは PreToolUse hook で Agent 呼び出しをログに記録するところから試してみてください!それでは! ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post Claude Code でサブエージェントが5段ネスト!?トークンを溶かす多段委譲をハーネスで防ぐ! first appeared on SIOS Tech Lab .
PSSL の佐々木です 移動中に Claude Code を回しておきたいのですが、満員電車でノート PC を開くのは無理です。カバンに入れたまま処理だけ走らせておきたいです。 そうすると解決したい問題が 2 つ出てきます。 フタを閉じるとスリープして処理が止まる テザリングだとスマホのバッテリーが減るうえ、PC とスマホが不意に離れると Wi-Fi が切れる この 2 つを潰す設定をまとめます。Windows 側はすべて PowerShell で完結させます。 最近はPCがスリープしないようにちょっとだけPCを開けたまま手に持ち歩く人もいるみたいですが、PCを落としたり普通に邪魔なのでカバンに入れた状態でもClaudeが動くための環境を作りました。 この設定は普通に会社から怒られるかもしれないので自己責任でお願いします。 1. フタを閉じても止めない 管理者権限の PowerShell で実行します。 powershell # フタを閉じたときの動作を「何もしない」に(バッテリー駆動時) powercfg /setdcvalueindex SCHEME_CURRENT SUB_BUTTONS LIDACTION 0 # 電源接続時も同様 powercfg /setacvalueindex SCHEME_CURRENT SUB_BUTTONS LIDACTION 0 # スリープ移行そのものを無効化(0 = 無効) powercfg /change standby-timeout-dc 0 powercfg /change standby-timeout-ac 0 # ディスプレイは切ってよい(1分) powercfg /change monitor-timeout-dc 1 # 反映 powercfg /setactive SCHEME_CURRENT LIDACTION の値です。 0 ... 何もしない 1 ... スリープ 2 ... 休止状態 3 ... シャットダウン 現在値の確認はこちら。 powershell powercfg /query SCHEME_CURRENT SUB_BUTTONS powercfg /a # モダンスタンバイ対応かの確認 WSL 側も落ちないようにしておきます。 %USERPROFILE%\.wslconfig に追記して wsl --shutdown 。 ini [wsl2] vmIdleTimeout = -1 処理自体は tmux の中で回しておくと、復帰後にそのまま続きが見られます。 bash tmux new -s claude claude --dangerously-skip-permissions # Ctrl+b → d でデタッチ   Macの場合はさらに簡単でamphetamineというアプリを入れておくと蓋を閉じてもスリープしなくなります。 https://apps.apple.com/jp/app/amphetamine/id937984704?mt=12 2. テザリングをやめてリチャージWi-Fiにする テザリングをやめる理由は 2 つです。スマホのバッテリーが目に見えて減ること、そして満員電車で体勢が変わって PC とスマホが離れると Wi-Fi が切れることです。これを解決するためにリチャージWi-Fiを買いました。 リチャージWi-FiはTypeCをPCに直刺しするとネットにつながります。(特に設定もなく、さしてしばらくするとネットにつながるようになります。) リチャージWi-Fiは100GB8000円ぐらいから売っていて、100GB使い切ったらポータルサイトでチャージするとまた使えるようになります。 https://www.amazon.co.jp/【リチャージWiFi】バッテリーレス-100GB-リチャージ-一体型【M4-100GB-365日】/dp/B0DXVNKZ8T/ref=asc_df_B0DXVNKZ8T?mcid=c2d7374fee12300a9c74a8ba8ade9134&tag=jpgo-22&linkCode=df0&hvadid=707549940401&hvpos=&hvnetw=g&hvrand=8194794017880763317&hvpone=&hvptwo=&hvqmt=&hvdev=c&hvdvcmdl=&hvlocint=&hvlocphy=1009285&hvtargid=pla-2444400325634&psc=1&hvocijid=8194794017880763317-B0DXVNKZ8T-&hvexpln=0 カバンにPCを入れるとこんな感じです。リチャージWi-Fiが映えますね カバンが汚いのは見なかったことにしてください。   3. 注意:フタを開けたら充電が空になっていることがある ここまでやると、フタを閉じてもディスプレイが消えるだけで中身は全力で動き続けます。当然ながら バッテリーの減りはかなり速い です。 私は移動中に走らせたまま忘れて、目的地でフタを開けたら電源が落ちていた経験があります。 客先での商談がある場合には注意が必要が必要です。 そのため、 カバンにはモバイルバッテリー(PD 対応)を一緒に入れておく 長時間走らせるときは充電しながらにする 移動が終わったら設定を戻す 4. まとめ 満員電車で立ったまま何もできない時間が、そのまま実行時間になります。設定は 10 分で終わるので、通勤が長い方は一度入れておくと効きます。 次は軽くて大容量のバッテリーが欲しいです。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 1人がこの投稿は役に立ったと言っています。 The post 満員電車でも Claude Code を動かし続ける技術 first appeared on SIOS Tech Lab .
こんにちは、サイオステクノロジー武井です。 AI コーディングエージェントを使い始めて、ふと不安になったことはないでしょうか。 「このAI、プロジェクトの中を自由に読めるけど…… .env   みたいな機密情報も読めてしまうのでは?」 そうなんです。読めちゃうんです。しかも、ただ読めるだけでは終わりません。読んだ内容がドキュメントに紛れ込んだり、コミットに含まれたりして、 GitHub リポジトリ経由で外部に漏れる ところまで想像すると、なかなか怖い話です((((;゚Д゚))))ガクガクブルブル この記事では、その対策として用意されている Claude Code の権限機能とサンドボックス機能を取り上げます。ただ「こう設定すれば安全」という話で終わらせるのではなく、 なぜその機能が必要なのか、どこまで守れて、どこに穴が残るのか を、OS の仕組みまで降りて整理していきます。途中で Linux カーネルの   fork / exec   という基本機能まで出てきますが、そこが腑に落ちると、サンドボックスが「なぜそういう形をしているのか」まで見通せるようになります。 なお、以降は Claude Code を題材に話を進めますが、 基本的な仕組みは他のコーディングエージェントでも多分同じ   です。設定ファイルの名前や項目名は製品ごとに違いますが、どれも「エージェント本体のプロセスがファイルを読む」「シェルコマンドを子プロセスとして起動する」という同じ形で動いていて、OS が用意している道具立て(macOS の Seatbelt、Linux の namespace など)も共通です。この記事で扱うのは、その   共通部分の構造   です。「アプリが自分で我慢しているのか、OS が強制しているのか」という見分け方を持っておけば、お使いのツールがどちらの層で守ってくれているのかも、同じ物差しで判断できるようになります。 何も気をつけないと、AIは機密情報を読んでしまう まず、問題の全体像を確認します。 多くのプロジェクトでは、API キーやデータベースの接続文字列といった秘密情報を   .env   ファイルに書いて、プロジェクト直下に置いています。ローカルで開発する分には便利な運用です。 ところが、AI コーディングエージェントはプロジェクト内のファイルを読んで文脈を理解します。つまり、 放っておくと   .env   も読める ということです。そして厄介なのは、読んだ後に何が起きるか予測しづらい点です。 「README を書いて」と頼んだら、環境変数の設定例として本物のキーが埋め込まれる コード生成の過程で、秘密情報を含むファイルを参照した内容がコメントに残る それらをコミットして   git push   した瞬間、 公開リポジトリに秘密が載る 「AIが勝手に漏らした」というより、「AIが読めたものが、生成物を経由して、いつの間にか外に出ていく」という流れです。悪意がなくても起きるからこそ、仕組みで防いでおく必要があります。 まず思いつく対策 ―― でも、これには穴がある Claude Code には、こうした事故を防ぐための権限機能があります。 settings.json   の   permissions.deny   に、読ませたくないファイルを書いておく方法です。 { "permissions" : { "deny" : [ "Read(./.env)" , "Read(./.env.*)" ] } } これで   Read   ツールから   .env   へのアクセスがブロックされます。 Glob   や   Edit   など、ファイルに触れる他のツールについても同様に指定できます。 一見これで安心に見えますが、 大きな穴があります 。 permissions.deny   がブロックしているのは、あくまで   Claude Code というプログラム自身が持つ機能(Read ツールなど)   です。言い換えると、これは   アプリケーションが自分で「このファイルは読まない」と自制しているだけ   なのです。 では、その自制を回避する経路があったらどうなるでしょうか。あります。そう、それは、、、、なんと!! Bash ツール です。 cat .env   も   diff .env other   も、Read ツールを使っていません。 別プロセスとして起動されるシェルコマンド です。だから「Read ツールを塞ぐ」というアプリレベルの自制は、ここには効きません。正面玄関に鍵をかけても、裏口が開いている状態です。 この「裏口」を塞ぐために登場するのが、サンドボックスです。 サンドボックスとは何か ―― システムコールそのものを止める アプリの自制ではなく、OSの強制 permissions.deny   が「アプリの自制」だったのに対し、サンドボックスは   OS レベルの強制   です。 Claude Code が Bash コマンドを実行するとき、そのコマンドを   サンドボックスで包んで起動   します。すると、そのコマンドは「特定のファイルにアクセスするシステムコール自体を呼べない」状態になります。 cat   だろうが   diff   だろうが Python スクリプトだろうが、ファイルを読むには必ず OS(カーネル)に   open()   というシステムコールを発行しなければなりません。サンドボックスはこの   カーネルへの入り口   で検問を張るので、どんなプログラムを使っても迂回できません。 どのレイヤーで検問しているのか 「アプリの自制」と「OS の強制」は、ソフトウェアの層で見るとまったく違う場所に立っています。図にすると、こうです。 上から順に見ていきます。 アプリケーション層 :   permissions.deny   はここにいます。この層の特徴は、 判定ロジックも判定対象も同じプロセスの中にある   ことです。Claude Code が「 .env   を読もうとしている自分」を、自分のコードで止めている。つまり自主規制です。自主規制の弱点は明確で、 そのルールを持っていないコードに仕事を渡せば、ルールは適用されない   こと。 cat .env   は Claude Code のコードではないので、 deny   の判定ロジックをそもそも通りません。 システムコールの境界 :   プロセスは自分の中では何でもできますが、 ファイルを読む、ネットワークに出る、といった「外の世界に触る操作」だけは自分では実行できません 。必ず   open()   connect()   といったシステムコールでカーネルに依頼する必要がある。ここがプロセスの自由が終わる線です。 そして重要なのは、この線が   プログラムの種類に依存しない   こと。 cat   でも   diff   でも Python でも、自作のバイナリでも、ファイルを読むなら全員この一本の線を越えます。だから、この線の上に検問を置けば、 「別のプログラムを使う」という迂回が原理的に成立しません 。アプリ層の検問が「使うツールを変える」で抜けられたのと、ちょうど対照的です。 OS カーネル層 :   実際の判定はこの層で行われます。 sandbox-exec   が登録したポリシーは   カーネル側にそのプロセスの属性として記録され 、以降そのプロセスがシステムコールを発行するたびにカーネルが照合します。 ここで立場が逆転しています。アプリ層では「自分が自分を止めていた」のに対し、カーネル層では   止める側が、止められる側から手出しできない場所にいる 。だから「自制」ではなく「強制」と呼べるわけです。実装は OS ごとに違いますが、性質は同じです。 macOS (Seatbelt) : カーネル内のフックでシステムコールごとにポリシーを照合し、許可されないアクセスを拒否する Linux (bubblewrap) : namespace を使って、 そのプロセスから見えるファイルシステムの景色自体を作り替える 。拒否する以前に、 .env   が存在しない世界を見せる 前者は「頼んでも断られる」、後者は「頼む対象がない」という違いですが、どちらも   プロセスの外側で決着している   点が共通です。 ハードウェア層 :   ここが「関与しない」になっているのは、単なる余白ではなく   限界の宣言   です。サンドボックスの強制力は「カーネルが正しく動いていること」に全面的に依存しています。カーネルの脆弱性を突かれて特権を取られた場合、判定者そのものが倒れるので、サンドボックスも一緒に倒れます。 つまりサンドボックスは、 「うっかり   cat .env   される」を確実に防ぐ道具 であって、「カーネルまで攻略してくる攻撃者」を想定した壁ではない、という位置づけです。 この「境界の上か下か」という視点は、後半で出てくる抜け穴を理解するときにも効いてきます。 どのプロセスに効いているのか では、その強制は Claude Code のどのプロセスに効いているのでしょうか。中の構造はこうなっています。 ポイントは、 Bash ツールは「起動時に明示的にサンドボックスでラップする」から拘束される   という点です。裏を返せば、ラップされていないものには効きません。ここが後半の伏線になります。 なぜ Read ツールにはサンドボックスが効かないのか 「システムコールを止めるなら、Read ツールも止められるのでは?」と思うかもしれません。ここが理解の勘所です。 サンドボックスは   プロセスに貼り付ける制約   です。プロセスが生成される瞬間に適用され、そのプロセスと、さらにその子プロセスに効きます。 一方、Read ツールは   Claude Code 本体プロセスに組み込まれた機能   で、 子プロセスを作りません 。本体が自分で   open()   を呼んでファイルを読むだけです。つまり、サンドボックスを「貼り付ける対象となる新しいプロセス」がそもそも生まれない。だから、Read ツールを守りたければ本体ごとサンドボックスに入れるしかなく、それをやると本体の正常動作(設定の読み書きや通信)まで壊れてしまいます。 だから役割分担になっているのです。 Read などの本体機能   →   permissions.deny (アプリの自制)で守る Bash が起動する子プロセス   → サンドボックス(OSの強制)で守る 両方揃えて初めて、正面玄関も裏口も塞がる   わけです。 設定はこう書く ―― サンドボックスを有効にする 仕組みの話が続いたので、ここで一度、実際の設定を見ておきましょう。 まずは   /sandbox   で様子を見る 一番手軽なのは、セッション中にスラッシュコマンドを打つ方法です。 /sandbox サンドボックスの設定パネルが開き、モードの選択(サンドボックス内のコマンドを自動承認するか、通常の権限確認を残すか)や、現在の設定内容を確認できます。ここで選んだ内容は、そのプロジェクトの   .claude/settings.local.json   に保存されます。 Linux で必要なパッケージが足りていない場合は Dependencies タブが出て、何が足りないかを教えてくれます。まずはこれを開いてみるのが早いです。 settings.json に書く すべてのプロジェクトで有効にしたいなら、ユーザー設定   ~/.claude/settings.json   に書きます。 { "sandbox" : { "enabled" : true } } これだけです。以降、Bash ツールが起動するコマンドは、次の節で見る「 fork   して、隙間で設定して、 exec 」という流れでラップされて動きます。 なお、動く環境には条件があります。 macOS : OS 内蔵の Seatbelt を使うので、追加インストールは不要 Linux / WSL2 :   bubblewrap (ファイルシステム隔離)と   socat (ネットワーク中継)が必要 sudo apt-get install bubblewrap socat # Ubuntu / Debian ネイティブの Windows は非対応で、WSL2 の中で動かす必要があります。 重要 ―― 有効にするだけでは   .env   は読めたままです そして、この記事のテーマにとって一番大事な注意点です。 "enabled": true   にしただけでは、 .env   の読み取りは止まりません。 デフォルトのポリシーは、読みと書きで非対称になっています。 書き込み : 作業ディレクトリ(と一時ディレクトリ)だけ許可 読み取り :   マシン全体が許可 (一部の拒否ディレクトリを除く) つまり、デフォルトのサンドボックスが主に想定しているのは「作業ディレクトリの外を勝手に書き換えられること」と「知らないドメインに通信されること」の防止です。 読み取りについてはかなり緩く、 ~/.aws/credentials   や   ~/.ssh/   すら読めます 。 ですから、 .env   を守りたければ、読み取り拒否を明示的に書く必要があります。 { "sandbox" : { "enabled" : true , "filesystem" : { "denyRead" : [ "./.env" ] } , "credentials" : { "files" : [ { "path" : "~/.aws/credentials" , "mode" : "deny" } , { "path" : "~/.ssh" , "mode" : "deny" } ] , "envVars" : [ { "name" : "GITHUB_TOKEN" , "mode" : "deny" } ] } } } filesystem.denyRead : サンドボックス内のプロセスからの読み取りを、OS レベルで拒否する credentials.files : 同じことを「秘密情報」としてまとめて書ける枠( "mode": "deny" ) credentials.envVars :   サンドボックス実行前に、その環境変数を消す 。ファイルを塞いでも、同じ秘密が環境変数に入っていては意味がないので、ここも大事です パスの書き方には癖があります。 sandbox.filesystem.*   は一般的な慣習どおりで、 /tmp/build   が絶対パス、 ~/   がホーム、 ./   がプロジェクトルートです。ただし   ./   がプロジェクトルートを指すのは   プロジェクト設定( .claude/settings.json )に書いた場合だけ   で、ユーザー設定に同じものを書くと   ~/.claude   からの相対になってしまいます。上の例のような   ./.env   は、プロジェクト側に置いてください。 2箇所に書くことになる ここで、2章の   permissions.deny   と並べてみると、役割分担がはっきりします。 守りたい経路 書く場所 効いている層 Read / Edit / Glob(本体の機能) permissions.deny アプリケーション層(自制) Bash が起動する子プロセス sandbox.filesystem.denyRead   /   sandbox.credentials OS カーネル層(強制) 同じ   .env   を守るために、 2箇所に書く ことになります。冗長に見えますが、これは重複ではありません。 効いている層が違うので、片方だけでは片方の経路しか塞げない のです。さきほどの「両方揃えて初めて塞がる」を、設定ファイルの言葉に翻訳するとこうなる、というわけです。 サンドボックスの正体 ――   fork   と   exec ここで、サンドボックスが具体的にどう「プロセスに制約を貼る」のかを見ておきます。実はこれは、特別な魔法ではなく   Linux カーネルの基本的な仕組み   をそのまま使っています。 Unix / Linux では、「プロセスを作る」と「プログラムを実行する」が   別々のシステムコール   に分かれています。 fork() → 今のプロセスを複製する。新しいプロセスができる exec() → 今のプロセスの「中身」を、別のプログラムに丸ごと入れ替える exec()   が独特です。新しいプロセスを作るのではなく、 自分自身の中身を捨てて、別のプログラムに変身する   のです。器(プロセス)はそのまま、中の人だけが入れ替わる。プロセス番号(PID)も変わりません。 一番わかりやすい確認方法があります。ターミナルで試してみてください。 echo "PID: $$ " exec bash # 新しい bash に変身する echo "PID: $$ " # ← PIDが変わっていない! 新しいシェルになったはずなのに PID が同じ。「変身」が起きている証拠です。 この仕組みが、サンドボックスの土台になっています。 sandbox-exec (macOS の場合)のようなラッパーは、次の順番で動きます。 1. fork() で子プロセスを作る(この時点では sandbox-exec のコード) 2. カーネルにサンドボックスのポリシーを登録する 3. exec("bash") で自分自身を bash に変身させる ↑ ここで「sandbox-exec プロセス」は消え、同じ器が bash になる 4. 以降、その bash には制約が貼り付いた状態 つまり、 「サンドボックスというプロセスが監視役として残る」わけではありません 。 fork   で複製し、 exec   で目的のコマンドに変身するときに、 制約という「属性」だけがそのプロセスに刻まれて残る   のです。制約はカーネル側でそのプロセスに紐づいて記録されるので、 exec   で中身が入れ替わっても消えません。むしろ、消えずに引き継がれるからこそ、この仕組みが成立します。 なぜわざわざ「複製してから変身」という回りくどいことをするのか。理由は、 fork   と   exec   の間に「設定をするための隙間」ができる   からです。この隙間で「サンドボックスのポリシーを登録する」「作業ディレクトリを変える」「環境変数を設定する」といった細工を、普通のコードとして自由に挟み込めます。 env (環境変数を設定して実行)や   nice (優先度を変えて実行)といったおなじみのコマンドも、実は全部この「隙間で細工してから   exec   する」という同じパターンで作られています。サンドボックスは、その一族の一員にすぎないのです。 そして、この制約は   子孫プロセスに継承されます 。サンドボックス化された Bash が   cat   を呼べば、 cat   も同じ制約を受け継ぐ。だから「Bash 経由なら何を使っても読めない」が成立するわけです。 疑似コードで見る ―― サンドボックスが適用される瞬間 ここまでの話を、コードの形で一度に見てみましょう。ラッパーがやっていることは、だいたいこういう形です。 if ( fork ( ) == 0 ) { // 子プロセスの側 // ここは「まだ自分のコード」なので、好きなことができる chdir ( "/tmp" ) ; // 作業ディレクトリを変える setuid ( 1000 ) ; // 権限を落とす close ( 0 ) ; open ( "input.txt" , . . . ) ; // 標準入力を差し替える setenv ( "LANG" , "C" , 1 ) ; // 環境変数を設定 sandbox_init ( profile , . . . ) ; // サンドボックスを適用 exec ( "/bin/ls" , . . . ) ; // ここで初めて ls に化ける } fork()   の戻り値が   0   になる側が子プロセスです。そして   この   if   の中、 exec()   までの数行が、さきほど言った「隙間」   です。この時点でプロセスの中身はまだラッパー自身のコードなので、特別な仕掛けは何もいりません。普通の関数呼び出しとして準備ができます。 注目してほしいのは、並んでいる5行が   全部同じ性質の操作   だということです。 chdir   でも   setuid   でも   setenv   でも   sandbox_init   でも、設定している相手は共通で、 「これから   ls   になる、いまの自分」   です。 つまり、 サンドボックスの適用は「特殊な起動方法」ではありません 。 chdir()   や   setenv()   と同じ列に、同じ資格で並んでいる   ただの1行   です。3章の冒頭で「サンドボックスは   env   や   nice   の一族にすぎない」と書いたのは、こういう意味です。 そして最後の   exec("/bin/ls") 。ここでプロセスの中身が   ls   に入れ替わりますが、 直前に設定したものは全部残ります 。 カレントディレクトリは   /tmp UID は 1000 標準入力は   input.txt 環境変数は   LANG=C そして、サンドボックスのポリシーも ls   のコードは、自分がこれらを設定された覚えなどありません。それでも、 生まれた瞬間からその条件下にいる 。ここがサンドボックスの本質です。 ls   はサンドボックスの存在を知らないし、協力することも拒否することもできません。制約は「 ls   が守るべきルール」ではなく、 「 ls   が置かれている環境」   だからです。 だからこそ、 ls   を   cat   に変えても、自作のバイナリに変えても意味がない。ここが、アプリケーション層の自制( permissions.deny )との決定的な違いです。 もうひとつ、 順番が命   だという点も押さえておきたいところです。 sandbox_init()   は必ず   exec()   より前になければいけません。「 exec   した後に設定すればいい」は成立しません。 exec   の後には   もう自分のコードが存在しない   からです(中身は   ls   になっている)。設定するチャンスは   fork   と   exec   の間、この隙間しかないのです。 Claude Code が Bash ツールを起動するときも、やっていることはこれと同じです。逆に言えば ――   この隙間を通らずに起動されたプロセスには、当然、何の設定もされていない 。これが次章の話につながります。 さらに残る抜け穴 「Read は   permissions.deny 、Bash はサンドボックス。これで完璧」と言いたいところですが、 まだ抜け穴があります 。 そのひとつが   MCP サーバー   です。MCP サーバーは、AI に外部ツール(ファイル操作、DB アクセスなど)を持たせるための仕組みで、Claude Code とは別のプロセスとして起動します。 「別プロセスなら、サンドボックスで守られるのでは?」と思うところですが、そうはなりません。もう一度、先ほどの図を見てください。 鍵は、図の一番上に書いてある   「Claude Code 本体はサンドボックス未適用」   という点です。 サンドボックスは「制約を持つプロセスから子へ継承される」仕組みでした。ところが本体自身は制約を持っていません。だから、本体が子プロセスを起動するとき、 その起動処理がわざわざサンドボックスでラップしなければ、子は素のまま生まれます 。 Bash ツールは「ラップする」ようにわざわざ実装されているので拘束されます。しかし MCP サーバーは   別のコードパスで起動される   ため、そのパスがラップしていなければ、制約なしで動いてしまう。「子プロセスだから安全」ではなく、「 ラップされた子プロセスだけが安全 」なのです。継承元が無拘束である以上、包む処理を通らない経路はすべて素通りになります。 さきほどのレイヤー図の言葉で言い直すと、こうなります。システムコールの境界は   すべてのプロセスに共通して存在している   のに、そこで照合されるポリシーは   プロセスごとに違う 。MCP サーバーは、境界を越えていないわけではありません。 境界は越えているが、そのプロセスには照らし合わせるルールが登録されていない   ので、カーネルはそのまま通してしまうのです。 MCP サーバーは、ファイルにも DB にもネットワークにもアクセスできる強力な存在です。それが制約の外で動くというのは、 .env   にとっては立派な裏口になり得ます。 MCP サーバーを包む ――   @anthropic-ai/sandbox-runtime では、この裏口は塞げないのでしょうか。塞げます。 @anthropic-ai/sandbox-runtime   という、Anthropic が公開しているサンドボックスツールを使います。 これは Claude Code の内蔵サンドボックスと同じ OS の仕組み(macOS なら Seatbelt、Linux なら bubblewrap)を、 任意のプロセスに対して外から適用できるようにした単体パッケージ   です。 srt   というコマンドを提供していて、公式のドキュメントでも「ローカル MCP サーバーを包むこと」が主要な想定用途として挙げられています。 npm install -g @anthropic-ai/sandbox-runtime やっていることは、3章で見た   sandbox-exec   とまったく同じです。 fork   して、隙間でポリシーを登録して、 exec   で目的のコマンドに変身する。だから、 包まれたプロセスの子孫にも制約が継承されます 。 .mcp.json   の   command   を差し替える やり方はシンプルで、 .mcp.json   の   command   を   srt   に差し替えて、本来のコマンドを引数に回すだけです。 包む前: { "mcpServers" : { "filesystem" : { "command" : "npx" , "args" : [ "-y" , "@modelcontextprotocol/server-filesystem" ] } } } 包んだ後: { "mcpServers" : { "filesystem" : { "command" : "srt" , "args" : [ "npx" , "-y" , "@modelcontextprotocol/server-filesystem" ] } } } 差分は「 command   を   srt   にして、元の   command   を   args   の先頭に押し込む」だけです。これで、この MCP サーバー(と、それが起動する子プロセス)はサンドボックスの中で動くようになります。 MCP サーバー側に手を入れる必要はありません 。サーバーは自分が包まれていることを知りませんが、それでも制約は効く ―― 3章の   ls   の話とまったく同じ構図です。 制約の内容は   ~/.srt-settings.json   に書きます。 { "filesystem" : { "denyRead" : [ "./.env" , "~/.ssh" , "~/.aws" ] , "allowWrite" : [ "." ] , "denyWrite" : [ "./.env" ] } , "network" : { "allowedDomains" : [ ] , "deniedDomains" : [ ] } } srt --settings /path/to/srt-settings.json ...   のように、サーバーごとに別の設定ファイルを渡すこともできます。 デフォルトの挙動には癖があるので、ここは押さえておいてください。 書き込み : デフォルトで   全部拒否 。 allowWrite   に書いたものだけ許可される ネットワーク : デフォルトで   全部拒否 。 allowedDomains   に書いたものだけ許可される 読み取り : デフォルトで   全部許可 。 denyRead   で塞ぐ 内蔵サンドボックスと同じで、ここでも   読み取りだけは緩い   という構図です。 .env   を守りたければ   denyRead   を明示的に書く必要があります。しかも   srt   では   allowRead   が   denyRead   より優先される   ので、広い   allowRead   を書くと   denyRead   が打ち消されてしまいます(内蔵サンドボックスとは優先順位が逆なので、ここは要注意です)。 逆に、書き込みとネットワークはデフォルトで全部閉まっています。 包んだ MCP サーバーが動かなくなったら、まず   allowWrite   と   allowedDomains   の不足を疑う   のが正解です。 ただし注意点もあります。 @anthropic-ai/sandbox-runtime   は   ベータのリサーチプレビュー   という位置づけで、設定フォーマットは今後変わる可能性があると明記されています。運用ルールに組み込む場合は、そのつもりで扱ってください。 じゃあ、結局どうすればいいのか ここまで、「経路を1つ塞いでも、別の経路が出てくる」というモグラ叩きを見てきました。Read を塞げば Bash、Bash を塞げば MCP……。 塞ぐアプローチには、常に「列挙し忘れた経路」が残る という本質的な弱さがあります。 そこで発想を変えます。 そもそも   .env   に本物の秘密を置かなければ、何経路から読まれても平気   です。守るべきものが最初からそこに無ければ、読まれても被害はありません。 理想的なのは、 短命なアクセストークン   を使う運用です。永続的な秘密鍵をファイルに置くのではなく、必要なときにだけ発行される、寿命の短いトークンで認証する。イメージとしては、こういう流れです。 1. 開発者が最初に CLI でログインする(人間の認証) $ my-tool login 2. その権限をもとに、短命なアクセストークンをリクエストする → 数十分で失効するトークンが発行される 3. アプリはそのトークンを使って動く → ファイルには本物の秘密鍵が存在しない → 万一トークンが漏れても、すぐ失効するので被害が限定的 実際のコマンドで見るとこうなる my-tool login   は架空のコマンドですが、実際のクラウドでは、おなじみのコマンドがそのまま当てはまります。 AWS の場合 # 1. 人間の認証(ブラウザが開いて SSO でログインする) aws sso login --profile dev # 2〜3. あとは CLI / SDK が一時credentialを自動で取得して使う aws s3 ls ~/.aws/credentials   に永続的なアクセスキーを置く代わりに、 aws sso login   で得た権限をもとに、裏側で数時間で失効する一時credentialが発行されます。トークンは   ~/.aws/sso/cache/   にキャッシュされるだけなので、期限が切れればそれ以上使えません。 Azure の場合 # 1. 人間の認証 az login # 2. 必要なリソース向けの短命トークンを取得(有効期限は1時間程度) az account get-access-token --resource https://vault.azure.net アプリ側は   DefaultAzureCredential   を使えば、この CLI のログイン状態を自動で拾ってくれます。 Google Cloud の場合 # 1. 人間の認証 gcloud auth application-default login # 2. 短命なアクセストークン(有効期限は1時間程度) gcloud auth print-access-token こちらも ADC(Application Default Credentials)という仕組みがあり、SDK が自動でトークンを取得してくれます。 どれも形はまったく同じです。 「人間が一度ログインする」→「そこから短命なトークンが発行される」→「アプリはそれを使う」 。そして共通して、 .env   に書くべき永続的な秘密がどこにも登場しません 。 クラウドのマネージド ID(実行環境自身の身元でシークレットストアからトークンを取得する仕組み)や、OIDC ベースの一時credential(GitHub Actions からクラウドに、静的なアクセスキーを Secrets に置かずに認証する構成など)が、この考え方の実装にあたります。 秘密は「ファイルに書いて守る」のではなく、「 そもそも書かず、必要な瞬間に短命なものを取りに行く 」。これが、経路を1つずつ塞ぐ発想から抜け出す、根本的な解決策です。 とはいえ、理想はなかなか大変 ただ、正直に言うと、この理想形はすぐに実現できるものではありません。 トークンを発行する仕組み(認証基盤、シークレットストア)を用意する必要がある 既存のアプリを「ファイルから読む」前提で書いている場合、その書き換えが要る ローカル開発、CI、本番で、それぞれトークンの取得経路を整える必要がある 規模の小さいプロジェクトや、とりあえず今動いているものに、いきなりこれを導入するのは腰が重い、というのが現実です。 なので、当面の現実的な立ち回りは、こうなります。 理想を目指しつつ 、本物の秘密を短命トークンに寄せていく(長期的な方向性) それができるまでは 、経路を1つずつ地道に塞ぐ permissions.deny   で Read / Edit / Glob をパス単位でブロック サンドボックスを有効にし、 denyRead   /   credentials   で読み取りも明示的に塞ぐ(有効化だけでは読み取りは止まらない) MCP サーバーは使うものを吟味し、 srt ( @anthropic-ai/sandbox-runtime )で包む .env   にはできる限りダミー値だけを置き、本物は最小限に 結局のところ、 「守るべきものを、そこに置かない」が最強の対策   です。しかしそれが難しい間は、 「経路をひとつずつ調べて防ぐ」しか、今のところ道はありません 。モグラ叩きに聞こえるかもしれませんが、どの経路がどのレイヤーで守られるのか(アプリの自制なのか、OS の強制なのか)を理解していれば、叩き漏らしはずっと減らせます。 まとめ コーディングエージェントを実用化させるためにはセキュリティは欠かせません。でもだからといって、コーディングエージェントならではの知識が必要なわけではなく、 OS の基本的な仕組みと、プロセスの性質を理解していれば十分   です。今回の話は、Claude Code に限らず、コーディングエージェント全般に当てはまります。なので、やっぱりいくらAIを使って業務をしても、やっぱり基本が大事だなと思います。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post コーディングエージェントに機密情報を読ませない基本的な仕組み first appeared on SIOS Tech Lab .
こんな方へ特におすすめ エヴァンゲリオンが好きな方 ローカルLLM( Ollama )や Raspberry Pi で、何か動くものを作ってみたい方 LangGraph でマルチエージェントを試したい方 概要 こんにちは。サイオステクノロジーのはらちゃんです! 今回はOSCの展示ブース用開発として、『新世紀エヴァンゲリオン』の意思決定コンピュータ MAGI を、Raspberry Pi 3台 + ローカルLLM(Ollama)で再現してみました。 ―― 3体の人格が別々のマシンで議論し、多数決で結論を出す その設計や速度チューニング、実機を立ち上げる過程でハマった落とし穴まで、実務にも通じる知見をまとめていきます。 背景 MAGI は、3つの独立したコンピュータ Melchior・Balthasar・Casper が、それぞれ異なる人格で同じ議題を判断し、多数決で結論を出す合議システムです。 これをただの1プログラムで再現するのは簡単です。 でも、それだと何かが違う。「 1エージェント = 1台の独立コンピュータ 」という原作の設定を、そのまま物理的に再現できないだろうか? 武井さん の協力の元、Raspberry Pi を3台並べることにしました。とはいえ、非力な Raspberry Pi でLLMなんてまともに動くのか、と最初は半信半疑でした。 結論から言うと、役割を割り切って軽量モデルとチューニングを重ねれば、合議AIは十分に動きます。 システム構成|3台の Pi + 母艦 役割分担はシンプルです。 各Pi は、ただの推論バックエンド。Ollama が待ち受けているだけで、人格ごとのプログラムは書きません。 母艦(オーケストレータ) となる1台のPCが、合議の進行・集計・画面表示を担当します。今回は PC上の WSL2 で Flask + LangGraph を動かしました。 ブラウザ / kiosk UI       ▲ ▼   SSEで投票をリアルタイム配信 母艦 — Flask + LangGraph(合議・集計)       ▲ ▼   イーサネット(各Piのollama :11434) +-----------------+-----------------+-----------------+ | Pi1 Melchior | Pi2 Balthasar | Pi3 Casper | | Ollama | Ollama | Ollama | +-----------------+-----------------+-----------------+ どのPiがどの人格かは、母艦の `.env` にある 各Piの固定IPだけ で決まります。人格ごとの特別なコードは無く、role名で接続先を引くだけ。この割り切りのおかげで、Pi側は「モデルを入れて待ち受ける」だけで済みます。 合議フロー|LangGraphで組む2ラウンド投票 合議の本体は LangGraph の StateGraph です。1回目で全会一致なら即確定、意見が割れたら「討論」を挟んで再投票する、という2ラウンド構成にしました。 prepare :開始を通知 3体を並列実行 :Melchior / Balthasar / Casper が同時に投票(fan-out) 1回目集計 :賛成が2票以上なら「可決」 分岐 :全会一致ならそのまま確定。割れたら 討論 → 再投票 → 再集計 finalize :評決を確定して配信 ここでは「アイス食べたい」をテーマにしています。2:1で可決されました。 各人格には異なる「観点」を与えています。 Melchior :論理性・合理性・整合性を最重視 Balthasar :人間要因・感情・受容性を重視 Casper :現実性・リスク・実務性を重視 ここでは「アイス食べたい」をテーマにしています。クリックで理由が表示されます。 実装のキモ|小さいモデルに「JSONだけ」返させる 各人格への問い合わせは1つの関数に集約しています。小さいモデルを安定させるコツは、 出力をJSONに固定する こと。 format="json" と temperature=0 を指定し、 role / reason / vote の3項目だけを返させます。 必ずJSONのみで返してください: { "role": "{role}", "reason": "80字以内で簡潔に(必ず日本語で)", "vote": "approve or reject" } 返ってきた文字列は、 ```json のコードフェンスを剥がし、最初の {` から最後の `} までを抜き出してからパースします。理由が80字を超えたら、途中で切れないよう「最後の句読点」で丸める。小さいモデルは文字数指示を守りきれないので、この後処理は必須でした。 Raspberry Pi で待たせない3つの速度チューニング 非力なPiで素直にLLMを動かすと、1票に何十秒もかかります。体感速度を詰めるために効かせた工夫が主に3つ。 1. コンテキスト長を絞る 対応: num_ctx を既定の 4096 から 1024 へ。 根拠: 投票プロンプトは数百字なので、大きな窓は無駄にメモリと時間を食うだけ。 2. 生成トークン数を制限する 対応: num_predict = 128 。 根拠: 理由は80字上限なのでこれで十分。 出力が短いほど速い。 3. モデルをRAMに常駐させる 対応: keep_alive = -1 + 起動時に一度空打ちして先読み。 根拠: 初回質問のコールドロード待ちを消せる。 OLLAMA_NUM_CTX=1024 # 4096 → 1024 OLLAMA_NUM_PREDICT=128 # 理由は80字で足りる OLLAMA_KEEP_ALIVE=-1 # モデルをRAMに常駐 このほか、3体を 並列実行 (直列の約1/3の時間)し、最終要約は LLMを使わず定型文 で組み立てて呼び出しを1パス削減しています。 モデル選定|0.5Bの罠を、プロンプトで直す 速度優先でまず qwen2.5:0.5b を使ったところ、奇妙な出力に遭遇しました。 理由には「適切だ」と書いてあるのに、投票は reject となっている、つまり reason と vote が食い違うのです。 原因は集計バグではなく、モデルの出力そのものの矛盾でした。しかも当初のプロンプトは vote を reason より先に書かせていたため、モデルは先に投票を決め打ちしてから、後付けで理由を書いていたのです。 そこで、JSONの並びを reason → vote に変更し、「理由を先に書き、それに一致する投票を選べ/矛盾してはならない」と明示。0.5Bのままでも矛盾が大きく減りました。 最終的に qwen2.5:1.5b に上げると、投票と理由の整合性が安定します。おまけに、 num_ctx=1024 のままでも約1300字の長文議題を破綻なく処理できました。 課題|本当に時間を溶かしたのはネットワークとOllama 正直、アーキテクチャよりも実機3台の立ち上げのほうが遥かに大変でした。同じ轍を踏む人のために残しておきます。 Wi-Fiが突然切れる GUIから静的IPを設定したらWi-Fiが切断。プロファイルからパスワード(PSK)が抜け落ちていました。 解決: プロファイルを削除して nmcli device wifi connect で作り直す。静的化も nmcli でパスワードごと一括指定するのが確実です。 (今回の構成としてはインターネット不要なのでここはスキップできちゃいます。) Ollamaのサービスが壊れる unit is masked → ディレクトリ欠落 → ssh: no key found と、1台だけドミノでおかしくなりました。 解決: 中途半端に直すより、 完全に削除してから install.sh で入れ直す のが最短でした。 母艦からPiに届かない Ollamaの既定は 127.0.0.1 待ち受けで、他マシンから見えません。 解決: OLLAMA_HOST=0.0.0.0:11434 を設定して再起動。 ss -tlnp | grep 11434 で待ち受けが 0.0.0.0 になっているか必ず確認しましょう。 検証|「意見が割れる議題」で試す 多数決システムの見どころは、票が割れて「討論→再投票」が発動する瞬間です。だから 論理・感情・現実の3視点が対立する議題 を選びます(例:「AIに人事評価を任せるべきか」「延命治療を中止すべきか」)。 面白かったのは頑健性で、 音声入力の変換ミスで議題が多少崩れていても 、3体とも妥当な結論に収束しました。むしろ本番では、コンテキスト長よりも 音声認識の精度のほうが実害リスクが大きい という気づきが得られました。 母艦を起動して [warmup] done が出て、3体の投票がそろい評決が返ってくれば成功です! ここでは「残業を法律で全面禁止すべき」をテーマにしています。 まとめ 「 Raspberry Pi でLLMなんて動くの?」という半信半疑から始めましたが、役割を割り切って設計し、チューニングを重ねることで、合議AIはしっかり動きました。 MAGI = 3つの人格が多数決で決める合議システム。 「1人格 = 1台の Raspberry Pi 」 を物理的に再現。 構成は 3台のPi(Ollama)+ 母艦(Flask + LangGraph) 。役割は .env のURLだけで振り分けるシンプル設計。 非力なPiでも、 コンテキスト長・生成量・モデル常駐 の3点を絞れば実用的な速度で動く。 小さいモデルの「投票と理由の矛盾」は、 プロンプトの並び替え という小さな工夫で大きく改善できた。 一番の敵はAIではなく ネットワークとサービス管理 。ここを乗り越えれば合議AIは立ち上がる。 今回はイベント会場に向けて軽量・高速に特化させましたが、精度を重視し他のモデルを試していきたいと考えています。 今後も、こうした個人開発を通して得られた知見を、皆さんに共有していきたいと思います! 宣伝 生成AIを活用する開発として、自分専用のRAGを作ることもやりました。興味がある方はぜひのぞいてみてください。 RAGの作り方|LlamaIndexで簡単2ステップ RAGの育て方|LlamaIndexでペルソナ設計 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 32人がこの投稿は役に立ったと言っています。 The post ラズパイ3台でエヴァのMAGIを作る|ローカルLLMで動く合議AIシステム first appeared on SIOS Tech Lab .
PSSLの佐々木です MCP サーバーを書いていると、ある機能を @mcp.tool で実装すべきか @mcp.resource で実装すべきかで迷う場面が出てきます。公式のコース教材でも「ドキュメントを読む」という同じ処理が tool と resource の両方で実装されていて、最初に読んだときは違いがピンと来ませんでした。 仕様を読み直して整理したところ、判断基準は思っていたよりはっきりしていたので、使い分けの考え方としてまとめます。 この記事では、 tool と resource の違いは「機能」ではなく「誰が呼ぶか」であること どちらで実装するかを決める判断フロー ユースケース別の使い分け resource 側にしかない機能(URI テンプレート、変更通知)の活かし方 迷ったときに両方出しておく実装パターン ハマりどころ(Messages API の MCP connector は tool のみ、など) についてまとめました。 1. 何がわからなかったのか Anthropic のコース教材では、インメモリのドキュメント管理サーバーを題材に、こういう tool が定義されます。 @mcp.tool( name="read_doc_contents", description="Read the contents of a document and return it as a string." ) def read_document( doc_id: str = Field(description="Id of the document to read") ): if doc_id not in docs: raise ValueError(f"Doc with id {doc_id} not found") return docs[doc_id] ところが少し後の章で、 まったく同じことをする resource が出てきます。 @mcp.resource("docs://documents", mime_type="application/json") def list_docs() -> list[str]: return list(docs.keys()) @mcp.resource("docs://documents/{doc_id}", mime_type="text/plain") def fetch_doc(doc_id: str) -> str: if doc_id not in docs: raise ValueError(f"Doc with id {doc_id} not found") return docs[doc_id] やっていることは docs[doc_id] を返すだけで、tool 版と 1 行も違いません。なぜ 2 つあるのか。 2. 違いは「誰が呼ぶか」 答えは機能差ではなく 制御主体 です。MCP の仕様では tool は model-controlled、resource は application-controlled と明確に区別されています。 つまり read_doc_contents は「Claude に自分で判断して使ってほしい機能」、 docs://documents/{doc_id} は「 @ メンション UI のためのデータソース」です。同じ処理でも役割がまったく違う、というのが教材の意図でした。 ここを押さえると、資料でよく見る「resource はデータ、tool はアクション」という説明が、もう一段深く理解できます。読み取り専用かどうかが本質なのではなく、 呼ぶ判断をモデルに委ねるのか、アプリが握るのか が本質です。 3. 判断フロー 実装するときは、この順番で考えるとよさそうです。 Q1 は単純です。書き込み・削除・外部への送信は必ず tool にします。 resources/read はクライアントが再読み込みやキャッシュをする前提の操作なので、ここに副作用を置くと何回呼ばれるか分からず事故ります。「GET に副作用を持たせない」と同じ話です。 Q2 と Q3 が実質的な分かれ目です。読み取り専用でも、 「どのデータが必要かをモデルに判断させたい」なら tool です。ここを「読み取りだから resource」と機械的に決めてしまうと、モデルからは存在しないデータになってしまいます。 仕様側にもこの指針が書かれていて、 モデルに対してデータを自動的に公開したい場合は Tools のような model-controlled なプリミティブを使うべき とされています。 4. ユースケース別の使い分け 具体例に落とすとこうなります。 やりたいこと 選択 理由 @ でドキュメントを参照させる resource ユーザーの UI 操作が引き金。モデルの判断は不要 「report.pdf を要約して」に応える tool どのドキュメントが必要かはモデルが判断する ドキュメントを編集する tool 副作用がある 全文検索して該当箇所を返す tool 検索クエリをモデルが組み立てる プロジェクト規約や DB スキーマを常に文脈に入れる resource アプリが定型的に注入すればよい ログの最新状態を追わせる resource + subscribe 変更通知がプロトコル標準にある チケットを作成する tool 副作用がある ユーザーが選んだファイルを添付する resource 選択したのはユーザー 「検索は tool、指定は resource」と覚えると整理しやすいと感じています。何を取るかが決まっていないなら tool、決まっているなら resource です。 5. resource 側の機能を活かす resource を選んだ場合、tool にはない仕組みが使えます。ここを使わないと resource にした旨味が薄くなります。 5.1 URI テンプレートと補完 docs://documents/{doc_id} のようなテンプレートは RFC 6570 の URI Template 構文で、 resources/templates/list で discovery できます。パラメータは MCP の completion API で自動補完に対応させられるので、 @ メンションの候補表示が標準の枠に乗ります。Python SDK はテンプレートのパラメータを自動でパースして関数のキーワード引数に渡してくれるので、実装側は URI のパースを書く必要がありません。 5.2 変更通知(subscribe) サーバーが subscribe capability を宣言すると、クライアントは resources/subscribe で個別の URI を購読でき、内容が変わると notifications/resources/updated が飛びます。クライアントはそれを受けて再読み込みします。ログやメトリクスのような「更新され続けるデータ」を扱うなら、ポーリングを自作せずに済みます。 5.3 mime_type mime_type はクライアントがレンダリングを決めるヒントになります。JSON を返すのに text/plain を書いておくと、クライアントによっては素の文字列として扱われます。SDK が戻り値のシリアライズはやってくれますが、MIME タイプの正しさは面倒を見てくれないので、ここはサボらないほうがいいです。 6. 迷ったら両方出しておく 判断フローで整理しても、実際には「両方あると便利」というケースが出てきます。そのときは 内部実装を 1 本にして、tool と resource の両方から呼ぶ のが素直です。 理由は、resource の扱いがクライアント実装に委ねられているからです。仕様上、ユーザーに明示的に選択させるクライアント、ヒューリスティクスで自動選択するクライアント、モデル自身に選ばせるクライアント、どれもあり得るとされています。実際にも resources/list は実装済みでも read の UX がまちまち、 subscribe は未対応、といった差があります。resource しか用意していないと、未対応のクライアントからは中身が空っぽに見えます。 from mcp.server.fastmcp import FastMCP from pydantic import Field mcp = FastMCP("DocumentMCP", log_level="ERROR") docs = { "deposition.md": "This deposition covers the testimony of Angela Smith, P.E.", "report.pdf": "The report details the state of a 20m condenser tower.", } # --- 実装は 1 箇所だけ --- def _list_doc_ids() -> list[str]: return list(docs.keys()) def _read_doc(doc_id: str) -> str: if doc_id not in docs: raise ValueError(f"Doc with id {doc_id} not found") return docs[doc_id] # --- resource: アプリの @ メンション UI 用 --- @mcp.resource("docs://documents", mime_type="application/json") def list_docs_resource() -> list[str]: return _list_doc_ids() @mcp.resource("docs://documents/{doc_id}", mime_type="text/plain") def fetch_doc_resource(doc_id: str) -> str: return _read_doc(doc_id) # --- tool: Claude が自分で判断して呼ぶ用 --- @mcp.tool( name="list_documents", description="List the ids of all available documents." ) def list_documents() -> list[str]: return _list_doc_ids() @mcp.tool( name="read_doc_contents", description="Read the contents of a document and return it as a string." ) def read_document( doc_id: str = Field(description="Id of the document to read") ): return _read_doc(doc_id) 追加コストはデコレータ数行なので、割に合うと思っています。ただし tool を増やすとその定義はモデルのコンテキストを常に消費するので、「何でも tool にも出しておく」は避けたほうがいいです。文脈注入で完結するデータは resource だけに留めます。 動作確認は Inspector が楽です。 uv run mcp dev mcp_server.py Resources と Resource Templates が別枠で表示されるので、テンプレートのパラメータ解決まで確認できます。 7. 注意点(ハマりどころ) 7.1 Messages API の MCP connector は tool しか使えない これは事前に知らないと詰みます。公式ドキュメントに明記されていて、MCP 仕様の機能セットのうち 現時点では tool call のみサポート です。さらにサーバーは HTTP で公開されている必要があり、 ローカル STDIO サーバーは直接接続できません 。 mcp_servers パラメータ(MCP connector) ├─ tool call ... OK ├─ resource ... NG ├─ prompt ... NG └─ stdio サーバー ... NG 「resource を作ったのに Messages API から読めない」は仕様どおりの挙動です。 7.2 resource を使いたいならクライアント側ヘルパーに寄せる では API 経由で resource を使う手段が無いのかというと、そうではなく 自分で MCP クライアント接続を管理する 側に回ります。Anthropic SDK にはそのための変換ヘルパーが用意されています。 # pip install "anthropic[mcp]" (Python 3.10 以降) from anthropic.lib.tools.mcp import mcp_resource_to_content resource = await mcp_client.read_resource(uri="file:///path/to/doc.txt") response = await client.beta.messages.create( model="claude-opus-5", max_tokens=1024, messages=[{ "role": "user", "content": [ mcp_resource_to_content(resource), {"type": "text", "text": "Summarize this document"}, ], }], ) mcp_resource_to_file を使えばそのまま Files API にアップロードもできます。公式の使い分けも明快で、 URL で到達できるリモートサーバーで tool だけ使いたいなら mcp_servers パラメータ、ローカルサーバーや prompt / resource を使いたいならクライアント側ヘルパー です。 resource は「Claude が勝手に読むもの」ではなく「アプリが読んでプロンプトに詰めるもの」だという 2 章の話が、SDK の API 設計にそのまま現れています。 7.3 resource link はクライアント側で解決してから渡す 変換ヘルパーは、未対応のコンテンツタイプや MIME タイプ、そして resource link を渡すと例外を投げます(Python なら UnsupportedMCPValueError )。resource link は MCP クライアント側で実体に解決してから変換する必要があります。 7.4 大きい resource はコンテキストを食う resource の中身は最終的にプロンプトに入ります。ログファイル全体のような resource をうっかり注入すると一撃でコンテキストが埋まります。大きいものは resource link で参照させるか、tool 側でフィルタ・要約してから返す設計にしたほうが安全です。 8. まとめ tool と resource の違いは機能ではなく 制御主体 。tool は model-controlled、resource は application-controlled 判断は「副作用があるか」→「呼ぶタイミングをアプリが決められるか」→「モデルに存在を知らせる必要があるか」の順で考える 読み取り専用でも、 何を取るかをモデルに判断させたいなら tool 。読み取りだから resource、と機械的に決めるとモデルから見えないデータになる resource を選んだら URI テンプレート・補完・ subscribe ・ mime_type まで使い切ると効果が出る 両方あると便利なケースは、内部関数を共有して二重提供する。ただし tool 定義はコンテキストを消費する点に注意 Messages API の MCP connector は tool のみ。resource を使うならクライアント側ヘルパー( mcp_resource_to_content / mcp_resource_to_file )に寄せる 「モデルに判断させる」のか「アプリが決め打ちする」のかを設計として先に決めておくと、実装もレビューもぶれなくなります。何でも tool にしてモデルに探索させるとトークンも増えて挙動も揺れますし、逆に何でも resource にするとモデルからは存在しないデータになります。AI エージェントと組み合わせる開発では、この「どこまでを機械的に確定させるか」の線引きが毎回論点になるなと感じています。 参考リンク MCP connector – Claude Platform Docs: https://platform.claude.com/docs/en/agents-and-tools/mcp-connector Resources – Model Context Protocol 仕様: https://modelcontextprotocol.io/specification/draft/server/resources Connect to local MCP servers: https://modelcontextprotocol.io/docs/develop/connect-local-servers Connect Claude Code to tools via MCP: https://code.claude.com/docs/en/mcp MCP TypeScript SDK: https://github.com/modelcontextprotocol/typescript-sdk AI エージェント導入のご相談 サイオステクノロジーでは、本記事で扱った MCP サーバーの設計・実装を含む AI エージェントの SI サービス を提供しています。既存システムと AI エージェントの接続、RAG の精度評価と改善など、PoC から本番運用までの実装フェーズをまとめてご支援します。 「自社の業務でどこまで自動化できるのか」「どのデータをエージェントに渡すべきか」といった構想段階のご相談も歓迎です。無料相談も承っておりますので、ご興味のある方は下記サービスサイトからお気軽にお問い合わせください。 サイオス ネクストテックソリューション 生成AI導入支援サービス: https://nextech-solutions.sios.jp/genai/ ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post MCP の tool と resource の使い分け first appeared on SIOS Tech Lab .
「Figmaデザインエージェント(ベータ版)」が登場 5月20日に、Figmaにて「デザインエージェント」機能がベータ版として登場しました。 今回は、この機能を少し利用した内容を、使用感レポートとして、キャプチャー画像中心にお伝えします。 公式の案内はこちらをご覧ください。 公式ブログ: Figmaデザインエージェントが登場 | Figma Blog 利用方法は簡単で、既存のFigmaデザインのナビゲーションに追加された「エージェント」や、各フレームなどに表示されるエージェントアイコン(✦)から自然言語でプロンプトを入力すると、「エージェント」がモックアップ生成や変更をしてくれます。 (現在この機能は有償アカウントにて、AIクレジットを消費せずに、試用できます。正式リリース後はAIクレジットが消費されるようです。) 例1)簡単なプロンプト まず、何も無い画面に、以下の簡単なプロンプトを送信しました。 Jira、Asana、Nulab Backlog、Redmineのような、SaaSのプロフェッショナルなタスク管理ツールの画面モックアップを作成してください。 画面は「タスク一覧ページ」と「各タスクの詳細ページ」の2つの画面。 2分ほどで、UIモックアップが描画され、品質としては、このまま利用しても、ほぼ問題無いものです。 配置されているUI要素、基本レイアウト、スペーシング(余白)、配色、文字サイズ、ダミーコンテンツ内容、どれも基本的に、不自然さはありません。  簡単なプロンプトでもUIモックアップが生成される もちろん、生成されたモックアップはFigmaデザインのオートレイアウトが適用されたレイヤー要素で構成されており、手動で編集可能です。 生成されたモックアップは手動編集も可能 例2)デザインシステムを指定 Figmaデザインエージェントは、既存のライブラリを指定可能です。UIコンポーネントやスタイル定義が格納されたライブラリを参照させることで、細かい指示なしでも意図に沿ったUI構築が期待できます。 具体的には、デザインファイルに対して、まずライブラリを追加します。(公式: Figmaのライブラリに関するガイド ) そのうえで、エージェントのプロンプト欄のオプションにて有効にするライブラリを指定します。(チェックを入れる) 今回は公開されている「 Primer Web 」(Figmaデザインファイル)をライブラリとして指定し、プロンプトでは「例1」のプロンプトに加えて、念のため「デザインシステムは『Primer Web』を使用」と追記して実行したところ、3分半ほどで精度の高い出力が得られました。 プロンプトを送信する前に、ライブラリを指定 ライブラリが適用されたUIモックアップ 出力されたモックアップについて、UIラベルや、コンテンツを日本語化したいため 画面の内容を日本語化して と指示を行い、 さらに、タスク詳細ページの要素に重なりが発生しているので、詳細画面を選択して コメント入力欄と、画面右側のステータスなどの要素が重なっているので、重ならないようにして。 と指示をすると、想定どおりに更新されました。 ライブラリのコンポーネント適用 各UI要素が、ライブラリのUIコンポーネントをインスタンスとして配置されているか、確認したところ、「ボタン」や「パンくずリスト」「ラベル」「アバター」などは、問題ありませんでした。 しかし「セレクト」や「データテーブル」などは、ライブラリが利用されていませんでした。 セレクト要素を選択して、ライブラリのコンポーネントを利用するように指示したところ、コンポーネントが適用されましたが、「優先度: すべて」などのラベル文字が無効化されたため、エージェントによる更新を「元に戻す」を行い、ラベル内容を保持するように再度指示をしたところ、そのとおりとなりました。 表(テーブル)部分は、DataTableコンポーネントの適用を指示しても、なかなか期待通りにはなりません。 テーブルには、ヘッダーや行、列、フッター、さらにセルの要素のバリエーションなど複雑なため、利用するコンポーネントの特性を踏まえて、指示をする必要がありそうです。 エージェントの制御が難しい場合は、早めに手動編集に切り替えるのが良いかもしれません。 なお、Figmaデザインエージェントは、同時に複数のプロンプトを送信しても並行して処理をすることも可能です。 例3)バリエーション生成 「例1」「例2」にて生成された画面は、デスクトップ用のライトモード画面でしたが、ダークモードやモバイル画面を、下記のようなプロンプトで依頼しました。 この画面のダークモード版を別のframeで作成して。 これら2つの画面に対する、モバイル(スマートフォン)用の画面を作成して。 結果としては、ダークモードは期待通りでした。 モバイル画面は、少々ぎこちないですが、たたき台として利用できそうです。 生成されたダークモードのUIモックアップ 生成されたモバイル版のUIモックアップ 他のサービスと比較 以上がFigmaデザインエージェント(ベータ版)の簡単な使用感ですが、比較として、同じプロンプトを次の2つのサービスに送信してみました。 ChatGPT(画像生成) 静的な画像として出力されます。レイアウトや要素の構成は適切ですが、Figmaなどで編集可能なレイヤーとして出力はされないため、プロトタイプへの落とし込みや、詳細を手動編集するには別途作業が必要です。 ChatGPTにより生成されたUIモックアップ Figma Make 「Figma Make」は2025年リリースの機能です。(公式: Figma Makeを探る ) プロンプトからフロントエンドコードの生成に重きを置いており、Figmaエージェントとは出力の指向性が異なります。Figma Makeで生成した内容はFigmaデザインファイルへコピー&ペーストして編集することも可能です。 プロンプトを送信すると、数分でタスク一覧画面、詳細画面のプロトタイプができました。 画面の要素、レイアウトや配色は完成されており、違和感はありません。 「Make」と「デザインエージェント」は同じFigmaのサービスでも、同じプロンプトから生成される内容は異なる印象です。 Figma Makeで生成されたプロトタイプ おわりに 上記の公式案内に …「AI生成か、それとも直接操作か?」といった、偽りの二者択一が浮上しています。しかし、どちらかを選ぶ必要などないはずです。(省略)私たちの目標は、Figmaに精通し、チームの働き方に自然に溶け込むエージェントを作成することでした。… とあるように、手作業とエージェントとをうまく合わせることで、UIモックアップ、プロトタイプ作成、バリエーションづくりの工数削減や、品質の向上が実現できそうです。 Photo by Zac Wolff on Unsplash   ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post Figmaデザインエージェントを試す first appeared on SIOS Tech Lab .
はじめに こちらの記事 で実際にKubernetes環境にKubeBlocksを導入し、DBaaSの基盤を構築しました。 前回の記事 ではKubeBlocksを利用してDBaaS基盤上にMySQLを構築しました。 今回は、KubeBlocksを利用してNoSQLのインメモリデータベースであるRedisを構築していきます。 導入環境構成図 以下の図は、DBaaS基盤上にRedisを導入する環境の構成図です。 「KubeBlocksオペレーター」は こちらの記事 で構築しました。 本記事では、赤丸で囲まれた「DB(Redis)」の構築を対象とします。 Redisはすべてのデータをメモリ上で処理する「インメモリデータベース」の一種で、NoSQLに分類されます。 ディスク(SSD/HDD)にアクセスする一般的なデータベースと比較して圧倒的に高速なのが特徴で、主にWebサイトやアプリの高速化(キャッシュ)や、リアルタイム処理に利用されるDBになります。 導入環境構成図 Redisの構築方法 KubeBlocksを使用してRedisを構築する手順をご紹介します。 今回は最もシンプルな、1台のサーバーでRedisを稼働させるスタンドアロン構成で作成していきます。 前提条件 KubeBlocksが構築済みであること KubeBlocksによってデフォルトでインストールされるRedisアドオン(以下コマンド結果のredis 1.0.1)が有効になっていること 以下のkbcliコマンドで有効化されているアドオンを確認することができます。 kbcli addon list # 出力例 NAME VERSION PROVIDER STATUS AUTO-INSTALL qdrant 1.0.1 community Disabled false rabbitmq 1.0.1 community Disabled false apecloud-mysql 1.0.1 community Enabled true etcd 1.0.1 community Enabled true kafka 1.0.1 community Enabled true mongodb 1.0.1 community Enabled true mysql 1.0.1 community Enabled true postgresql 1.0.1 community Enabled true redis 1.0.1 community Enabled true Namespeaceの作成 まずはRedisをデプロイするNamespeaceを作成します。 kubectl create namespace redis # 出力例 namespace/redis created デフォルトユーザー認証用Secretの作成 Redisのデフォルトユーザー用のユーザー名・パスワードを設定したSecretを作成します。 kubectl create secret generic custom-redis-root-secret \ --from-literal=username='default' \ --from-literal=password='<任意の値>' \ -n redis # 出力例 secret/custom-redis-root-secret created ※Redis作成時に本手順で作成したSecretを指定することで、デフォルトユーザーのパスワードを任意の値で設定することができます。 Secretの指定がない場合は、KubeBlocksがデフォルトユーザー用のパスワードを自動発行します。 Redisの作成 MySQLクラスター構築時と同様に、KubeBlocksのカスタムリソースである「Cluster」のマニフェストを適用し、Redisを作成します。 cat <<EOF | kubectl apply -f - apiVersion: apps.kubeblocks.io/v1 kind: Cluster metadata: name: redis-cluster namespace: redis spec: terminationPolicy: Delete clusterDef: redis topology: standalone componentSpecs: - name: redis replicas: 1 systemAccounts: - name: default secretRef: name: custom-redis-root-secret namespace: redis serviceVersion: 8.0.3 disableExporter: false resources: limits: cpu: "0.5" memory: "0.5Gi" requests: cpu: "0.5" memory: "0.5Gi" volumeClaimTemplates: - name: data spec: accessModes: - ReadWriteOnce resources: requests: storage: 20Gi EOF # 出力例 cluster.apps.kubeblocks.io/mycluster created clusterDef: redis Redisアドオンが提供するRedisの構成テンプレートを、作成するDBクラスターのベースとして指定する設定です。 topology: standalone Redisを単一のRedisサーバーインスタンスで構成されるスタンドアロンクラスターとして起動する設定です。 terminationPolicy: Delete クラスターを削除した際、関連するデータも一緒に削除する設定です。 systemAccounts Redisのデフォルトユーザーの認証情報(ユーザー名・パスワード)に、事前に作成したSecretを割り当てる設定です。 volumeClaimTemplates DBのデータを保存するためのPVの設定です。 Redisの作成確認 Redis作成コマンド実行後、以下のコマンドでクラスター・Podのステータスを確認します。ステータスがRunningになっていれば正常に動作しています。 kubectl get cluster redis-cluster -n redis # 出力例 NAME CLUSTER-DEFINITION TERMINATION-POLICY STATUS AGE redis-cluster redis Delete Running 110s kubectl get pods -n redis # 出力例 NAME READY STATUS RESTARTS AGE redis-cluster-redis-0 4/4 Running 0 2m15s 動作確認 Redisへの接続テストを行います。 まず、作成したRedisのエンドポイント(Service名)を確認します。 kubectl get svc -l app.kubernetes.io/instance=redis-cluster -n redis # 出力例 NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE redis-cluster-redis-redis ClusterIP 10.43.163.160 <none> 6379/TCP 3m kubectl runコマンドでRedisクライアント用のPodを作成し、コンテナ内でシェルを起動します。 kubectl run redis-client -n redis --rm -i --tty --image=redis:8.0.3 --restart=Never -- bash # 出力例 If you don't see a command prompt, try pressing enter. root@redis-client:/data# redis-clientコマンドを使用し、確認したRedisのエンドポイント、「 デフォルトユーザー認証用Secretの作成 」で作成したユーザ名・パスワードを指定して接続します。 root@redis-client:/data# redis-cli -h redis-cluster-redis-redis.redis.svc.cluster.local -p 6379 --user default --pass xxxxx # 出力例 Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe. redis-cluster-redis-redis.redis.svc.cluster.local:6379> Redisに正常に接続できているか、以下のPINGコマンドを使用して確認します。 redis-cluster-redis-redis.redis.svc.cluster.local:6379> PING # 出力例 PONG 「PONG」が出力されれば、正常に接続できています。 続いて、データの登録・取得が行えるかを確認します。 キー「test」に値「”Hello World” 」を登録します。 redis-cluster-redis-redis.redis.svc.cluster.local:6379> SET test "Hello World" # 出力例 OK 登録したデータが正しく取得できるかを確認します。 redis-cluster-redis-redis.redis.svc.cluster.local:6379> GET test # 出力例 "Hello World" 登録した「”Hello World” 」という文字列が出力されれば、Redisへのデータ登録と取得は正常に行えています。 これでRedisの構築と動作確認は完了になります。 おわりに 前々回構築したKubeBlocksを使用し、実際にRedisの構築から接続テストを行うまでの流れをご紹介しました。 前回のMySQL構築に引き続き、容易にRedisを構築できることを体感できたのではないでしょうか 。 今回は最もシンプルな「Redisサーバ1台のスタンドアロン構成」として構築しましたが、KubeBlocksなら本番環境向けの冗長構成も、マニフェストの設定を少し変更するだけで簡単に構築することができます。 KubeBlocksは様々なDBをサポートしているため、他のDBも同様の方法で手軽に導入することができます。 本記事が、Kubernetes上でDBを構築する際の選択肢として、KubeBlocksを検討するきっかけになれば幸いです。 参考文献 https://kubeblocks.io/docs/preview/kubeblocks-for-redis/03-topologies/01-standlone https://kubeblocks.io/docs/preview/kubeblocks-for-redis/06-custom-secret/01-custom-secret ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post KubeBlocksでRedisを導入!Kubernetes上での高速キャッシュ/NoSQL構築を体験 first appeared on SIOS Tech Lab .
前回の記事 ではHCPの概要やメリットについて触れましたが、本記事では実際にROSA HCPクラスターを構築する手順を徹底解説します。 各種CLIツールのインストールから、ROSA特有のIAMロールの作成、そして実際のクラスターデプロイと接続確認まで、ハンズオン形式で一通り実践できる内容となっています。 「ROSAを触ってみたいけれど、何から始めればいいか分からない」という方は、ぜひ手元の環境で実際に構築してみてください。 ROSA構築のための事前準備 ROSA(Red Hat OpenShift on AWS)でクラスターを実際に作成していくにあたって、事前に必要となるアカウントや環境は以下の通りです。 AWSアカウント この記事では、AWSアカウントが作成済みであることを前提としています。 補足:ROSAの構築には適切なIAM権限(AdministratorAccessなど)が必要になります。 Red Hatアカウント この記事では、Red Hatアカウントが作成済みであることを前提としています。 まだ作成していない場合は、 Red Hatの公式サイト からアカウントを作成してください。 AWSアカウントとRed Hatアカウントの紐づけ AWSコンソールから、Red Hat OpenShift Service on AWS (ROSA)ページに移動し、「使用を開始」をクリックします。 ROSA HCPを有効化にし、「Red Hatに進む」でRed Hat側でアカウントを紐づけてください。 ターミナル この記事では、Windowsの WSL(Ubuntu) を使用して構築を進めていきます。 macOS: 標準のターミナル(bash/zsh)であれば、基本的にそのまま同様の手順で進行可能です。 その他(GitBash, PowerShellなど): コマンドの挙動が異なる場合があり、動作を保証できませんのでご注意ください。 注意事項 ROSAの構築・利用にあたっては、 AWSのインフラ利用料およびRed Hatのサブスクリプション料金(従量課金など)が発生します 。 予期せぬ課金を防ぐため、検証が終わったら必ずクラスターを削除するなど、料金面についてあらかじめご承知おきください。 環境設定 それでは、実際にROSA構築に入る前に、必要なツール(CLI)をインストールしていきましょう。 ROSAの構築・操作には、基本的に以下の3つのCLIを利用します。 AWS CLI ROSA CLI OpenShift CLI AWS CLIのインストール ROSAはAWSのマネジードサービスであるため、AWSアカウントにログインしている状態で利用できます。まずは、以下のコマンドでAWS CLIをインストールします。 # インストーラーのダウンロードと解凍 $ curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" $ sudo apt update && sudo apt install -y unzip $ unzip awscliv2.zip # インストール実行 $ sudo ./aws/install # インストール確認 $ aws --version ROSA CLIのインストール ROSA CLIは、ROSAクラスターの新規作成や、クラスター全体のステータス確認・削除など、クラスター単位の操作で使用するCLIです。 以下のコマンドでROSA CLIをインストールします。 # ダウンロードと解凍 $ curl -L https://github.com/openshift/rosa/releases/download/v1.2.60/rosa_Linux_x86_64.tar.gz -o rosa-linux.tar.gz $ tar xvzf rosa-linux.tar.gz # binaryディレクトリに移動 $ sudo mv ./rosa /usr/local/bin/ # インストール確認 $ rosa version OpenShift CLIのインストール OpenShift CLI(oc)は、作成したROSAクラスターの内部にある各種リソース(NodeやPodなど)を操作するためのCLIです。KubernetesのkubectlのOpenShift拡張版とイメージしていただければ大丈夫です。 以下のコマンドでOpenShift CLIをインストールします。 # ダウンロードと解凍 $ curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/ocp/4.20.12/openshift-client-linux.tar.gz $ tar -xvf openshift-client-linux.tar.gz # binaryディレクトリに移動 $ sudo mv ./oc ./kubectl /usr/local/bin/ # インストール確認 $ oc version client ROSAへのログイン 各種CLIの準備ができたら、実際にアカウントへログインしてみましょう。まずROSAを操作するために、AWSへのログイン(認証)を行います。 $ aws login --remote コマンドを実行すると、https://us-east-1.signin.aws.amazon.com/v1/authorize?response_type=…というURLが表示されるのでブラウザで接続します。 Copy verification codeをクリックしてコピーした認証コードをターミナルのEnter the authorization code displayed in your browser:のところに貼り付けます。 ログイン完了後、以下のコマンドを実行し、認証情報を現在のシェル環境変数にエクスポートしておきます。 $ eval $(aws configure export-credentials --format env) 正しくログインできているか、接続中のAWSアカウント情報を確認してみましょう。   $ aws sts get-caller-identity AWSにログインできたら、次はROSAにログインします。 $ rosa login --use-device-code コマンドを実行すると、以下のようにURLと認証用のコード( XXXX-XXXX 部分)がターミナルに表示されます。 INFO: To login, navigate to https://sso.redhat.com/device on another device and enter code XXXX-XXXX 提示されたURL(https://sso.redhat.com/device)をブラウザで開き、ターミナルに表示されているコードを入力してサインインを承認してください。 ROSAへのログインが完了したら、クラスターを構築するデフォルトのリージョンを指定しておきます(ここではus-east-1を指定しています)。 $ export AWS_REGION=us-east-1 最後に、以下のコマンドで接続中のROSAのアカウント情報を確認し、正しく表示されたら、ROSAのクラスターを作る準備はすべて完了です。 $ rosa whoami # 出力例 W: The current version (1.2.60) is not up to date with latest rosa cli released version (1.2.64). W: It is recommended that you update to the latest version. AWS ARN: arn:aws:iam::123456789:XXXXXX AWS Account ID: 123456789 AWS Default Region: us-east-1 OCM API: https://api.openshift.com OCM Account Email: XXXXXX OCM Account ID: XXXXXXXX OCM Account Name: XXXXX OCM Account Username: XXXXXXXX もしこのようなエラーが出てきたら、AWS CLIのセッション切れが原因です。 E: Failed to create AWS client: operation error STS: GetCallerIdentity, https response error StatusCode: 403, RequestID: 76d46612-1094-49a6-a78c-4635978a2a42, api error ExpiredToken: The security token included in the request is expired 以下のコマンドで認証情報を更新してください。 $ unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_CREDENTIAL_EXPIRATION $ eval $(aws configure export-credentials --format env) その後再度rosaコマンドを試してみてください。 クラスター構築 Account Roleの作成 Account Role(アカウントロール)は、Red Hat側がユーザーのAWSアカウント内でクラスターの構築や運用を行うために、安全に権限を委譲するためのIAMロールです。 AWSアカウント単位の共通権限となるため、ユーザーのAWSアカウントにつき1回だけ生成すれば、以降は使い回すことができます。 以下のコマンドで必要なAccount Roleが一括で自動作成されます。 $ rosa create account-roles --hosted-cp --mode auto –hosted-cpというのは、前回の記事で紹介したHCP(Hosted Control Planes)方式に最適化されたAccount Roleを生成するという指定です。 Classic方式との一番大きな違いは、ControlPlane-Role(コントロールプレーン用のロール)が作成されない点です。HCP方式では、コントロールプレーンはRed Hat側で管理するため、ユーザーのAWSアカウント側にIAMロールを生成する必要がありません。 具体的には以下の3つのロールが生成されます。ManagedOpenShift はデフォルトのプレフィックス名です。プレフィックスを指定したい場合は、–prefix <指定したいプレフィックス名> で設定可能です。 ManagedOpenShift-HCP-ROSA-Installer-Role: クラスターの基盤となるVPCやEC2などを自動で組み立てるための権限。 ManagedOpenShift-HCP-ROSA-Support-Role: Red HatのSREチームが障害発生時などにクラスターの状況を調査・対応するための権限。 ManagedOpenShift-HCP-ROSA-Worker-Role: アプリケーションが起動するワーカーノード(EC2インスタンス)が、AWSの他の機能(ストレージなど)を操作するための権限。 作成されたAccount Roleは以下のコマンドで確認できます。 $ rosa list account-roles # 出力例 ROLE NAME ROLE TYPE ROLE ARN OPENSHIFT VERSION AWS Managed ManagedOpenShift-HCP-ROSA-Worker-Role Worker arn:aws:iam::XXXXXXXXXXXX:role/ManagedOpenShift-HCP-ROSA-Worker-Role 4.21 Yes ManagedOpenShift-HCP-ROSA-Installer-Role Installer arn:aws:iam::XXXXXXXXXXXX:role/ManagedOpenShift-HCP-ROSA-Installer-Role 4.21 Yes ManagedOpenShift-HCP-ROSA-Support-Role Support arn:aws:iam::XXXXXXXXXXXX:role/ManagedOpenShift-HCP-ROSA-Support-Role 4.21 Yes Operator Roleの作成 上で作成したAccount Roleは、Red Hat側がユーザーのAWSアカウントを利用して作業するための権限でしたが、Operator Roleは、OpenShiftの内部からAWSリソースを直接操作するためのIAMロールです。 OpenShiftの内部では、ネットワーク管理やストレージ管理、ログ管理など、それぞれの役割に特化した「Operator(オペレーター)」と呼ばれる複数のプログラムが動いています。それらのプログラムがAWSリソース(ロードバランサーやEBSなど)を自動で作成・管理することになるため、各Operatorに必要な最小限の操作権限だけIAMロールとして切り出して、割り当てる仕組みになっています。 また、OperatorにAWSのアクセスキーなどの永続的な認証情報を持たせるのを防ぐため、「AWS STS(AWS Security Token Service)」という仕組みを利用して、OIDCプロバイダーと連携し安全に一時的な操作権限を受け取れるようにします。 そのため、Operator Roleを作成する前に、連携先となるOIDCプロバイダーを用意する必要があります。 以下のコマンドでOpenID Connect Config(oidc-config)を作成することで、AWS側にもOIDCプロバイダーが自動で作成されます。 $ rosa create oidc-config --mode=auto --managed=true --yes 作成できたら、以下のコマンドで生成されたOIDC Configの情報を確認しましょう。 $ rosa list oidc-config # 出力例 ID MANAGED ISSUER URL SECRET ARN XXXXXXXXX true XXXXXXXXX 出力結果に表示される IDはこの後の手順で利用するため、手元に控えておきます。 準備が整ったら、以下のコマンドでOperator Roleを生成します。 $ rosa create operator-roles --hosted-cp --mode auto \ --prefix=demo \ --oidc-config-id=<上で作成されたOIDC ConfigのID> \ --installer-role-arn=<上で作成されたInstaller-RoleのARN> 以下のコマンドで作成されたOperator Roleを確認できます。 $ rosa list operator-roles # 出力例 ROLE PREFIX AMOUNT IN BUNDLE  demo         8 具体的なAWSのリソースとしては、以下のロールが自動生成されます。 補足:IAMロール名がプレフィックス値を含めて64文字を超える場合は、64文字になるように末尾が切り捨てられます。 <prefix>-openshift-ingress-operator-cloud-credentials :ユーザーがアプリを外部公開する際に、AWS側にロードバランサー(ALBやNLB)を自動作成したり、Route 53のDNS設定を管理したりする権限。 <prefix>-openshift-cloud-network-config-controller-cloud-credential :OpenShift内部のネットワークと、AWSのネットワークを連動させるための権限。 <prefix>-openshift-cluster-csi-drivers-ebs-cloud-credentials :AWSのEBSをコンテナのストレージとして割り当てるための権限。 <prefix>-openshift-image-registry-installer-cloud-credentials :コンテナイメージを保存するためにAWSのS3バケットを生成・操作するための権限。 また、従来のClassic方式と比べると、HCP方式ではコントロールプレーンが分離されているため、kube-system-〜 から始まるHCP専用のOperator Roleも生成されます。 <prefix>-kube-system-capa-controller-manager :ワーカーノード(EC2)の台数を自動で増減させる機能の権限(オートスケーリングなど)。 <prefix>-kube-system-control-plane-operator :Red Hat側のコントロールプレーンからの指示でユーザーのAWS環境のインフラを操作できる権限。 <prefix>-kube-system-kube-controller-manager :Kubernetesの標準的な管理プログラムが、AWSのリソースを監視できるようにする権限。 <prefix>-kube-system-kms-provider :AWSのKMSと連携し、Kubernetesのシークレットを暗号化するための権 VPCとサブネットの作成 ROSAクラスタをインストールするVPCを作成します。今回はAWSコンソールで簡単に構築してみます。 AWSでVPCを開き、「 お使いのVPC」で VPCを作成を選択します。 VPCだけでなくサブネットやNAT Gatewayも一緒に作成するために「VPCなど」を選択します。 選択後の各種設定値は、以下の表を参考にしてください。表に記載がない項目はデフォルト(初期状態)のままで問題ありません。今回はデモのために作るので、なるべく最小限必要な構成にしています。 パラメータ 説明 名前タグの自動生成 任意の名前(例:rosa-demoなど) アベイラビリティゾーン (AZ) の数 1 パブリックサブネットの数 1 プライベートサブネットの数 1 NAT ゲートウェイ Zonal(1 AZ 内に配置) VPC エンドポイント なし すべての設定が終わったら、画面一番下にある 「VPC を作成」 ボタンを押します。 (作成完了には数分程度かかります) また、subnet-から始まるパブリックサブネットのIDとプライベートサブネットのIDはこのあとのクラスター作成で使うため、テキストエディタ等にメモしておいてください。 ROSAクラスター作成 Account RoleとOperator Role、そして事前にAWS側に用意したVPC(サブネット)の準備ができたら、以下のコマンドを実行してクラスターを作成します。 $ rosa create cluster --cluster-name <クラスター名> \ --sts \ --mode auto \ --hosted-cp \ --subnet-ids <パブリックサブネットID>,<プライベートサブネットID> \ --oidc-config-id <上で作成されたOIDC ConfigのID> \ --operator-roles-prefix <Operator Roleを作成するときに指定したprefix> \  --domain-prefix <クラスターのURLのサブドメイン名> \ --yes 各パラメータの説明 パラメータ 説明 –cluster-name 作成するクラスターの一意の名前。任意の英数字で指定。 –sts AWSの安全な一時認証(Security Token Service)を使用するという指定。HCP方式では必須。 –mode auto 途中の質問に対して、すべて自動(デフォルト値)で作成を進めるための指定。 –hosted-cp  HCP方式 でクラスターを作成するためのフラグ。 –subnet-ids クラスターを配置するAWSの既存サブネット(パブリックとプライベート)のIDをカンマ区切りで指定。 –oidc-config-id Operator Role作成時に指定したprefixの値(例: demo)を指定。 –operator-roles-prefix 前の手順で作ったOperator Role群を識別するための接頭辞(例: ManagedOpenShift)を指定。 –domain-prefix 自動生成されるクラスターのURL(管理画面やAPIサーバーのURL)のサブドメイン部分をカスタマイズするための設定(オプション)。 –yes コマンド実行時の最終確認(Are you sure?)をスキップするフラグ。 クラスターの作成が完了するまではおよそ10分〜15分程度かかります。バックグラウンドでどのように構築が進んでいるかリアルタイムで確認したい場合は、以下のコマンドを実行してください。 $ rosa logs install --cluster <クラスター名> --watch # 出力例 bak@1010-00867:~$ rosa logs install -c rosa-demo --watch W: The current version (1.2.60) is not up to date with latest rosa cli released version (1.2.64). W: It is recommended that you update to the latest version. I: Cluster 'rosa-demo' is in validating state waiting for installation to begin. Logs will show up within 5 minutes \ 0001-01-01 00:00:00 +0000 UTC hostedclusters rosa-demo Version 2026-07-13 07:04:09 +0000 UTC hostedclusters rosa-demo Condition not found in the CVO. 2026-07-13 07:04:09 +0000 UTC hostedclusters rosa-demo The hosted control plane is not found 以下のようなログが出たら、ROSAクラスターのデプロイは完了です。 I: Cluster 'rosa-demo' is now ready 接続確認 クラスターの構築が完了したら、実際にログインして接続確認を行いましょう。 接続するためには、まず管理者アカウントである cluster-admin を作成する必要があります。 以下のコマンドでcluster-adminを作成します。コマンドを実行すると、passwordが自動生成され、そのままログインできるコマンド(oc login〜)がターミナルに出力されます。 $ rosa create admin --cluster=<クラスター名> # 出力例 I: Admin account has been added to cluster 'your-cluster-name'. I: Please securely store this generated password. If you lose this password you can delete and recreate the cluster admin user. I: To login, run the following command: oc login https://api.your-cluster-name.xxxx.p1.openshiftapps.com:443 --username cluster-admin --password XXXXX-XXXXX-XXXXX-XXXXX 以下のようにoc loginコマンドを入力してLogin successful. と表示されれば、CLIからの接続確認は完了です。 $ oc login https://api.your-cluster-name.xxxx.p1.openshiftapps.com:443 --username cluster-admin --password XXXXX-XXXXX-XXXXX-XXXXX Login successful. もし、以下のエラーが出たら、数分待ってから再度試してみてください。 Login failed (401 Unauthorized) Verify you have provided the correct credentials. また、CLIからだけでなく、ウェブブラウザを使って管理コンソール(GUI)にログインする方法もあります。 以下のコマンドを実行して、出力結果から「Console URL」の項目を確認します。 $ rosa describe cluster --cluster=<作成したクラスター名> #出力例 W: The current version (1.2.60) is not up to date with latest rosa cli released version (1.2.64). W: It is recommended that you update to the latest version. Name: rosa-demo Domain Prefix: rosa-demo Display Name: rosa-demo ID: XXXXXXXXXX External ID: XXXXXXXXXX Control Plane: ROSA Service Hosted OpenShift Version: 4.20.28 Channel Group: stable DNS: XXXXXXXXXX AWS Account: XXXXXXXXXX AWS Billing Account: XXXXXXXXXX API URL: XXXXXXXXXX Console URL: https://console-openshift-console.apps.rosa.xxxxx.openshiftapps.com Console URLをブラウザに入力してアクセスすると、以下のように管理コンソールに接続できますので、CLIでのログイン(oc login)時に使用した、管理者(cluster-admin)のusernameとpasswordを入力してログインします。 このような画面が出たら管理コンソールにログイン成功です。 (オプション)後片付け ROSAを構築するために作成した各種リソースは費用が発生するので、もし今後使わないのであれば片付けておくことをおすすめします。 また、漏れなく削除するために以下の記載順で削除していくことをおすすめします。 1. クラスターの削除 まず、以下のコマンドでクラスターを削除します。 $ rosa delete cluster --cluster=<クラスター名> --yes 削除中のログの確認は以下のコマンドでできます。 $ rosa logs uninstall --cluster=<クラスター名> --watch 実行例です。 $ rosa logs uninstall --cluster=rosa-demo --watch W: The current version (1.2.60) is not up to date with latest rosa cli released version (1.2.64). W: It is recommended that you update to the latest version. 2026-07-13 07:41:44 +0000 UTC hostedclusters rosa-demo invalid service account signing key: failed to get hostedcluster ServiceAccountSigningKey secret bound-service-account-signing-key: Secret "bound-service-account-signing-key" not found 2026-07-13 07:41:45 +0000 UTC hostedclusters rosa-demo pull secret unavailable: Secret "rosa-demo-pull" not found 2026-07-13 07:41:46 +0000 UTC hostedclusters rosa-demo Reconciliation completed successfully 2026-07-13 07:45:48 +0000 UTC hostedclusters rosa-demo All is well 2026-07-13 07:45:49 +0000 UTC hostedclusters rosa-demo ValidAWSIdentityProvider StatusUnknown 2026-07-13 07:45:49 +0000 UTC hostedclusters rosa-demo All guest resources destroyed I: Cluster 'rosa-demo' completed uninstallation 「Cluster ‘XXXXXX’ completed uninstallation」というログが出たら削除完了です。 クラスターの削除が完了したら、AWSコンソールの「VPC」サービス画面を開き、AWSのリソースを削除していきます。 2. NATゲートウェイの削除 VPCコンソール > 左メニューの「NAT ゲートウェイ」に移動し、作成したNATゲートウェイを削除します。削除済み(Deleted)になるまで数分かかることがあります。 3. VPCの削除 VPCコンソール > 左メニューの「お使いの VPC」に移動し、作成したVPCを選択し削除します。VPCを削除すると、以下の関連リソースもすべて削除されます。 サブネット(パブリック / プライベート) ルートテーブル インターネットゲートウェイ(IGW) セキュリティグループ ※注意:VPCの削除でエラーが出る場合 クラスターの削除完了直後にVPCを消そうとすると、「eni-xxxx (VPC Endpoint Interface) が使用中(in use)のため削除できません」というエラーが出る場合があります。 5分ほど待つか、AWSコンソールの「VPC」>「エンドポイント」から対象のID(vpce-xxxx)を手動で削除してから、再度VPCの削除を行ってください。 4. Elastic IP(EIP)の解放 VPCコンソール > 左メニューの「Elastic IP」に移動し、NATゲートウェイ用に自動取得されていたElastic IPを選択し、画面右上の「アクション」>「Elastic IP アドレスの解放」をクリックします。 これでクラスターと関連リソースの削除は完了です。 5. オペレーターロールの削除 $ rosa delete operator-roles --prefix=<プレフィックス名> --mode=auto --yes 以下は実行例です。 $ rosa delete operator-roles --prefix=demo --mode=auto --yes W: The current version (1.2.60) is not up to date with latest rosa cli released version (1.2.64). W: It is recommended that you update to the latest version. I: Fetching operator roles for the prefix: demo I: Deleting operator role 'demo-kube-system-capa-controller-manager' I: Deleting operator role 'demo-kube-system-control-plane-operator' I: Deleting operator role 'demo-kube-system-kms-provider' I: Deleting operator role 'demo-kube-system-kube-controller-manager' I: Deleting operator role 'demo-openshift-cloud-network-config-controller-cloud-credentials' I: Deleting operator role 'demo-openshift-cluster-csi-drivers-ebs-cloud-credentials' I: Deleting operator role 'demo-openshift-image-registry-installer-cloud-credentials' I: Deleting operator role 'demo-openshift-ingress-operator-cloud-credentials' I: Successfully deleted the operator roles 6. OIDC Configの削除 $ rosa delete oidc-config --oidc-config-id=<OIDCのID> --mode=auto --yes 以下は実行例です。 $ rosa delete oidc-config --oidc-config-id 2reuclobhhsao2hdrjpc43lcoq5151go W: The current version (1.2.60) is not up to date with latest rosa cli released version (1.2.64). W: It is recommended that you update to the latest version. ? OIDC Config deletion mode: auto ? Delete the OIDC provider 'arn:aws:iam::XXXXXXXXXXXX:oidc-provider/oidc.op1.openshiftapps.com/2reuclobhhsao2hdrjpc43lcoq5151go'? Yes I: Successfully deleted the OIDC provider arn:aws:iam::XXXXXXXXXXXX:oidc-provider/oidc.op1.openshiftapps.com/2reuclobhhsao2hdrjpc43lcoq5151go I: Registered OIDC Config ID '2reuclobhhsao2hdrjpc43lcoq5151go' has been removed from OCM and can no longer be used まとめ 以上で、ROSAクラスターの構築に必要な事前準備から、各種IAMロールの作成、実際のデプロイ、そして接続確認までの一連の手順が完了しました。 「ROSAって名前は聞くけど、具体的にどうやって始めれば良いんだろう?」という漠然とした疑問やハードルが、この記事を通して少しでも解消できたら幸いです。 次回からは、ログの保管やメトリクスの転送など、クラスターの内部をさらに充実させていく内容を紹介していきます。ぜひ楽しみにしていてください。 参考資料 Getting started with Red Hat OpenShift Service on AWS ( Red Hat Documentation ) Chapter 2. Creating Red Hat OpenShift Service on AWS clusters using the default options( Red Hat Documentation) Create a ROSA with HCP cluster using the ROSA CLI(Red Hat Documentation) Hosted Control Planes(rosaworkshop.io) ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post ROSA(Red Hat OpenShift Service on AWS)を利用したコンテナプラットフォーム構築 ~クラスタ構築~ first appeared on SIOS Tech Lab .
こんにちは、OSSよろず相談室のSKです。 OSS に関するお問い合わせが日々寄せられる中で、今回は 自社でDNSサーバを運用している環境において、BIND の DNSSEC に関連して寄せられたお問い合わせをご紹介します。 少し前ですが、以下のような問い合わせをいただきました。 KSKロールオーバーについてJPRSから以下が公開されています。 新しいKSK(KSK-2024)がルートサーバーで事前公開 DNSSEC に対応しているDNSサーバの、トラストアンカーの自動更新の設定がautoになっています。 ・named.confの設定:dnssec-validation auto; この場合、KSKロールオーバーに関して追加の作業は特に必要ないでしょうか。 回答は「追加の作業は必要ありません。」 named.conf の dnssec-validation auto; 設定が有効になっている場合、BINDは新しいトラストアンカーを自動的に取得するようになっているからです。 rndc managed-keys status コマンドでトラストアンカーが更新されたかどうかを確認することができます。 今回は、このやり取りに関連して、「DNSSEC」「トラストアンカー」「KSKロールオーバー」とは何か、そして dnssec-validation を auto にしておく影響について解説します。 DNSSECとは? DNSはサーバ名からIPアドレスを問い合わせる仕組みですが、応答を途中で改ざんして別のIPアドレスに誘導する「DNSキャッシュポイズニング」という攻撃手法があります。 このDNSの応答データが改ざんされていないことを、暗号署名を用いて検証するセキュリティ拡張の仕組みが「DNSSEC」です。 トランスアンカーとは? DNSSECの署名検証を行う上で、起点となるルートゾーンのKSK(鍵署名鍵)のことです。 BINDなどのDNSサーバは、このルートKSKのコピーを検証の基点となる「トラストアンカー」として自ら保持します。 KSKロールオーバーとは DNSSECでは、ゾーンデータの署名に用いる「ZSK」と、鍵情報の署名に用いる「KSK」が使用されます。 DNSSECの仕組み(DS方式) KSKロールオーバーとは、セキュリティ維持のためにこのKSKを新しい鍵に置き換える(更新する)プロセスのことです。 ルートゾーンのKSKが更新される場合、世界中のDNSサーバは新しいルートKSKをトラストアンカーとして認識し直す必要があります。JPRSの発表にある通り、現在ルートゾーンでは 新しいKSK(KSK-2024) への移行が進行しており、 2026年10月11日 に新KSKへの本番切り替えが予定されています。 新しいKSK(KSK-2024)がルートサーバーで事前公開 dnssec-validation を auto にする BINDの named.conf にて dnssec-validation auto; を設定すると、DNSSECの署名検証とともに、RFC 5011に基づくトラストアンカーの自動更新機能が有効になります。 ルートKSKのロールオーバーが実施された際、BINDは自動的に新しい鍵を検知します。その後、30日間の保留期間(trust pending)を経てから、新しい鍵を正式なトラストアンカーとして信頼(trusted)します。 この設定を auto にしておくことで、手作業による鍵ファイルの書き換えが不要になり、古い鍵のまま検証が失敗して名前解決ができなくなるトラブルを防ぐことができます。 (dnssec-validation の参考: BIND 9 Administrator Reference Manual – DNSSEC Validation Explained ) rndc managed-keys status コマンド トラストアンカーの自動更新の状態は、以下のコマンドで確認できます。 $ rndc managed-keys status rndcコマンド は、BINDのプロセスnamedを制御するためのコマンドツールです。 実行結果の trust の項目を確認することで、BINDが認識している鍵が現在「保留中(trust pending)」なのか、無事に「信頼されている(trusted)」状態に移行したのかなど、ロールオーバーの進捗状況を把握できます。 $ rndc managed-keys status view: _default class: IN name: . keyid: 20326 trust: trusted 出力結果の trust の項目が trusted となっていれば、その鍵は正常にトラストアンカーとして機能しています。ロールオーバーの過渡期には、新しい鍵のステータスが initializing や pending といった状態を経て、最終的に trusted に移行する様子を確認することができます。 まとめ dnssec-validation auto; が設定されている環境では、トラストアンカーの自動管理機能が働くため、KSKロールオーバーに向けた手動での追加作業は必要ありません。 2026年10月11日に予定されている新KSKへの本番切り替えに向けて、30日間の保留期間を考慮すると、残り約3ヶ月となった現在のタイミングで自社のDNSサーバが auto 設定になっているかを確認しておくことを推奨します。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post OSSサポートの現場から!BIND / ルートKSKロールオーバーへの対応 first appeared on SIOS Tech Lab .
2018年6月の創刊から、毎月一歩ずつ歩みを進めて参りました「SIOS OSSよろずNews」のメルマガも、このたび「100号」という大きな節目を迎えることができました。長きにわたり、私たちの発信する情報に目を通し、温かいフィードバックをお寄せくださった読者の皆様に、心からの感謝を申し上げます。 100号記念企画として、皆さまの「現場で体験したOSSにまつわるエピソード」 を募集します。 これまで本メルマガでは、OSSに関する最新情報や技術動向をお届けしてまいりました。今回は少し趣向を変え、OSSを利用・運用する中で皆さまが経験された出来事や思い出をぜひお聞かせください。 「今では笑い話になった失敗談」「OSSに助けられた経験」「思わぬトラブルとの格闘」「コミュニティとの心温まる交流」など、どんなエピソードでも大歓迎です。印象に残った出来事をぜひお聞かせください。 投稿はこちら 過去のメルマガはこちら The post SIOS OSS よろず Newsメルマガ100号記念! first appeared on SIOS Tech Lab .
今号では、2026年 5月号でご紹介した Linux におけるターミナル操作に関する tips の続きをご紹介します! 2026年 5月号の記事は こちら カーソル移動のショートカットキーあれこれ [Ctrl] + [A] カーソルが行頭へ一気に移動します。 [Ctrl] + [E] カーソルが行末へ一気に移動します。 [Ctrl] + [U] カーソルより前の文字をすべて削除します。 [Ctrl] + [K] カーソルより後ろの文字をすべて削除します。 [Ctrl] + [K] カーソルより後ろの文字をすべて削除します。 [Alt] + [←] カーソルより 1単語分戻ります。 [Alt] + [→] カーソルより 1単語分進みます。 ※一部、過去の記事( 知っておくとちょっと便利!コマンド5選 ~ショートカットキー編~ )でご紹介した内容と重複しています。ご了承ください。 出力内容を自在に制御する小技 grep -v で不要な情報を出力しない grep で -v オプションを指定すると、指定した文字を “含まない” 行のみを出力します。 この機能を利用して、下記のように必要な情報だけを抽出するのに便利です。 設定ファイルのコメントアウト部分 (行頭に # が記載されている個所) を除外して検索 # less httpd.conf | grep -v "^#" アクセスログから、正常なログ (ステータスコードが 200) を除外して検索 # less /var/log/httpd/access_log | grep -v "status:200" less -S で出力の折り返しを防ぐ less で -S オプションを指定すると、画面の右端で出力を折り返さず、矢印キー (← →) でスクロールして読めるようになります。 特に長いログを表示した場合は、形が崩れずに読みやすさが向上します。 192.168.10.45 - - [06/Jul/2026:12:01:45 +0900] "GET /api/v1/products?category=> 10.0.2.15 - - [06/Jul/2026:12:02:10 +0900] "POST /api/v1/cart/checkout HTTP/1.> 192.168.11.102 - - [06/Jul/2026:12:03:02 +0900] "GET /assets/js/main.min.js?v=> 172.16.5.89 - - [06/Jul/2026:12:04:15 +0900] "GET /api/v1/user/profile HTTP/1.> 192.168.10.45 - - [06/Jul/2026:12:05:59 +0900] "GET /images/products/laptop_01> ※矢印キー (← →) で左右にスクロールします。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 1人がこの投稿は役に立ったと言っています。 The post 知っておくとちょっと便利!ターミナル操作の時短テクニック2 first appeared on SIOS Tech Lab .
こんにちは! 今月も「OSSのサポートエンジニアが気になった!OSSの最新ニュース」をお届けします。 2026/7/1、LPI-Japan は Linux サーバ構築の知識を学べる学習用教材「Linuxサーバー構築標準教科書」の Ubuntu版をリリースし、ホームページで公開しました。 LPI-Japan、「Linuxサーバー構築標準教科書」のUbuntu版を公開 https://cloud.watch.impress.co.jp/docs/news/2121848.html 2026/7/13、株式会社ニチレイは不正アクセスによるシステム障害が発生したと発表しました。 ニチレイロジグループ各社の冷蔵倉庫の入出庫業務、およびニチレイフーズの冷凍食品出荷業務に影響が出ているとのことです。 ニチレイ、不正アクセスによるシステム障害が発生。入出庫などの業務に影響 https://internet.watch.impress.co.jp/docs/news/2124795.html 2026/7/23~24、グランフロント大阪にて「事業変革を推進するための最新技術とつながる総合展」EdgeTech+ West 2026 が開催されます。 EdgeTech+ West https://www.jasa.or.jp/etwest/ ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post 【2026年7月】OSSサポートエンジニアが気になった!OSS最新ニュース first appeared on SIOS Tech Lab .