[{"data":1,"prerenderedAt":176},["ShallowReactive",2],{"tag-programming":3,"tag-programming-articles-1":13},{"contents":4,"totalCount":11,"offset":12,"limit":11},[5],{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},"8q8qyy8sz3","2025-04-29T08:44:23.406Z","2025-12-03T16:07:24.495Z","programming","プログラミング",1,0,{"contents":14,"totalCount":174,"offset":12,"limit":175},[15,36,63,75,85,94,111,122,133,143,153,165],{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":18,"revisedAt":18,"title":19,"content":20,"tags":21,"is_no_index":35},"0bs4f42te","2026-04-29T00:46:22.148Z","2026-04-29T01:01:31.544Z","向き不向きの前に、まず手を動かすべきでは","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>最近、というかここ数年、タイムラインを眺めていると、「プログラミングを始めました！」「LLMでアプリを作りました！」みたいな投稿を頻繁に目にするようになりました。それ自体はとても素晴らしいことだと思いますし、新しく何かを始めようとしている人を否定するつもりは毛頭ありません。\u003C/p>\u003Cp>ただ、その後の様子を眺めていると、どうにも釈然としない気持ちになることが多くあります。\u003C/p>\u003Cp>｢動きません｣｢エラーが出ます｣｢分かりません｣｢何もしていないのに壊れました｣、そういう質問が、携わるコミュニティで、Twitterで、Discordで、GitHubで、毎日のように流れてきます。それ自体は別に良いんです。分からないことを聞くのも大事だと思いますから。\u003C/p>\u003Cp>問題は、その質問に何の情報も含まれていないことや、そもそも自分の手元で起きていることを、自分で確認すらしていないことです。\u003C/p>\u003Cp>タイトルは少しばかり煽情的かもしれませんが、自分自身も普段から書いては消し、書いては消しを繰り返している身として、向いてないと切り捨ててしまう前に、もう少し言いたいことがあります。そんな話を、つらつらと書き散らしていきたいなと。\u003C/p>\u003Ch1 id=\"h833a42ac8c\">エラーメッセージは目の前にあるのに何故か読まれません\u003C/h1>\u003Cp>世の中には、エラーが出た瞬間に、画面の前で固まる方々が一定数いらっしゃいます。\u003C/p>\u003Cp>固まったまま、5分、10分、酷い時には30分。エラーメッセージは画面の真ん中にずっと表示されているのですが、その本文を読んでいる気配がありません。読んでいたら、その後の口から｢分かりません｣という言葉が出てくるはずがないからです。\u003C/p>\u003Cp>\u003Ccode>Connection refused\u003C/code>, \u003Ccode>Permission denied\u003C/code>, \u003Ccode>Module not found\u003C/code>, \u003Ccode>Unexpected token\u003C/code>.\u003C/p>\u003Cp>これらは何かの呪文ではなく、コンピュータがあなたに対して、丁寧に問題を教えてくれている、とてもありがたい案内です。\u003C/p>\u003Cp>エラーメッセージを読むというのは、別に難しいことではありません。\u003Ccode>Connection refused 127.0.0.1:5432\u003C/code>と書いてあれば、｢ローカルの5432ポート宛接続が拒否された｣と読めばいいだけですし、5432はPostgreSQLのデフォルトポートなのでPostgreSQLが起動していないか、接続情報が間違っているか、そのあたりに当たりがつきます。1分もあれば原因に辿り着ける問題に何を無駄な時間を費やしているのでしょう。\u003C/p>\u003Cp>｢英語が読めません｣と言う方もいらっしゃいますが、refusedをリフューズドと読み下せれば、それだけで意味は推測できますし、どうしても分からなければ翻訳ツールに通せば済む話です。あなたが普段スマホで観ている海外動画の字幕、あれだって機械翻訳でしょ？同じです。\u003C/p>\u003Cp>そして、信じられないかもしれませんが、エラーメッセージを読まない方々は、何度プログラムを実行しても出続けるエラーに対して、毎回最初から驚きます。｢またこのエラーが出ました｣と言うのですが、そら当然です。書かれていることに対処しない限り、何回実行しても同じエラーが出ますし、コンピュータは、あなたの祈りとかには反応しません。中身を変えず同一の試行を繰り返した挙句、違う結果を望むのはとても愚かなことです。小学生ですらやりません。\u003C/p>\u003Cp>エラーが出るというのは本来ありがたいことです。間違っている箇所を教えてくれるものですから、感謝しながら読んで直せば済みます。逆に、エラーも出ずに動いている、けれど結果が間違っている、という状況の方が、遥かに恐ろしく感じます。エラーは敵ではありません。\u003C/p>\u003Ch1 id=\"h7437e44bed\">｢こう動くはずなんですけど｣って、それあなたの感想ですよね\u003C/h1>\u003Cp>これも本当によく見かけるパターンで、本当に心底嫌いです。\u003C/p>\u003Cp>曰く、｢私は、ここはこういう風に動くはずだと思ってるんですけど、動かないんですよ｣と。\u003C/p>\u003Cp>そうですか。それで、あなたの思いと実際の挙動、どちらが正しいと思います？\u003C/p>\u003Cp>答えは決まっています。動いていないのなら、あなたの思い10割10分が間違っています。\u003C/p>\u003Cp>ところが、自分の認識を譲らない方が一定数居るようで、｢いや、ここはこう動くはずなんですよ｣だとか、｢でも、こういう風に書いたんですから、こう動くはずじゃないですか｣みたいなことを平然と言ってのけます。すごい自信家ですよね、感心します。でも実は違うんです。あなたの認識と、コンピュータの実際の挙動がズレているんです。修正すべきは、コードでも、ライブラリでも、コンピュータでもなく、傲慢なあなた自身です。\u003C/p>\u003Cp>思いをベースに議論しても、コンピュータは1nmも動きませんし、コードを動かすのは、そこに書かれている事実です。実際に出力された値、実際に走った行、実際に発生したエラー等々。それらだけが、原因の手がかりであって、あなたの思いは、原因の特定に役立ちません。\u003C/p>\u003Cp>デバッグの第一歩は、自分の思いを一旦脇に置くことでしょう。｢ここは絶対こう動いているはず｣と思い込んでいる箇所こそ、\u003Ccode>print\u003C/code>でも \u003Ccode>console.log\u003C/code> でも \u003Ccode>dbg!\u003C/code> でも構わないので、実際の値を出力して確認してください。殆どの場合、あなたの思い込みは外れています。本当に、本当に、外れています。騙されたと思って私を信じてください。\u003C/p>\u003Cp>ちなみに、デバッガを使ったことがない方も、かなりいらっしゃるようですが、VSCodeでもJetBrains系IDEでもブラウザのDevToolsでも、ブレークポイントを置いて、ステップ実行して、変数の中身を覗く、ということができます。これを覚えるだけで、デバッグの効率が劇的に変わります。printデバッグも悪くはないのですが、ちゃんとしたデバッガを使えるようになっておくと、人生の何割かを取り戻せます。暇なら覚えておきましょう。\u003C/p>\u003Ch1 id=\"h471a83884c\">詰まったら考えるより先に手を動かそう\u003C/h1>\u003Cp>エラーメッセージを読みました。それでも原因が分かりません。さて、どうしましょうか。\u003C/p>\u003Cp>ここで多くの方が、画面の前で固まる作業に戻ります。けれど、それでは何も解決しません。考え込んでも、コンピュータは何も教えてくれません。\u003C/p>\u003Cp>ここでやるべき事は、手を動かして状況を観測することだけです。\u003C/p>\u003Cp>怪しい箇所の値を出力する。リクエストの中身をダンプする。条件分岐の手前で実行を止めて状態を覗く。データベースに直接クエリを投げて状態を確認する、みたいな。動いているはずと思っている部分が、本当に思った通りに動いているか、ひとつずつ確かめていく必要があります。\u003C/p>\u003Cp>考えてから手を動かすのではなく、手を動かしながら考えるべきです。これは、おそらくベテランの方も全く同じことをしています。違うのは、観測の精度と、観測する場所を絞り込む速度だけです。経験を積むと最初に怪しいと睨んだ箇所が当たる確率が上がっていく、というだけの話に思えます。\u003C/p>\u003Cp>さて、｢闇雲に試して動いたら、それで本当に直ったと言えるんですか？｣と言われてそうです。良い指摘です。実際、闇雲に値を変えていたら偶然動いた、というケースは、本質的な問題の解決には至っていません。\u003C/p>\u003Cp>しかし、観測した結果から｢ここの値が想定と違っていた｣と特定できれば、それは立派な原因究明です。観測のないトライアンドエラーは博打ですが、観測のあるそれは、ちゃんとしたデバッグであると考えます。\u003C/p>\u003Cp>考え込まないでください。手を動かしてください。コードは、あなたの思考の中ではなく、メモリ上で実際に動いています。\u003C/p>\u003Ch1 id=\"hd508ed06f1\">｢LLMに聞いたら違うことを言ってきました｣\u003C/h1>\u003Cp>そんな事を言うくらいなら、はなから私なんかに聞かずとも、永遠に一人寂しくLLMとイチャついてればいいと思うのですが、最近特に増えたのがこのパターンです。\u003C/p>\u003Cp>｢LLMに聞いたらこう書けばいいって言われたんですけど、動きません｣\u003C/p>\u003Cp>そうですか？うんうん、すごいでちゅね〜！それで、あなたはその実装を読みました？\u003C/p>\u003Cp>LLMが生成したコードは、それなりに動きます。それなりに、です。ライブラリに破壊的な変更があれば動きませんし、APIが廃止されていれば動きません。そもそもLLMの学習データに無かったマイナーなライブラリだと、平気でハルシネーションを起こします。実在しない関数を、自信満々で提案してきたりしやがります。\u003C/p>\u003Cp>それを確認もせずにコピペして、挙句、動かなかったら｢LLMが嘘をついたんだ！！！！｣と憤慨するのは、些か筋違いです。\u003C/p>\u003Cp>LLMは便利なツールですが、便利なツールを使うために必要な能力は、依然として人間側に求められています。これは、Stack Overflowが流行った時も、Qiitaが流行った時も、ChatGPTが流行った時も、Clineに全部賭けたときも、Cursorが流行った時も、ずっと変わっていません。\u003C/p>\u003Cp>LLMに頼ること自体は何も悪くありませんし、私も普段からLLMにかなりの出力を強要しています。GeminiにもOpusにも毎日お世話になっています。ただ、LLMが出力したコードは必ず自分で読みます。読んで理解して、おかしいところがあれば修正します。これをやらない人間がLLMを使うと、ただのコピペ職人が爆誕します。それも、自分が何をコピペしているかすら分からない、史上最悪のコピペ職人が、です。\u003C/p>\u003Cp>ちなみに、LLMに｢このコード動きません｣と投げて、LLMが返してきた修正をまたコピペして、それでも動かない、というループに陥っている方も散見されます。それを何時間も繰り返した挙句、LLMはダメだと結論付ける。残念ながら、ダメなのはLLMではなく、あなたの頭です。\u003C/p>\u003Cp>LLMはあなたの状況を完全には理解していません。あなたが理解した上で、適切に指示を出す必要があります。\u003C/p>\u003Cp>以前にも別の記事で書きましたが、LLMがどれだけ優秀になっても、使う人間の側に何もなければ、出力される成果物も同様のものでしょう。\u003C/p>\u003Ch1 id=\"h5cbe4915a0\">自分が何をしたいのか整理して\u003C/h1>\u003Cp>これも本当によく言われます。やめてくださいね。\u003C/p>\u003Cp>｢○○がしたいんですけど、どうすればいいです？｣\u003C/p>\u003Cp>その○○が、抽象的すぎてなんと言うかものすごくふわっとしている。\u003C/p>\u003Cp>｢Webサイトを作りたいんだけど〜｣\u003C/p>\u003Cp>どんなサイトを？静的サイトですか、動的サイトですか？認証認可は必要ですか？どんなコンテンツを載せたいですか？利用者は何人を想定していますか？運用のコスト感は？収益化したいですか、趣味ですか？\u003C/p>\u003Cp>これらはあなたが答えるべき質問です。わたしが答える質問ではありません。\u003C/p>\u003Cp>目的を整理するというのは、プログラミング以前のごく当たり前の力です。これができないと、コードを書く以前に何を書けばいいかすら定まりません。そんなんじゃお話になりません。\u003C/p>\u003Cp>これはエンジニアリングの問題ではなく、もっと手前の自分の思考を整理して言語化する国語の問題です。プログラミングは思考を厳密にコンピュータが理解できる形に翻訳する作業ですから、思考が曖昧なままではそもそもコードに翻訳することすらままならないことは自明でしょう。\u003C/p>\u003Cp>紙でもメモアプリでもLLMでも構わないので、自分が何をしたいのかを書き出してください。やりたいこと、やるべきではないこと、分かっていること、分からないこと等々。整理されていない要望をぶつけられても、こちらは何も答えようがありません。\u003C/p>\u003Cp>そして、もうひとつ。課題文を読めない方も本当に多いです。\u003C/p>\u003Cp>例えば、業務でちょっとしたツールを依頼する場面で、｢Slackに来たメッセージのうち、特定のキーワードを含むものをスプレッドシートに記録してください｣と伝えたとします。これは、｢Slackからメッセージを受け取る｣、｢キーワードでフィルタする｣、｢スプレッドシートに書き込む｣の3つの工程に分解できる、ごく単純な依頼です。\u003C/p>\u003Cp>ところが、これが分解できない方は、依頼文を一塊のまま受け取って、最初の一歩で固まります。｢何から手を付ければいいんでしょうか｣と相談されるのですが、お渡しした依頼文には既に手を付ける順番が書いてあります。一文として読むのではなく、要素ごとに区切って読んでください。それだけで、何をすればいいかが見えてきます。\u003C/p>\u003Cp>これはもうプログラミング以前の、国語の問題です。\u003C/p>\u003Cp>それすらできないようであれば、小学生のお勉強からやり直すことを推奨します。\u003C/p>\u003Ch1 id=\"h0d45d9ccaf\">大きい問題に挑む前に小さい問題に分割して\u003C/h1>\u003Cp>｢○○を作ってください｣という課題が出たとします。例えば、社内向けのちょっとしたダッシュボード、みたいな。\u003C/p>\u003Cp>ここで、できない方は、いきなり全部を一気に書こうとして、数時間悩んだ末に画面の前で固まる作業に戻ります。｢データ取得をどう書いて、それをどう加工して、どうUIに表示して、認証はどうして、エラー処理はどうして…｣と、全てを同時に考えようとして、結局1行も書けないのです。\u003C/p>\u003Cp>わたしならこう書きます。\u003C/p>\u003Cpre>\u003Ccode class=\"language-csharp\">var dashboard = new Dashboard();\nvar data = await dashboard.FetchDataAsync();\ndashboard.Render(data);\u003C/code>\u003C/pre>\u003Cp>3行、終わり。これでダッシュボードが完成です。\u003C/p>\u003Cp>「いや、それじゃ動かないじゃないですか」と思われるかもしれません。その通りです。動きません。\u003Ccode>Dashboard\u003C/code> とかいう存在しない抽象クラスを呼んでいるだけですから、当然です。\u003C/p>\u003Cp>しかし、これで全体像は捉えられます。あとは適当に\u003Ccode>Dashboard\u003C/code>クラスと\u003Ccode>FetchDataAsync\u003C/code>、\u003Ccode>Render\u003C/code>の中身をそれぞれ実装するだけです。中身もいきなり全部書こうとせず、同じように存在しないメソッドを呼んでおいて後から埋めていきます。これを繰り返していると、いつの間にか動くものが出来上がっています。\u003C/p>\u003Cp>トップダウン設計と呼ばれたりもしますが、名前はどうでも良くて、要するに、自分が一度に考えられる範囲まで、問題を細かく細分化するということです。難しい問題を、難しいまま扱える人は、ごく一部の天才だけであって、私を含む殆どの無能は、問題を1ファイルに1,000行で書ける程度の小ささに分解しないと解けません。\u003C/p>\u003Cp>勉強すれば難しい問題も解けるようになると期待されている方もいらっしゃるかもしれません。残念ながら、勉強しても難しい問題は難しいままです。賢くなるのではなく、問題を小さくする技術を身につけるべきです。これは才能の話ではなく、訓練の話なので、正直誰でも身につけられるかと思います。\u003C/p>\u003Ch1 id=\"h89f6c9455f\">動的型付け言語を選ぶ前に考え直しませんか\u003C/h1>\u003Cp>ここから少し、私の個人的な好みが強く入ります。先に断っておきますね。\u003C/p>\u003Cp>私は動的型付け言語が好きではありません。PythonもRubyもJavaScriptも、業務では書きますが、好きでは無いです。理由は単純で、書いた本人が何を扱っているか分からないコードを、後から他人が読んで分かるはずがないためです。\u003C/p>\u003Cp>巷では｢Pythonは書きやすい｣だとか、｢JavaScriptは手軽｣だとか、馬鹿の一つ覚えのように永遠と言われています。確かに、書き始めるまでのハードルは低いでしょう。しかし、それは短期的には楽であるというだけの話であって、長期的には負債が積み上がります。\u003C/p>\u003Cp>引数が何の型か書かれていない関数。返り値が状況によって変わる関数。エラー時に\u003Ccode>None\u003C/code>を返したり、空文字を返したり、例外を投げたりする一貫性のない設計。これらは、動的型付け言語特有の問題ではありませんが、動的型付けの環境では検出されないまま放置されやすい傾向にあります。\u003C/p>\u003Cp>そして、そういうコードを書いた本人は、3ヶ月後には何ひとつ覚えていません。私にも前科があります。\u003C/p>\u003Cp>これからプログラミングを始めるのであれば、最初から型のある言語を選んでください。GoでもRustでもKotlinでもSwiftでも、TypeScriptでも、好きなものを選べば良いと思います。動的型付け言語から始めて後から型に移行するのは苦痛でしょうし、最初から型に親しんだ方が遥かに早いかと思われます。\u003C/p>\u003Ch1 id=\"ha2c9bbc147\">質問とか\u003C/h1>\u003Cp>環境, やりたかったこと, やったこと, 起こったこと, 試したこと, 仮説\u003C/p>\u003Cp>このあたりが揃っていれば、回答する側は、スムーズに原因を絞り込めます。逆に、これらが何ひとつ揃っていない｢動きません｣だけの質問は、回答できません。回答できないというより、回答する前の調査だけで時間が溶けて、結果として誰も答えなくなります。私ならムカついて返信しないこともあるかもしれません。\u003C/p>\u003Cp>｢初心者なので〜｣と前置きする方もいますが、初心者かどうかは関係ありません。初心者であろうと、そうでなかろうと、項目を埋めることはできますし、初心者ほど丁寧に書くべきです。慣れている人なら省略して伝わることも、初心者の場合は丁寧に書かないと伝わらないためです。\u003C/p>\u003Cp>これは偏見ですが、質問が下手な人は概ね実装も汚いです。例外もあるかもしれませんが、私の観測範囲ではほぼ相関しているように思います。質問を組み立てることは、(本質的に)状況を分解して構造化する作業であって、それがプログラミングそのものであるためです。質問が組み立てられないということは、思考が組み立てられないということと同義で、思考が組み立てられなければ、コードも書けないでしょう。当たり前です。\u003C/p>\u003Ch1 id=\"h4eac98b528\">｢ググれ｣が冷たく聞こえるのなら申し訳ないですが\u003C/h1>\u003Cp>わたしは以前、ggrksは最大限の優しさだと書きました。今でも、その考えは変わっていません。\u003C/p>\u003Cp>技術的な質問に対して｢自分で調べてね〜｣と返すのは、決して冷たいわけではありません。\u003C/p>\u003Cp>\u003Cs>答えるのが面倒くさい訳ではないと言ってしまうと大嘘になるのですが、\u003C/s>｢あなたが自分で調べれば、もっと正確で網羅的な情報が手に入りますよ｣の意であり、｢私が中途半端に答えるよりも、検索した方が確実ですよ｣という意味であり、｢自身で調べる力を身につけた方がしあわせになれますよ｣という意味でもあります。\u003C/p>\u003Cp>これを冷たいと感じる方は、自分が答えを与えてもらえる立場だと思い上がっているのかもしれません。しかし、私はあなたの家庭教師でも、メンターでも、サポート窓口でもありません。私は\u003Cs>とても優しいので\u003C/s>聞かれたことにはなるべく答えるようにしていますが、それを当然のように要求されるのは、少し違うように思います。\u003C/p>\u003Cp>ついでに、ググる行為自体にも割とスキルが要ります。｢ECONNREFUSEDが出たんだけどどう治すの？｣みたいな雑な自然言語ではなく、｢ECONNREFUSED 127.0.0.1:5432 postgres:18.3-alpine｣のように、自分の環境に近い具体的なワードを並べる、英語で検索する、公式ドキュメントを優先する、GitHubのIssuesを見る、Stack Overflowは新しい順に並び替える等の工夫が必要です。\u003C/p>\u003Cp>ググれと言われて冷たいと感じる前に、自分のググり方を一度見直してみてください。それだけで、解決できる問題の幅が多分広がるかと思います。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>正直、プログラミングなんてものはどんなに馬鹿でも時間さえかければできるようになります。\u003C/p>\u003Cp>これは、別に綺麗事として書いているのではなく、私自身がソースです。\u003C/p>\u003Cp>私自身、地頭(笑)が良いタイプでも、論理的思考力が突出しているタイプでもなく、どちらかと言えば落ちこぼれ側の人間です。覚えたての頃に書いていたコードなんて、今読み返すと冗談みたいに酷いものですし、未だに簡単なバグで数時間詰まることもざらにあります。\u003C/p>\u003Cp>何とかコードを書いて、何とかお仕事を頂いて、何とか運用できているのは、ただただ触っている時間がそれなりに長いからというだけでしょう。そこに特筆すべき才能なんてものは介在していません。\u003C/p>\u003Cp>なので、自分には才能がないからという理由で諦めるのは、的外れだと思っています。あなたに無いのは才能ではなく、ただの時間です。退屈な作業を、何百時間、何千時間と積み上げる時間が足りていないだけです。\u003C/p>\u003Cp>逆に言えば、これを積み上げる気が無いのなら、永遠にできるようにはなりませんし、｢いつか分かるようになる｣だとか、｢コツを掴めば｣みたいな、そういう商材屋の好きそうな胡散臭い魔法はありません。触った分だけ出来るようになります。\u003C/p>\u003Cp>それが面倒だと感じるのであれば、それは多分、あなたがプログラミングをそこまで好きではない、というだけの話です。それは別に悪いことではありません。世の中には、プログラミング以外にも、楽しい営みがいくらでもあります。プログラミングが特別な何かだと思い込むのは、業界の人間の自意識過剰でしょう。\u003C/p>\u003Cp>何とかしたいと思うのであれば、何とかしてください。何とかしたくないのであれば、別に何ともしなくて構いません。\u003C/p>\u003Cp>埋蔵金探しとかも新鮮で楽しそうですよ。\u003C/p>\u003Cp>それでは。\u003C/p>",[22,28,29],{"id":23,"createdAt":24,"updatedAt":25,"publishedAt":24,"revisedAt":25,"slug":26,"name":27},"wia4slp55q","2025-04-28T07:14:14.541Z","2025-12-03T16:08:13.798Z","thoughts","独り言",{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":30,"createdAt":31,"updatedAt":32,"publishedAt":31,"revisedAt":32,"slug":33,"name":34},"qfey3yw0z1","2025-04-29T08:44:41.687Z","2025-12-03T16:07:14.622Z","technology","テクノロジー",false,{"id":37,"createdAt":38,"updatedAt":39,"publishedAt":39,"revisedAt":39,"title":40,"content":41,"tags":42,"is_no_index":35},"u6nouw78z6k","2026-03-27T09:42:30.473Z","2026-03-27T10:05:58.634Z","Mewkの認証のおはなし","\u003Ch1 style=\"text-align: start\" id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp style=\"text-align: start\">どうも、わたしです。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://mq1.dev/entry/ql0n4uqo0fo\">前回の記事\u003C/a>では、Mewkのアーキテクチャやインフラ構成、OGP画像生成、モデレーションまわりの話を書きました。「まあ自分の記録として残しておければいいか」くらいの温度感で書いたもので、読んでくれる人がいるだけで御の字だと思っていましたが、思っていたより反応をもらえました。個人開発者が書く技術記事なんて、よほどのことがない限り誰にも読まれないのが常ですし、多くはなかったですが、それでも自分の想定を超えていたのは素直に嬉しかったです。\u003C/p>\u003Cp style=\"text-align: start\">その中で、認証まわりの話をもう少し詳しく聞きたいという声を何人かからいただきました。前回の記事でMiAuthトークンの暗号化やNuxt側へのロジック集約について軽く触れていたのが引っかかった方がいたようで、続きを書いてほしいというリクエストをもらいました。書くきっかけをもらえたので、今回はその認証基盤に絞って書くことにします。\u003C/p>\u003Cp style=\"text-align: start\">認証まわりは地味です。ユーザから見えるものではないし、うまく動いていても誰も褒めてくれません。しかし、サービスを真っ当に運営するためには、ある程度は考えなければいけない部分です。\u003C/p>\u003Cp style=\"text-align: start\">書いてみると、思いのほか細かい判断が積み重なっていて、自分でも整理になりました。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h6f17e1addd\">MiAuthトークンを直接扱いたくなかった\u003C/h1>\u003Cp style=\"text-align: start\">MewkはMiAuthでユーザを認証します。詳細は省きますが、MiAuthでは一連の認証フローを完了すると、Misskeyからアクセストークンを取得することができます。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://misskey-hub.net/ja/docs/for-developers/api/token/miauth/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">https://misskey-hub.net/ja/docs/for-developers/api/token/miauth/\u003C/a>\u003C/p>\u003Cp style=\"text-align: start\">ここで最初に決めたのが、このアクセストークンをクライアントに一切渡さないという方針です。\u003C/p>\u003Cp style=\"text-align: start\">Misskey向けのアプリケーションでよく見かける実装として、MiAuthで取得したアクセストークンをそのままSPAに持たせ、APIリクエストのたびにバックエンド側で検証するというパターンがあります。実装としては確かにシンプルです。しかしながら、この設計には問題があると私は考えています。\u003C/p>\u003Cp style=\"text-align: start\">Misskeyのアクセストークンを「サービス側が発行・管理しているもの」として扱っていない点です。サービス側でこのトークンを失効させる手段がなく、ユーザが自らMisskeyの設定画面から認可を取り消す以外に無効化できません。もし何らかの経緯でトークンが漏洩した場合、サービスとしては当然何もできません。サービス側でのRevoke手段を持たない認証設計は、失効対応が必要な場面で致命的になると考えられます。\u003C/p>\u003Cp style=\"text-align: start\">加えて、多くのユーザはアクセストークンが何であるかを理解していないという現実もあります。MewkがMiAuthで要求する権限スコープは\u003Ccode>read:account\u003C/code>, \u003Ccode>write:notes\u003C/code>, \u003Ccode>write:notifications\u003C/code>, \u003Ccode>read:drive\u003C/code>, \u003Ccode>write:drive\u003C/code>の5つで、これだけあればノートの投稿やドライブへのアクセスといった、Mewkが必要とする範囲の操作は全てできます。そして当然ながら、それ以外にもかなり色々なことができてしまいます。そういうトークンをクライアントに持たせ、ユーザに気づかれないまま扱う設計は、とても設計として筋が悪い。\u003C/p>\u003Cp style=\"text-align: start\">そこで、MiAuthフローが完了した時点でバックエンド側のみでトークンを受け取り、Mewkが独自に発行したJWTアクセストークンとリフレッシュトークンをクライアントに返すという構成にしました。Misskeyトークンはバックエンド内に閉じ込め、クライアントはMewkのJWTだけを使う。リフレッシュトークンをDBで管理することで、サービス側からいつでも全セッションを無効化できます。sidebaseのLocal実装をそのまま使いつつ、肝心な部分は全てバックエンド側で握る感じです。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/api/v1/auth/miauth/callback.post.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">\n// MiAuthフロー完了時点でMisskeyからトークンを受け取る\nconst response = await $fetch&lt;{ ok: boolean, token?: string, user?: MisskeyUser }&gt;(checkUrl, {\n  method: &apos;POST&apos;,\n});\n\n// Misskeyトークンは暗号化してDBに保存\nconst encryptedAccessToken = encryptMiAuthToken(response.token);\nconst user = await prisma.users.upsert({ ... });\n\n// Mewk独自のJWTとリフレッシュトークンを発行してクライアントへ\nconst jwt = await signJWT({ userId: user.id });\nconst refreshToken = await generateRefreshToken(user.id);\n\nsetSessionCookies(event, { accessToken: jwt, refreshToken });\n\nreturn { token: jwt, refreshToken, ... };\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">JWTはHS256アルゴリズム、有効期限1時間の短寿命トークンです。\u003Ccode>jose\u003C/code>ライブラリを使ってサーバ側で署名・検証しており、issuer/audienceのクレームも設定しています。\u003C/p>\u003Cp style=\"text-align: start\">勘の良い方ならここで一つ疑問が生まれるかもしれません。\u003C/p>\u003Cp style=\"text-align: start\">JWTはステートレスであるという前提なのに、「サービス側からいつでも全セッションを無効化できる」と言えるのはなぜか、という話です。\u003C/p>\u003Cp style=\"text-align: start\">基本的に、JWTの検証はDBを必要としません。署名が正しく、有効期限内であれば、それだけで有効なトークンとして扱われます。つまり、一度発行したJWTをサーバ側から即座に無効化する方法は、仕様上原則として存在しません。ブロックリストをDBやKVに持たせてJWT検証のたびにチェックするという実装も可能ですが、そうするとリクエストごとにストア参照が発生し、ステートレスであるJWT本来の旨味が半減してしまいます。\u003C/p>\u003Cp style=\"text-align: start\">Mewkでは、この問題をJWTの有効期限を短く保つことで許容しています。アクセストークンの有効期限は1時間です。ユーザのログアウトや全セッション無効化(モデレーションに基づく利用制限、Misskeyトークン失効検知など)の操作は、JWTではなくリフレッシュトークンをDB上でrevokeすることで実現します。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/refreshToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">// ログアウト\nexport async function revokeRefreshToken(token: string): Promise&lt;void&gt; {\n  const tokenHash = hashToken(token);\n  await prisma.refreshToken.updateMany({\n    where: { tokenHash, revokedAt: null },\n    data: { revokedAt: new Date(0) },\n  });\n}\n\n// 全セッション無効化\nexport async function revokeAllUserRefreshTokens(userId: string): Promise&lt;void&gt; {\n  await prisma.refreshToken.updateMany({\n    where: { userId, revokedAt: null },\n    data: { revokedAt: new Date(0) },\n  });\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">リフレッシュトークンを失効させれば、次のトークン更新のタイミングでセッションが復元できなくなり、実質的にログアウトが完了します。言い換えると、即時の無効化ではなく無操作時で最長1時間(実質10-30分程度)の猶予ウィンドウを持つ無効化という設計です。\u003C/p>\u003Cp style=\"text-align: start\">1時間の猶予はトレードオフの結果です。ブロックリスト方式にすれば即時無効化が可能ですが、前述の通り、全APIリクエストにDB/KVアクセスが加わります。Cloudflare Workers上でエッジのレイテンシを活かしたいという方針と、通常のユーザ体験において最長1時間の猶予が実害になるケースは(おそらく)ほぼないという判断から、今の設計に落ち着いています。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h8594e013d3\">httpOnly Cookie\u003C/h1>\u003Cp style=\"text-align: start\">当初はトークンの管理をhttpOnly属性付きのCookieで統一しようとしていました。ただ、これについては少し補足が必要です。\u003C/p>\u003Cp style=\"text-align: start\">httpOnly Cookieに対してよく言われる「JavaScriptから読めないのでXSSに強い」というのは、正確ではあるけれど文脈を省きすぎた主張だとわたしは思っています。XSSが成立した時点で、攻撃者は\u003Ccode>credentials: &apos;include&apos;\u003C/code>を付けたfetchリクエストを送るだけで、httpOnly Cookieをそのまま乗せた状態で同一オリジンに任意のAPIリクエストを投げられます。「JavaScriptからCookieの値が読めない」と「Cookieが悪用できない」は全く別の話で、前者が達成されていても後者は保証されません。XSSが実現した時点でできることはいくらでもありますし、少なくとも私は悪いことを思いついてしまいます。\u003C/p>\u003Cp style=\"text-align: start\">正しい理解は、セキュリティは多層防御の文脈に依存するものであり、httpOnly Cookieはその内の一層に過ぎないということです。localStorageにトークンを保管するよりはhttpOnly Cookieの方が攻撃面が狭い、という程度の話であって、httpOnly Cookieさえ使えば安全というわけではありません。XSSが刺さった時点で無意味になるのはどちらも同じで、根本的な対策はXSSを作り込まないことです。\u003C/p>\u003Cp style=\"text-align: start\">こういう誤解を招きやすい主張が広まっているせいで、httpOnly Cookieを使えば安全という誤った安心感を持つ開発者が少なくありません。まあ、それはさておき。\u003C/p>\u003Cp style=\"text-align: start\">いずれにせよ、全てのトークンをhttpOnly Cookieで管理するという方針はsidebaseのLocal provider実装との相性問題で断念することになりました。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://github.com/sidebase/nuxt-auth/blob/33873aa356abdd2d52ab1b6b60930fa09005ef72/src/runtime/composables/local/useAuthState.ts#L30-L114\">https://github.com/sidebase/nuxt-auth/blob/33873aa356abdd2d52ab1b6b60930fa09005ef72/src/runtime/composables/local/useAuthState.ts#L30-L114\u003C/a>\u003C/p>\u003Cp style=\"text-align: start\">sidebaseのLocal providerはアクセストークンとリフレッシュトークンをそれぞれ\u003Ccode>useCookie()\u003C/code>で読み書きしており、\u003Ccode>useAuth().refreshToken\u003C/code>のようにComposable経由でJavaScriptから値にアクセスできる設計になっています。httpOnly属性を付けてしまうと\u003Ccode>useCookie()\u003C/code>でCookieの値が取得できなくなるため、リフレッシュトークンのローテーションが機能しなくなってしまいます。\u003C/p>\u003Cp style=\"text-align: start\">微妙だとは思うのですが、ここでは許容することとしています。\u003C/p>\u003Cp style=\"text-align: start\">また、現在の構成では、サーバ側のCookie操作(\u003Ccode>setSessionCookies\u003C/code>)でsidebaseのコンフィグ(Cookie名・maxAge・secure属性等)をそのまま引き継ぎ、サーバとクライアントで同じCookie設定が使われることを保証しています。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/auth/sessionCookies.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">function getLocalProviderConfig(): LocalProviderConfig {\n  const provider = useRuntimeConfig().public.auth.provider as LocalProviderConfig;\n  if (provider.type !== &apos;local&apos;) throw new Error(&apos;Local auth provider is required&apos;);\n  return provider;\n}\n\nfunction buildSidebaseCookieOptions(config: LocalCookieConfig): CookieSerializeOptions {\n  return {\n    path: &apos;/&apos;,\n    maxAge: config.maxAgeInSeconds,\n    sameSite: config.sameSiteAttribute,\n    secure: config.secureCookieAttribute,\n    domain: normalizeDomain(config.cookieDomain),\n    httpOnly: config.httpOnlyCookieAttribute,\n  };\n}\n\nexport function setSessionCookies(event: H3Event, session: { accessToken: string; refreshToken?: string | null }): void {\n  const provider = getLocalProviderConfig();\n  setCookie(event, provider.token.cookieName, session.accessToken, buildSidebaseCookieOptions(provider.token));\n  // ...\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">Cookieの属性は一元管理しているため、バックエンド側でCookieを書く際も同じ設定が自動的に適用されます。httpOnly属性で全てを閉じる設計にはできませんでしたが、冒頭に書いた通りそれが全ての解決策にはならないことも事実で、最終的な妥協点としては許容できる範囲であると考えます。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"ha9d0cf4895\">MiAuthトークン\u003C/h1>\u003Cp style=\"text-align: start\">前回の記事でも少しだけ言及しましたが、MisskeyのアクセストークンをそのままDBに平文で保存するのは論外です。万が一DBの内容が流出した場合、全ユーザのMisskeyアカウントに対して任意の操作が可能になってしまいます。\u003C/p>\u003Cp style=\"text-align: start\">そこで、MiAuthで取得したトークンはAES-256-GCMで暗号化してDBに保存しています。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/bba3194de09d4fbba244eb6664d20264/image.png\" alt=\"\" width=\"953\" height=\"84\">\u003C/figure>\u003Cdiv data-filename=\"./packages/app/server/utils/miauthToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">const ALGORITHM = &apos;aes-256-gcm&apos;;\nconst IV_LENGTH = 12;\nconst TAG_LENGTH = 16;\nconst VERSION_PREFIX = &apos;mewk-miauth:v1&apos;;\n\nexport function encryptMiAuthToken(token: string): string {\n  const key = getEncryptionKey();\n  const iv = randomBytes(IV_LENGTH);\n  const cipher = createCipheriv(ALGORITHM, key, iv);\n  const encrypted = Buffer.concat([cipher.update(token, &apos;utf8&apos;), cipher.final()]);\n  const tag = cipher.getAuthTag();\n\n  return [VERSION_PREFIX, toBase64Url(iv), toBase64Url(tag), toBase64Url(encrypted)].join(&apos;:&apos;);\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">暗号化されたトークンは\u003Ccode>mewk-miauth:v1:&lt;iv&gt;:&lt;tag&gt;:&lt;ciphertext&gt;\u003C/code>という形式で保存されます。GCMモードを採用しているのは認証付き暗号(AEAD)であるためで、改ざん検知が組み込まれています。ivはリクエストごとに\u003Ccode>randomBytes\u003C/code>で生成するため、同じトークンを暗号化しても毎回異なる暗号文になります。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ccode>mewk-miauth:v1\u003C/code>というPrefixを付けているのは後方互換性のためです。将来的にアルゴリズムや鍵長を変える必要が生じた場合、Prefixのバージョンで判別して適切な復号ロジックに分岐させることを想定しています。AES-256-GCMのまま運用し続けても問題はないですが、暗号プリミティブも長い目で見れば更新が必要になるでしょうし、バージョンを明示する習慣をつけておくだけで、後の改修がだいぶ楽になるはずです。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/miauthToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export function isEncryptedMiAuthToken(value: string): boolean {\n  return value.startsWith(`${VERSION_PREFIX}:`);\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">復号は\u003Ccode>resolveStoredMiAuthToken()\u003C/code>という関数にまとめており、DBから取得したトークンが暗号化済みかどうかをPrefixで判別してから復号します。暗号化済みでない場合はその旨を記録して処理を継続します。将来的にバージョンを上げる際も、このPrefix判定を拡張するだけで移行ロジックを組めるはずです。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h84ff7cb54a\">リフレッシュの重複\u003C/h1>\u003Cp style=\"text-align: start\">アクセストークンの有効期限は1時間です。これを自動で延長するために、sidebaseのセッションリフレッシュ機能を使っています。\u003C/p>\u003Cp style=\"text-align: start\">ここで地味に厄介なのが、複数タブで同時にリフレッシュが走る可能性です。\u003C/p>\u003Cp style=\"text-align: start\">ユーザが同じアカウントで複数タブを開いている状態でリロードしたり、アクセストークンが期限切れになると、各タブが独立してリフレッシュリクエストを飛ばします。リフレッシュのたびにリフレッシュトークンをローテーションしているため、最初のリクエストが成功して旧トークンが無効化された直後に、別タブの遅延リクエストが同じ旧トークンで来ると弾かれてしまい、該当するタブに引き摺られるようにログアウトさせられることになります。\u003C/p>\u003Cp style=\"text-align: start\">この問題に対して、二段構えで対応しています。\u003C/p>\u003Ch2 style=\"text-align: start\" id=\"h3336b53f2d\">クライアント\u003C/h2>\u003Cdiv data-filename=\"./packages/app/app/utils/deduplicatedAuthRefresh.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">let inflightRefresh: Promise&lt;unknown&gt; | null = null;\n\nexport function runDeduplicatedAuthRefresh(refresh: () =&gt; Promise&lt;unknown&gt;): Promise&lt;unknown&gt; {\n  if (inflightRefresh) return inflightRefresh;\n\n  // 同時に走るrefreshを1本にまとめる\n  inflightRefresh = refresh().finally(() =&gt; {\n    inflightRefresh = null;\n  });\n\n  return inflightRefresh;\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">モジュールスコープの変数でin-flightなPromiseを保持し、すでにリフレッシュが走っているなら同じPromiseを返します。同じタブ内で複数のリフレッシュトリガーが走っても(ウィンドウフォーカス復帰・定期実行・ミドルウェア等)、リクエストは1本しか飛びません。\u003C/p>\u003Cp style=\"text-align: start\">これをsidebaseのカスタムリフレッシュハンドラとして差し込んでいます。\u003C/p>\u003Cdiv data-filename=\"./packages/app/app/utils/DeduplicatedRefreshHandler.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">class DeduplicatedRefreshHandler implements RefreshHandler {\n  init(): void {\n    this.auth = useAuth();\n    document.addEventListener(&apos;visibilitychange&apos;, this.boundVisibilityHandler, false);\n\n    // ページロード時にリフレッシュトークンがあればセッション復元\n    if (this.auth.refreshToken.value &amp;&amp; !this.auth.data.value) {\n      runDeduplicatedAuthRefresh(this.auth.refresh);\n    }\n\n    // 55分ごとに定期リフレッシュ(アクセストークン期限1時間の直前)\n    this.refetchIntervalTimer = setInterval(() =&gt; {\n      const auth = this.getRefreshableAuth();\n      if (auth) runDeduplicatedAuthRefresh(auth.refresh);\n    }, intervalTime);\n  }\n\n  visibilityHandler(): void {\n    // タブが前面に戻ってきた時もリフレッシュを試みる\n    if (document.visibilityState !== &apos;visible&apos;) return;\n    const auth = this.getRefreshableAuth();\n    if (auth) runDeduplicatedAuthRefresh(auth.refresh);\n  }\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">\u003Ccode>visibilitychange\u003C/code>イベントを購読しているのは、ブラウザのサスペンドや別タブへの切替から復帰した際にセッションが失効している可能性が想定されるためです。ブラウザ/PWAを長時間バックグラウンドに置いていた場合などでも、タブに戻ってきた瞬間にリフレッシュが走ります。\u003C/p>\u003Ch2 style=\"text-align: start\" id=\"ha2cc149486\">バックエンド\u003C/h2>\u003Cp style=\"text-align: start\">クライアント側の重複排除だけでは不十分で、異なるタブ(=別のモジュールスコープ)からのリクエストは防げません。そこでサーバ側でも対策しています。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/refreshToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export async function verifyRefreshToken(token: string): Promise&lt;{ userId: string } | null&gt; {\n  const tokenHash = hashToken(token);\n  const refreshToken = await prisma.refreshToken.findUnique({ where: { tokenHash } });\n\n  if (!refreshToken) return null;\n\n  // 無効化済みトークンは拒否\n  if (refreshToken.revokedAt) {\n    const GRACE_PERIOD_MS = 30_000;\n    if (Date.now() - refreshToken.revokedAt.getTime() &gt; GRACE_PERIOD_MS) {\n      return null;\n    }\n  }\n\n  if (refreshToken.expiresAt &lt; new Date()) return null;\n\n  return { userId: refreshToken.userId };\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">リフレッシュトークンを無効化(\u003Ccode>revokedAt\u003C/code>を記録)してから30秒以内であれば、同じトークンでのリクエストを許容します。これにより、複数タブが同じ旧トークンでほぼ同時にリフレッシュを要求してきた場合でも、全てのタブが新しいアクセストークンを受け取れます。\u003C/p>\u003Cp style=\"text-align: start\">ただし、意図的なセッション失効(ログアウトや不正なトークン使用への対応)ではグレースピリオドをバイパスする必要があります。その場合は\u003Ccode>revokedAt\u003C/code>に\u003Ccode>new Date(0)\u003C/code>を設定することで、前述の30秒を許容する条件を満たせないようにしています。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/refreshToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">// ログアウト等の強制無効化\nawait prisma.refreshToken.updateMany({\n  where: { tokenHash, revokedAt: null },\n  data: { revokedAt: new Date(0) },\n});\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">また、リフレッシュ処理の実装では新トークンの発行を先に行い、旧トークンの無効化を後に行っています。順序が逆だとDB障害のタイミングによっては旧トークンが無効化されたが新トークンが発行されていない状態になり、ユーザが強制ログアウトされる可能性が考えられます。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/refreshToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export async function rotateRefreshToken(oldToken: string, userId: string): Promise&lt;string&gt; {\n  // 新トークンを先に発行\n  // NOTE: DB障害でログアウトされないよう順序を保証\n  const newToken = await generateRefreshToken(userId);\n\n  // 旧トークンを無効化\n  await prisma.refreshToken.updateMany({\n    where: { tokenHash: oldTokenHash, revokedAt: null },\n    data: { revokedAt: new Date() },\n  });\n\n  return newToken;\n}\u003C/code>\u003C/pre>\u003C/div>\u003Ch1 style=\"text-align: start\" id=\"h2f06bf172a\">\u003Cstrong>middlewareで割り込みトークン更新\u003C/strong>\u003C/h1>\u003Cp style=\"text-align: start\">認証が必要なページへのアクセス時に、トークンの状態に応じてリフレッシュや認証ページへのリダイレクトを行うのがNuxtのルートミドルウェアです。\u003C/p>\u003Cdiv data-filename=\"./packages/app/app/middleware/auth.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export default defineNuxtRouteMiddleware(async (to) =&gt; {\n  if (import.meta.server) {\n    // SSR時はセッションAPIを直接叩いて検証\n    const { data, error } = await useFetch(&apos;/api/v1/auth/session&apos;);\n    if (error.value || !data.value?.user) {\n      return redirectToSignIn();\n    }\n    return;\n  }\n\n  const { status } = useAuth();\n  const { waitForAuthReady, restoreSessionWithRetry, withSessionRestoreOverlay } = useDeduplicatedRefresh();\n\n  if (status.value === &apos;authenticated&apos;) return;\n\n  return withSessionRestoreOverlay(async () =&gt; {\n    await waitForAuthReady();\n    if (status.value === &apos;authenticated&apos;) return;\n\n    // 明示的ログアウト後はリフレッシュをスキップ\n    if (sessionStorage.getItem(&apos;mewk:explicit-logout&apos;)) {\n      return redirectToSignIn();\n    }\n\n    const restored = await restoreSessionWithRetry();\n    if (restored) return;\n\n    return redirectToSignIn();\n  });\n});\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">いくつか細かい工夫があります。\u003C/p>\u003Cp style=\"text-align: start\">まず\u003Ccode>waitForAuthReady()\u003C/code>で、sidebaseの初期ロード中(\u003Ccode>status === &apos;loading&apos;\u003C/code>)が完了するのを最大5秒待ちます。ページロード直後はsidebaseがセッションを確認している途中であることが多く、\u003Ccode>loading\u003C/code>状態を無視してリダイレクトしてしまうとちらつきが発生します。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ccode>waitForAuthReady()\u003C/code>が完了しても\u003Ccode>authenticated\u003C/code>でなかった場合、リフレッシュを試みます。ここでも\u003Ccode>restoreSessionWithRetry()\u003C/code>を挟んでおり、最大3回, 500ms間隔でリトライします。モバイル環境等のネットワークが不安定な場合など、一発でリフレッシュが成功しないことがあるためです。\u003C/p>\u003Cdiv data-filename=\"./packages/app/app/composables/useDeduplicatedRefresh.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">async function restoreSessionWithRetry(options: RestoreSessionOptions = {}): Promise&lt;boolean&gt; {\n  const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; // 3\n\n  for (let attempt = 0; attempt &lt;= maxRetries; attempt++) {\n    try {\n      await deduplicatedRefresh();\n    } catch { }\n\n    if (status.value !== &apos;authenticated&apos;) {\n      await Promise.race([\n        until(status).toBe(&apos;authenticated&apos;),\n        sleep(authenticatedWaitMs), // 最大1秒待機\n      ]);\n    }\n\n    if (status.value === &apos;authenticated&apos;) return true;\n    if (attempt &lt; maxRetries) await sleep(retryDelayMs);\n  }\n\n  return false;\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">\u003Ccode>withSessionRestoreOverlay()\u003C/code>はUIのちらつき防止のためのラッパーで、セッション復元中はオーバーレイカウントをインクリメントしてローディング状態を表現しています。\u003C/p>\u003Cp style=\"text-align: start\">なお、\u003Ccode>sessionStorage.getItem(&apos;mewk:explicit-logout&apos;)\u003C/code>のチェックは、ユーザが自分でログアウトした後にリフレッシュトークンが残っていても再ログインしてしまう問題を防ぐためです。明示的なログアウト操作時にはsessionStorageにフラグを立て、ミドルウェアがこれを検知した場合はリフレッシュをスキップして\u003Ccode>/auth/signin\u003C/code>にリダイレクトします。ちなみに、sessionStorageはタブを閉じると消えるため、以降のセッションでは正常にリフレッシュが走ります。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h6181bfeeb6\">SSR時のセッション処理\u003C/h1>\u003Cp style=\"text-align: start\">SSRが絡むと少し複雑になります。サーバサイドレンダリング時には\u003Ccode>useAuth()\u003C/code>が使えないため、セッションAPIを直接叩いてセッションを確認します。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ccode>/api/v1/auth/session\u003C/code>のエンドポイントでは、アクセストークンがCookieに存在する場合はそれで認証し、存在しない場合はリフレッシュトークンで1回だけセッション復元を試みます。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/api/v1/auth/session.get.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">let payload: { userId: string };\n\ntry {\n  payload = await requireAuthWithoutBanCheck(event);\n} catch (error) {\n  // アクセストークンが無ければリフレッシュトークンで復元\n  const refreshToken = getRefreshTokenFromCookies(event);\n  if (!refreshToken) throw error;\n\n  const restoredSession = await restoreSessionFromRefreshToken(event);\n  payload = { userId: restoredSession.userId };\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">\u003Ccode>restoreSessionFromRefreshToken()\u003C/code>はセッション復元と同時に新しいアクセストークンとリフレッシュトークンを発行し、Cookieをセットします。つまりSSR時にもトークンのローテーションが透過的に行われるため、ユーザは意識することなく認証状態が維持されます。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"hbdedd2bd61\">Misskeyトークン失効の検知\u003C/h1>\u003Cp style=\"text-align: start\">Misskeyのアクセストークンは、ユーザが連携アプリの認可を取り消したり、アカウントが凍結された場合に無効になります。この場合、ユーザには継続して有効なMewkの発行するJWTが手元にありますが、バックエンドが実際にMisskey APIを叩こうとすると失敗します。\u003C/p>\u003Cp style=\"text-align: start\">この不整合を検知するため、セッション確認のたびにMisskeyの\u003Ccode>/api/i\u003C/code>へのアクセス確認を行っています。ただし毎回叩くとレイテンシが悪化する可能性があるため、KVを使って5分間キャッシュしています。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/auth.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export async function checkMisskeyTokenValidity(\n  event: H3Event,\n  userId: string,\n  domain: string,\n  accessToken: string,\n  options: MisskeyTokenCheckOptions,\n): Promise&lt;void&gt; {\n  const kv = getKV(event);\n  const cacheKey = `misskey-valid:${userId}`;\n  const cached = await kv.get(cacheKey);\n  if (cached) return;\n\n  // タイムアウト付きでMisskeyに問い合わせ\n  const result = await Promise.race([fetchPromise, timeoutPromise]);\n\n  if (status === 401 || status === 403) {\n    // トークン無効 全セッションを強制破棄\n    await revokeAllUserRefreshTokens(userId);\n    throw createError({ statusCode: 401, statusMessage: &apos;MISSKEY_SESSION_EXPIRED&apos;, ... });\n  } else if (status &gt;= 200 &amp;&amp; status &lt; 300) {\n    await kv.put(cacheKey, &apos;1&apos;, { expirationTtl: 300 });\n  }\n  // 5xx\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">タイムアウトは1500msに設定していて、Misskeyのレスポンスが遅い場合は検証をスキップします。不整合が発生したからといって、外部サービスの応答待ちでユーザのリクエストを阻害するのは設計として微妙すぎるためです。\u003C/p>\u003Cp style=\"text-align: start\">Misskeyがダウンしているだけで全ユーザがセッションを失うような事態を避けるため、5xx系のレスポンスやタイムアウトは正常扱いしてスルーしています。明確に401/403のレスポンスが返った場合のみ、ユーザの全リフレッシュトークンを無効化し、ログアウト理由をフロントに伝えるために非httpOnlyのCookie(\u003Ccode>mewk_logout_reason\u003C/code>)を短命で立てています。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp style=\"text-align: start\">振り返ると、認証まわりで一番頭を使ったのはrefreshの競合問題です。「複数タブで同時に開いてたら」「ブラウザのサスペンドから復帰したら」「ネットワークが不安定だったら」といった微妙なエッジケースを一つひとつ潰していくのは、手を抜けない部分でした。\u003C/p>\u003Cp style=\"text-align: start\">セキュリティまわりの設計については、「ライブラリを使っているから大丈夫」という発想を極力持たないようにしました。sidebaseを使っていますが、MiAuthトークンをクライアントに渡さない・DB保存時に暗号化する・revoke手段をサービス側で持つ、といった判断はライブラリに任せられるものではありません。ライブラリはあくまで実装の補助であって、設計上の責任まで肩代わりしてくれるわけではありません。ここを混同すると、ライブラリのデフォルト挙動に乗っかったまま後から気づけない穴を作ることになります。\u003C/p>\u003Cp style=\"text-align: start\">書いていて改めて思いましたが、認証の設計というのはこうすれば完璧という答えがなく、トレードオフの連続でした。JWTの即時revoke問題もhttpOnly Cookieの限界も、どこかで折り合いをつけなければいけない。大事なのは、そのトレードオフが何なのかを理解した上で判断することで、何も考えずにデフォルトに従うことではないと考えます。\u003C/p>\u003Cp style=\"text-align: start\">今後も機能追加や仕様変更の中で認証まわりはじわじわ変わっていくと思いますが、基本的な設計の方針は変えるつもりはありませんし、Misskeyのアクセストークンをバックエンドに閉じ込め管理するという構造は、使い続けていてもそれほど不便を感じていませんし、むしろ正解だったと思っています。\u003C/p>\u003Cp style=\"text-align: start\">それでは。\u003C/p>",[43,49,50,51,57],{"id":44,"createdAt":45,"updatedAt":46,"publishedAt":45,"revisedAt":46,"slug":47,"name":48},"vmhb23sq4","2025-07-29T12:56:07.884Z","2025-12-03T16:06:19.140Z","misskey","Misskey",{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":30,"createdAt":31,"updatedAt":32,"publishedAt":31,"revisedAt":32,"slug":33,"name":34},{"id":52,"createdAt":53,"updatedAt":54,"publishedAt":53,"revisedAt":54,"slug":55,"name":56},"fwj_nwhj1-s","2025-04-30T04:55:28.953Z","2025-12-03T16:07:03.591Z","server","サーバー",{"id":58,"createdAt":59,"updatedAt":60,"publishedAt":59,"revisedAt":60,"slug":61,"name":62},"lte0t59xm8sb","2025-05-02T16:12:38.708Z","2025-12-03T16:06:52.753Z","network","ネットワーク",{"id":64,"createdAt":65,"updatedAt":66,"publishedAt":66,"revisedAt":66,"title":67,"content":68,"tags":69,"is_no_index":35},"ql0n4uqo0fo","2026-03-25T17:59:57.852Z","2026-03-26T11:42:46.429Z","Misskey向け(匿名)質問箱「Mewk」を作った","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>先日、Misskeyユーザ向けの(匿名)質問箱サービス\u003Ca href=\"https://mewk.app\" target=\"_blank\" rel=\"noopener noreferrer\">Mewk\u003C/a>を作りました。\u003C/p>\u003Cp>\u003Ca href=\"https://mewk.app/\" target=\"_blank\" rel=\"noopener noreferrer\">https://mewk.app/\u003C/a>\u003C/p>\u003Cp>リリースから1ヶ月少々が経ち、現時点で約2,000ユーザ、7,000件弱の質問が投稿されています。ありがたいことですね。\u003C/p>\u003Ch1 id=\"h18f5531d80\">なんでつくったの\u003C/h1>\u003Cp>Misskeyには既存の匿名質問箱サービスがいくつか存在します。\u003Cbr>そんな中、何故わざわざ新しく作ったのか。\u003C/p>\u003Cp>正直に言うと、既存のサービスに満足できなかったからです。\u003C/p>\u003Cp>Misskeyは分散型のSNSであり、無数のインスタンスが独立して運営されています。しかし、質問箱となると、インスタンスを問わずに利用できる選択肢が限られていました。特定のインスタンス向けに提供されているサービスはありましたが、それらはそのインスタンスのユーザのために作られたものであり、他のインスタンスのユーザが使えるものではありません。Misskeyのエコシステム全体を見渡した時に、誰でも使える汎用的な質問箱が不足しているという状況がありました。\u003C/p>\u003Cp>インスタンスを問わず利用できるものもいくつか存在しましたが、正直なところ常用するには厳しいものがありました。OGP画像の生成に対応していないため、質問や回答をSNS上で共有した際にリッチなプレビューが表示されず、せっかくの回答が素っ気ないリンクにしかなりません。UIもお世辞にも洗練されているとは言えず、全体的に作り込みの甘さが目立ちました。使っていて「もう少しなんとかならないのか」という思いが拭えませんでした。\u003C/p>\u003Cp>既存ソフトウェアに文句を言うだけなら簡単です。しかし、他人が作ったものにケチをつけるくらいなら、自分が納得できるものを自分で作った方が建設的です。\u003Cbr>どうせ作るなら、MFMを完全にサポートし、ユーザがカスタマイズできる機能を充実させ、リッチなUIでどのインスタンスからでも利用できる質問箱を作ろう。そう思って開発を始めました。\u003C/p>\u003Cp>もう一つ、\u003Ca href=\"https://mq1.dev/entry/e-3nu72af97k\">非公式Misskeyサーバリスト\u003C/a>を作った時に感じた、自分が作ったものを誰かに使ってもらえる体験をもう一度味わいたいという気持ちもありました。承認欲求と言ってしまえばそれまでですが、個人開発のモチベーションなんてみんなそんなものです。\u003C/p>\u003Ch1 id=\"h0d376e5d4e\">構成\u003C/h1>\u003Cp>Mewkのアーキテクチャは大体以下の通りです。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/cae9d3fdeecb462fb681da6dba2781f0/architecture.drawio.png\" alt=\"\" width=\"1292\" height=\"866\">\u003C/figure>\u003Cp>フロントエンドとバックエンドはNuxt 4で統一し、Cloudflare Workers上にデプロイしています。\u003C/p>\u003Cp>データベースにはCockroachDBを採用していますが、PostgreSQL系のDBは総じてコネクション確立のコストが重く、リクエストのたびに新規接続を張る環境ではそのオーバーヘッドが顕著になります。そのため、Hyperdriveを経由して接続プーリングを行い、コネクションの使い回しによって応答速度を確保しています。\u003Cbr>OGP画像の保存にR2、キャッシュやレートリミットにKV、非同期ジョブの処理には独立したKiribi Queue Workerを使い、インフラ管理はTerraform、デプロイはGitHub Actionsが担っています。\u003C/p>\u003Cp>以前、非公式Misskeyサーバリストを作った際はGCPのCloud Runで動かしていましたが、今回はCloudflareに全振りしました。\u003Cbr>理由は単純で、エッジコンピューティングの恩恵をフルに受けたかったことと、ずば抜けて安価であること、Cloudflareのエコシステムが以前に比べてかなり成熟してきたことが大きいです。\u003C/p>\u003Cp>CockroachDBだけは外部ですが、Hyperdriveの接続プーリングのおかげで、エッジからDBへのレイテンシは実用上の問題にはなっていません。\u003C/p>\u003Ch1 id=\"h193f373b58\">リソース管理\u003C/h1>\u003Cp>Cloudflareのリソース管理には、例によってTerraformを使っています。\u003C/p>\u003Cp>KV Namespace、R2、Hyperdrive、D1、Queue、Workers Script、Custom Domain。これらの初期構築を全てIaCで管理し、stg/prodの環境分離もTerraformのworkspaceで行っています。\u003Cbr>人の温もりが介在するようなインフラなんてとんでもなく恐ろしいですからね。\u003C/p>\u003Cp>ところが、Workers Scriptのデプロイに関しては、Terraformだけで完結させることができません。Cloudflare APIに起因する厄介な問題があるためです。\u003C/p>\u003Cdiv data-filename=\"./terraform/modules/app/main.tf\">\u003Cpre>\u003Ccode>resource &quot;cloudflare_workers_script&quot; &quot;app&quot; {\n  # 初回作成のみ Terraform が担当\n  content = &quot;export default { fetch() { return new Response(&apos;Run wrangler deploy to update&apos;) } }&quot;\n\n  bindings = [\n    { name = &quot;HYPERDRIVE&quot;, type = &quot;hyperdrive&quot;, id = cloudflare_hyperdrive_config.db.id },\n    { name = &quot;R2_BUCKET&quot;, type = &quot;r2_bucket&quot;, bucket_name = var.r2_bucket_name },\n    { name = &quot;BROWSER&quot;, type = &quot;browser&quot; },\n    { name = &quot;KV_CACHE&quot;, type = &quot;kv_namespace&quot;, namespace_id = cloudflare_workers_kv_namespace.cache.id },\n    { name = &quot;KIRIBI&quot;, type = &quot;service&quot;, service = cloudflare_workers_script.kiribi.script_name },\n    # ... シークレット等\n  ]\n\n  lifecycle {\n    ignore_changes = all\n  }\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp>Cloudflare APIは、Workers ScriptリソースをGETした際に\u003Ccode>content\u003C/code>フィールドを返しません。そのため、Terraform planを実行するとstateの\u003Ccode>content\u003C/code>が\u003Ccode>null\u003C/code>になります。この状態で他の属性(bindingの追加等)を変更しようとすると、PUTリクエストに\u003Ccode>null\u003C/code>のcontentが含まれてしまい、syntax errorで死んでしまいます。\u003C/p>\u003Cp>この問題を回避するため、Terraformにはリソースの初回作成とbindingの定義だけを担当させ、\u003Ccode>lifecycle { ignore_changes = all }\u003C/code>で以降の変更を完全に無視させています。実際のコードデプロイはCDパイプラインから\u003Ccode>wrangler deploy\u003C/code>で行い、bindingの実態は\u003Ccode>wrangler.toml\u003C/code>(Terraform outputから自動生成)で管理するという構成です。\u003C/p>\u003Cp>一見すると冗長に見えるかもしれませんが、リソースの作成・削除はTerraformのplan/applyで安全に管理しつつ、頻繁に更新されるWorkerのコードはWranglerに任せるという棲み分けは、運用上かなり快適です。新しいbindingを追加する際も、Terraformでリソースを作成してoutputを更新し、\u003Ccode>wrangler deploy\u003C/code>で反映するだけなので、手順に迷うこともありません。\u003C/p>\u003Ch1 id=\"h6a72217b1b\">Prisma on Cloudflare Workersの苦しみ\u003C/h1>\u003Cp>MewkではORMにPrismaを採用しています。\u003C/p>\u003Cp>Prisma自体はCloudflare Workersランタイムに対応しており、WASMベースのクエリエンジンを使って動作する仕組みになっています。ところが、Nitro(Nuxtのサーバエンジン)がバンドルを行う際に、Prismaが生成したコード内の\u003Ccode>.wasm\u003C/code>インポートパスが壊れるという既知の問題がありました。\u003C/p>\u003Cp>\u003Ca href=\"https://github.com/prisma/prisma/issues/28657\">https://github.com/prisma/prisma/issues/28657\u003C/a>\u003C/p>\u003Cp>Nitroのバンドラがソースツリー上の絶対パスをそのままバンドル出力に持ち込んでしまい、デプロイ先のCloudflare Workers環境では当然そのパスが存在しないため、WASMの読み込みに失敗しているようです。\u003C/p>\u003Cp>正直、この問題にぶつかった時はかなり萎えました。ORMの選択を間違えたかと一瞬後悔しましたが、Prisma以外を使いたくはなかったので、力技で解決する道を選びました。\u003C/p>\u003Cp>そこで、ビルド後の成果物を直接書き換えるスクリプトを用意しています。\u003C/p>\u003Cdiv data-filename=\"./packages/nuxt-app/scripts/fix-prisma-wasm-path.mjs\">\u003Cpre>\u003Ccode class=\"language-js\">const OUTPUT_SERVER = resolve(&apos;.output&apos;, &apos;server&apos;);\nconst WASM_SRC = resolve(&apos;generated&apos;, &apos;prisma&apos;, &apos;internal&apos;, &apos;query_compiler_fast_bg.wasm&apos;);\nconst WASM_DEST = join(OUTPUT_SERVER, &apos;chunks&apos;, &apos;query_compiler_fast_bg.wasm&apos;);\n\n// WASMファイルをビルド出力にコピー\ncopyFileSync(WASM_SRC, WASM_DEST);\n\n// Nitro出力内のWASMパスを正しい相対パスに修正\nconst fixed = content.replace(\n  /[&quot;&apos;][^&quot;&apos;]*generated\\/prisma\\/internal\\/query_compiler_fast_bg\\.wasm(?:\\?module)?[&quot;&apos;]/g,\n  &apos;&quot;../query_compiler_fast_bg.wasm?module&quot;&apos;,\n);\n\n// Wranglerのmodule collectorが?module付きパスでENOENTになるため正規化\nconst withoutModuleSuffix = fixed.replace(\n  /query_compiler_fast_bg\\.wasm\\?module/g,\n  &apos;query_compiler_fast_bg.wasm&apos;,\n);\n\n// Wranglerの警告ノイズを抑制\nconst withoutNodeProcessImport = fixed.replace(\n  /import\\s*[&quot;&apos;]node:process[&quot;&apos;];?/g,\n  &apos;&apos;,\n);\u003C/code>\u003C/pre>\u003C/div>\u003Cp>やっていることを整理すると、まずPrismaが生成したWASMバイナリをビルド出力ディレクトリにコピーし、Nitroが出力した\u003Ccode>.mjs\u003C/code>ファイル群を走査して、壊れた絶対パスを正しい相対パスに書き換えます。さらに、Wranglerのmodule collectorが\u003Ccode>?module\u003C/code>サフィックス付きのパスをそのまま\u003Ccode>open()\u003C/code>してENOENTになることがあるため、サフィックスを除去して正規化します。最後に、Wrangler側で\u003Ccode>sideEffects=false\u003C/code>判定により無視される\u003Ccode>node:process\u003C/code>のbare importを事前に除去して、デプロイ時のwarnを抑えています。\u003C/p>\u003Cp>実装としては全然綺麗じゃないですし、寧ろすごく汚いと思います。ビルド成果物を正規表現で書き換えるなんて、お世辞にも上品とは言えない力技です。\u003C/p>\u003Cp>しかし、こんなのでも動いてしまいます。Prismaのバージョンが上がるたびにパスの形式が微妙に変わって壊れないか冷や冷やしていますが、今のところは安定して動いてくれています。Prisma側でこの問題が修正されるのを心待ちにしつつ、それまではこの暫定対応で凌ごうかと思っています。\u003C/p>\u003Cp>他にいい感じの方法をご存じの方がいれば教えて頂けるとうれしいです。\u003C/p>\u003Ch1 id=\"hba9e6b2d9d\">非同期ジョブの処理\u003C/h1>\u003Ch2 id=\"h5a5a3075c7\">Kiribi\u003C/h2>\u003Cp>非同期ジョブの処理には\u003Ca href=\"https://github.com/aiji42/kiribi\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">Kiribi\u003C/a>を採用しています。\u003Cbr>これは、Cloudflare QueuesベースのジョブワーカーフレームワークでD1でジョブの状態管理を行い、Cronトリガーで定期実行をスケジューリングできます。\u003C/p>\u003Cp>リリース直後はCloudflare Queuesを素で使っていましたが、Queues単体ではメッセージの取りこぼしや重複配信が発生することがあり、at-least-onceの保証も万全ではありませんでした。KiribiはD1でジョブの状態を永続化しているため、Queues側で問題が起きてもジョブの追跡と再実行が可能であり、信頼性の面で大きな旨味があります。\u003C/p>\u003Cp>サービスの性質上、非同期で処理したいタスクはいくつもあります。定期投稿の配信、Misskey通知の送信、アカウント削除の後処理。これらをメインのWorkerで同期的に処理してしまうと、レスポンスが悪化しますし、外部APIの障害に引きずられてサービス全体が不安定になりかねません。\u003C/p>\u003Cp>特にMisskeyの場合、接続先はビックテックの安定した中央集権的なサーバではなく、個人が運営するインスタンスが大半を占めており、その多くが不安定です。サーバの応答速度もまちまちですし、メンテナンスで丸一日落ちていることも珍しくありません。さらに厄介なのが、まともなスコープ設計もできないくせにWAFの設定を雑に盛る管理者の存在です。\u003Ccode>/api/*\u003C/code>のようなパスにまでmanaged challengeを課しているせいで、API呼び出しが容赦なく蹴られてしまいます。まともな設計すらできないようであれば、デフォルトのまま運用してほしいものですが、こういうインスタンスのせいで的外れな問い合わせがこちらに無限に届くのは本当につらい😭\u003C/p>\u003Cp>少し話が逸れましたが、ともあれ、こういった不安定で理不尽な外部依存を同期的に抱えるのは、サービスの安定性にとって致命的です。\u003C/p>\u003Cdiv data-filename=\"./packages/kiribi/src/index.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export default class extends Kiribi {\n  defaultMaxRetries = 20;\n\n  async scheduled() {\n    await this.enqueue(&apos;SCHEDULED_POST_DISPATCH&apos;, {}, {\n      retryDelay: { exponential: true, base: 2 },\n    });\n    await this.enqueue(&apos;PROCESS_ACCOUNT_DELETIONS&apos;, {}, {\n      retryDelay: { exponential: true, base: 2 },\n    });\n    await this.sweep();\n  }\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp>\u003Ccode>defaultMaxRetries = 20\u003C/code>は一見やりすぎに見えるかもしれませんが、前述の通り、Misskeyサーバは個人運営のものも多く、メンテナンスで数時間から数日停止することは珍しくありません。指数的バックオフでリトライ間隔が指数的に伸びていくため、20回リトライしたところで相手サーバに過剰な負荷をかけることは\u003Cs>おそらく\u003C/s>ありません。むしろ、粘り強くリトライすることで、サーバが復帰した際に確実にジョブを完了させることができます。\u003C/p>\u003Cp>ユーザにとっては「通知が来なかった」「定期投稿が飛んだ」といった誰にでも目に見えて分かる不具合が不満になりそうなので、ここは粘っておくべきなのかなと考えます。\u003C/p>\u003Ch2 id=\"h06750e06c4\">JobとService binding\u003C/h2>\u003Cp>現在、Kiribiで処理しているジョブは4種類あります。\u003C/p>\u003Cul>\u003Cli>SCHEDULED_POST_DISPATCH\u003Cp>定期投稿のdispatch。Cron triggerから毎時実行され、投稿待ちのユーザを検索して個別のSCHEDULED_POSTをenqueue\u003C/p>\u003C/li>\u003Cli>SCHEDULED_POST\u003Cp>実際のMisskey投稿処理。Nuxtの内部向けAPIエンドポイントでDBバリデーションとトークンの解決を行い、Misskey APIを叩いてノートを作成\u003C/p>\u003C/li>\u003Cli>MISSKEY_NOTIFICATION\u003Cp>各Misskeyインスタンスへ通知の送信\u003C/p>\u003C/li>\u003Cli>PROCESS_ACCOUNT_DELETIONS\u003Cp>アカウント削除の非同期処理\u003C/p>\u003C/li>\u003C/ul>\u003Cp style=\"text-align: start\">これらのジョブを実行する上で特徴的なのが、Kiribi WorkerとNuxt間の通信です。\u003C/p>\u003Cdiv data-filename=\"./packages/kiribi/src/index.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export class ScheduledPost extends MewkPerformer {\n  async perform(payload: { userId: string }) {\n    // NuxtでDB prep(バリデーション、トークン解決、テキスト構築)\n    const prepRes = await callMewkInternal&lt;...&gt;(\n      this.env, &apos;/api/_internal/scheduled-post-prepare&apos;, payload\n    );\n    if (!prepRes.success) return;\n\n    // Misskey API呼び出し\n    const noteRes = await fetch(`https://${prepRes.domain}/api/notes/create`, { ... });\n\n    if (!noteRes.ok) {\n      // 失敗時: KV ロック解除\n      await callMewkInternal(this.env, &apos;/api/_internal/scheduled-post-release&apos;, { ... });\n      throw new Error(`Misskey note create failed`);\n    }\n\n    // 成功記録\n    await callMewkInternal(this.env, &apos;/api/_internal/scheduled-post-complete&apos;, payload);\n  }\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp>Kiribi WorkerはService BindingでNuxtのWorkersに接続し、\u003Ccode>X-Internal-Secret\u003C/code>ヘッダで認証を行っています。DB操作やトークンの暗号化・復号といったメインロジックは全てNuxt側の\u003Ccode>_internal\u003C/code>エンドポイントに集約し、Kiribi側では外部API呼び出しだけを責務としています。\u003C/p>\u003Cp>なお、ここで軽く触れているトークンの暗号化・復号についてですが、MewkではMiAuthで取得したユーザのアクセストークンを平文のままDBに保存することはしていません。\u003Cbr>万が一DBの内容が流出するような事態が起きたとしても、ユーザのMisskeyアカウントが乗っ取られるという最悪のケースを防ぐため、トークンは全て環境変数に持たせたキーを利用してAES-256-GCMで暗号化した上で保存しています。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/bba3194de09d4fbba244eb6664d20264/image.png\" alt=\"\" width=\"953\" height=\"84\">\u003C/figure>\u003Cp>これらの処理を含め、Nuxt側にロジックを寄せた理由は明確で、DBアクセスや機密情報を扱う処理がKiribi Workerに漏れ出すことを防ぎたかったためです。\u003Cbr>Prismaの依存をNuxtに閉じ込めることで、Kiribi Workerは純粋にHTTP呼び出しの組み合わせだけで構成されます。\u003C/p>\u003Cp>先述のPrisma WASMパスの問題も含め、Prismaの面倒はNuxtだけが見ればいい。依存関係がシンプルになれば、それだけ楽になります。\u003C/p>\u003Ch1 id=\"h6e7a5d8bb9\">MFMを含むOGP画像の生成\u003C/h1>\u003Cp>OGP画像の生成は、Mewkの開発において最も試行錯誤した部分の一つです。結論から言えば、最終的にCloudflare Browser Renderingに落ち着いたのですが、そこに至るまでに2回の挫折を経ています。\u003C/p>\u003Ch2 id=\"h8871b5b516\">Satori\u003C/h2>\u003Cp>最初に検討したのは\u003Ca href=\"https://github.com/vercel/satori\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">Satori\u003C/a>でした。Vercelが開発しているHTML/CSSからSVGを生成するライブラリで、v8でも動作し、動的なOGP画像を生成する用途では広く使われています。エッジで完結するのでそれなりのパフォーマンスが期待できますし、外部依存もない。理想的な選択に見えました。\u003C/p>\u003Cp>しかし、PoCの段階で早々に断念しました。\u003C/p>\u003Cp>Mewkは、MFMのレンダリングをマストな要件としています。\u003Cbr>MFMはただの単純なMarkdown拡張構文ではなく、アニメーション、カスタム絵文字、回転、反転、虹色テキストなど、かなり独自の記法を含むマークアップです。これを\u003Ccode>mfm-js\u003C/code>でパースし、Vueコンポーネントとしてレンダリングするのがフロントエンド側の実装なのですが、SatoriはHTMLのサブセットしかサポートしておらず、MFMの多彩な装飾を再現することが根本的に困難でした。\u003C/p>\u003Cp>CSSアニメーションは当然動きませんし、カスタム絵文字は外部画像として取得・埋め込みが必要で、MFM特有のネストされた装飾の組み合わせをSatoriのレイアウトエンジンで正確に再現するのは現実的ではありませんでした。\u003C/p>\u003Cp>OGPは静止画なのでアニメーション自体は不要ですが、それでもMFMの見た目をある程度再現しようとすると、Satoriの表現力では足りませんでした。\u003C/p>\u003Ch2 id=\"h3a205c08eb\">Playwright on Cloud Run\u003C/h2>\u003Cp>Satoriがダメなら、実際のブラウザでレンダリングしてスクリーンショットを撮るしかない。そこで次に試みたのが、GCPのCloud Run上でPlaywrightを動かす方法でした。\u003C/p>\u003Cp>コンテナにChromiumを詰め込み、Playwrightでヘッドレスレンダリングを行い、スクリーンショットを撮影する。\u003Cbr>これはちゃんと動きます。実際にPoCレベルでは問題なく動作しました。やったね。\u003C/p>\u003Cp>しかし、いざ本番を見据えてコスト試算を行うと、頭を抱えることになりました。\u003Cbr>ブラウザの起動にはそれなりのリソースが必要で、Cloud Runのインスタンスにブラウザを常駐させるとメモリ消費が馬鹿にならず、コールドスタートからの起動も遅い。\u003C/p>\u003Cp>OGP画像の生成はユーザ登録時や質問投稿時に都度発生するため、スケールさせるとリソース消費が線形に増加します。勿論そんな金銭的余裕はありません。こんなものを多用していたら破産してしまいます。\u003C/p>\u003Cp>加えて、処理速度にも難がありました。Cloud Runのコールドスタートを含めると、1枚のOGP画像生成に数秒から十数秒かかることもあり、UXとしても許容しがたいものでした。\u003C/p>\u003Ch2 id=\"h51783b1d0a\">Cloudflare Browser Rendering\u003C/h2>\u003Cp>2回の挫折を経て、最終的に採用したのが\u003Ca href=\"https://developers.cloudflare.com/browser-rendering/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">Cloudflare Browser Rendering\u003C/a>です。Cloudflareが提供するヘッドレスブラウザ環境で、Puppeteerを使ったページのスクリーンショットを撮影できます。\u003C/p>\u003Cp>Cloud Run + Playwrightとやっていることの本質は同じですが、決定的な違いはインフラ管理が不要であること、そして追加コストが(ほぼ)かからないことです。ブラウザの起動やリソース管理はCloudflare側がよしなにやってくれますし、レイテンシも低い。まさに求めていたものでした。\u003C/p>\u003Cp>発想としてはシンプルで、OGPレンダリング専用のVueページを用意し、そのページをBrowser Renderingでスクリーンショットに撮る、というだけの話です。実際にブラウザでレンダリングするので、MFMの装飾もカスタム絵文字も、フロントエンドと全く同じ見た目で出力できます。\u003C/p>\u003Cp>OGPレンダリング用のVueページはユーザーページ向けのものと、質問ページ向けのものを2種類を用意しています。ユーザのプロフィールカードと質問カードをそれぞれ1200x630のJPEGとしてキャプチャし、R2にアップロードしてDBにキーとBlurHashを記録します。\u003C/p>\u003Cp style=\"text-align: start\">生成された画像は次回以降のリクエストではR2から直接配信されるため、Browser Renderingが毎回走ることはありません。画像の再生成が必要なタイミング(ユーザがプロフィールを更新した場合など)にのみ、非同期で再生成を行うようにしています。\u003C/p>\u003Ch2 id=\"hf033e7fccc\">循環レンダリング\u003C/h2>\u003Cp>ここで一つ、躓きました。\u003C/p>\u003Cp>OGP画像の生成は、ユーザページや質問ページへのアクセス時にトリガーされます。具体的には、APIレスポンスにOGP画像キーが存在しない場合、\u003Ccode>waitUntil()\u003C/code>を利用バックグラウンドで生成処理を走らせます。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/api/v1/questions/[id]/index.get.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">const shouldRegenerateOgp = !skipOgpRegeneration &amp;&amp; (!ogpImageKey || hasLegacyPngOgp);\nif (shouldRegenerateOgp) {\n  const cfCtx = event.context.cloudflare.context;\n  const ogpPromise = generateOgp(...).catch(() =&gt; {});\n  if (cfCtx?.waitUntil) {\n    cfCtx.waitUntil(ogpPromise);\n  } else {\n    await ogpPromise;\n  }\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp>問題は、OGPレンダリング用ページもSSRで動作するため、内部的に同じAPIを叩くということです。何も対策しないと、OGP生成→レンダリングページアクセス→API呼び出し→OGP画像なし→OGP生成→…といった循環参照に陥ってしまいます(ました)。\u003C/p>\u003Cp>これを防ぐため、OGPレンダリング用ページからのAPIリクエストには\u003Ccode>skipOgpRegeneration=1\u003C/code>というクエリパラメータを付与し、再生成をスキップさせています。地味ですが、これがないとBrowser Renderingのセッションを無限に食い潰してしまうので、割と致命的です。実際、開発中にこの対策を入れ忘れた状態でテストしてしまい、Browser Renderingのセッション数が一瞬で枯渇したことがあります。\u003C/p>\u003Cp>リトライは最大3回、線形バックオフ付きです。Browser Renderingは稀にタイムアウトすることがあるため、リトライなしでは運用に耐えませんでした。\u003C/p>\u003Ch1 id=\"h73fa2d2d11\">モデレーション大変だよね\u003C/h1>\u003Cp>OGP画像の生成も手強かったですが、正直に告白しますと、開発期間の中で最も時間を食ったのは主要機能の実装ではなく、このモデレーション系の実装でした。\u003C/p>\u003Cp>匿名質問箱というサービスの性質上、悪意のある投稿への対策は避けて通れません。匿名であるがゆえに、誹謗中傷やスパム、有害コンテンツの投稿は必ず発生します。「善意のユーザが大半だから大丈夫だろう」などという楽観は、サービスを公開した瞬間に砕け散ってしまいます。後々面倒なことになるのが簡単に予想できてしまったので、ここで手を抜くわけにはいきませんでした。\u003C/p>\u003Cp>開発を始めた当初は、モデレーションにここまで時間がかかるとは思っていませんでした。\u003C/p>\u003Cp>質問の送受信、MiAuth、MFMレンダリングといったガワの機能は、やるべきことが明確なので実装も比較的スムーズに進みます。しかし、モデレーションは何を防ぐべきか、どこまで防ぐべきか、防いだ結果として正常な利用を阻害していないかという判断の連続で、技術的な難しさよりも設計上の判断の多さに消耗しました。\u003C/p>\u003Ch2 id=\"haf7490bdf2\">コンテンツフィルタリング\u003C/h2>\u003Cp style=\"text-align: start\">質問のフィルタは2層構造で実装しています。\u003C/p>\u003Ch3 id=\"h8782749b84\">NGワード\u003C/h3>\u003Cp style=\"text-align: start\">各ユーザが自分で設定できるNGワードフィルタです。単純な文字列マッチだけでなく、正規表現にも対応しています。ただし、ユーザが入力する正規表現をそのまま\u003Ccode>new RegExp()\u003C/code>に突っ込むわけにはいきません。ReDoSの対策が必要です。\u003C/p>\u003Cp style=\"text-align: start\">悪意がなくても、正規表現に不慣れなユーザが壊滅的にパフォーマンスの悪いパターンを入力してしまうことは十分にあり得ます。Cloudflare Workersには厳しいCPU Time Limitがあるため、一つの正規表現マッチングでWorkerが死ぬのは避けなければなりません。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/safeRegex.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">function checkNgWords(content: string, ngWords: NgWord[]): boolean {\n  const normalizedContent = normalizeForNgCheck(content);\n  const normalizedContentLower = normalizedContent.toLowerCase();\n\n  for (const ngWord of ngWords) {\n    const normalizedPattern = normalizeForNgCheck(ngWord.pattern);\n    if (!normalizedPattern) continue;\n\n    // ざっくりReDoS対策\n    if (normalizedPattern.length &gt; 200) continue;\n\n    if (ngWord.isRegex) {\n      // 安全でない正規表現はリテラルマッチにフォールバック\n      if (!isSafeRegexPattern(normalizedPattern)) {\n        if (normalizedContentLower.includes(normalizedPattern.toLowerCase())) return true;\n        continue;\n      }\n      if (new RegExp(normalizedPattern, &apos;i&apos;).test(normalizedContent)) return true;\n    } else {\n      if (normalizedContentLower.includes(normalizedPattern.toLowerCase())) return true;\n    }\n  }\n  return false;\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">まず、パターンの長さが200文字を超える場合は問答無用でスキップします。次に、パターンの安全性を検証し、危険と判定された場合はリテラルマッチにフォールバックします。\u003C/p>\u003Cp style=\"text-align: start\">正規表現としての解釈を諦める代わりに、少なくとも文字列としてのマッチは試みるという、可用性重視の設計です。正規表現が使えなくても、テキストに含まれているかどうかのチェックはできるのでよしとしています。\u003C/p>\u003Cp style=\"text-align: start\">また、入力テキストとパターンの双方にNFKC正規化とゼロ幅文字の除去を適用しています。\u003C/p>\u003Cp style=\"text-align: start\">これがないと、見た目が同じでもバイト列が異なる文字列でフィルタをすり抜けられてしまいます。ゼロ幅文字を挟んで単語を分断するという手口も、この正規化で潰しています。\u003Cbr>こういった回避手法は割といくらでも思いつくものあって、正直イタチごっこ感は否めません。\u003C/p>\u003Ch3 id=\"h5f6438f558\">OpenAI Moderation API\u003C/h3>\u003Cp style=\"text-align: start\">ユーザが有効にしている場合のみ、OpenAI Moderation APIで有害コンテンツを検出します。このAPIを採用した理由は非常にシンプルで、無料だからです。何度叩いても課金が発生しません。すごくありがたい。\u003C/p>\u003Cp style=\"text-align: start\">hate, harassment, self-harm, sexual, violenceなど11カテゴリの判定に対応しており、カテゴリ別のカスタム閾値も設定できるようにしました。\u003Cbr>というのも、OpenAI Moderation APIがデフォルトで返すboolean判定は、正直なところ微妙な精度です。閾値が固定であるため、カジュアルな表現を過剰にブロックしてしまったり、逆に明らかに有害なコンテンツを見逃したりすることがあります。\u003Cbr>そこで、APIが返すスコアに対してユーザ自身がカテゴリ別の許容ラインを調整できるようにしています。\u003C/p>\u003Cp style=\"text-align: start\">また、設計思想として、モデレーション全体を通じて可用性を最優先にしています。OpenAI APIが落ちていたり、レートリミットに達した場合は、投稿をブロックせず通します。匿名質問箱のモデレーションAPIが障害を起こしているからといって質問が一切送れなくなるのは本末転倒ですし、モデレーションはあくまで補助的な防衛線であって、サービスのコア機能を止めてまで守るべきものではありません。\u003C/p>\u003Cp style=\"text-align: start\">リトライは最大2回、指数バックオフで行い、429の場合は\u003Ccode>Retry-After\u003C/code>ヘッダを尊重します。全ての結果はDBに記録し、質問のPKとの紐付けも行っているため、後から監査トレイルとして追跡可能です。\u003C/p>\u003Ch2 id=\"h79eabb11ab\">レートリミットと重複検出\u003C/h2>\u003Cp style=\"text-align: start\">レートリミットはKVベースのスライディングウィンドウ方式で実装しています。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/rateLimit.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export async function checkRateLimit(\n  kv: KVNamespace | null,\n  action: string,\n  identifier: string,\n  options: RateLimitOptions,\n): Promise&lt;RateLimitResult&gt; {\n  if (!kv) {\n    return { allowed: true, remaining: options.maxRequests - 1, retryAfterSeconds: 0 };\n  }\n\n  const key = `${KV_PREFIX}${action}:${identifier}`;\n  const entry = await kv.get(key, { type: &apos;json&apos; });\n  const validTimestamps = entry.timestamps.filter(t =&gt; now - t &lt; options.windowMs);\n\n  if (validTimestamps.length &gt;= options.maxRequests) {\n    return { allowed: false, remaining: 0, retryAfterSeconds: ... };\n  }\n  return { allowed: true, remaining: options.maxRequests - validTimestamps.length - 1, retryAfterSeconds: 0 };\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">IPアドレスごとに1分間5回までの制限をかけています。加えて、直近20件のコンテンツハッシュを保持し、同一IPからの重複投稿を検出します。ハッシュはtrim・lowercase・スペース正規化した上で簡易ハッシュを取っているため、微妙な表記揺れ程度では重複として弾かれません。完全に同じ内容の連続投稿を防ぐのが目的です。\u003Cs>CG-NAT配下のグローバルなアドレスを独占しないデバイス等からの書き込みも想定できますが、まず同一の書き込みを行うことはないでしょうし、多分これで問題ないかと思っています。\u003C/s>\u003C/p>\u003Cp style=\"text-align: start\">また、KVは結果整合のためisolate間でわずかなタイムラグがあります。完璧なレートリミットにはなりませんが、in-memoryのMapだとisolateが分離されているWorkers環境では全く機能しないため、KVを使うのが現実的な落とし所だと考えました。\u003C/p>\u003Cp style=\"text-align: start\">もしKVへの接続が失敗した場合はリクエストを許可する方向に倒し、レートリミットの記録に失敗してもエラーは無視します。レートリミットが一時的に効かなくなることと、サービス自体が利用不能になることを比較すれば無難な選択をしていると思います。\u003C/p>\u003Ch2 id=\"h80bcb409bf\">質問の破棄\u003C/h2>\u003Cp style=\"text-align: start\">こうしたフィルタで不正な投稿をブロックした場合の処理にも、ひと工夫入れています。具体的には、ブロックした事実を攻撃者に伝えないよう、エラーレスポンスは返さず、あたかも質問が正常に送信されたかのような成功レスポンスを返すようにしました。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/api/v1/questions/index.post.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">if (ngWords.length &gt; 0 &amp;&amp; checkNgWords(body.content, ngWords)) {\n  return {\n    question: {\n      id: &apos;hogehogefugafuga&apos;,\n      recipientId: body.recipientId,\n      content: body.content,\n      isAnonymous: body.isAnonymous ?? false,\n      createdAt: new Date().toISOString(),\n    },\n  };\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">ここの実装で少し悩んだのは、悪意のないユーザがNGワードに引っかかった場合のことです。\u003C/p>\u003Cp style=\"text-align: start\">自分の質問が届いていないことに気づかない、という体験は決して良いものではありません。しかし、NGワードを公開してしまえばフィルタとして機能しなくなりますし、質問がブロックされたことを明示すればどの単語がNGなのかを推測される可能性があります。\u003C/p>\u003Cp style=\"text-align: start\">結局、セキュリティと利便性のトレードオフとして、ユーザに見えない形で自動的に破棄することにしました。 \u003Cbr>これが一番無難なのかなと思っています。\u003C/p>\u003Ch1 id=\"h035523fdca\">Internationalization\u003C/h1>\u003Cp style=\"text-align: start\">公開後、改修・機能追加を進める中でフロントエンドの課題として浮上したのが多言語対応です。\u003C/p>\u003Cp style=\"text-align: start\">現状、Mewkは日本語・英語・韓国語の3言語に対応しています。各言語のロケールファイルはそれぞれ15万文字前後のTypeScriptオブジェクトで、エラーメッセージ、UIラベル、通知テキスト、設定画面の説明文に至るまで、全ての文言を網羅しています。\u003C/p>\u003Cp style=\"text-align: start\">正直なところ、多言語対応は当初の要件には入っていませんでした。しかし、Misskeyのユーザ層を考えると、日本語だけでは拾いきれない潜在的なユーザがかなりいます。特に韓国語圏のMisskeyコミュニティは活発で、対応しない手はありませんでした。\u003C/p>\u003Cp style=\"text-align: start\">この翻訳作業にClaude Codeが非常に役立ちました。日本語のファイルを渡して「これを英語に翻訳して」「これを韓国語に翻訳して」と指示するだけで、文脈を理解した上でかなりの精度で翻訳してくれます。技術用語の扱いや、UIにおける文字数の感覚も概ね適切でした。もしこれを人力で全てやっていたら、それだけで数日は余計にかかっていたでしょう。\u003C/p>\u003Cp style=\"text-align: start\">実際、手動での確認も含め、2日程度で完成しています。すごいですね。\u003C/p>\u003Cp style=\"text-align: start\">数ヶ月前まで、LLMにコードを書かせるなんてとんでもないと割と本気で思っていました。\u003Cbr>いつぞやの記事では、LLMは確率的に嘘をつくことしかできない残念な存在だと書きましたし、その認識は本質的には今も変わっていません。ただ、実際に使ってみると、翻訳やボイラープレートの生成、リファクタリングの提案といった、ある程度パターンが決まった作業においては驚くほど有用です。\u003C/p>\u003Cp style=\"text-align: start\">とはいえ、放置するととんでもない実装をすることがあります。\u003Cbr>勝手にエラーハンドリングを追加したり、聞いてもいない最適化を施したり、存在しないAPIを自信満々に呼び出したり。「ドキュメントに書いてあることだけをやってね」と明確に指示しないと、暴走が始まります。結局のところ、LLMが吐き出したコードを正しく評価し、取捨選択できるだけの知識と勘所がなければ、道具として使いこなすことはできないのでしょう。\u003C/p>\u003Cp style=\"text-align: start\">コードが書けない人間がLLMでコードを書ける時代が来た、みたいな言説は相変わらずナンセンスだと思います。\u003Cbr>LLMは優秀な手下ではありますが、それを使いこなすためには、吐かれた成果物を評価できるだけの能力が使う側に求められます。\u003C/p>\u003Cp style=\"text-align: start\">LLMによって開発者の仕事がなくなるのではなく、開発者がLLMを使うことでより多くのことを、より速くできるようになる。それだけの話です。\u003C/p>\u003Cp style=\"text-align: start\">職を失うことは当面なさそうで残念です。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>開発開始から2週間で公開できたとはいえ、体感としては思ったより時間がかかりました。\u003Cbr>いや、2週間で公開しているのだから客観的には速い方なのでしょうが、開発中は「こんなに時間がかかるはずじゃなかった」という感覚が常につきまとっていました。\u003C/p>\u003Cp>質問の送受信、MiAuth認証、MFMレンダリングといった主要機能の実装自体はそこまで複雑ではなかったのですが、それ以外のあまり目立たない機能の実装が、開発時間の大部分を占めました。機能を作ることよりも、その機能が悪用されないようにすることの方が難しい。これはサービス開発における普遍的な教訓なのだと思います。\u003C/p>\u003Cp>ありがたいことに、リリースから1ヶ月少々が経過した現在、約2,000人ものユーザにご登録いただき、日々たくさんの質問が飛び交っています。\u003Cbr>直近では、ユーザから「MFMが使える質問箱が欲しかった」「新着質問の通知がMisskeyへ届いて嬉しい」、「韓国語に対応していてありがたい」といったフィードバックをいただいており、苦労が報われたような気がします。\u003C/p>\u003Cp>自分が不便だから、自分が欲しいから作ったサービスが、結果的にこれほど多くの方に役立てているのだとすれば、開発者としてこれ以上の喜びはありません。\u003Cbr>まだまだ細かい改善点や追加したい機能は山積みですが、引き続きモダンなエコシステムの恩恵を最大限に享受しながら、運用と開発を続けていこうと思います。\u003C/p>\u003Cp>最後になりますが、Mewkの顔である可愛いロゴデザインや、プロダクト全体の色彩設計は\u003Ca href=\"https://cinnamon.works/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">しなもんさん\u003C/a>にやってもらいました。本当に大感謝です🙏🏻\u003C/p>\u003Cp>長い駄文にお付き合いいただき、ありがとうございました。\u003Cbr>それでは。\u003C/p>",[70,71,72,73,74],{"id":44,"createdAt":45,"updatedAt":46,"publishedAt":45,"revisedAt":46,"slug":47,"name":48},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":30,"createdAt":31,"updatedAt":32,"publishedAt":31,"revisedAt":32,"slug":33,"name":34},{"id":52,"createdAt":53,"updatedAt":54,"publishedAt":53,"revisedAt":54,"slug":55,"name":56},{"id":58,"createdAt":59,"updatedAt":60,"publishedAt":59,"revisedAt":60,"slug":61,"name":62},{"id":76,"createdAt":77,"updatedAt":78,"publishedAt":78,"revisedAt":78,"title":79,"content":80,"tags":81,"is_no_index":35},"8-d49ulp-r","2026-03-04T11:33:54.209Z","2026-03-04T11:41:56.073Z","LLMに.envを読まれたら困る環境があるらしい","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>最近、自律型LLMエージェントの普及に伴ってか、ローカルの\u003Ccode>.env\u003C/code>ファイルから機密情報が漏洩するのではないか、という話題を頻繁に見かけるようになりました。LLMがウェブ検索などを実行した際、悪意のあるサイトからプロンプトインジェクションを受け、意図せずローカルの環境変数を外部サーバへ送信してしまう。このコンテクスト汚染と呼ばれる新たな脅威モデルに対して、AIエンジニア界隈(笑)では強い警鐘が鳴らされ、実行時にのみ環境変数を注入して隠蔽するような対策ツールが\u003Cs>車輪の再\u003C/s>発明されたりと、ちょっとしたパニックのような様相を呈しています。\u003C/p>\u003Cp>間接的なプロンプトインジェクションという攻撃手法自体は、技術的に成立し得るものであり、セキュリティの観点から興味深いテーマであることは間違いありません。しかし、この「\u003Ccode>.env\u003C/code>を読まれるのが怖い」という人々の過剰な反応を眺めていると、わたしはどうしても純粋な疑問を抱かずにはいられません。彼らが本当に恐れるべきなのは、LLMエージェントの未知の挙動などではなく、単に自らの環境構築の杜撰さと、基礎知識が欠落しているという事実ではないでしょうか。\u003C/p>\u003Ch1 id=\"h260af987f2\">ローカルに本番キーを置くという異常性\u003C/h1>\u003Cp>まず、この話題において最も根本的な前提として問うべきなのは、「なぜ手元環境に、Productionのクリティカルなクレデンシャルが存在しているのか」という点です。\u003C/p>\u003Cp>「LLMに\u003Ccode>.env\u003C/code>を読み取られて、本番DBや決済APIのSecretsが外部に抜かれたらどうするんだ」と声高に叫んでいる人々は、そもそも開発者が普段持ち歩き、様々なネットワークに接続するラップトップ端末の平文ファイルに、システムの中枢へアクセスするための鍵が鎮座している状況の異常性に気づいていません。LLMエージェントの脅威を論じる以前の問題として、そのシステム運用と開発フローは根底から破綻しています。\u003C/p>\u003Cp>現代のWeb開発において、まともなインフラ設計とCI/CDパイプラインが構築されていれば、本番環境のシークレット情報はクラウドプロバイダが提供するセキュアなシークレットマネージャ等で一元管理されるべきものです。それらの情報は、デプロイ実行時や、あるいは実行環境の立ち上げ時にのみ、安全な経路で注入されるのが妥当なアーキテクチャでしょう。\u003C/p>\u003Cp>ローカルの\u003Ccode>.env\u003C/code>に記述されるべきは、ローカル環境で立ち上げたモックのエンドポイントや、使い捨ての開発用クレデンシャルのみであるはずです。自らの手で作り出した旧態依然とした巨大な技術的負債を放置したまま、新しいツールの危険性を嘆く姿は、控えめに言って滑稽に映ります。\u003C/p>\u003Ch1 id=\"h2ea620ba31\">開発用キーは使い捨て\u003C/h1>\u003Cp>百歩譲って、「Productionのキーではなく、開発環境で利用しているLLMのAPI Secretや、サードパーティのテストキーが漏洩するのも困るのだ」という反論があるかもしれません。しかし、これについても権限管理とライフサイクルの基本に立ち返れば、夜も眠れなくなるほど大騒ぎするような事態にはなり得ません。\u003C/p>\u003Cp>外部のAPIを開発用途で利用する場合、そのアクセスキーは万が一漏洩したとしても、Revokeしてローテーションするという前提で運用されて然るべき、単なる使い捨てのものに過ぎません。仮に、LLMエージェントがコンテクスト汚染を受け、その開発用キーがどこかの悪意あるサーバに送信されてしまったとしても、該当キーをRevokeし、新しいものを再発行するだけの、ほんの数分の作業で事態は収束します。\u003C/p>\u003Cp>事前に利用金額のハードリミットを適切に設定しておき、スコープを最小化しておくという最小権限の原則を守っていれば、大した金銭的な被害も生じません。もし、開発用のキーが漏洩しただけでリカバリ不能なダメージを受けるような設計になっていたり、キーをRevokeする運用フローすら確立されていないのだとすれば、それはLLMの暴走のせいではなく、単なる設計の怠慢です。\u003C/p>\u003Ch1 id=\"h4b5fc8ef25\">抽象化のツケと無知の露呈\u003C/h1>\u003Cp>さらに言えば、この騒動は、システム全体における脅威モデルを著しく見誤っています。彼らは\u003Ccode>.env\u003C/code>ファイルの内容が外部に送信されることばかりを恐れていますが、LLMエージェントがローカル環境で任意のコマンドを実行できる権限を持っている状態で乗っ取られた場合、想定される被害は環境変数の流出程度にとどまりません。\u003C/p>\u003Cp>もしLLMがターミナルを自由に操作できるのであれば、攻撃者は\u003Ccode>.env\u003C/code>など見向きもせず、ホームディレクトリの隠しフォルダに直接アクセスし、本番サーバへのアクセス権を持つSSHの秘密鍵をごっそり抜き取ることでしょう。あるいは、ブラウザのプロファイルからセッションファイルやCookieを抽出し、あらゆるクラウドサービスへのアクセス権を奪取することすら容易に想像がつきます。\u003C/p>\u003Cp>つまり、平文の\u003Ccode>.env\u003C/code>ファイルを隠すためだけの小手先のツールを導入して安堵したところで、それは根本的な解決には全くなっていません。真にこの脅威に向き合うのであれば、LLMエージェントそのものをdevcontainerのような完全に隔離されたサンドボックス環境内で実行し、ホストOSのファイルシステムへのアクセス権限を根本から制限するアーキテクチャを組むのが本筋というものです。\u003C/p>\u003Cp>少し前に、「LLMにGitを爆破された」「大事な未コミットのコードを消された」と騒いでいた人たちを見かけましたが、今回の騒動も全く同じ構図です。多くの先人たちが積み上げ、高度に抽象化してくれた便利なツールの表層だけを啜り、バージョン管理や権限分離といった土台となる基本原則を理解しようとしない。ツールが賢くなる速度に対して、それを使う側のレベルが低すぎるだけな気もしますけどね。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>LLMエージェントは、私たちの開発体験を劇的に向上させてくれる便利な道具ですが、同時に、私たちがこれまで見て見ぬふりをしてきた運用上の手抜きや、基礎的な理解の欠如を容赦なく炙り出す試薬でもあります。\u003C/p>\u003Cp>粗探しをして無闇に恐れる前に、まずは自分自身の足元の開発環境と、技術者としての基本作法をもう一度見直してみてはいかがでしょうか。\u003C/p>\u003Cp>それでは。\u003C/p>",[82,83,84],{"id":23,"createdAt":24,"updatedAt":25,"publishedAt":24,"revisedAt":25,"slug":26,"name":27},{"id":30,"createdAt":31,"updatedAt":32,"publishedAt":31,"revisedAt":32,"slug":33,"name":34},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":86,"createdAt":87,"updatedAt":88,"publishedAt":88,"revisedAt":88,"title":89,"content":90,"tags":91,"is_no_index":35},"qxerlbel9l1j","2026-01-28T14:05:23.242Z","2026-01-29T12:09:47.600Z","TwitterではOGPにWebPが使えないみたい","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>先日、Twitterを何気なく眺めていたときに相互の子がここのURLを投稿してくれているのが目に留まりました。\u003C/p>\u003Cp>自分の書いた記事が共有されているのは嬉しいことですが、その投稿を見て一つ気になる点がありました。本来ならアイキャッチ画像が表示されるはずのOGPが、画像なしのグレーの表示になっていたのです。\u003C/p>\u003Cp>環境の問題かと思い何度かリロードしてみたり、別の端末で確認してみたりしましたが、状況は変わりません。どうやら、私のサイトのOGP設定に何か不具合が起きているようでした。\u003C/p>\u003Ch1 id=\"h1f37091dcb\">WebP移行の落とし穴\u003C/h1>\u003Cp>実はこのサイト、かなり前の改修でパフォーマンス向上を目的として、配信する画像のフォーマットを全面的にWebPへ統一していました。WebPは軽量で取り回しやすく、現代においては事実上のスタンダードと言える形式です。\u003C/p>\u003Cp>その際、主要なブラウザでの表示確認は行っていましたが、TwitterでのOGP表示に関しては特に個別の検証をしていませんでした。というのも、\u003Ca href=\"https://developer.x.com/en/docs/x-for-websites/cards/overview/markup#:~:text=URL%20of%20image%20to%20use,SVG%20is%20not%20supported.\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">Twitterの公式ドキュメント\u003C/a>にはサポートされる画像形式としてWebPが明記されていたため、「公式が言ってるんだから大丈夫だろう」と高を括っていたのです。\u003C/p>\u003Cp>おそらく、その改修を行った時点からずっと、Twitter上では画像が表示されない状態が続いていたのだと思われます。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/8b9ca34d125749b48cc329940674b108/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88%202026-01-28%2022.21.16.png\" alt=\"\" width=\"589\" height=\"252\">\u003C/figure>\u003Cp>\u003C/p>\u003Ch1 id=\"h63fb55d34f\">記事ページだけ表示されない\u003C/h1>\u003Cp>状況を詳しく確認してみると、サイト内のすべてのページで画像が表示されないわけではありませんでした。\u003C/p>\u003Cp>サイト内にある\u003Ccode>/\u003C/code>, \u003Ccode>/about\u003C/code>のような固定ページは、Twitter上でも問題なく画像が表示されています。一方で、ブログのメインコンテンツである記事ページやタグページのURLを投稿すると、画像が表示されません。\u003C/p>\u003Cp>特定のページだけが表示されないという挙動から、metaタグの記述ミスではなく、画像ファイルそのものや配信方法に原因があるのではないかと推測しました。そこで、正常に表示されているページと、表示されないページの違いを比較してみることにしました。\u003C/p>\u003Ch1 id=\"h9c1f22bd6d\">原因\u003C/h1>\u003Cp>調査の結果、両者の決定的な違いは画像のファイル形式にありました。\u003C/p>\u003Cp>表示されている固定ページのOGP画像は、サーバー上に配置された静的なPNGファイルを指定していました。対して、表示されない記事ページの画像は、記事ごとにアイキャッチを生成するAPIを経由しており、動的に生成されたWebP画像を指定していました。\u003C/p>\u003Cp>このサイトの仕様として、APIはリクエストに応じてWebP形式で画像を返し、レスポンスヘッダーのContent-Typeも \u003Ccode>image/webp\u003C/code> を正しく設定しています。Webの標準仕様に準拠した実装であり、ブラウザで直接URLを開けば画像は問題なく表示されます。\u003C/p>\u003Cp>\u003Ca href=\"https://github.com/chan-mai/mq1-web/blob/33aabbbebed9ab839ce6d6759753be2cbcd06a3e/server/api/og/article/%5BcontentId%5D.ts#L49-L55\">https://github.com/chan-mai/mq1-web/blob/33aabbbebed9ab839ce6d6759753be2cbcd06a3e/server/api/og/article/%5BcontentId%5D.ts#L49-L55\u003C/a>\u003C/p>\u003Cp>このサイトでは、通常のブラウザ向けにはWebPを標準としていますが、どうやらTwitterのクローラーに対しては、WebP形式の画像は正しく認識されないようです。私のサイト環境においては、動的・静的問わず、WebPを指定している箇所でOGPが表示されるケースはありませんでした。\u003C/p>\u003Ch1 id=\"h9ebabf7874\">公式ドキュメントと実挙動の乖離\u003C/h1>\u003Cp>納得がいかないのは、\u003Ca href=\"https://developer.x.com/en/docs/x-for-websites/cards/overview/markup#:~:text=URL%20of%20image%20to%20use,SVG%20is%20not%20supported.\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">Twitter公式のDeveloper Platform\u003C/a>にある\u003Ccode>twitter:image\u003C/code>の仕様記述です。そこには明確にこう書かれています。\u003C/p>\u003Cblockquote>\u003Cp>URL of image to use in the card. Images must be less than 5MB in size. JPG, PNG, WEBP and GIF formats are supported. Only the first frame of an animated GIF will be used. SVG is not supported.\u003C/p>\u003C/blockquote>\u003Cp>「JPG, PNG, WEBP and GIF formats are supported」と、はっきりと書かれているのです。\u003C/p>\u003Cp>しかし現実は、ドキュメント通りにWebPを設定しても表示されません。この現象について相互の子に話を聞いたところ、全く同じ挙動に悩まされた経験があり、結局はWebPを諦めてPNG配信に切り替えることで解決したそうです。個別の環境依存というよりは、プラットフォーム側でWebPへの対応が不完全、あるいは不安定である可能性が高そうです。\u003C/p>\u003Cp>ドキュメント通りの正しい実装をしているにもかかわらず、プラットフォーム側の都合で弾かれてしまうというのは納得しづらい部分ですが、外部サービスに依存している以上、こちらが合わせるしかありません。\u003C/p>\u003Ch1 id=\"hd6164b4a9e\">PNG形式に戻して解決\u003C/h1>\u003Cp>原因がプラットフォーム側のWebP対応状況にあると判断したため、OGP画像の生成ロジックを変更することにしました。\u003C/p>\u003Cp>\u003Ca href=\"https://github.com/chan-mai/mq1-web/blob/8899159ec6a9901fcdc70933b572cf63a4f47ddc/server/api/og/article/%5BcontentId%5D.ts#L49-L55\">https://github.com/chan-mai/mq1-web/blob/8899159ec6a9901fcdc70933b572cf63a4f47ddc/server/api/og/article/%5BcontentId%5D.ts#L49-L55\u003C/a>\u003C/p>\u003Cp>バックエンドの処理を修正し、OGP用画像のリクエストに対しては、PNGを強制して返すように変更しました。本来であればファイルサイズの小さいWebPを使いたいところですが、OGPが表示されないデメリットの方が大きいため、ここはやむを得ない対応です。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/91b8bee5f7384b60b7ba80ee88c24a37/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88%202026-01-28%2022.22.13.png\" alt=\"\" width=\"589\" height=\"430\">\u003C/figure>\u003Cp>修正をデプロイして確認したところ、PNGになった画像はTwitterに認識され、カードが正常に表示されるようになりました💩\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>公式ドキュメントには「WebP対応」と明記されているのに、実際には動かない。開発者としてはモヤっとしますが、プラットフォーム側の都合には逆らえません。\u003C/p>\u003Cp>同じようにTwitterのOGP問題で頭を抱えている方の参考になれば嬉しいです。\u003C/p>",[92,93],{"id":30,"createdAt":31,"updatedAt":32,"publishedAt":31,"revisedAt":32,"slug":33,"name":34},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":95,"createdAt":96,"updatedAt":97,"publishedAt":97,"revisedAt":97,"title":98,"content":99,"tags":100,"is_no_index":35},"881vyfdq_5","2026-01-12T19:08:19.276Z","2026-01-12T19:14:08.297Z","さようならVercel, こんにちはGCP","\u003Cp>こんばんは。あるいはこんにちは。\u003C/p>\u003Cp>実はお気づきの方もいらっしゃるかもしれませんが、このサイト、数ヶ月前にインフラのお引越しをしています。暫くお世話になっていたVercel基盤を離れ、現在はGCPの上で稼働しています。移行作業からしばらく経ち、運用も安定していて特に問題なさそうなので、ここらで重い腰を上げて移行の経緯でも書き残しておこうかなと思います。\u003C/p>\u003Cp>今回、以降に踏み切った理由はいろいろとあるのですが、きっかけとして大きかったのは、単純にVercelの無料枠を使い果たしてしまったことです。個人の趣味サイトとはいえ、トラヒックが増えればリソースも食いますし、記事数が増えれば(移行当時は部分的にSSG構成だったので)ビルド時間も伸びます。もちろん、お金で殴ってプランを上げてしまえばそのまま使い続けることは造作もないことなのですが、そこでふと損得勘定が働いてしまいました。毎月10USDを固定で払うくらいなら、必要な分だけ課金されるCloud Runで動かした方が、結果的にはお財布に優しいしお得なんですよね。\u003C/p>\u003Cp>そして2つ目。実はこっちの方が心情的な割合としてはかなり大きいのですが、VercelのCEOであるGuillermo Rauch氏の倫理観に対して、どうしても拭えない不信感を抱いてしまったという点です。ご存じの方も多いかと思いますが、彼がイスラエルのネタニヤフ首相と会談し、ML技術について議論を交わし、その後に満面の笑みで撮影したセルフィーをTwitterに投稿したあの一件です。\u003C/p>\u003Cp>相手は国際刑事裁判所から指名手配を受け、ガザでのジェノサイド容疑もかけられている人物です。政治的なスタンスは人それぞれあるにせよ、公人として、tech企業のトップとして、そのセルフィーを世界に発信することの意味をどう捉えているのか。本当にこの人の倫理観はどうなっているんだろうと、背筋が寒くなるような思いでした。\u003C/p>\u003Cp>「たかが個人の趣味サイトなんだから、プラットフォームのCEOの思想なんて関係ないじゃん」と言われればそれまでですし、別にわたしのサイトは法人の看板を背負っているわけでも、社会的な信用を切り売りしている商売でもありません。実害は全然ないんです。でも、それはそれとして、一度すこしでも嫌悪感を感じてしまうと、それ以外の部分まで全部が色褪せて見えてしまうのものです。所謂、坊主憎けりゃ袈裟まで憎いというやつです。\u003C/p>\u003Cp>そうなると不思議なもので、元々は妥協していた小さな不満が、急に許せなくなってきます。例えば、NuxtImgを使った画像最適化の取り回しについて、Vercelプロバイダを利用するときだけ妙に設定が面倒だったり、挙動に癖があったりして、結局対応するのが億劫でipxを利用して回避していた事なんかが思い出されます。\u003C/p>\u003Cp>あとはあれですね、令和の現代においても未だにIPv6をネイティブでサポートしていない点とか。先進的なフロントエンド体験を謳っているプラットフォームなのに、足回りのネットワークに関しては意外と適当なんだなと、以前から感じていた違和感が嫌悪感に変わりました。そういう小さな「うーん」という澱みのようなものが積み重なって、最終的に「もう全部嫌だ、引っ越そう」という決断に至ったわけです。\u003C/p>\u003Cp>移行先にGCPを選んだ理由は至ってシンプルで、安くて使いやすくて、なんとなくいい感じだからです。ありがたいことに無料枠もそれなりに充実していますし、貧乏開発者には優しい環境が整っています。\u003C/p>\u003Cp>ちなみに、このサイトを作り始めたちょうど1年くらい前は、Cloudflare Workersを利用していました。なので今回もWorkersに出戻りすることを真っ先に検討したのですが、いざ検証してみると壁にぶつかりました。現在の構成がNode.js依存の実装を含むライブラリとズブズブの関係になってしまっており、Workersのランタイム環境では動かすのが厳しかったのです。加えて、仮にPaidプランにしたとしても、ランタイムサイズの制限を大幅に超過してしまうことは明白でした。\u003C/p>\u003Cp>あれこれ悩んだ結果、CockroachDBがちょうどGCP上にあったこともあり、それならアプリケーションも同じGCPに置いてしまった方が合理的だという結論に達しました。結果的に、GCPへの集約は正解だったと思います。\u003C/p>\u003Cp>インフラの構築に関しては、例によって私はGUIの管理画面をポチポチして手動で設定するのが大嫌いなので、今回も全てTerraformを使ってIaC化しています。人の温もりを感じるインフラなんて、いつ壊れるかわからない時限爆弾みたいなものですからね。\u003C/p>\u003Cp>あ、そうそう、ついでにGoogle Fontsもやめました。Lighthouseのパフォーマンススコアの足を引っ張っていて邪魔だったので思い切って削除したのですが、代わりのシステムフォントはどうにも気持ち悪くて、納得できませんでした。結局、Adobe Fontsを導入してFutura-PTを利用することにしました。そのせいで削除前よりもLighthouseのスコアは低下してしまったのですが、見た目が圧倒的にかわいくなったのでよしとしています。\u003C/p>\u003Cp>最適化については、またいつか気が向いた時にでも頑張ろうと思います。\u003C/p>\u003Cp>それでは。\u003C/p>",[101,102,103,104,105],{"id":30,"createdAt":31,"updatedAt":32,"publishedAt":31,"revisedAt":32,"slug":33,"name":34},{"id":52,"createdAt":53,"updatedAt":54,"publishedAt":53,"revisedAt":54,"slug":55,"name":56},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":23,"createdAt":24,"updatedAt":25,"publishedAt":24,"revisedAt":25,"slug":26,"name":27},{"id":106,"createdAt":107,"updatedAt":108,"publishedAt":107,"revisedAt":108,"slug":109,"name":110},"4oy2jc8mdg","2025-04-28T07:14:01.179Z","2025-12-03T16:08:24.495Z","diary","日記",{"id":112,"createdAt":113,"updatedAt":114,"publishedAt":115,"revisedAt":114,"title":116,"content":117,"tags":118,"is_no_index":35},"qqupfxdxo","2026-01-08T17:11:19.647Z","2026-01-09T05:27:38.651Z","2026-01-08T19:30:52.271Z","「LLMがプログラミングするのなら直接マシン語書けるんじゃね？」←そんなわけがない","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp style=\"text-align: start\">ここ最近、なんちゃって技術界隈で、なんだかすごい言説が流れてくるようになりました。\u003C/p>\u003Cp style=\"text-align: start\">曰く、「現代の高級言語は人間がバイナリを読み書きするための単なる中間言語に過ぎないのだから、LLMが直接実行可能なバイナリを生成すればいいのではないか」とのこと。これを聞いた瞬間、まともな技術者なら、苦笑い、爆笑、冷笑(あるいはその他の否定的な感情)を禁じ得なかったのではないでしょうか。\u003C/p>\u003Cp style=\"text-align: start\">彼らの言う、LLMでプロダクションレベルのコードが書ける時代(笑)では、それが合理的なものに見えているのかもしれません。しかしそれは「明日から全員、口で喋るのをやめて脳波で直接テレパシー通信しようよ！！」と言われるくらい、現実味のない笑い話です。\u003C/p>\u003Cp style=\"text-align: start\">なぜ、現代のLLMによるバイナリコード出力がこれほどまでにナンセンスで、実現不可能なお笑い草なのか。技術的な無理解とその無謀さをあえて基礎的な観点から整理して笑い飛ばしてみましょう。だいたいそういう内容の記事です。\u003C/p>\u003Ch1 id=\"h6bc683b58b\">Excel方眼紙の呪い\u003C/h1>\u003Cp style=\"text-align: start\">人間が直接コードを書かず、何か別の上位概念からシステムを生成しようという発想自体はそう新しいものではありません。LLMが覇権を握る以前から、この国では「Excel方眼紙さえ完璧ならシステムは動く」と信じて疑わない人々が彷徨っていました\u003C/p>\u003Cp style=\"text-align: start\">「設計書さえ完璧に記述すれば、あとは誰がコードを書いても同じ。コードなんてただの実装作業でしかないんだから。」\u003C/p>\u003Cp style=\"text-align: start\">そう謳って構築された、数千億円規模とも言われるの某銀行の巨大システム群が何を生み出したか。それは、誰も全容を把握できず、メンテナンス不能に陥った巨大なブラックボックスでした。\u003C/p>\u003Cp style=\"text-align: start\">ちなみに、某銀行のシステムが、特定のツールで作られたかどうかなど些末な問題です。ここでの本質的な問題は、マルチベンダによる巨大な分業体制の中で、設計と実装を完全に切り離し、言い換えれば、人をコンパイラのように扱ったことにあります。\u003C/p>\u003Cp style=\"text-align: start\">その結果どうなったか。コードを見なくていいはずが、コードと乖離し続ける膨大な設計の整合性を取るために、人間が血眼になってExcelを管理する羽目になりました。\u003C/p>\u003Cp style=\"text-align: start\">厳密な仕様定義と、優秀なエンジニアを大量投入してなお、この苦しみです。確率的にしか動かない現代の残念なLLMにバイナリを生成させればどうなるか、想像に難くありません。\u003C/p>\u003Cp style=\"text-align: start\">実装を軽視し、抽象的な指示だけでシステムが動くという妄想は、過去に我々が大量のお金と時間をドブに捨てて学んできた、SIer仕草の悪しき再生産に他なりません。出来上がるのはシステムではなく、誰も中身を理解できず、修正も検証も不可能な巨大な産業廃棄物です。行く先にはまたしても、何人もの屍が積み上がることでしょう。\u003C/p>\u003Cp style=\"text-align: start\">中には、\u003Ca href=\"https://x.com/takeshy/status/2009130068149408150\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">こんなこと\u003C/a>を本気で言う人もいます。\u003C/p>\u003Cp style=\"text-align: start\">「なんか不具合あったらAIに直してって言えば、バイナリ解析して修正してくれるんでしょ？(意訳)」\u003C/p>\u003Cp style=\"text-align: start\">なるほど。原因の特定も、修正の妥当性も、影響範囲の検証も、すべて省略できる魔法の言葉があるらしい。もしそれが本当なら、我々はとっくにデバッガもテストもCIも捨てているはずです。\u003C/p>\u003Ch1 id=\"h07e54e9c97\">高級言語はセマンティクスを定義する\u003C/h1>\u003Cp style=\"text-align: start\">これら主張の根底には、プログラミング言語は任意バイナリへの変換を楽にするための中間言語であるという誤った認識があります。これは大きな間違いです。\u003C/p>\u003Cp style=\"text-align: start\">現代において、高級言語の本質は、ハードウェアの抽象化だけではなく、システムが何であるかというセマンティクスの定義そのものです。そしてさらに重要なのは、高級言語は表現力を上げると同時に、意図的にできないことを増やしているという点です。\u003C/p>\u003Cp style=\"text-align: start\">RustやGo、TypeScriptなどの現代的な言語を見れば明らかですが、これらは「メモリ領域を勝手に操作できない」「型が合わない計算はできない」といった強力な制約を持っています。なぜそんな制約を入れるのかといえば、自由度と検証可能性がトレードオフの関係にあるからです。\u003C/p>\u003Cp style=\"text-align: start\">制約があるからこそ、コンパイラは「このバイナリは言語仕様上のメモリ安全性が担保されている」「この並行処理は競合しない」といった、ある意味での数学的な正しさを検証できます。LLMに生のバイナリを直接扱わせるということは、安全性を担保するための制約を全てかなぐり捨て、メモリ破壊もセキュリティホールも作り放題の無法地帯に放り込むことを意味します。ガードレールを撤去してゴツいクソデカスポーツカーを自動運転させるようなもので、事故が起きないわけがありません。\u003C/p>\u003Ch1 id=\"h42b14bd67b\">コンテクストウィンドウとトークンコスト\u003C/h1>\u003Cp style=\"text-align: start\">現実的なリソースとお金の話をしましょう。\u003C/p>\u003Cp style=\"text-align: start\">実行バイナリの情報密度は、高級言語で記述されたソースコードに比べて極めて低く冗長です。例えば、Goで\u003Ccode>fmt.Print(&quot;Hello world!&quot;)\u003C/code>と書けば済む処理も、バイナリになれば、システムコールの番号をレジスタに入れ、メモリアドレスを指定し、割り込みを発生させ、スタックを退避し…といった細かな命令コードの羅列になります。ソースコードなら数行で済むロジックが、バイナリデータとしては数KiB, MiBに膨れ上がります。\u003C/p>\u003Cp style=\"text-align: start\">現在のLLMには、コンテクストウィンドウの限界があります。前述のような、意味の詰まった高級言語でさえ、大規模なシステムを読み込ませればコンクキスト溢れを起こして使い物にならないのが現状です。そこに、冗長極まりないバイナリを流し込めばどうなるか。一瞬で埋まり、LLMがまともに期待する動作をしてくれることはまずないでしょう。\u003C/p>\u003Cp style=\"text-align: start\">最近ではだいぶ安くなったものの、LLMの出力コストは、安いものでも1万トークンあたり数円〜数十円かかります。数MiBにも及ぶバイナリデータを生成させるために、数万、数十万円の金銭を支払うつもりでしょうか？\u003C/p>\u003Cp style=\"text-align: start\">手元のPCにあるコンパイラを使えば、事実上0円かつ数秒で、正確に変換してくれる作業を、わざわざ高コストで不確実性のあるLLMにやらせる合理性は、どこにも存在しません。\u003C/p>\u003Cp style=\"text-align: start\">まともな頭があればこのくらい分かりそうなものですけどね。\u003C/p>\u003Ch1 id=\"h26b276b313\">コンパイラがどうの\u003C/h1>\u003Cp style=\"text-align: start\">｢コンパイラなんてただのバイナリ変換機だろう｣と思っているなら、それも認識を改めるべきです。現代のコンパイラ多くは、過去数十年にわたる人類の英知と膨大な検証の結晶です。\u003C/p>\u003Cp style=\"text-align: start\">コンパイラは単に変換しているだけではありません。ループの展開やデッドコードの削除、パイプラインを停滞させないための命令順序の並べ替え、レジスタ割り当ての最適化など、高度な処理を厳密に行っています。同じソースコードを食わせても、コンパイルオプション一つで生成されるバイナリは大きく変わってしまいます。\u003C/p>\u003Cp style=\"text-align: start\">良くも悪くも、LLMは確率的な出力をするものです。なんとなくそれっぽい文字列を生成することには長けていますが、バイナリを正しく生成するなんてことは当然不可能です。\u003C/p>\u003Cp style=\"text-align: start\">残念なことに、世界には無数の環境があり、命令セットも多岐にわたります。コンパイラは、これらの組み合わせの差異を吸収して、それぞれの環境に最適なバイナリを出し分けてくれています。これをLLMにやらせるということは、OSやプロセッサごとの微妙な仕様の違いやシステムコールの番号を網羅的に充分な量の学習をさせ、環境ごとに毎回何MiBものバイナリを生成し直させるということです。正気の沙汰とは思えません。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp style=\"text-align: start\">ひとつ、勘違いしてはいけないことがあります。\u003C/p>\u003Cp style=\"text-align: start\">各社が提供しているエージェントは、決して論理を組み立てているわけではありません。あれは膨大な学習データから次に来そうな文字を確率的に吐き出しているだけであり、そこに意思も、理解も、厳密なロジックも存在しません。\u003C/p>\u003Cp style=\"text-align: start\">だからこそ、私たちは高級言語を使うのです。\u003C/p>\u003Cp style=\"text-align: start\">確率的に嘘をつくことしかできない残念なLLMが吐き出したコードを確認した上で、コンパイラという決定論的な論理エンジンに通すことで、初めて整合性を担保できるからです。\u003C/p>\u003Cp style=\"text-align: start\">LLMが直接バイナリを書けばいいなどという妄言は、これらの前提条件を捨てているといっても過言ではありません。\u003C/p>\u003Cp style=\"text-align: start\">少なくとも技術者を名乗るのであれば、LLMガチャ師仕草をやめて、その残念な自らの頭を活用してみてはいかがでしょうか？\u003C/p>\u003Cp style=\"text-align: start\">驚き屋を追いかけてのお勉強(笑)には熱心なのに、抽象化された高級言語すらまともに読み書きできないのであれば、技術者としてお話になりません。\u003C/p>",[119,120,121],{"id":23,"createdAt":24,"updatedAt":25,"publishedAt":24,"revisedAt":25,"slug":26,"name":27},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":30,"createdAt":31,"updatedAt":32,"publishedAt":31,"revisedAt":32,"slug":33,"name":34},{"id":123,"createdAt":124,"updatedAt":125,"publishedAt":125,"revisedAt":125,"title":126,"content":127,"tags":128,"is_no_index":35},"e-3nu72af97k","2025-12-29T18:01:40.332Z","2025-12-30T10:20:45.448Z","非公式Misskeyサーバーリストをつくった","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>Misskeyのサーバー探しで困ったことはありませんか？公式のリストが止まっていたり、実態のわからないサーバーが混ざっていたり。そんな状況に業を煮やし、最新の状況を自動で反映し続けるサーバーリストを作成しました。\u003C/p>\u003Cp>\u003Ca href=\"https://servers.misskey.ink/\" target=\"_blank\" rel=\"noopener noreferrer\">https://servers.misskey.ink/\u003C/a>\u003C/p>\u003Cp>本当は今年のMisskey Advent Calendarのネタにしようかとも思っていたのですが、どうせ技術的な記事は他の方々がたくさん書かれているだろうし、何より当時のUIはちょっと見せびらかすには残念な出来だったので見送っていました。 そんなわけで、諸々がいい感じに仕上がってきたので、少し遅れての登場です。\u003C/p>\u003Cp>私が (Unofficial) Misskey Server List を作成しようと思い立った直接のきっかけは、Misskey公式のMisskeyHubに掲載されていたインスタンス一覧が、長期メンテナンスに入ったまま復旧しなくなってしまったことでした。 開発者からは「ユーザーの混乱を避けるため」といった趣旨の説明がなされていましたが、その裏にある実情は十中八九、Misskeyの派生プロジェクトであるCherryPickによる実装変更が引き金でしょう。\u003C/p>\u003Cp>現行のCherryPickは、\u003Ca href=\"https://github.com/yunfie-twitter/cherrypick/commit/98ae8b5d869bac470aad2b8f025318f2c222e432#diff-c99e10daaf6c2db57012b73a31a76d7e3bbbf9e2599ed226e2f1651cdff40c20\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">User-AgentにJoinMisskeyの文字列を含むリクエストに対してのみ、メタデータ上で自身をMisskeyであると偽装して返すという、極めて行儀の悪い実装\u003C/a>を行っています。JoinMisskeyとは、aqz氏が個人でメンテナンスを行っているインスタンス一覧のデータソースであり、MisskeyHubも内部的にこれを利用していました。その結果、本来掲載されるべきではないCherryPickのインスタンスが、Misskeyの顔をして公式リストに混入してしまう事態が発生していました。\u003C/p>\u003Cp>私自身、新しいインスタンスが生えていないか定期的に見に行っていたため、この信頼できない状況には困っていました。 「ならば、自分で納得できるクリーンなリストを作ろう」というのが、開発の出発点です。\u003C/p>\u003Ch1 id=\"h89265527fa\">本物を識別する\u003C/h1>\u003Cp>「Misskeyのインスタンスだけを集めたい。」言葉にするのは簡単ですが、いざ実装しようとすると、すぐに多様性とスプーフィングの壁にぶち当たります。ActivityPubのおかげで、MastodonもFirefishも、そして悪意ある偽装サーバーも、外向きには同じような顔をして通信してくるからです。加えて、フォーク文化が盛んであり、独自機能を追加した派生版も無数に存在します。そんな混沌としたFediverseの中から、どうやって本物と偽物を分別しているのかと言えば、幸いなことにCherryPickはJoinMisskeyに対してのみMisskeyを名乗るような仕草をしていたので、これを逆手に取りました。\u003C/p>\u003Cp>私が実装した検出機構では、単にリクエストを投げるだけでなく、意図的に異なるUAでリクエストを投げています。まず通常のブラウザなどを模したUAと、JoinMisskeyを模したUAの二種類を用意し、その双方から対象サーバーの\u003Ccode>/api/meta\u003C/code>および\u003Ccode>/nodeinfo/[2.0|2.1]\u003C/code>にリクエストを投げます。もし、ここで返ってきたJSONレスポンスの内容、特に\u003Ccode>softwareName\u003C/code>やバージョン情報に矛盾がある場合、あるいは相手によって応答を変えるような挙動が見られた場合は、その時点で信頼できないノードとして即座に弾く仕組みにしました。\u003C/p>\u003Cp>また、なるべく全ての工程を自動化したかったので、Misskey以外のソフトウェアを機械的に判定し、排除する機構が必要でした。対象のホストがMisskeyを名乗っており、JoinMisskeyが公開している\u003Ccode>ignorehosts.yml\u003C/code>に含まれていないことは最低条件ですが、それだけでは足りません。正常なレスポンスを返すかどうかの監視に加え、レポジトリ情報の検証も徹底しています。具体的には、APIで返却されるレポジトリURLが公式のものなのか、あるいは信頼できないフォークを含まないかを厳密にチェックします。\u003C/p>\u003Cp>中にはType-4nyのように過度な独自修正が加えられて独自のブランディングを行っていてもなお、nodeinfo上ではMisskeyを自称するようなフォークも存在します。そういったものを判定するため、データベース上に保持した除外リストや、既知のレポジトリパターン判定ロジックを用いて検知を行っています。このフィルタリングロジックは適宜更新し、常に概ね純粋なMisskeyを提供しているサーバーのみを抽出できるよう調整をしています。\u003C/p>\u003Cp>中規模以上のサーバーだいたい何かしらのカスタマイズを施したMisskeyを利用しているのもあって、結局のところ「どの程度までをMisskeyと見なすか」は難しいラインではあります。しかし、そこに明瞭な指標もないので、取り敢えずは私の主観とコード上のロジックで決定しています。「これこそが公平な判定だ！」といういい感じのアイデアがある方がいれば、GitHubにPRを投げてくれれば採用するかもしれません。\u003C/p>\u003Ch1 id=\"hde3dc27fcf\">連合探索\u003C/h1>\u003Cp>静的なリストではなく、各インスタンスの現状をリアルタイムに反映するため、いくつかの定期タスクを実装しています。\u003C/p>\u003Cp>まず\u003Ccode>update\u003C/code>タスクが6時間おきに実行され、登録済みのインスタンスを検証します。ユーザー数やノート数、登録開放状況といったメタデータを最新の状態に更新し、応答がないサーバーや410を返すサーバーはリストから除外します。\u003C/p>\u003Cp>そして、新規開拓を行うのが\u003Ccode>discovery\u003C/code>タスクです。既存のリストの中で\u003Ccode>is_alive=true\u003C/code>状態のMisskeyインスタンスをランダムに抽出し、そのサーバーが持つ連合先インスタンス一覧を取得します。そこに含まれるFQDNが未登録であれば、検証プロセスへ回します。\u003C/p>\u003Cp>ActivityPubの特性上、稼働しているサーバーは高い確率で他のサーバーと連合しています。\u003Cbr>つまり、既知のサーバーを起点にネットワークを辿ることで、主要なリレーやリストに登録されていない小規模な個人サーバーであっても自動的に検出が可能になります。これにより、手動登録に頼らずともある程度網羅的なリストを作成できるエコシステムを構築しました。\u003C/p>\u003Ch1 id=\"hd04cfc55ee\">アーキテクチャ\u003C/h1>\u003Cp>システムの構成についてですが、当初はCloudflare Workers / D1 / Queuesを中心に、APIだけを提供するつもりで開発を進めていました。しかしながら、数千件に及ぶ大量の連合情報を数時間に1度収集・解析する都合上、WorkersのCPU Time Limitに引っかかり、当然耐えられませんでした。特にJSONのパースやバリデーションといったCPUインテンシブな処理が重なり、タイムアウトが頻発しました。その致命的な問題に気付いたのは、コードを書き始め、実装の半分以上が完成してからでした。\u003C/p>\u003Cp>どうせ構成を見直すなら、ついでにフロントも付けて目に見える形で多くの人に使って貰えたらなと思い立ったのもこのタイミングです。ここで技術選定を根本からやり直し、フレームワークにはNuxt4を採用、データベースにはCockroachDBを使用してアプリケーションを再構築しました。インフラ基盤はGoogle Cloud Platformで統一し、Cloud Run / Cloud Build / Cloud Scheduler / Cloud Tasks / Secrets Managerで構成しています。\u003C/p>\u003Cp>この構想から設計、実装、そしてデプロイに至るまでの全ての工程は、丸2日程度で行いました。\u003C/p>\u003Cp>全体的なアーキテクチャのイメージとしては、Cloud Schedulerが定期的にトリガーを引き、Cloud Tasksがジョブを管理し、Cloud Run上のアプリケーションがCockroachDBと連携しながら外部のMisskeyインスタンス群やGitHub APIを巡回する形になります。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/4248fb5b4e75471ab42e22d4d653b993/mi-server-list.png\" alt=\"\" width=\"1324\" height=\"878\">\u003C/figure>\u003Cp>また、Cloud Runをasia-northeast1を含む特定のリージョンで利用している際、Custom Domain Mappings を利用するとレイテンシが顕著に上昇する既知の問題がありました。これに対し Cloud Load Balancingを利用したルーティングと比較検証を行いましたが、結果として40ms 程度の違いしか見られなかったため、LB の維持コストとの兼ね合いでそのままマッピングを行っています。検索クエリを叩かなければレスポンスはキャッシュされる前提で実装をしているおかげか、ユーザー体験に顕著な差は現れませんでした。\u003C/p>\u003Cp>これらの基盤は全て Terraform でコード化して管理しています。\u003Cbr>会社でも普段はAWS基盤ばかり利用していますが、GCPの開発体験はAWSのそれと比べてだいぶ分かりやすく、直感的であるという印象を覚えます。無料枠も太っ腹なので嫌いじゃないです。\u003C/p>\u003Cp>\u003C/p>\u003Ch1 id=\"h38757ef838\">非同期分散処理\u003C/h1>\u003Cp>タスク実行基盤として、当初はCloud SchedulerからCloud Runのエンドポイントを直接呼び出していましたが、処理量の増加に伴いタイムアウトで処理が中断されるリスクが顕在化し、Cloud Tasksを導入しました\u003C/p>\u003Cp>現在のアーキテクチャでは、Cloud Schedulerはタスクの実行をエンキューする軽量な処理のみを行います。具体的には、Cloud Schedulerが\u003Ccode>/api/tasks/:name\u003C/code>を叩くと、アプリケーションは処理を直接実行せず、Google Cloud Tasksのキューにタスクを積みます。\u003C/p>\u003Cp>ここの重要な点として、キューの\u003Ccode>max_concurrent_dispatches\u003C/code>(最大同時実行数)を1に制限しています。これは、外部インスタンスへの過度なリクエストを防ぐ行儀の良さの担保と、自身のデータベース接続数の枯渇を防ぐためです。タスク名にタイムスタンプを含めて重複実行を排除し、失敗時にはCloud Tasksのリトライポリシーによって自動的に再試行される堅牢な設計としています。\u003C/p>\u003Cp>また、Cloud TasksからCloud RunへのリクエストにはOIDCトークンが付与されており、Cloud Run側でリクエストが正当な権限を持ったサービスアカウントからのものであるかを厳格に検証しています。つまり、\u003Ccode>/tasks/workers/[:name]\u003C/code>エンドポイントはインターネットに公開されていますが、実質的には内部からしか叩けない設計となっています。人間が手動でキックするためのエンドポイントには独自のBearerトークン制限をかけ、システムからの自動実行にはIAM認証を用いるいい感じのセキュリティを実現しました。\u003C/p>\u003Ch1 id=\"h08ac57933c\">言語の判定\u003C/h1>\u003Cp>実装において最もかつ苦労したのはインスタンスの主要言語判定です。ユーザーが新しいサーバーを探す際、「そこで何語が話されているか」はサーバーのテーマと同じくらい重めな条件になります。しかし、Misskeyの仕様上、言語設定は必須ではなく、説明文も英語と日本語が混在している文章や極端に短い文章で構成されているといったケースが多々あります。APIから得られる情報だけでは、そのサーバーが実際にどの言語圏のコミュニティなのかを正確に断定することが困難でした。\u003C/p>\u003Cp>既存の言語判定ライブラリもいくつか試しましたが、短文やノイズの多いテキストに対してはどれも単体では実用に耐えうる精度が出ませんでした。そこで、複数のロジックを組み合わせた合議制アルゴリズムを実装して解決することにしました。\u003C/p>\u003Cp>判定プロセスは三段階に分かれており、第一段階として、テキスト内の文字コードの範囲を走査するヒューリスティック判定を行います。日本語や中国語、韓国語のように特徴的な文字種が含まれていれば、重い処理を回すまでもなく高速に言語を確定できます。ここで判別がつかない場合は第二段階として、特性の異なる3つの言語判定ライブラリに判定を委ね、多数決投票を行います。アルゴリズムの異なるライブラリに投票をさせることで、個々の誤判定を相互に補完させる狙いです。\u003Cbr>さらに、それでも結果が割れて怪しい場合は、実際にそのサーバーのLTLから直近の投稿20件を取得し、実データのサンプリングに基づいて解析を行っています。\u003C/p>\u003Cp>最近だとLLMのAPIもだいぶ安くて、Geminiあたりを使ってもよかったのですが、自動で収集と判定を延々と繰り返している、かつMisskeyを利用する全てのインスタンス数が把握できない以上、ランニングコストの試算ができなかったため無謀すぎてやめました。もし対象が小規模かつ試算ができる状態であれば、実装コスト諸々との天秤にかけて利用していたと思います。\u003C/p>\u003Cp>また、どれだけ収集能力が高くても、ノイズだらけでは意味がありません。そのため、除外リストの運用には細心の注意を払っています。ベースとなるのはJoinMisskeyが公開している\u003Ccode>ignorehosts.yml\u003C/code>ですが、これはあくまで最低ラインです。当リストでは、これに加えて独自の検疫プロセスを導入しています。例えば、一度は正常にMisskeyと判定されたサーバーであっても、その後の定期観測でソフトウェア名の偽装や、Misskey以外の実装への変更などといった不審な変更が見られた場合、自動的にシステム側の除外テーブルに追加され、リストから排除されます。また、Misskey以外のAP実装が誤って検出された場合も、同様にフィルタリングされます。このプロセスは完全に自動化されており、私の主観が入り込む余地を排除しています。\u003C/p>\u003Ch1 id=\"hfa9126a93a\">透明性がどうのこうの\u003C/h1>\u003Cp>幸か不幸か、インターネット人間として長年活動している中で、私を嫌う方々に粘着されてしまっていて、こういうものを作ると必ずいちゃもんを付けられることが懸念されました。具体的には、「私の気に食わないサーバーを意図的に排除している」だとか、「公平性に欠く基準で恣意的にレコメンドしている」なんかがそうですね。\u003C/p>\u003Cp>まぁ、個人運営のリストに対する主張としては妥当だと思うのですが、これに関して言えば私に限らず公式だろうと似たり寄ったりだろうと思うわけです。それにしても、こういうことをイチイチ言われるのも気分がいいものではありません。\u003C/p>\u003Cp>こうした懸念を払拭するため、私は全ての情報を公開する方針をとりました。ソースコードはGitHub上にAGPL-3.0ライセンスのもと公開しており、収集した統計データ、除外リスト、そしてその除外理由に至るまで、全てAPIで取得可能です。\u003C/p>\u003Cp>特にRecommendedスコアの算出ロジックについても、コード内の\u003Ccode>calculateRecommendationScore\u003C/code>関数を見ていただければ分かる通り、完全に数式です。ユーザー数や投稿数といった規模感に加え、GitHub Releasesから取得した最新バージョンに対する追随度や、活動率を係数として掛け合わせ、機械的にスコアを弾き出しています。たとえば、バージョンが古いサーバーはセキュリティリスクと見なし、指数関数的にスコアを減衰させる処理を入れています。そこに私の主観やお気持ちが入る余地は当然1ミリもありません。\u003C/p>\u003Cp>実際、この懸念は杞憂ではなくて、公開直後にある方から直々に文句を言われてしまいました。こういうの言ってくる人って、自身の主張に妥当性があるかどうかすら考えないんですかね。コードも公開データも見ずに、イメージだけでいちゃもんをつけて来るのはかなり無理があります。文句があるならGitHubでIssueでも建ててもらえば、こちらとしても対等な議論として対応できるので幾分建設的だとは思うのですが、残念ながら彼女にその考えはなかったようです。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>そんなわけで、年の瀬ギリギリの滑り込み記事でした。\u003C/p>\u003Cp>元々は自分が不便だからという理由だけで作ったツールですが、公開してみると予想以上の反響があり、私としてはうれしい限りです。非中央集権の海は広大で、自由で、そして少しだけ不親切です。そんな中で、結果として誰かの役に立てたのなら、それはとても嬉しいことだと思います。\u003C/p>\u003Cp>収集したデータはWebで見れるだけじゃなく、登録不要のAPIとして全開放しています。Botを作るもよし、統計を取ってニヤニヤするもよし。私のお財布が吹き飛ばない程度に、自由に叩き倒してください。ドキュメントも一応用意してあるので、バグや要望があればGitHubまで。PRはいつでも歓迎します。\u003C/p>\u003Cp>それでは、よいお年を。\u003C/p>",[129,130,131,132],{"id":44,"createdAt":45,"updatedAt":46,"publishedAt":45,"revisedAt":46,"slug":47,"name":48},{"id":30,"createdAt":31,"updatedAt":32,"publishedAt":31,"revisedAt":32,"slug":33,"name":34},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":52,"createdAt":53,"updatedAt":54,"publishedAt":53,"revisedAt":54,"slug":55,"name":56},{"id":134,"createdAt":135,"updatedAt":136,"publishedAt":136,"revisedAt":136,"title":137,"content":138,"tags":139,"is_no_index":35},"w4q57e0zh0v1","2025-10-05T08:09:52.259Z","2025-10-05T08:20:42.619Z","サイトをリニューアルしたにょ","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>先日、このサイトにいくつかの変更を加えました。 \u003Cbr>元々は、壁打ちブログのつもりで運営していたのですが、承認欲求がとうとう無視できない大きさになってしまいまして。 \u003Cbr>ちなみに、ここで話すソースコードは全てGitHub上に公開しているので、もし気になる人がいれば、読んでみてもらえると嬉しいです。\u003Cbr>\u003Ca href=\"https://github.com/chan-mai\" target=\"_blank\" rel=\"noopener noreferrer\">https://github.com/chan-mai\u003C/a>\u003C/p>\u003Cp>開発を始めた当初は、ただただコメントといいね機能を追加するだけの、簡単な改修のつもりでした。\u003Cbr>ところが、いざ作り始めると「コメントは絶対に自分の目で見てから承認したい」「どうせなら管理画面もちゃんと作りたい」と、次から次へとやりたいことが溢れてきてしまい、その結果、そこそこ本格的なWebアプリケーションへと変貌を遂げました。\u003Cbr>最初の動機を考えると、拍子抜けするほど大掛かりなものになってしまったな、というのが正直な感想です。\u003C/p>\u003Ch1 id=\"h2220f0ef7d\">やったこと\u003C/h1>\u003Cp>今回の改修の始まりは、サイトに外との接点となるインタラクション機能を設けることでした。\u003C/p>\u003Cp>まず手始めに、いいね機能に手を入れています。\u003Cbr>仕組みは簡単なもので、ユーザーがハートを押すとAPIが叩かれ、記事の\u003Ccode>contentId\u003C/code>やIPアドレスなんかが記録されます。\u003Cbr>同時にlocalStorageにuuidが保存されるのですが、これはいいねの解除時に使うためだけのものであって、IPベースでの制限はしていないので、スマホとPCから同じ記事に…みたいなことも普通に出来てしまいます。\u003Cbr>普通に欠陥な気もするのですが、ちゃんと実装する気力はなかったので仕様です。\u003C/p>\u003Cp>そして、コメント機能。\u003Cbr>だいたいコメントというのは常々、おぞましいほどに醜い言葉で溢れるのが大概で、私はそういうものを目にしたくないのです。\u003Cbr>だから、どうしてもコメントを完全にコントロールする手段が必要でした。\u003Cbr>その解決策が、投稿されたコメントを一つひとつ自分の目で確認し、手動で承認するという運用フローと、それを実現するための管理コンソールだったわけです。\u003C/p>\u003Cp>ここからは、技術的な話をちょっとだけします。\u003C/p>\u003Ch1 id=\"h55235f9fca\">コメント機能の裏側\u003C/h1>\u003Cp>まず、コメント機能そのものの話から。\u003C/p>\u003Cp>ユーザーがコメントを投稿すると、\u003Ccode>POST /api/comment/[contentId]\u003C/code>というAPIが叩かれます。\u003Cbr>バックエンドでは、ユーザー入力値の基本的なバリデーションと、お気持ち程度のTurnstileのtoken検証のみを行なっています。\u003Cbr>投稿されたコメントは、まずデータベースに\u003Ccode>PENDING\u003C/code>ステータスで保存されます。\u003Cbr>このステータス管理が肝で、Prismaのスキーマに\u003Ccode>CommentStatus\u003C/code>というenumを定義して、\u003Ccode>PENDING\u003C/code>、\u003Ccode>APPROVED\u003C/code>、\u003Ccode>REJECTED\u003C/code>の3つの状態を持たせました。\u003Cbr>承認されるまでAPIの返り値を含めフロントには一切露出しない、完全な事前承認制です。\u003C/p>\u003Ch1 id=\"heb9db543a7\">データベース\u003C/h1>\u003Cp>これらのデータを保存しているのが、CockroachDBという分散SQLデータベースです。\u003Cbr>所謂NewSQLとかいうやつですね。\u003Cbr>PostgreSQLと互換性がありつつ、スケーラビリティと耐障害性に優れている面白い子で、名前が気持ち悪いことを除けば結構好きです。\u003C/p>\u003Cp>ORMにはPrismaを選びました。\u003Cbr>「型安全でマイグレーションが簡単！」とは言うものの、最近のORMであれば大体は似たようなことができる気がします。\u003Cbr>選定理由は単なる好みです。\u003Cbr>名前かわいいし！\u003C/p>\u003Cp>今回追加した主なモデルは、いいね用の\u003Ccode>Favorites\u003C/code>、コメント用の\u003Ccode>Comments\u003C/code>、そして管理者情報を格納する\u003Ccode>AdminUser\u003C/code>と権限を管理する\u003Ccode>AdminPermission\u003C/code>。\u003C/p>\u003Cdiv data-filename=\"prisma/schema.prisma\">\u003Cpre>\u003Ccode>// いいね\nmodel Favorites {\n  id        String   @id @default(uuid(7))\n  contentId String   @map(&quot;content_id&quot;)\n  userIp    String   @map(&quot;user_ip&quot;)\n  createdAt DateTime @default(now()) @map(&quot;created_at&quot;)\n  updatedAt DateTime @updatedAt @map(&quot;updated_at&quot;)\n\n  @@index([contentId])\n  @@index([userIp])\n  @@map(&quot;favorites&quot;)\n}\n\n// コメントステータス\nenum CommentStatus {\n  PENDING // 承認待ち\n  APPROVED // 承認\n  REJECTED // 拒否\n}\n\n// コメント\nmodel Comments {\n  id        String        @id @default(uuid(7))\n  contentId String        @map(&quot;content_id&quot;)\n  name      String\n  comment   String\n  userIp    String        @map(&quot;user_ip&quot;)\n  status    CommentStatus @default(PENDING)\n  createdAt DateTime      @default(now()) @map(&quot;created_at&quot;)\n  updatedAt DateTime      @updatedAt @map(&quot;updated_at&quot;)\n\n  @@index([contentId])\n  @@index([userIp])\n  @@index([status])\n  @@map(&quot;comments&quot;)\n}\n\n// 権限の種類\nenum Permission {\n  COMMENT_VIEW      // コメント閲覧\n  COMMENT_ADMIN     // コメント管理\n  FAVORITE_VIEW     // お気に入り閲覧\n  FAVORITE_ADMIN    // お気に入り管理\n  ADMIN_USER_VIEW   // 管理者ユーザー閲覧\n  ADMIN_USER_ADMIN  // 管理者ユーザー管理\n}\n\n// 管理者ユーザー\nmodel AdminUser {\n  id              String            @id @default(uuid(7))\n  githubUsername  String            @unique @map(&quot;github_username&quot;)\n  githubUserId    BigInt            @map(&quot;github_user_id&quot;)\n  displayName     String?           @map(&quot;display_name&quot;)\n  email           String?\n  avatarUrl       String?           @map(&quot;avatar_url&quot;)\n  isActive        Boolean           @default(true) @map(&quot;is_active&quot;)\n  createdAt       DateTime          @default(now()) @map(&quot;created_at&quot;)\n  updatedAt       DateTime          @updatedAt @map(&quot;updated_at&quot;)\n  permissions     AdminPermission[]\n\n  @@index([githubUsername])\n  @@index([isActive])\n  @@map(&quot;admin_users&quot;)\n}\n\n// 管理者権限\nmodel AdminPermission {\n  id         String     @id @default(uuid(7))\n  adminId    String     @map(&quot;admin_id&quot;)\n  permission Permission\n  createdAt  DateTime   @default(now()) @map(&quot;created_at&quot;)\n  updatedAt  DateTime   @updatedAt @map(&quot;updated_at&quot;)\n  admin      AdminUser  @relation(fields: [adminId], references: [id], onDelete: Cascade)\n\n  @@unique([adminId, permission])\n  @@index([adminId])\n  @@index([permission])\n  @@map(&quot;admin_permissions&quot;)\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp>\u003C/p>\u003Ch1 id=\"h495fd6e106\">管理コンソール\u003C/h1>\u003Cp>この管理コンソールこそ、今回の改修で最も大きな作業となった代物です。\u003Cbr>「コメントは絶対に自分の目で見てから承認したい」という要件が、この巨大な機能を生み出すきっかけでした。\u003Cbr>最初は適当なBASIC認証でなんとかしようと思っていたのですが、「どうせならとログイン機能も…」と考えたのが運の尽きです。\u003C/p>\u003Cp>認証には手軽なGitHub OAuthを採用しました。\u003Cbr>ユーザーがログインするとGitHubの認証ページに飛び、認可が済むとコールバックURLに戻ってくる。\u003Cbr>GitHubから受け取ったユーザー情報が\u003Ccode>AdminUser\u003C/code>テーブルに登録されていて、かつアクティブならセッションを発行する、という流れです。\u003C/p>\u003Cp>セッション管理には\u003Ccode>nuxt-auth-utils\u003C/code>を利用しました。\u003Cbr>内部的にJWTを使い、いい感じにサーバーサイドでセッションを検証してくれます。\u003Cbr>認証関連の処理は\u003Ccode>server/utils/auth.ts\u003C/code>にまとめていて、特に\u003Ccode>requireAdminSession\u003C/code>関数では、リクエストごとに管理者の存在とアクティブ状態をDBに確認しにいく割と厳格な権限確認を行っています。\u003Cbr>Middlewareで未認証ユーザーをサインインページに弾いてくれるので、部外者が管理画面を覗くことはできません。\u003C/p>\u003Cp>そして極め付けが、RBACの実装です。\u003Cbr>このサイトの管理者は私一人しかいないのに、いくつかの細かい権限を定義しています。完全に過剰な機能ですね。\u003C/p>\u003Cp>でも、この設計のおかげで、将来「この人にはコメントの承認だけ任せたい」なんてことが可能になりました。\u003Cbr>各APIエンドポイントの冒頭で\u003Ccode>requirePermission\u003C/code>関数を呼び出して権限をチェックし、フロント側でもComposableで、権限に応じた表示に切り替えています。\u003C/p>\u003Cp>UIも割といい感じにできたので自慢がてら見せびらかしておきます。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/a0e5f14bbe1c4e50a04cb7770218956d/image.png\" alt=\"\" width=\"3600\" height=\"2338\">\u003C/figure>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/a257735d764c4c85b995d6f9b42517ea/image.png\" alt=\"\" width=\"3600\" height=\"2338\">\u003C/figure>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/554a3692ecaa488880af06b58022b603/image.png\" alt=\"\" width=\"3600\" height=\"2338\">\u003C/figure>\u003Cp>\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>今回のサイト改修は、過去のちょっとした機能追加が嘘のように、大規模なものとなりました。 \u003Cbr>承認欲求という、どちらかといえばネガティブな感情から始まった作業でしたが、いざ手を動かし始めると、技術的な好奇心がすべてを上回ってしまった感じです。\u003C/p>\u003Cp>満足したので安心して眠れます。\u003Cbr>それでは。\u003C/p>",[140,141,142],{"id":106,"createdAt":107,"updatedAt":108,"publishedAt":107,"revisedAt":108,"slug":109,"name":110},{"id":30,"createdAt":31,"updatedAt":32,"publishedAt":31,"revisedAt":32,"slug":33,"name":34},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":144,"createdAt":145,"updatedAt":146,"publishedAt":146,"revisedAt":146,"title":147,"content":148,"tags":149,"is_no_index":35},"kkrzgveya","2025-09-02T11:43:40.111Z","2025-09-02T11:46:23.890Z","Nuxt4がいつの間にか出ていたのでサイトを更新した","\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>先日、Nuxtのバージョン4がリリースされたことに、数ヶ月経ってから気づきました。\u003Cbr>告知はあったようですが、知らないところでいつの間にかひっそりと公開されていて、少しばかり驚いています。\u003Cbr>Nuxt 3のリリースから約2年半ぶりのメジャーバージョンアップだそうです。\u003C/p>\u003Cp>メジャーバージョンアップと聞くと、多くの破壊的変更が伴うのが常で、正直なところ「面倒だなーーー」という感情が先に立ちました。\u003Cbr>しかし、どうせいつかは対応しないといけないので、少しばかり重い腰を上げて、移行作業に取り掛かることにしました。\u003C/p>\u003Cp>\u003Ca href=\"https://nuxt.com/blog/roadmap-v4\" target=\"_blank\" rel=\"noopener noreferrer\">公式のロードマップ\u003C/a>に目を通すと、近々Nuxt5のリリースも控えてそうな雰囲気で、今回のv4は、v5へ向けた地ならし、つまり一度破壊的変更のステップを踏んでおくためのリリースみたいです。\u003C/p>\u003Ch1 id=\"ha8a4a373ac\">Nuxt 3から4への移行、所感とか\u003C/h1>\u003Cp>結論から言うと、めちゃくちゃスムーズにアップグレードできました。\u003C/p>\u003Cp>以前のNuxt 2から3への移行を経験された方なら同意していただけると思うのですが、あれは本当に悪夢でした。\u003Cbr>エコシステムの対応は追いついていないし、Composition APIへの移行は思考の根本的な書き換えを要求されるしで、多くの方が移行作業で心を消耗させたはずです。\u003Cbr>当時のわたしもその一人でした。\u003C/p>\u003Cp>その反動なのか、今回のNuxt 3から4への移行は、驚くほどストレスなく、シンプルに完了しました。\u003Cbr>対応が必須となる箇所が少なく、後方互換性もかなり考慮されている印象です。\u003Cbr>あの地獄のようなマイグレーション作業を思うと、拍子抜けするほどでした。\u003C/p>\u003Ch1 id=\"h2220f0ef7d\">やったこと\u003C/h1>\u003Cp>詳細な手順については、ほぼ\u003Ca href=\"https://nuxt.com/docs/4.x/getting-started/upgrade\" target=\"_blank\" rel=\"noopener noreferrer\">公式のアップグレードガイド\u003C/a>の通りに進めて問題ありませんでした。\u003C/p>\u003Cp>既にNuxt 3の環境がある場合、\u003Ccode>nuxt.config.ts\u003C/code>に\u003Ccode>future.compatibilityVersion: 4\u003C/code>を一行書き足すのが最も手軽な方法として紹介されています。\u003Cbr>しかし、古いパッケージを使いながら設定だけで新しいバージョンに擬態させるのは、どうにも気持ち悪く、私はこの方法を選びませんでした。\u003C/p>\u003Cp>というわけで、パッケージごと更新する、よりクリーンなアプローチを選択しました。\u003Cbr>公式で紹介されていたマイグレーション用のコマンド\u003Ccode>npx codemod@latest nuxt/4/migration-recipe\u003C/code>は、残念ながら私の環境ではうまく動作しませんでした。\u003C/p>\u003Cp>やったことは、本当にこれだけです。\u003C/p>\u003Col>\u003Cli>\u003Ccode>pnpm i nuxt@^4.0.0\u003C/code> を実行\u003C/li>\u003Cli>その他、関連ライブラリを最新版に更新\u003C/li>\u003Cli>ディレクトリ構成を変更\u003C/li>\u003C/ol>\u003Cp>\u003Ccode>pnpm dev\u003C/code>を実行して、ローカルサーバーが何事もなかったかのように起動したときは、あっさりすぎて逆にびっくり。\u003Cbr>Nuxt 2から3への移行で経験した延々と続くエラーとの格闘は、もう過去のものみたいです。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>今回のNuxt 4への移行は、過去の経験が嘘のようにスムーズなものでした。\u003Cbr>あの時の苦労を思えば、今回の変更は部屋の模様替え程度の軽微な作業だったと言えます。\u003C/p>\u003Cp>しかし、前述の通り、これは数ヶ月後にリリースが予定されているNuxt 5への布石に過ぎないようです。\u003Cbr>この束の間の平穏がいつまで続くのかは分かりませんが、とりあえずは最新の環境で運用できることに満足しています。\u003C/p>\u003Cp>みさなんも、メジャーアップデートと聞いて身構えず、早めに対応しておくのがよさそうです。\u003Cbr>それでは。\u003C/p>",[150,151,152],{"id":106,"createdAt":107,"updatedAt":108,"publishedAt":107,"revisedAt":108,"slug":109,"name":110},{"id":30,"createdAt":31,"updatedAt":32,"publishedAt":31,"revisedAt":32,"slug":33,"name":34},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":154,"createdAt":155,"updatedAt":156,"publishedAt":157,"revisedAt":156,"title":158,"content":159,"tags":160,"is_no_index":35},"px6vdcxupeg","2025-08-24T09:38:57.191Z","2025-08-24T13:07:52.435Z","2025-08-24T12:52:35.857Z","【GCP破産RTA】SSGでのLLM要約機能実装の顛末","\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>先日、このブログにLLMによる記事の要約機能を実装しました。\u003Cbr>記事一覧のDescriptionが、単に本文の冒頭を切り取ったものではなく、Gemini 2.5 Flashが生成した要約文になる、というものです。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/d5eca816bbbf4ccc812a30c867c1b9c8/image.png\" alt=\"\" width=\"3600\" height=\"2010\">\u003C/figure>\u003Cp>技術的な課題としては、microCMSの無料プランではwriteAPIが利用できず、生成した要約をCMS側に保存できないという制約をどう乗り越えるか、という点にありました。\u003C/p>\u003Ch1 id=\"h6a33b9ef65\">アーキテクチャ選定と過信\u003C/h1>\u003Cp>最も安直なSSRでは、リクエスト毎にAPIを叩くことになり、コストとUXの観点から論外です。\u003Cbr>そこで、ビルド時に全記事の要約を済ませておくSSGのアプローチを選択しました。\u003Cbr>具体的には、Nuxtのビルドフックを利用して、ビルドプロセスの中で全記事のデータをmicroCMSから取得、記事IDごとに要約生成用のAPIエンドポイント (\u003Ccode>/api/summarize-article/:id\u003C/code>) を\u003Ccode>prerender\u003C/code>の対象に加える、という設計です。\u003C/p>\u003Cp>これなら、LLMのAPIコールはビルド時に限定され、ユーザーへのレスポンスは静的ファイルなので高速。\u003Cbr>コストとUXの問題を両立できる、かなりいい感じでイケイケな設計だと、この時点では本気で考えていました。\u003Cbr>夜のテンションも相まって、自分の実装に若干の陶酔すら覚えていたかもしれません。\u003C/p>\u003Cp>\u003Ca href=\"https://github.com/chan-mai/mq1-web/commit/c21b057b369abada5554390d6e825160fcb4e38f\" target=\"_blank\" rel=\"noopener noreferrer\">https://github.com/chan-mai/mq1-web/commit/c21b057b369abada5554390d6e825160fcb4e38f\u003C/a>\u003C/p>\u003Ch1 id=\"ha0bd70f85e\">破滅\u003C/h1>\u003Cp>しかし、実装から24時間も経たないうちに、GCPから予算アラートの通知が届きました。\u003C/p>\u003Cp>一瞬、何かの間違いかと思いました。\u003Cbr>APIキーの漏洩も考えましたが、クライアントサイドからの呼び出しは実装しておらず、キーの管理も適切に行っているはず。\u003Cbr>GCPの請求ダッシュボードを確認すると、グラフが異常な角度で跳ね上がっており、明らかに何かがおかしい。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/508f2e9ff39940d5a578e5b44cd16bb7/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88%202025-08-24%2017.51.40.png\" alt=\"\" width=\"3600\" height=\"2004\">\u003C/figure>\u003Cp>メトリクスを確認すると、APIの呼び出し時刻はビルド実行時のみ。\u003Cbr>ここでようやく、問題が自分の実装そのものにある可能性に行き着きました。\u003C/p>\u003Cp>原因は、わたしの設計にあった致命的な考慮漏れでした。\u003Cbr>以下が問題のコードです。\u003C/p>\u003Cdiv data-filename=\"/nuxt.config.ts\">\u003Cpre>\u003Ccode class=\"language-typescript\">  nitro: {\n    prerender: {\n      autoSubfolderIndex: true,\n      crawlLinks: false,\n      routes: [],\n      failOnError: false,\n    }\n  },\n  hooks: {\n    async &quot;nitro:config&quot;(nitroConfig) {\n      if (nitroConfig.dev)  return;\n      if (nitroConfig.prerender?.routes === undefined) return;\n      \n      const client = createClient({\n        serviceDomain: process.env.MICROCMS_SERVICE_DOMAIN!,\n        apiKey: process.env.MICROCMS_API_KEY!,\n      });\n\n      const [articles, tags] = await Promise.all([\n        client.get({\n          endpoint: &apos;articles&apos;,\n          queries: {\n            limit: 100, // &lt;-- 問題の根源その1\n            orders: &quot;-publishedAt&quot;,\n          },\n        }),\n        client.get({\n          endpoint: &apos;tags&apos;,\n          queries: {\n            limit: 100,\n            orders: &quot;-publishedAt&quot;,\n          },\n        }),\n      ]);\n\n      // 記事\n      const articleRoutes = articles.contents.flatMap((mount: any) =&gt; [\n        `/entry/${mount.id}`,\n        `/api/summarize-article/${mount.id}`, // &lt;-- 問題の根源その2\n      ]);\n      // タグ\n      const tagRoutes = tags.contents.map((mount: any) =&gt; `/tag/${mount.id}`);\n\n      nitroConfig.prerender.routes = [\n        ...nitroConfig.prerender.routes,\n        ...articleRoutes,\n        ...tagRoutes,\n      ];\n    },\n  },\u003C/code>\u003C/pre>\u003C/div>\u003Cp>勘のいいガキは嫌いだよ…なんてセリフを言うまでもなく、聡明な読者の方ならこの実装の問題に気づいたかもしれません。\u003C/p>\u003Cp>わたしは、Nuxtのビルドプロセスにフックを仕掛けて、microCMSから記事を100件まとめて取得しています。\u003Cbr>そして、その取得した100件の記事すべてに対して、\u003Ccode>prerender.routes\u003C/code>に要約生成APIのエンドポイント (\u003Ccode>/api/summarize-article/${mount.id}\u003C/code>) を追加しているのです。\u003C/p>\u003Cp>これが何を意味するか、わかりますか？\u003C/p>\u003Cp>ビルドが走るたびに、記事の内容が1文字も変更されていなくても常に100件の記事すべてに対する要約生成リクエストがGeminiに飛ばされる。\u003Cbr>そう、毎回、です。\u003Cbr>ちょっとCSSを1行直しただけのコミットでも、\u003Ccode>package.json\u003C/code>のライブラリを更新しただけのコミットでも、CI/CDが健気にビルドを走らせるたびに、100回、GeminiのAPIが叩かれる。\u003C/p>\u003Cp>しかも、このブログの記事、一つ一つが無駄に長い。\u003Cbr>短いものでも3,000文字、長いものだと3万文字を超えたりします。\u003Cbr>LLMの料金は概ね入力と出力のトークン数で決まります。\u003Cbr>大量の文字は、大量のトークンを消費する。\u003C/p>\u003Cp>つまり、私は「ビルドのたびに、長文記事100個分の要約リクエストを強制的に実行する」という、GCP破産直行便みたいな処理を良かれと思って実装してしまっていたのです。\u003Cbr>本当に、ぐえーーって感じ。\u003Cbr>昨日の私を本気で殴りに行きたい。\u003Cbr>なんで気づかなかったんだろう。\u003Cbr>キャッシュ戦略がどうとか、差分だけ更新するとか、そういう当たり前の地味で大事なことをすっかり忘れていました。\u003Cbr>成長が、ない。\u003C/p>\u003Cp>恐ろしい。本当に恐ろしいです。\u003C/p>\u003Ch1 id=\"h55b1e78e3d\">対処と結果\u003C/h1>\u003Cp>原因が特定できた以上、選択肢は一つしかありません。\u003Cbr>このまま放置すれば、わたしのけして分厚くはないお財布は、Google神への強制的なお布施によって、数日のうちに塵と化すでしょう。\u003Cbr>リアルに生活が終わってしまう。\u003C/p>\u003Cp>LLM関連のコミットをすべてRevertし、機能を丸ごとロールバック。\u003C/p>\u003Cp>副次的な効果として、ビルド時間が劇的に改善されました。\u003Cbr>以前は律儀に100件近い記事の要約APIの応答を待っていたため、8分ほどかかっていたビルドが、機能削除後はわずか50秒で完了するようになってしまいました。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/790de29cb7eb49e7924e6ccbe02a3c11/image.png\" alt=\"\" width=\"3600\" height=\"2008\">\u003C/figure>\u003Cp>あまりの速さにもはや笑うしかありませんでした。\u003Cbr>物理的に重たい処理がなくなったのですから当然ですが、これだけの時間をAPIとの通信に費やしていたという事実を改めて突きつけられました。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>今回のトラブルから得られた教訓は、極めてシンプルです。\u003C/p>\u003Cul>\u003Cli>ビルドプロセスにおける外部有料APIのコールは慎重に検討する\u003Cp>ビルドは、コンテンツの更新以外にも、依存関係の更新や設定変更など、様々なトリガーで実行されます。\u003Cbr>そのたびに意図しないコストが発生する可能性があることを常に念頭に置くべきでした。\u003C/p>\u003C/li>\u003Cli>冪等性を意識し、キャッシュ戦略を導入する\u003Cp>今回のようなケースでは、一度生成した要約はCloudflare R2やVercel KVのような外部ストレージにキャッシュとして保存し、記事が更新された場合のみ再生成する、といった差分更新の仕組みを導入すべきでした。\u003Cbr>全件処理は、やはり悪手です。\u003C/p>\u003C/li>\u003C/ul>\u003Cp>割と高い勉強代にはなりましたが、インフラコストに対する意識が甘かったことを痛感させられる良い経験だったと、今は思うことにしています。\u003Cbr>皆さんも、ビルド時のAPIコールにはくれぐれもご注意ください。\u003C/p>\u003Cp>それでは。\u003C/p>",[161,162,163,164],{"id":106,"createdAt":107,"updatedAt":108,"publishedAt":107,"revisedAt":108,"slug":109,"name":110},{"id":23,"createdAt":24,"updatedAt":25,"publishedAt":24,"revisedAt":25,"slug":26,"name":27},{"id":30,"createdAt":31,"updatedAt":32,"publishedAt":31,"revisedAt":32,"slug":33,"name":34},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":166,"createdAt":167,"updatedAt":168,"publishedAt":168,"revisedAt":168,"title":169,"content":170,"tags":171,"is_no_index":35},"k-lcybtix7fk","2025-08-17T15:37:33.717Z","2025-08-17T16:17:49.112Z","DXごっこの新たな玩具、Vibe Codingは無能を糊塗する行為に過ぎない","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>タイトルは少し、過激すぎたかもしれません。\u003Cbr>巷ではこういう扇情的な見出しで読者の気を惹き、中身のない文章を読ませるのが流行りの手法らしいので、少しだけ真似をしてみました。\u003C/p>\u003Cp>先日、Twitter(自称X)このような投稿を見かけ、少し思うところがありました。\u003C/p>\u003Cblockquote>\u003Cp>プログラミング、AIエージェントが必須ツールになってきてお金かかる趣味になってきちゃって最近学生が大変という話を聞いたことある\u003Cbr>\u003Ca href=\"https://x.com/kotomin_m/status/1957057306686058496\" target=\"_blank\" rel=\"noopener noreferrer\">https://x.com/kotomin_m/status/1957057306686058496\u003C/a>\u003C/p>\u003C/blockquote>\u003Cp>「LLMが必須だから、プログラミングはお金がかかる」。\u003Cbr>一見すると、現代の学生さんを心配しているようにも読めます。\u003Cbr>ですが、わたしはこのLLMが必須という部分に、強い違和感を覚えてしまうのです。\u003C/p>\u003Cp>言葉を聞くだけで不快になるレベルで、わたしは「Vibe Coding」という、この種の思考停止を心の底から嫌っています。\u003Cbr>必須と言えてしまうほどLLMに依存しなければまともなロジックを書けないのなら、もはやプログラミングそのものに適性がないのではないでしょうか。\u003C/p>\u003Cp>\u003C/p>\u003Ch1 id=\"h0151668440\">LLMは必須なのか\u003C/h1>\u003Cp>そもそも、プログラミングの文脈における必須のツールとは何でしょう。\u003Cbr>プログラミングで言うなら、コードを書くためのエディタや、それを動かすための実行環境がそれに当たります。\u003Cbr>これがないと、何も始まりません。\u003C/p>\u003Cp>では、LLMも同じレベルで必須なのでしょうか。\u003Cbr>少なくとも私はそう思いません。\u003C/p>\u003Cp>LLMは、確かに便利なアシスタントです。\u003Cbr>例えば、書いていてあまり楽しくない退屈なロジックの実装を手伝わせたり、あるいは厳密な仕様が定まっているマイクロサービスの定型的なコードを生成させたりする場面では、非常に役立ちます。\u003C/p>\u003Cp>ですが、ソフトウェア開発で本当に頭を使う核心的な部分はそこではありません。\u003Cbr>何を作るべきかを考える要件定義や、どういう構造で作るかを決める設計、そしてなぜか動かない原因を根気強く探るデバッグといった、答えのない問題に対して自分の頭で論理を組み立てていく作業にあります。\u003Cbr>これらこそが、プログラミングの本質だとわたしは思うのです。\u003Cbr>LLMは、この思考のプロセスそのものを肩代わりしてくれるわけではありません。\u003C/p>\u003Cp>LLMがなければ何も出来ないレベルなら、それはもうプログラミングとは呼べない別の何かです。\u003C/p>\u003Ch1 id=\"h784ae03490\">Vibe Codingのツケ\u003C/h1>\u003Cp>LLMの出力した任意のコードを、よく理解しないままコピペして「なんとなく動いたからﾖｼ」とする。\u003Cbr>この雰囲気(Vibe)で進めるコーディングは、将来、必ず大きな問題を引き起こします。\u003Cbr>所謂、技術的負債というものです。\u003C/p>\u003Cp>この光景、どこかで見たことがあると思いませんか？ \u003Cbr>そう、数年前からDX推進という大義名分のもと、オフィスに蔓延したあの光景です。\u003C/p>\u003Cp>引き継ぎ資料もなく、書いた本人にしか解読不能な野良VBAマクロ。\u003Cbr>あるいは、複雑に絡み合った矢印と条件分岐で、もはや誰も全容を把握できない秘伝のタレ化したPower Automateフロー。\u003C/p>\u003Cp>これらは、目の前の手作業を短絡的に自動化しただけで、長期的な保守性という視点が絶望的に欠けていました。\u003C/p>\u003Cp>Vibe Codingは、この構造と全く同じです。\u003Cbr>ツールがExcelからLLMに変わっただけで、やっていることは思考停止による負債の自動生成に他なりません。\u003Cbr>ツールが新しくなったからといって、使う側の人間が何も学んでいなければ、生まれてくるものが良くなるはずもないのです。\u003C/p>\u003Cp>結局のところ、このやり方は、中途半端な知識でDXを謳いながら、質の低いVBAマクロやPower Automateのフローを量産していた層にウケているだけではないでしょうか。\u003C/p>\u003Cp>例えば、危険なコードを無自覚に埋め込んでしまうことがあります。\u003Cbr>多くのLLMにおいて、特筆すべき指定がない場合はセキュリティを全く考慮せず、外部からの攻撃に非常に弱いコードを平気で提案してくることがあります。\u003C/p>\u003Cpre>\u003Ccode class=\"language-go\">// 巷でよく観測しているもの\nfunc GetUser(w http.ResponseWriter, r *http.Request) {\n    // パスワードをハードコード\n    db, err := sql.Open(&quot;postgres&quot;, &quot;user=admin password=password123 dbname=mydb sslmode=disable&quot;)\n    if err != nil {\n        // ...\n    }\n\n    // サニタイジングしてね\n    userID := r.URL.Query().Get(&quot;id&quot;)\n    query := fmt.Sprintf(&quot;SELECT name FROM users WHERE id = %s&quot;, userID)\n   \n    row := db.QueryRow(query)\n    // ...\n}\u003C/code>\u003C/pre>\u003Cp>これをそのまま使えば、簡単に不正アクセスされてしまいます。\u003Cbr>なぜ危険なのかを知らなければ、LLMのサジェストを疑うことすらできません。\u003C/p>\u003Cp>あるいは、誰も触れないブラックボックスが完成してしまうことも少なくありません。\u003Cbr>複雑な計算ロジックなどをAIに丸投げすると、どうしてその答えになるのか誰にも説明できないコードが出来上がることがあります。\u003Cbr>これでは、後からバグが見つかったりしても、誰も修正できませんよね。\u003C/p>\u003Cp>さらに、見えないところでパフォーマンスが劣化するというのも典型的なパターンです。\u003Cbr>一見正しく動いているように見えても、実は非常に非効率なコードが生成されることもよくあります。\u003C/p>\u003Cpre>\u003Ccode class=\"language-go\">// よくあるN+1問題\nfunc getTimelinePosts(db *sql.DB, currentUserID int) ([]Post, error) {\n    // まずフォローしているユーザーの一覧を取得 (1クエリ)\n    rows, err := db.Query(&quot;SELECT followed_id FROM follows WHERE follower_id = ?&quot;, currentUserID)\n\n    // ... \n    var followedUserIDs []int\n    for rows.Next() {\n        var id int\n        rows.Scan(&amp;id)\n        followedUserIDs = append(followedUserIDs, id)\n    }\n\n    var allPosts []Post\n    // ユーザーの数だけループ内でクエリが発生 (nクエリ)\n    for _, userID := range followedUserIDs {\n        postRows, err := db.Query(&quot;SELECT id, content FROM posts WHERE author_id = ?&quot;, userID)\n        // ...\n    }\n    return allPosts, nil\n}\u003C/code>\u003C/pre>\u003Cp>最初は問題なくても、データが増えるにつれてシステムがどんどん重くなっていく。\u003Cbr>そんな時限爆弾が簡単に作れてしまいます。うれしいですね。\u003C/p>\u003Ch1 id=\"hefede90b9f\">エラー読もうね\u003C/h1>\u003Cp>ところで、エラーメッセージを読まない人が多すぎませんか？\u003C/p>\u003Cp>エラーメッセージには、問題解決のヒント、ほとんど答えそのものが書かれています。\u003Cbr>それなのに、画面に表示された赤い文字列を、ただの失敗という記号としてしか認識できず、その内容を読み解こうとすらせずに「動きません、助けてください」とスクリーンショットだけを貼り付ける。\u003Cbr>これはあまりにも非効率ですし、他者の時間を無駄に奪う行為です。\u003C/p>\u003Cp>彼らにとってエラーとは、解決すべき論理的なパズルではなく、ただただ不快な邪魔者でしかないのでしょう。\u003Cbr>確かに、メッセージの多くは英語です。でも、現代には優秀な翻訳ツールがいくらでもあります。\u003Cbr>それを乗り越えられないのは、もはやスキル以前の問題ではないでしょうか。\u003C/p>\u003Cp>LLMにエラーメッセージをそのままコピペして答えを得る行為も、この延長線上にあります。\u003Cbr>自分で「読む」「理解する」という最も重要なステップを放棄して、答えだけを求めているのですから。\u003C/p>\u003Cp>エラーを読むという基本的なステップを飛ばして、一体何を学ぼうというのでしょうか。\u003Cbr>それは学習という行為を、あまりにも侮辱しているように、わたしには思えてなりません。\u003C/p>\u003Ch1 id=\"h52d2e1922c\">本当に「お金がかかる」の？\u003C/h1>\u003Cp>最後に、「プログラミングはお金がかかる」という点についても、少し見方を変える必要があると思います。\u003C/p>\u003Cp>プログラミングを学ぶための言語やツール、そして情報のほとんどは、今も昔も無料で手に入ります。\u003Cbr>本当に必要なのは、有料のLLMサービスを契約することよりも、自分の頭で考え、試行錯誤するために時間というリソースを投資することではないでしょうか。\u003C/p>\u003Cp>その最も大切な投資を惜しんで、安易な答えにお金を払ってしまうことこそが、本当の意味でのコストなのかもしれません。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>誤解しないでほしいのですが、わたしはLLMという技術そのものを否定しているわけでは全くありません。\u003Cbr>あれは本当に素晴らしい道具です。\u003C/p>\u003Cp>退屈な作業を驚異的な速度で片付け、私たちの思考を拡張してくれる可能性に満ちています。\u003Cbr>賢明な人間が使えば、これほど心強いパートナーはいないでしょう。\u003Cbr>そう、賢明な人間が使えば、の話ですが。\u003C/p>\u003Cp>結局のところ、どんなに優れた道具も、使う人間を選ぶのです。\u003Cbr>そして、Vibe Codingに何の疑問も抱かない人々を見ていると、彼らはその選別に残念ながら漏れてしまったのではないか、と感じずにはいられません。\u003C/p>\u003Cp>もしかしたら、彼らのやり方は次世代の働き方であり、思考という最もコストのかかる作業をLLMに外部委託する、究極の効率化なのかもしれません。\u003Cbr>面倒なエラーメッセージの読解や、地道な学習プロセスから解放され、もっとクリエイティブな作業、つまりLLMへの指示出しに集中できるのですから、素晴らしいことなのでしょう。\u003Cbr>タイパも最高ですよね。\u003C/p>\u003Cp>しかし、そのクリエイティブな指示出しですら、いずれはより優秀なモノに最適化されてしまう未来がすぐそこまで来ていることに、彼らは気づいているのでしょうか。\u003Cbr>思考を放棄した先に待っているのは、自らが不要になるという、あまりにも当然な結末です。\u003C/p>\u003Cp>まあ、わたしがここで何を言っても、彼らの耳には届かないのでしょう。\u003Cbr>彼らはこれからも「動けば正義」「効率こそすべて」と信じ、その場しのぎのコードを量産し続けるのだと思います。\u003C/p>\u003Cp>彼らが残していくのは、動いているように見えるだけのデジタルな廃墟です。\u003Cbr>そして、その瓦礫を片付けるのは、いつだって物事の本質を理解しようと努めてきた人なのです。\u003Cbr>彼らは自分たちが楽をすることしか考えておらず、その尻拭いを未来の誰かがすることを全く想像していない。\u003Cbr>その想像力の欠如こそが、彼らの行為は害悪以外の何物でもありません。\u003C/p>\u003Cp>結局、彼らが何もしないでいてくれることが、社会にとっては一番の貢献なのかもしれません。\u003Cbr>だから、お願いだから、静かに布団にでも入って寝ていてほしいと心から思っています。\u003C/p>",[172,173],{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":23,"createdAt":24,"updatedAt":25,"publishedAt":24,"revisedAt":25,"slug":26,"name":27},13,12,1783975282455]