[{"data":1,"prerenderedAt":273},["ShallowReactive",2],{"global":3,"index-tags":74,"index-articles":115},{"createdAt":4,"updatedAt":5,"publishedAt":4,"revisedAt":5,"pinned_articles":6},"2025-12-04T13:08:50.321Z","2026-03-12T03:08:15.596Z",[7,46,63],{"id":8,"createdAt":9,"updatedAt":10,"publishedAt":11,"revisedAt":10,"title":12,"content":13,"tags":14,"is_no_index":45},"a7dv5t3bip","2025-06-21T17:10:41.140Z","2026-02-10T15:43:19.260Z","2025-06-21T17:17:01.575Z","Misskey鯖缶後悔記","\u003Cp>お久しぶりです。\u003Cbr>先日ふとした想いたちから自動車学校に通いはじめた挙句、運転が下手すぎて技能教習を増やされてしまった私です。\u003C/p>\u003Cp>今日は、そんな私が始めてしまった、ある小さなインスタンスについて語り散らかしていきたい。\u003Cbr>ちなみにこれは、誰かの心を照らすような成功譚ではありません。\u003C/p>\u003Ch1 id=\"h432d4192e0\">全ての始まり、小さな寂しさと淡い理想\u003C/h1>\u003Cp>今から遡ること、およそ3年。\u003Cbr>まだ入学したばかりの高校生だった私は、「しゃふすきー」という、少し変わった名前のMisskeyインスタンスを広大なインターネットの片隅にぽつんと構築しました。\u003C/p>\u003Cp>なぜ、数あるSNSの中から、わざわざ自前でサーバーを立てるなんていう面倒な選択をしたのか。\u003Cbr>それは、TwitterやInstagramといった巨大なプラットフォームに渦巻く、常に誰かの視線を意識し、他人からの評価に一喜一憂するような空気感に、少しだけ息苦しさを覚えていたからです。\u003Cbr>お金儲けのためのレコメンドアルゴリズムによって最適化され、差し出される情報ではなく、もっと属人的で、手触りのある、自分のためだけの場所が欲しかった。\u003Cbr>最初は、誰にも見つからない日記帳のような、自分だけのおひとり様インスタンスとして、静かに運営していくつもりでした。\u003C/p>\u003Cp>しかしながら、たった一人きりのLTLは、私が想像していた以上に静かでした。\u003Cbr>投稿しても、ローカルの誰からも反応がない。\u003Cbr>聞こえるのは、自分の思考が壁に当たって跳ね返ってくる、その反響音だけ。\u003Cbr>その完璧なまでの静寂が、私の心の奥底に横たわる、名前のつかない寂しさの輪郭を、あまりにもありありと映し出す鏡のように感じられて、次第に怖くなっていきました。\u003Cbr>あるいは、心のどこかに巣食っていた「誰かに見つけてほしい」「私の存在を肯定してほしい」という、承認欲求が、むくむくと鎌首をもたげたのかもしれません。\u003C/p>\u003Cp>「誰かが、この場所に居てくれたら」。\u003Cbr> 「私と同じように、既存のSNSの喧騒に少しだけ疲れた人が、その羽を休められるような、ささやかな止まり木になれたら」。\u003C/p>\u003Cp>そんな淡い期待と、今にして思えば少しばかり傲慢な理想を抱いて、私はインスタンスの重い扉を開け、誰でも自由に登録できるように設定を変更しました。\u003Cbr>この、ほんの数クリックの、しかし後戻りのできない操作が、私のその後の3年間を決定づける、全ての始まりでした。\u003C/p>\u003Cp>開設して間もなく、本当にありがたいことに、数十人の方々が私の小さなサーバーにアカウントを作ってくださいました。\u003Cbr>中には、LTLに定住し、日々の出来事をこまめに投稿してくれる方も現れました。\u003C/p>\u003Cp>「おはよう」という、一日の始まりを告げる挨拶。\u003Cbr>今日食べたランチの、少しピントのずれた写真。\u003Cbr>好きな音楽や映画の話。\u003Cbr>深夜の、誰に聞かせるともない、とりとめのない悩み事。\u003Cbr>自分の知らない誰かが、自分の作った場所で笑ったり、怒ったり、悩みを打ち明けたりしている。\u003Cbr>その事実の一つひとつが、まるで乾いた土に染み込む水のように、私の心をじんわりと温め、満たしていきました。\u003C/p>\u003Cp>LTLを眺めていると、まるで自分が神様にでもなったような、不思議な全能感がありました。\u003Cbr>私が維持しているこの場所が、誰かの日常の一部になっている。\u003Cbr>私の存在が、誰かのささやかな居場所になっているのかもしれない。\u003Cbr>その感覚は、現実の生活では得難い、抗いがたいほどに甘美なものでした。\u003Cbr>人の気配がそこにあるというだけで、私の慢性的な寂しさは、確かに少しだけ、その輪郭をぼやかしてくれるような気がしていたのです。\u003C/p>\u003Ch1 id=\"h659334fa2a\">楽しかった\u003C/h1>\u003Cp>しかし、現実は残酷なもので、あの夢のように穏やかで、希望に満ちていた日々は、残念ながら永遠ではありませんでした。\u003Cbr>人が増え、タイムラインの流量が増え、コミュニティがゆっくりと拡大していくにつれて、水面下に潜んでいた様々な問題が、一つ、また一つと、ぬるりとした感触で顕在化してきたのです。\u003C/p>\u003Cp>もちろん、技術的な問題は常にありました。\u003Cbr>GCPのプロジェクトが突然凍結されたり、深夜にPostgreSQLクラスタ間のデータ不整合を見つけてしまい、半泣きで復旧作業にあたったこともあります。\u003Cbr>急激なユーザー増で特定ノードへの負荷が高くなり、慌ててスケールしたことも一度や二度ではありません。\u003Cbr>でも、そういった技術的なトラブルは、ある意味で解決がシンプルです。\u003Cbr>エラーログと向き合い、ドキュメントを漁り、試行錯誤を繰り返せば、大体はなんとかなってしまう。\u003Cbr>むしろ、困難な問題を解決したときには、明確な達成感さえありました。\u003Cbr>というか、私ですらできるようなそんなことができないようなら、そもそも自前でインスタンスなんて建てるべきではない、とさえ思っています。\u003C/p>\u003Cp>本当に私の心を蝕んでいったのは、そういった技術的・金銭的に解決できる問題ではありませんでした。\u003Cbr>もっとアナログで、複雑で、正解がなく、そして終わりもない、人間という存在そのものが孕む問題です。\u003C/p>\u003Cp>インスタンスの開設から1年半か、あるいは2年が経った頃でしょうか。\u003Cbr>それまで保たれていた牧歌的な雰囲気に、少しずつ、しかし確実に、不協和音が混じり始めました。\u003Cbr>最初は些細な違和感でした。\u003Cbr>あるのユーザーが、LTLを自分の日記帳かのように使い、延々と自分語りを続ける。\u003Cbr>会話の流れを無視して、唐突に自分の話にすり替える。\u003Cbr>そういった、コミュニケーションの僅かなズレが、日に日に目立つようになってきたのです。\u003C/p>\u003Cp>他者への配慮が決定的に欠けている、人格に難のあるユーザーの存在が、タイムライン上で無視できない大きさになってくると、負の連鎖が静かに、しかし着実に始まります。\u003Cbr>もともといた良識的なユーザーたち、あの穏やかな空気を作ってくれていた人々は、その居心地の悪さに耐えかねたのか、あるいは単純に飽きたのか、一人、また一人と、何も言わずに姿を消していきました。\u003Cbr>お気に入りのユーザーのアイコンが、ある日を境にデフォルトの画像に戻っているのを見つけるたび、胸の奥が冷たくなるのを感じました。\u003C/p>\u003Cp>彼らが去った分だけLTLは静かになり、その空白を埋めるように、さらに似たような性質の、声の大きい人たちが居座るようになる。\u003Cbr>結果として、コミュニティはゆっくりと、でも確実に、私が望んでいたものとは全く違う、歪で、息苦しい姿へと変貌してしまいました。\u003C/p>\u003Cp>私の運営しているインスタンスは「しゃふすきー」という名前です。\u003Cbr>社会不適合者の「しゃふ」と、Misskeyの「すきー」を組み合わせた、安直な名前。\u003Cbr>余っていたドメイン(当時はgenkaishahu.com)で、本当に深く考えずに名付けてしまいました。\u003Cbr>その自虐的な名前の響きからか、開設当初から、メンタルヘルスに何かしらの問題を抱えている方が多く登録される傾向がありました。\u003Cbr>私自身もその一人ですから、同じような境遇の人たちが、社会の喧騒から離れて安らげる場所になれば、という気持ちがなかったわけではありません。\u003C/p>\u003Cp>これは今でこそ思えるのですが、あまりにもナイーブで、独りよがりで、そして何よりも傲慢な考えでした。\u003Cbr>同じ傷を持つ者同士が、必ずしも寄り添い合えるとは限らない。\u003Cbr>むしろ、互いの傷口を舐め合い、膿を啜りあうような、共依存的な関係に陥りやすい。\u003Cbr>メンタルに問題を抱える人たち「だけ」が集まる場所を、健全なコミュニティとして成り立たせることは、もしかしたら元より不可能に近い、あまりにも過酷な挑戦だったのかもしれないのです。\u003C/p>\u003Ch1 id=\"h4860e8be57\">地獄とか\u003C/h1>\u003Cp>現状を言葉を選ばずに言えば、割と地獄のようなものです。\u003C/p>\u003Cp>「自分はこんなに可哀想」「誰も分かってくれない」「世界が私をいじめる」と、常に被害者としての立場から世界を語る人々。\u003Cbr>しかし、その言動の端々からは、他者への攻撃性や、「自分だけが注目されるべきだ」「私を慰め、肯定しなさい」という強烈な支配欲が、全く隠しきれていない。\u003Cbr>そういう人たちが、無数に点在しています。\u003C/p>\u003Cp>「誰も私を助けてくれない」という嘆きは、いつしか「だからあなたは私を助けるべきだ」という、他者への一方的な要求に変わります。\u003Cbr>「辛い」という一言は、他者の時間や感情を際限なく奪うための、万能の呪文になります。\u003Cbr>そして、その要求が満たされないと分かると、今度はその相手を「冷たい人だ」と断じ、新たな攻撃の対象にするのです。\u003C/p>\u003Cp>そんな言葉で埋め尽くされた投稿を眺めていると、本当に、心がじりじりと焼かれていくような、耐え難い感覚に襲われるのです。\u003C/p>\u003Cp>管理者として、「こんなつもりではなかった」なんて言う資格は、私には微塵もありません。\u003Cbr>サーバーの扉を開け、誰でもどうぞと招き入れたのは、他の誰でもない私自身なのですから。\u003Cbr>全ての責任は、この私にあります。\u003C/p>\u003Cp>でも、その責任論とは全く別の話として、今のこの状況は、一人の人間として、本当に耐え難く、辛い。\u003Cbr>めっちゃしんどい。\u003Cbr>まるで、自分の善意や寂しさが産み出してしまった化け物たちに、自分の手で築いた城の中で、じわじわと四方から追い詰められ、精神的な逃げ場を失っていくような気分です。\u003C/p>\u003Cp>ここからは、私自身が精神疾患の当事者だからこそ言える、とても残酷で、差別的な内容です。\u003Cbr>もし、あなたの心をざわつかせてしまったら、本当に申し訳ない。\u003C/p>\u003Cp>メンタルヘルスに問題を抱えている人間は、悲しいかな、大概において、コミュニケーションの面で碌でもない側面を持っています。\u003Cbr>もちろん、私自身を棚に上げるつもりは全くありません。\u003Cbr>むしろ、私こそがその筆頭なのかもしれない。\u003Cbr>だから、どんなに崇高な理想を掲げたとしても、他人に安易に手を差し伸べようとするべきではない。\u003Cbr>それが、この3年間で私が心と身体に深く刻み込んだ、最も痛みを伴う教訓でした。\u003C/p>\u003Cp>私のような層は、多くの場合、社会性が決定的に欠けています。\u003Cbr>だからこそ、大衆に向けられた「誰でもどうぞ」という無償の善意を、「自分だけを見てくれている」「この人は私を理解してくれるはずだ」という、特別な好意だと勘違いしてしまう。\u003Cbr>そして、際限なく甘え、依存し、もっと注目してくれと要求し、思い上がっていく。\u003Cbr>彼らの言動の中に、私が必死で蓋をしている自分自身の醜い部分、認めたくない側面を見てしまうからこそ、これほどまでに苦しいのかもしれません。\u003C/p>\u003Cp>彼女ら(そして私たち)は、理由があって社会という大きな輪の中から弾き出され、他人からまともに相手にされていない人たちなのです。\u003Cbr>そう考えると、そのような歪んだコミュニケーションしか取れないのは、ある意味で当然の帰結とも言えます。\u003C/p>\u003Cp>もっとも、私の場合は「誰かを救いたい」なんていう崇高な動機があったわけではありません。\u003Cbr>ただ、自分のどうしようもない寂しさと、肥大した承認欲求を満たしたかっただけ。\u003Cbr>その結果として、同じような空虚を抱えた人たちを、磁石のように引き寄せてしまった。\u003Cbr>ただ、それだけのことだったのです。\u003C/p>\u003Cp>社会に適合できない者たちを集めて、小さな社会を作ろうとしたら、そこではより純度が高く、煮詰められた社会不適合が生まれるだけ。\u003Cbr>そんな単純な道理に、私は気づくのがあまりにも遅すぎました。\u003C/p>\u003Ch1 id=\"h092c6dc24d\">静かな隠居\u003C/h1>\u003Cp>私は、もうそろそろ限界です。\u003Cbr>朝、目が覚めてPWAからの通知を見た瞬間に、ずしりとした疲労感が全身を覆う。\u003Cbr>TLを開くのが怖い。\u003Cbr>誰かの投稿に反応するのが億劫。\u003Cbr>あの場所は、もはや私にとって、安らぎの場所ではなく、心をすり減らすだけの苦役になってしまいました。\u003C/p>\u003Cp>サーバーを完全に閉鎖することも、考えなかったわけではありません。\u003Cbr>全てを投げ出して、この忌まわしい人間関係から逃げ出してしまえたら、どれだけ楽だろうかと、この数ヶ月、夜ごと何度も考えました。\u003C/p>\u003Cp>でも、こんな場所になってしまっても、変わらずに使い続けてくれている数少ない、本当に良識的なユーザーさんたちがいます。\u003Cbr>私が落ち込んでいるときに、そっと優しい言葉をかけてくれた人。\u003Cbr>「この場所が好きです」と、言ってくれた人。\u003Cbr>そして、サーバーの維持のために、毎月貴重なお金を寄付という形で支援し続けてくれている方々もいます。\u003C/p>\u003Cp>私の個人的な「もう無理」という感情だけで、その人たちのささやかな居場所や、これまでの思い出、そして寄せてくれた善意を、全て無碍に消し去ってしまうのは、あまりにも無責任で、身勝手だと思いました。\u003C/p>\u003Cp>だから、私は少しだけ、この場所との関わり方を変えることにします。\u003C/p>\u003Cp>もちろん、管理者として必要な最低限のモデレーションや、サーバーのメンテナンスは、責任をもって続けるつもりです。\u003Cbr>でも、TLを常に監視して心をすり減らすような関わり方は、もうやめます。\u003Cbr>当面は全く関係のない、インターネットの片隅でひっそりと、ただのユーザーとして隠居生活を送るつもりです。\u003Cbr>誰のことも気にせず、ただ好きなことを呟ける、あの最初の頃のような自由を取り戻したいのです。\u003C/p>\u003Cp>結局のところ、私には、一つのコミュニティを健全に管理し、そこに集う人々の人生の断片を預かり、その責任を最後まで背負い続けられるほどの器がなかった。\u003Cbr>ただ、それだけのことなのでしょう。\u003Cbr>\u003Cs>もっと規模が大きく、個々人の投稿が目に止まらないほどのTLを構築できていたらまた話は違ってきたのかもしれません。\u003C/s>\u003C/p>\u003Cp>美しい夢だったと思います。\u003Cbr>そして、その夢は、私の手には余るほど、重たく、そしてあまりにも壊れやすいものだったのです。\u003C/p>\u003Cp>自分の未熟さと無力さ、そして人間関係のどうしようもなさを、これでもかというほど思い知らされた、長くて短い3年間でした。\u003C/p>\u003Cp>それでは。\u003C/p>",[15,21,27,33,39],{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":17,"revisedAt":18,"slug":19,"name":20},"wia4slp55q","2025-04-28T07:14:14.541Z","2025-12-03T16:08:13.798Z","thoughts","独り言",{"id":22,"createdAt":23,"updatedAt":24,"publishedAt":23,"revisedAt":24,"slug":25,"name":26},"4oy2jc8mdg","2025-04-28T07:14:01.179Z","2025-12-03T16:08:24.495Z","diary","日記",{"id":28,"createdAt":29,"updatedAt":30,"publishedAt":29,"revisedAt":30,"slug":31,"name":32},"qfey3yw0z1","2025-04-29T08:44:41.687Z","2025-12-03T16:07:14.622Z","technology","テクノロジー",{"id":34,"createdAt":35,"updatedAt":36,"publishedAt":35,"revisedAt":36,"slug":37,"name":38},"fwj_nwhj1-s","2025-04-30T04:55:28.953Z","2025-12-03T16:07:03.591Z","server","サーバー",{"id":40,"createdAt":41,"updatedAt":42,"publishedAt":41,"revisedAt":42,"slug":43,"name":44},"vmhb23sq4","2025-07-29T12:56:07.884Z","2025-12-03T16:06:19.140Z","misskey","Misskey",false,{"id":47,"createdAt":48,"updatedAt":49,"publishedAt":50,"revisedAt":49,"title":51,"content":52,"tags":53,"is_no_index":45},"krpvl5itbr9h","2025-07-29T13:13:13.582Z","2026-06-21T14:30:14.475Z","2025-07-30T18:27:14.444Z","Misskeyサーバー構築から爆破までのすべて","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>巷に溢れるMisskeyインスタンスの構築記事、正直「うーん…」ってなるものが多くないですか？\u003Cbr>xsnsなんかはそもそも話にならないとして、書かれているコマンドの意味もよくわからないままコピペするだけで、本当に大丈夫なのかなって、わたしは少し心配になってしまいます。大した理解もないのに自信満々に記事を書けちゃうメンタル、どこから来るんでしょうね。\u003C/p>\u003Cp>当エントリでは、サーバの構築から、いつか来る爆破の時までを、私のわかる範囲でちゃんとマニュアル化してみようと思います。なるべく理解できる形にするつもりではいますが、手取り足取りという感じではないです。「ちゃんと自分で調べて、理解してから触ろうね」というスタンスですので、その点だけご了承ください。\u003C/p>\u003Cp>言ってしまうと、Misskeyインスタンスを建てること自体は非常に簡単です。小学校低学年くらいの子でも全然できると思います。\u003Cbr>しかし、コマンドをコピペするだけの方、あるいはそれすら難しいと感じる方は、悪いことは言わないので手を出さないほうが良いでしょう。\u003C/p>\u003Cp>さもなくば、何かトラブルがあった際にどう対応するつもりなのでしょうか。問題が生じるたびに、投げ出して破棄するのでしょうか？\u003Cbr>当たり前の話ですが、サーバには継続的なメンテナンスが不可欠です。建てるだけ建てて、後のことは知らないというのは、管理者としてあまりに無責任が過ぎます。\u003C/p>\u003Cp>「習うより慣れろ」だとか「まずは不完全でも動かしてみることが大事」なんて言葉を免罪符に、無知なまま他人のデータを預かろうとするのは、単なる傲慢です。技術的な未熟さを熱意やコミュニティへの貢献で誤魔化せると思っているのなら、それは大きな間違いだと言わざるを得ません。そもそも、この程度の内容を調べながら読んでも理解できないのであれば、明らかに知識不足ですので、出直してください。誰もしあわせにはなれません。\u003C/p>\u003Cp>一方で、高コンテクストな内容を同時に広く扱う都合上、当エントリは全体で短編小説ほどの文量になってしまっています。すべてを律儀に読み通すのはなかなかの苦行でしょうから、目次を活用し、あなたの気になる見出しからご覧いただくことを推奨します。\u003C/p>\u003Cp>また上記の都合から、いたずらに文量を増やさないためにも、技術の導入方法や初歩的な操作手順といった、すでに優れた技術記事や公式ドキュメントで十分に解説されている箇所については一部省略し、参考としてリンクを積極的に記載する方針を採っています。\u003C/p>\u003Cp>結果として当エントリは、サーバを構築するための具体的なHowToは他の記事に譲りつつ、そこで解説を省かれがちな管理者として極めて重要であると考える項目を補完することに重きを置く形となりました。それゆえ、そうした外部の解説記事やドキュメントをひっくるめて、初めてひとつの技術記事として成立するよう設計されています。記載されたリンク先へも適宜目を通していただけると、より理解が深まるはずです。\u003C/p>\u003Cp>なお、エントリ内で「インスタンス」や「サーバ」といったMisskeyのホストを指す語に表記揺れが存在しますが、これらは同義のものとして扱って構いません。Misskey Project公式がインスタンスをサーバと呼称するようになって久しいですが、未だにインスタンスと呼ぶ癖が抜けないのです。\u003Cs>SEO的な浅ましい狙いも否定はしませんが。\u003C/s>\u003C/p>\u003Cp>本エントリを読んでわかることはおおまかに以下の通りです\u003C/p>\u003Cul>\u003Cli>VPSと独自ドメインを使ったMisskeyサーバの構築手順\u003C/li>\u003Cli>Docker + Cloudflare Tunnelによる公開方法\u003C/li>\u003Cli>絵文字・オブジェクトストレージ・メール・監視など運用の実務\u003C/li>\u003Cli>ユーザ増加時のスケール戦略と、最終的な閉鎖手順\u003C/li>\u003C/ul>\u003Ch1 id=\"h392131f5fc\">Misskeyインスタンスを建てるためのVPSを用意する\u003C/h1>\u003Cp>Misskeyサーバを構築するにあたり、まずそのアプリケーションが稼働するための計算基盤、すなわちサーバを用意する必要があります。\u003Cbr>そのアーキテクチャは、大別して二つの選択肢に集約されるでしょう。\u003Cbr>物理的なサーバ実体を自らの管理下に置くか、専門事業者が提供する仮想化された計算資源を利用することです。\u003C/p>\u003Cp>ここでまず、わたしの基本的なスタンスを明確にしておきます。 わたしはこれからMisskeyインスタンスを運用しようとする方に対して、自宅サーバという選択肢を推奨しません。\u003C/p>\u003Cp>もちろん、技術的な探求や学習といった特定のコンテクストにおいては、自宅サーバは依然として優れた選択肢です。\u003Cbr>しかし、安定したサービス提供を目的とするならば、それは合理的な判断とは言えないでしょう。物理的な障害対応や、見過ごされがちな電気代、そして何より人的な運用コストといったものを考慮すると、その経済的・時間的合理性は極めて限定的だからです。\u003C/p>\u003Cp>ここでは、あなたが本来集中すべきアプリケーションの運用から乖離するような、不必要な複雑さからは距離を置きます。\u003C/p>\u003Cp>ということで、いずれかのVPS事業者と契約することになります。(マネージドサービス系はお財布に優しくないので)\u003C/p>\u003Cp>正直なところ、特定の事業者を強く推奨したいのですが、現実問題として、どの事業者を選択しても何かしらの微妙なところが存在します。そのため、ここでは比較的マシであり、特にこだわりがないのであれば大きな失敗には繋がりにくいであろう選択肢を挙げるに留めます。\u003C/p>\u003Cul>\u003Cli>Vultr\u003C/li>\u003Cli>Linode\u003C/li>\u003C/ul>\u003Cp>基本的には、何を契約しても構いません。\u003Cs>(というのは嘘で、国内のベンダはまともなところが少ないので、あまりオススメしません。)\u003C/s>\u003C/p>\u003Cp>仮に公開インスタンスとして運用するとしても、最初は1Core/1GiB程度のスペックを持ちインターネットへの疎通性のある月額2,300円前後のプランで十分です。もちろん、これ以上のスペックがあればより快適ですが、最初から過剰な投資をする必要はありません。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/56985da3f6d24c0b97f33e68e053dad2/image.png\" alt=\"\" width=\"1047\" height=\"809\">\u003C/figure>\u003Cp>安定したサービス運用は、ある種のインフラ投資です。無闇にコストを削ることが、結果として技術的負債や将来的な運用負荷に繋がる可能性を、常に念頭に置いておくべきでしょう。\u003Cbr>あなたが合理的だと判断した事業者とプランを選択し、契約を進めてください。\u003C/p>\u003Cp>今回は3Core/4GBの環境を利用しています。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/4617e6ff91834c4c97e49a1297d0ae22/image.png\" alt=\"\" width=\"841\" height=\"406\">\u003C/figure>\u003Cp>Xserverの無料の子です。検証用途であればきっと差し支えないでしょう。\u003C/p>\u003Ch1 id=\"h2da3fdb1f2\">ドメインを用意する\u003C/h1>\u003Cp>Misskeyのインスタンスを公開するにあたり、固有のドメインが必要になります。既存のドメインがあり、サブドメインを利用する場合、この工程は飛ばして構いません。\u003C/p>\u003Cp>ActivityPubの仕様上、ドメイン名を後から変更することができないので、ある程度は考えて選んでください。\u003Ccode>.com\u003C/code>や\u003Ccode>.dev\u003C/code>といったgTLDであれば、基本的には何でも構いません。\u003C/p>\u003Cp>ドメインはレジストラから取得しますが、どのレジストラを選ぶかは重要です。国内でよく名前が挙がる「お名前.com」のような事業者を個人的には推奨しません。\u003C/p>\u003Cp>もし、あなたに特別なこだわりがないのであれば、\u003Ca href=\"https://www.cloudflare.com/products/registrar/\" target=\"_blank\" rel=\"noopener noreferrer\">Cloudflare Registrar\u003C/a>をお勧めします。 Cloudflareはドメインを卸値で提供しており、余計な手数料がかからず安価です。また、この後利用することになるCDNやセキュリティ機能との連携もスムーズで、管理画面もシンプルです。特別な理由がない限り、Cloudflareを選んでおけば間違いないでしょう。\u003C/p>\u003Cp>Cloudflareのアカウントを作成し、希望のドメインを取得してください。\u003C/p>\u003Cp>ドメインを取得できたら、Auto MinifyとRocket Loader™をすべて無効化しておきます。\u003Cbr>この手順を飛ばすとMisskeyが正常に動作しない場合がありますので、必ず行ってください。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/0c79a3f446b54e54b5a906e2e71b9e5c/image.png\" alt=\"\" width=\"1920\" height=\"929\">\u003C/figure>\u003Cp>\u003Ccode>Domains &gt; Spped &gt; Settings &gt; Content Optimization\u003C/code>から設定が可能です。\u003C/p>\u003Ch1 id=\"h0cb67a7186\">Cloudflare TunnelでMisskeyインスタンスを公開する準備\u003C/h1>\u003Cp>今回の手順では、IPv6のみの環境など、より多くのケースで汎用的に利用できるよう\u003Ca href=\"https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/\" target=\"_blank\" rel=\"noopener noreferrer\">Cloudflare Tunnel\u003C/a>を利用します。\u003Cbr>これはCloudflareが提供するトンネリングサービスで、サーバとCloudflareのデータセンター間を安全な経路で接続し、外部にポートを公開することなくウェブサイトを公開できるものです。後述の手順でトークンが必要になるため、あらかじめ取得しておきます。\u003C/p>\u003Cp>Cloudflareの管理画面は少し分かりにくいのですが、以下の手順で進めてください。\u003C/p>\u003Col>\u003Cli>Cloudflareのダッシュボードにログインし、サイドメニューからZero Trustを選択。\u003C/li>\u003C/ol>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/bfe6a4c4edfc432dbc5d7da24b27c000/%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-06-21%2022.48.25.png\" alt=\"\" width=\"1920\" height=\"929\">\u003C/figure>\u003Col start=\"2\">\u003Cli>Networks &gt; Connectors より Create a tunnelをクリック\u003C/li>\u003C/ol>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/29ebb87f9f72404aa4d9b0015340ba07/%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-06-21%2022.49.21.png\" alt=\"\" width=\"1920\" height=\"929\">\u003C/figure>\u003Col start=\"3\">\u003Cli>Cloudflaredを選択\u003C/li>\u003C/ol>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/e944c6ec6f6f4203962a12876515af5b/%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-06-21%2022.49.30.png\" alt=\"\" width=\"1918\" height=\"928\">\u003C/figure>\u003Col start=\"4\">\u003Cli>任意の名称を入力し、Save Tunnelを押下\u003C/li>\u003C/ol>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/afd3bbd4a3d64ccd9673f38509af9949/%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-06-21%2022.50.24.png\" alt=\"\" width=\"1920\" height=\"929\">\u003C/figure>\u003Col start=\"5\">\u003Cli>トークンを控える\u003C/li>\u003C/ol>\u003Cp>\u003Ccode>cloudflared.exe service install eyJhIjoiYThlODIxMWM2N...\u003C/code>のような文字列がコピーできるので、\u003Ccode>eyJhIjoiYThlODIxMWM2N...\u003C/code>のようなトークン部のみを控え、Nextを押下。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/2cc357c1b9a147fab9d2db6ab30e6370/%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-06-21%2022.50.51.png\" alt=\"\" width=\"1920\" height=\"928\">\u003C/figure>\u003Col start=\"6\">\u003Cli>FQDNとの紐付け\u003C/li>\u003C/ol>\u003Cp>前項の手順で用意したドメインと、サービスを紐付けます。\u003Cbr>このとき、Serviceは必ず\u003Ccode>http://app:3000\u003C/code>となるようにしておいてください。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/995f65dea38747b8bdfbfdd4a117b8c3/%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-06-21%2022.51.47.png\" alt=\"\" width=\"1920\" height=\"931\">\u003C/figure>\u003Cp>ちなみに、ここでServiceにループバックアドレスを指定しても疎通自体は可能です。\u003Cbr>しかしながら、ループバックアドレスを設定してしまうとパフォーマンス上の問題が発生する可能性がでてきます。\u003C/p>\u003Cp>Dockerは、起動時に各サービスが通信するための内部ネットワークを構築します。このネットワーク内では、各コンテナにサービス名を使って直接アクセスできる、一種のDNS機能が提供されます。\u003Cbr>Serviceの向き先として \u003Ccode>http://app:3000\u003C/code> を指定することで、CloudflaredのコンテナからMisskeyコンテナへの通信は、最適化されたDocker内部ネットワークで完結します。 \u003C/p>\u003Cp>一方、ループバックアドレスを指定した場合、通信は一度ホストOSのネットワークスタックを経由し、ポートマッピングの仕組みを介してコンテナへと届けられます。リクエスト数が少ない場合、パフォーマンスへの影響は少ないのですが、大量のリクエストが発生した場合はdocker-proxyが大量のCPUリソースを消費してしまう現象があるようです。\u003C/p>\u003Cp>無駄なことをするのは控えておきましょう。\u003C/p>\u003Ch1 id=\"haf9b57cc17\">DockerでMisskey本体を構築する\u003C/h1>\u003Cp>サーバとドメインの準備が整いました。\u003Cbr>いよいよ、Misskey本体を構築していきます。\u003C/p>\u003Cp>最新のイケイケな技術に惹かれる気持ちは、技術者であれば誰しもが抱くものかもしれません。わたしも、その例外ではありません。 \u003Cbr>しかし、わたしたちの目的が技術的な探求ではなく、インスタンスの安定稼働であるならば、その技術選定はより現実的な視点で行う必要があります。\u003C/p>\u003Cp>例えば、Kubernetesなんかがその典型例でしょう。\u003Cbr>スケーラビリティがそこまで重要ではない小規模なインスタンスにおいて、Kubernetesの導入は無用な複雑さとオーバーヘッドを生み、管理コストを増大させるだけです。期待するほどのメリットは、まず得られません。新しい技術に挑戦する姿勢は評価できますが、目的と手段を見誤った技術選定は賢明と言えないでしょう。\u003C/p>\u003Cp>結論から言えば、個人や小規模なコミュニティが運営するMisskeyインスタンスにおいて、その基盤はシングルノードDockerで十分である、とわたしは考えています。\u003Cbr>できもしない無謀な試みをせず、身の丈に合った選択をしましょう。\u003C/p>\u003Cp>ここでは、その方針に基づき、Dockerを利用してMisskeyを構築していきます。\u003C/p>\u003Ch2 id=\"h64e4921e27\">Dockerの導入\u003C/h2>\u003Cp>契約したVPSにSSHを行い、以下のコマンドを実行します。\u003C/p>\u003Cp>\u003Ccode>sudo apt install curl -y &amp;&amp; curl https://get.docker.com/ | sudo sh -\u003C/code>\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/5ebe2386901c40b5b72406f09a935341/image.png\" alt=\"\" width=\"351\" height=\"40\">\u003C/figure>\u003Cp>現時点で最新のものです。\u003C/p>\u003Ch2 id=\"h581c75cdc4\">環境の用意\u003C/h2>\u003Cp>ディレクトリを作成し、必要なファイル類を作成します。\u003C/p>\u003Cpre>\u003Ccode class=\"language-shell\">mkdir ./misskey ./misskey/config\ncd ./misskey\ntouch ./compose.yaml ./config/.env\ncurl https://raw.githubusercontent.com/misskey-dev/misskey/refs/heads/develop/.config/docker_example.yml &gt; ./config/default.yml\u003C/code>\u003C/pre>\u003Cp>vimやらnanoやらを使って各ファイルを編集します。\u003C/p>\u003Cdiv data-filename=\"./compose.yaml\">\u003Cpre>\u003Ccode class=\"language-yaml\">services:\n  app:\n    image: misskey/misskey:latest\n    restart: always\n    links:\n      - db\n      - redis\n    depends_on:\n      db:\n        condition: service_healthy\n      redis:\n        condition: service_healthy\n    networks:\n      - internal_network\n    volumes:\n      - ./config/default.yml:/misskey/.config/default.yml:ro\n\n  redis:\n    restart: always\n    image: eqalpha/keydb:alpine\n    networks:\n      - internal_network\n    volumes:\n      - ./data/keydb:/data\n    healthcheck:\n      test: &quot;redis-cli ping&quot;\n      interval: 5s\n      retries: 20\n\n  db:\n    restart: always\n    image: groonga/pgroonga:latest-debian-16\n    ports:\n      - &apos;5430:5432&apos;\n    networks:\n      internal_network:\n    env_file:\n      - ./config/.env\n    volumes:\n      - ./data/db:/var/lib/postgresql/data\n    healthcheck:\n      test: &quot;pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB&quot;\n      interval: 5s\n      retries: 20\n\n  tunnel:\n    restart: always\n    image: cloudflare/cloudflared\n    command: tunnel --no-autoupdate run\n    profiles:\n      - tunnel\n    env_file:\n      - ./config/.env\n    networks:\n      - internal_network\n\nnetworks:\n  internal_network:\u003C/code>\u003C/pre>\u003C/div>\u003Cdiv data-filename=\"./config/.env\">\u003Cpre>\u003Ccode class=\"language-yaml\">POSTGRES_PASSWORD=データベースのパスワードとして利用する任意の英数字\nPOSTGRES_USER=misskey\nPOSTGRES_DB=mk1\nDATABASE_URL=&quot;postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}&quot;\n\nTUNNEL_TOKEN=Cloudflare Tunnelのトークン\u003C/code>\u003C/pre>\u003C/div>\u003Cdiv data-filename=\"./config/default.yml\">\u003Cpre>\u003Ccode class=\"language-yaml\">#   ┌─────┐\n#───┘ URL └─────────────────────────────────────────────────────\n\n# Final accessible URL seen by a user.\n# You can set url from an environment variable instead.\nurl: https://利用するドメイン/\u003C/code>\u003C/pre>\u003C/div>\u003Cp>\u003Cs>2025.12.15追記\u003Cbr>Misskey v2025.12.0-alpha.2以降、\u003C/s>\u003Ca href=\"https://github.com/misskey-dev/misskey/security/advisories/GHSA-wwrj-3hvj-prpm\" target=\"_blank\" rel=\"noopener noreferrer\">\u003Cs>セキュリティ上の懸念\u003C/s>\u003C/a>\u003Cs>により\u003C/s>\u003Ca href=\"https://github.com/misskey-dev/misskey/commit/5512898463fa8487b9e6488912f35102b91f25f7\" target=\"_blank\" rel=\"noopener noreferrer\">\u003Cs>デフォルト値が変更されたため\u003C/s>\u003C/a>\u003Cs>、クライアントのIPを正確に把握するため、プロキシ環境下では\u003C/s>\u003Ccode>trustProxy\u003C/code>\u003Cs>を明示的に\u003C/s>\u003Ccode>true\u003C/code>\u003Cs>にしておく必要があります。\u003Cbr>本エントリの構成においては、CaddyとCloudflare Tunnelを利用しているため、\u003C/s>\u003Ccode>true\u003C/code>\u003Cs>を明示する必要があります。\u003Cbr>また、この設定項目については\u003C/s>\u003Ca href=\"https://github.com/misskey-dev/misskey/issues/16994\" target=\"_blank\" rel=\"noopener noreferrer\">\u003Cs>将来的に変更される可能性\u003C/s>\u003C/a>\u003Cs>があります。\u003C/s>\u003C/p>\u003Cp>2025.12.16追記\u003Cbr>\u003Ccode>trustProxy\u003C/code>周りの\u003Ca href=\"https://github.com/misskey-dev/misskey/issues/16994#issuecomment-3659950721\" target=\"_blank\" rel=\"noopener noreferrer\">変更が戻されました\u003C/a>。現時点では、必要がなければ設定する必要はありません。\u003Cbr>また、この設定項目については\u003Ca href=\"https://github.com/misskey-dev/misskey/issues/16994\" target=\"_blank\" rel=\"noopener noreferrer\">将来的に変更される可能性\u003C/a>があります。\u003C/p>\u003Cdiv data-filename=\"./config/default.yml\">\u003Cpre>\u003Ccode class=\"language-yaml\">#   ┌──────────────────────────┐\n#───┘ PostgreSQL configuration └────────────────────────────────\n\ndb:\n... 中略\n  # Database name\n  # You can set db from an environment variable instead.\n  db: mk1\n\n  # Auth\n  # You can set user and pass from environment variables instead.\n  user: misskey\n  pass: データベースのパスワードとして利用する任意の英数字\u003C/code>\u003C/pre>\u003C/div>\u003Cdiv data-filename=\"./config/default.yml\">\u003Cpre>\u003Ccode class=\"language-yaml\">#   ┌───────────────────────────────┐\n#───┘ Fulltext search configuration └─────────────────────────────\n\n# These are the setting items for the full-text search provider.\nfulltextSearch:\n... 中略\n  provider: sqlPgroonga\u003C/code>\u003C/pre>\u003C/div>\u003Cp>\u003Ccode>./config/default.yml\u003C/code>はあまりにも長いので変更部位のみピックアップしています。\u003C/p>\u003Ch2 id=\"hec00bc706e\">DBの準備\u003C/h2>\u003Cp>次のコマンドでデータベースの初期化と全文検索のためのPGroongaの有効化を行います。 \u003Cbr>これにはしばらく時間がかかります。\u003C/p>\u003Cpre>\u003Ccode class=\"language-shell\">sudo docker compose run --rm app pnpm run init\nsudo docker compose exec db psql -U misskey -d mk1 -c &quot;create extension if not exists pgroonga;&quot;\nsudo docker compose exec db psql -U misskey -d mk1 -c &quot;create index idx_note_text_with_pgroonga on note using pgroonga (text);&quot;\u003C/code>\u003C/pre>\u003Ch2 id=\"h1d40932a6f\">起動\u003C/h2>\u003Cp>以下のコマンドでMisskeyを起動できます。\u003C/p>\u003Cpre>\u003Ccode class=\"language-shell\">sudo docker compose --profile tunnel up -d\u003C/code>\u003C/pre>\u003Cp>うまくいくとこんな感じ\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/be983a0182834397b2ae4615c74eaa20/image.png\" alt=\"\" width=\"1096\" height=\"420\">\u003C/figure>\u003Cp>ここでtunnelをprofileとして指定しているのは、misskeyを更新するたびにCloudflare Tunnelのコンテナまで再生成されないようにする意図があります。\u003C/p>\u003Ch1 id=\"h6ae33e7349\">設定\u003C/h1>\u003Ch2 id=\"h69f761e8b8\">セットアップウィザード\u003C/h2>\u003Cp>これまでの手順が正しく完了していれば、取得したドメインにお手元のブラウザからアクセスすると、Misskeyの初期設定画面が表示されるはずです。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/80d40f22d58f476da7cbe5a2077b75e9/image.png\" alt=\"\" width=\"1918\" height=\"909\">\u003C/figure>\u003Cp>ここでは管理者ユーザを作成するのですが、このアカウントは一般ユーザとは扱いが異なり、削除等が通常できません。\u003C/p>\u003Cp>あなたがこのインスタンスを個人的なおひとり様インスタンスとしてではなく、コミュニティとして運用するつもりなのであれば、ここで作成するアカウントを日常的に利用することは避けるべきでしょう。普段使いのアカウントとは別に、管理作業のためだけの専用アカウント(例えば@adminといったユーザ)を作成することを、強く推奨します。\u003C/p>\u003Cp>\u003Cs>わたしはこれで後悔しました。\u003C/s>\u003C/p>\u003Cp>入力後、次を押下するとウィザードへ遷移します。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/9ac6d52c9f1a4553b82a845da6e316d4/image.png\" alt=\"\" width=\"1918\" height=\"906\">\u003C/figure>\u003Cp>この項目をスキップすることもできますが、ここで片付けておきましょう。\u003Cbr>基本的な設定はこれで完了します。\u003C/p>\u003Ch2 id=\"hcae32190de\">オブジェクトストレージ\u003C/h2>\u003Cp>Misskeyではドライブの格納先としてAWS S3または互換のオブジェクトストレージを使う設定ができます。\u003Cbr>これを有効化しておかないと、files/ディレクトリ配下にファイルが蓄積されるのですが、運用上あまり好ましくないので、可能であればオブジェクトストレージの利用をしましょう。\u003Cbr>ここではCloudflare R2を利用します。\u003C/p>\u003Cp>Cloudflareのアカウントホームを開き、\u003Ccode>Storage &amp; databases &gt; R2 Object Storage &gt; Overview\u003C/code>を開き、新規bucketを作成します。\u003C/p>\u003Cp>\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/cc353e7ef83145288617702ada17f758/%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-06-21%2023.06.05.png\" alt=\"\" width=\"1920\" height=\"929\">\u003C/figure>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/53e652db8f4348ba81d53f4cb384c643/%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-06-21%2023.14.17.png\" alt=\"\" width=\"1918\" height=\"930\">\u003C/figure>\u003Cp>名称は何でも構いませんが、ここではmi-testとしています。\u003C/p>\u003Cp>作成後、Settingsを開き、S3 APIのURLを控えておきます。\u003Cbr>また、キャッシュを効かせるためカスタムドメインを追加します。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/518c02b0bfd64dbe84e76bf88ea929f6/%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-06-21%2023.03.30.png\" alt=\"\" width=\"1920\" height=\"929\">\u003C/figure>\u003Cp>カスタムドメインは適当なサブドメインにでもしておきましょう。\u003Cbr>ここでは\u003Ccode>media-mi-test.mq1.dev\u003C/code>を指定しました。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/27bd7b46e2474a43b5c5e3b17f1958a7/%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-06-21%2023.03.54.png\" alt=\"\" width=\"1920\" height=\"928\">\u003C/figure>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/397518301e5c43f8924bcb5c62129b7b/%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-06-21%2023.04.16.png\" alt=\"\" width=\"1920\" height=\"930\">\u003C/figure>\u003Cp>R2のダッシュボードから、\u003Ccode>API Tokens &gt; Manage\u003C/code>を開き、\u003Ccode>Create Account API token\u003C/code>からトークンを新規作成します。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/eaf0036cfbbd48e88bdcf9ea7eb5c8bc/%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-06-21%2023.19.43.png\" alt=\"\" width=\"1920\" height=\"930\">\u003C/figure>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/d93bd20d282a4a83b2b452299a574483/%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-06-21%2023.06.57.png\" alt=\"\" width=\"1920\" height=\"929\">\u003C/figure>\u003Cp>権限はObject Read &amp; Write、スコープを先ほど作成したbucketのみに絞っておきます。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/d9b90711b2404092a6440c868162b162/%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-06-21%2023.07.40.png\" alt=\"\" width=\"1920\" height=\"928\">\u003C/figure>\u003Cp>Create Account API Tokenを押下し、Access Key IDとSecret Access Keyを控えておきます。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/5fdfdd68d2b54d4896dd98fb1611febe/%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-06-21%2023.08.18.png\" alt=\"\" width=\"1920\" height=\"928\">\u003C/figure>\u003Cp>閉じてしまうと再表示できなくなるので注意してください。\u003C/p>\u003Cp>Misskeyへ戻り、\u003Ccode>コントロールパネル &gt; 設定 &gt; オブジェクトストレージ\u003C/code>より、R2の情報を入力します\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/2ca617b554374fe99e4756b486908805/image.png\" alt=\"\" width=\"1916\" height=\"909\">\u003C/figure>\u003Cp>ここでの入力値は以下のものに対応します。\u003C/p>\u003Cul>\u003Cli>Base URL\u003Cp>設定したカスタムドメイン(末尾に/を入れないでください)\u003Cbr>例: \u003Ccode>https://media-mi-test.mq1.dev\u003C/code>\u003C/p>\u003C/li>\u003Cli>Bucket\u003Cp>Bucket名\u003Cbr>例:  \u003Ccode>mi-test\u003C/code>\u003C/p>\u003C/li>\u003Cli>Prefix\u003Cp>任意のPrefix名\u003Cbr>例: \u003Ccode>drive\u003C/code>\u003C/p>\u003C/li>\u003Cli>Endpoint\u003Cp>控えておいたS3 APIのURL\u003Cbr>例: \u003Ccode>https://a8e8211c674c2b00f3a8996b65b56447.r2.cloudflarestorage.com\u003C/code>\u003C/p>\u003C/li>\u003Cli>Region\u003Cp>\u003Ccode>us-east-1\u003C/code>\u003C/p>\u003C/li>\u003Cli>Access Key\u003Cp>控えておいたAccess Key ID\u003C/p>\u003C/li>\u003Cli>Secret Key\u003Cp>控えておいたSecret Access Key\u003C/p>\u003C/li>\u003C/ul>\u003Cp>すべての項目を入力し、保存を押下します。\u003C/p>\u003Cp>2025.08.21追記\u003Cbr>Endpointの例として\u003Ccode>https://a8e8211c674c2b00f3a8996b65b56447.r2.cloudflarestorage.com/mi-test\u003C/code>を挙げていましたが、これは誤りです。\u003Cbr>正しくはBucket名を含まない上記の例(\u003Ccode>https://a8e8211c674c2b00f3a8996b65b56447.r2.cloudflarestorage.com\u003C/code>)をご参照ください。\u003C/p>\u003Ch2 id=\"hee49c0d379\">メールサーバ\u003C/h2>\u003Cp>メールの配信を行うSMTPサーバの設定を行います。\u003Cbr>公開インスタンスとして運営するつもりであれば、新規登録の確認やパスワードリセットなど、システムからの通知に必須となりますので、必ず設定しておきましょう。\u003C/p>\u003Cp style=\"text-align: start\">SMTPが利用できれば根本的には何でも構いませんが、手軽さと配送品質の観点から、当エントリでは\u003Ca href=\"https://resend.com/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">Resend\u003C/a>の利用を推奨します。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://resend.com/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">https://resend.com/\u003C/a>\u003C/p>\u003Cp>私はインスタンス設立当初SESを利用していましたが、現在はResendに落ち着いています。メールの送信にTLSを強制している影響か、\u003Cs>d○c○m○をはじめとする\u003C/s>キャリアメール宛への送信がうまくいかない場合がありますが、ガラパゴスな仕様へのサポートは切り落とすに限ります。\u003C/p>\u003Cp>ただし、ResendのFree Planを利用する上で、一つだけ極めて重要な注意点があります。それは、1つのアカウントにつき、紐付けられる独自ドメイン(FQDN)は1つまでという制約です。\u003C/p>\u003Cp>もし将来的に、別のMisskeyインスタンスを別ドメインで立てたり、個人的なプロジェクトでResendを利用する可能性が少しでもあるのなら、アカウント作成時に安易なGitHub等のSSOの利用は避けておくといいでしょう。SSOで紐づけてしまうと、いざ別のアカウントを作ろうとした時に同じメールアドレスが使えず、すごくに面倒なことになります。\u003C/p>\u003Cp>Resendのアカウントを作成し、ドメインの認証が完了したら、API keysからSMTP用のクレデンシャルを発行します。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ccode>コントロールパネル &gt; 設定 &gt; メールサーバー\u003C/code>より、SMTPのクレデンシャルを入力し保存します。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/552c3d5d9960490380f0acd134f3b178/image.png\" alt=\"\" width=\"1918\" height=\"910\">\u003C/figure>\u003Cp>Resendを使う場合、必要なことはすべて\u003Ca href=\"https://resend.com/docs/send-with-smtp\" target=\"_blank\" rel=\"noopener noreferrer\">ここ\u003C/a>に書かれているので参照してください。\u003Cbr>\u003Ca href=\"https://resend.com/docs/send-with-smtp\" target=\"_blank\" rel=\"noopener noreferrer\">https://resend.com/docs/send-with-smtp\u003C/a>\u003C/p>\u003Cp>\u003C/p>\u003Cp>また、「宗教上の理由でResentは使いたくない」「おすすめされたものを使うのは癪だ」という奇特な方のために、GmailをSMTPサーバとして利用する方法も存在します。制限こそありますが、小規模なインスタンスであれば実用に耐え得ます。\u003C/p>\u003Cp>これについては、私が過去に書いた以下の記事に詳細をまとめていますので、興味のある方は参照してみてください。\u003C/p>\u003Cp>\u003Ca href=\"https://qiita.com/mai_llj/items/1002932eb46ce39d4045\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">https://qiita.com/mai_llj/items/1002932eb46ce39d4045\u003C/a>\u003C/p>\u003Cp>\u003C/p>\u003Ch2 id=\"h79c675fda4\">ServiceWorker\u003C/h2>\u003Cp>通知の機能をユーザが利用できるようにするため、ServiceWorkerの設定を行う必要があります。\u003Cbr>以下のコマンドを、任意の環境で実行し、Public KeyとPrivate Keyを生成し、設定を行います。\u003C/p>\u003Cpre>\u003Ccode class=\"language-shell\">sudo apt install nodejs npm -y &amp;&amp; sudo npm install web-push -g &amp;&amp; web-push generate-vapid-keys\u003C/code>\u003C/pre>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/1fa6e99905144fc694bf4a50f0f8a540/image.png\" alt=\"\" width=\"831\" height=\"185\">\u003C/figure>\u003Cp>\u003Ccode>コントロールパネル &gt; 設定 &gt; 全般 &gt; ServiceWorker\u003C/code>より、生成したPublic KeyとPrivate Keyを入力します。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/e3876e30f4724b17b8b5b18a7aa6b5fa/image.png\" alt=\"\" width=\"1919\" height=\"907\">\u003C/figure>\u003Cp>保存を押下し、完了です。\u003C/p>\u003Ch2 id=\"h08b84998b2\">リレー\u003C/h2>\u003Cp>Misskeyにはリレーとよばれるサーバによって投稿を中継配信する機能があります。\u003C/p>\u003Cp>建てたての小規模インスタンスや、お一人様鯖であれば、GTLの流量をかさ増しするために設定することをおすすめします。\u003Cbr>しかし、リレーに参加することでリレーからのノートが大量に発生するため、DBの容量などを考慮した上で設定を行ってください。\u003C/p>\u003Cp style=\"text-align: start\">リレーサーバはいくつかありますが、有名なものであれば。\u003Cbr>このあたりが参考になりそうです。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://hisubway.online/blog/fediverse_relay/\" target=\"_blank\" rel=\"noopener noreferrer\">https://hisubway.online/blog/fediverse_relay/\u003C/a>\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://note.com/marjo/n/n9eec21c5623a\" target=\"_blank\" rel=\"noopener noreferrer\">https://note.com/marjo/n/n9eec21c5623a\u003C/a>\u003C/p>\u003Cp style=\"text-align: start\">\u003Ccode>コントロールパネル &gt; 設定 &gt; リレー\u003C/code>より追加が可能です。\u003C/p>\u003Ch2 id=\"h0e5871ed38\">Log IP address\u003C/h2>\u003Cp>有無を言わず有効化しておきましょう。\u003C/p>\u003Cp>\u003Ccode>コントロールパネル &gt; 設定 &gt; セキュリティ &gt; Log IP address\u003C/code>をEnable化するだけです。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/344bcbb9b9104fa791af2b9542bb84f6/image.png\" alt=\"\" width=\"1917\" height=\"909\">\u003C/figure>\u003Cp>\u003C/p>\u003Ch2 id=\"h6fa6283080\">ノート検索を許可する\u003C/h2>\u003Cp>デフォルトの状態では、ノート検索ができないようになっています。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/f3a41953583c47ea8512c00ddba77652/image.png\" alt=\"\" width=\"1918\" height=\"910\">\u003C/figure>\u003Cp>今回構築した環境ではPGroongaと呼ばれる全文検索のためのPostgreSQLの拡張機能が導入されているので、ロールを変更するだけで高速な全文検索が利用可能です。\u003C/p>\u003Cp>\u003Ccode>コントロールパネル &gt; 管理 &gt; ロール &gt; ベースロール\u003C/code>より、\u003Ccode>ノート検索の利用\u003C/code>を\u003Ccode>はい\u003C/code>に変更します。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/2bff26665faa444a858b66c3064769c8/image.png\" alt=\"\" width=\"1916\" height=\"908\">\u003C/figure>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/182f55d0492e4f7ab3f6fb39e26c184f/image.png\" alt=\"\" width=\"1918\" height=\"909\">\u003C/figure>\u003Cp>\u003C/p>\u003Ch2 id=\"he7a6755d7e\">その他の諸々\u003C/h2>\u003Cp>ブランディングやBotプロテクションの設定など、Misskeyでは自由度が高い分弄れる項目がかなり多いです。\u003Cbr>その他の細かい部分については、\u003Ca href=\"https://qiita.com/mai_llj/items/a1a4c65651af904cac60\" target=\"_blank\" rel=\"noopener noreferrer\">私が過去に書いたQiitaの記事\u003C/a>にも色々あるので、そちらも併せて参照してください。\u003C/p>\u003Cp>ちなみに、2025年に入ってから行われたMisskey本体のアップデートでかなりの変更が入っているので、若干古い部分があったりします。許してください。\u003C/p>\u003Ch1 id=\"h611365bd16\">絵文字の追加\u003C/h1>\u003Cp>多くの人が知っているように、Misskeyにおけるコミュニケーションは、カスタム絵文字によってその豊かさと深さが定義されると言っても過言ではありません。\u003Cbr>これは単なる装飾機能ではなく、リアクションやノートの文脈を補強し、時に言語以上に雄弁な非言語的コミュニケーションを可能にする、このプラットフォームの根幹を成す文化的なものです。\u003C/p>\u003Cp>インスタンスにの管理者は、この重要な資源を管理する権限を持ちます。\u003C/p>\u003Cp>絵文字の管理は、\u003Ccode>コントロールパネル &gt; 管理 &gt; 絵文字\u003C/code>より行います。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/f1d20e19795e469183769a415e71b5af/image.png\" alt=\"\" width=\"1919\" height=\"905\">\u003C/figure>\u003Cp>\u003C/p>\u003Ch2 id=\"h9150da3d7b\">手動での追加\u003C/h2>\u003Cp>オリジナルの絵文字や、特にあなたのコミュニティで必要とされる特定の画像を追加する際の基本的な方法です。\u003Cbr>カスタム絵文字の管理パネルを開き、右上の\u003Ccode>+\u003C/code>から、画像をアップロードし、名前やカテゴリ、タグ、ライセンス等を割り当てます。\u003C/p>\u003Cp>この過程ににおいて、いくつか留意しておくといい点があります。\u003C/p>\u003Cp>ます、その絵文字に与える名前です。\u003Cbr>この名前は絵文字ピッカーでの検索性に直結します。\u003Cbr>わたしの運用例ですが、主要な名前をキャメルケース(例: \u003Ccode>shahuchan_always_watching_you\u003C/code>)で統一し、エイリアスに、ひらがな、カタカナ、漢字といった様々な呼称とキーワードを複数登録しています。\u003Cbr>登録の手間こそありますが、どのような単語で検索しても、目的の絵文字に辿り着ける可能性が高まり、しあわせになれるかもしれません。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/a1d99231350c4fb081585144b370c175/image.png\" alt=\"\" width=\"1629\" height=\"363\">\u003C/figure>\u003Cp>そして、個々の絵文字の命名規則と同様に、全体を俯瞰した際の秩序を保つのがカテゴリ分類の役割です。\u003Cbr>無秩序に追加された絵文字は、ピッカーの利便性を低下させますから、「リアクション」や「キャラクター」といった分類を設けるべきであると考えます。\u003Cbr>さらにカテゴリが増えた際には、\u003Ccode>001_original\u003C/code>, \u003Ccode>002_blob/001_blobcat\u003C/code>のように接頭詞として連番を割り当てたり、\u003Ccode>002_blob/001_blobcat\u003C/code>, \u003Ccode>002_blob/002_blobika\u003C/code>のように階層化したりすることで、ピッカー内での視認性と表示順を意図通りに制御できるでしょう。\u003C/p>\u003Cp>最後に、これら全ての作業の前提として、権利者への配慮を忘れてはなりません。\u003Cbr>安易な画像の使用が、様々なリスクに繋がる可能性を常に念頭に置いてください。\u003C/p>\u003Ch2 id=\"h8f3007f16f\">他インスタンスからのインポート\u003C/h2>\u003Cp>Misskeyでは、連合先に存在する観測された限りの絵文字をインポートする機能が備わっており、手動での追加とは対照的に、極めて効率的に思えます。\u003Cbr>しかし、その手軽さは、あなたが予期しない種類の、そして極めて面倒なリスクを抱え込むことと表裏一体です。\u003C/p>\u003Cp>あなたが他のインスタンスから絵文字をインポートする時、その絵文字のライセンスに関する一切の責任も同時にインポートしている、という事実を認識しなくてはなりません。\u003Cbr>残念ながら、インターネット上に存在する多くの絵文字は、著作権を全く考慮されずに作成・登録されています。\u003Cbr>アニメの一場面、企業のロゴ、ファンアート。\u003Cbr>その出所は玉石混淆です。\u003C/p>\u003Cp>あなたのサーバ単体で閉じていれば、それは大きな問題にはならないかもしれません。\u003Cbr>しかし、あなたのサーバが他のサーバと連合を始めた瞬間、その絵文字は連合先のタイムラインにも表示されることになります。\u003C/p>\u003Cp>そして、連合先のインスタンスには、時としてライセンスの扱いに極めて厳格な、気難しい連中が必ずと言っていいほど存在します。\u003Cbr>彼ら彼女らは、あなたのサーバのユーザが付けた一つのリアクションからライセンスに反する絵文字を発見し、その出所であるあなたのサーバを突き止め、そして、あなたに苦言を呈すでしょう。\u003C/p>\u003Cp>このような外部からの指摘に対応する時間は、サーバ管理者にとって最も不毛な時間の一つです。\u003Cbr>もちろん、ライセンスの問題に加え、ストレージやデータベースへの負荷、文化的な不一致といった技術的な問題も依然として存在します。\u003C/p>\u003Cp>故に、インポート機能を利用する際は、その手軽さに惑わされてはなりません。\u003Cbr>最も安全なのは、やはりあなた自身がライセンスをクリアできると確信した画像のみを、手動で追加していくことです。\u003Cbr>遠回りに見えても、将来的な紛争の火種を自ら抱え込むよりは、遥かに賢明な選択と言えるでしょう。\u003C/p>\u003Ch2 id=\"h435e240727\">ちょっとした小技\u003C/h2>\u003Cp>しかし、時に、一点ずつ手動で登録するのも、あるいは他のインスタンスからライセンスを確認しつつインポートするのも、煩雑で面倒だと感じることもあるでしょう。\u003C/p>\u003Cp>そのような状況に対応するため、Misskeyには、特定の形式でパッケージ化されたZIPファイルから、絵文字を一括でインポートする機能が存在します。\u003Cbr>これは、絵文字パックを配布・導入する際などに利用される、効率的な手段です。\u003C/p>\u003Cp>パックのZIPファイルが要求する詳細な仕様については、各自で調べていただくとして、ここではいくつかの参考資料を提示するに留めます。\u003C/p>\u003Cp>公式ドキュメント\u003Cbr>\u003Ca href=\"https://misskey-hub.net/ja/docs/for-admin/features/managing-emojis/#%E4%B8%80%E6%8B%AC%E3%82%A4%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%88\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">https://misskey-hub.net/ja/docs/for-admin/features/managing-emojis/#%E4%B8%80%E6%8B%AC%E3%82%A4%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%88\u003C/a>\u003C/p>\u003Cp>仕様解説といい感じのツール\u003Cbr>\u003Ca href=\"https://scrapbox.io/defaultcf/Misskey%E3%81%A7%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%A0%E7%B5%B5%E6%96%87%E5%AD%97%E3%82%92%E4%B8%80%E6%B0%97%E3%81%AB%E5%85%A5%E3%82%8C%E3%82%8B\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">https://scrapbox.io/defaultcf/Misskey%E3%81%A7%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%A0%E7%B5%B5%E6%96%87%E5%AD%97%E3%82%92%E4%B8%80%E6%B0%97%E3%81%AB%E5%85%A5%E3%82%8C%E3%82%8B\u003C/a>\u003Cbr>\u003Ca href=\"https://tools.e17.dev/emoji-manager/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">https://tools.e17.dev/emoji-manager/\u003C/a>\u003Cbr>\u003Ca href=\"https://github.com/alicerose/misskey-emoji-archive-generator\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">https://github.com/alicerose/misskey-emoji-archive-generator\u003C/a>\u003C/p>\u003Cp>\u003C/p>\u003Ch1 id=\"ha2925ef888\">DBの定期バックアップ\u003C/h1>\u003Cp style=\"text-align: start\">この項目は、本エントリ全体で最も重要です。\u003Cbr>もし、この先の内容を理解し、実行する自信がないのであれば、あなたは自前でMisskeyインスタンスを運用すべきではありません。\u003Cbr>今すぐ、全てを諦めてお布団にはいりましょう。\u003C/p>\u003Cp>万が一何かしらの問題が発生した場合、サーバは再構築できます。\u003Cbr>オブジェクトストレージに保存したファイルも、再設定すれば済む話です。\u003Cbr>しかし、データベースだけは、失ってしまえば二度と元には戻りません。\u003C/p>\u003Cp>そして、Misskeyが利用するActivityPubというプロトコルの仕様上、データベースに保存されている署名鍵を失うことは、そのドメインでサーバを継続することの完全な終わりを意味します。\u003Cbr>新しいサーバを同じドメインで建て直しても、過去の投稿やフォロワーを引き継ぐことはできず、あなたのサーバは連合ネットワークの中で完全に孤立した、全くの別物になってしまうのです。\u003C/p>\u003Cp>何があっても、データベースだけは死守しなければなりません。\u003C/p>\u003Cp>これは、脅しでも、単なる建前でもありません。\u003Cbr>何を隠そう、わたしは過去に土砂崩れで旧自宅の半分とサーバ群を文字通り全て失い、ドメインを変えてゼロからサーバを再構築した経験があります。\u003Cbr>あの時の絶望感と、ユーザに対する申し訳なさは、今でも忘れられません。\u003C/p>\u003Cp>だからこそ、バックアップの重要性だけは、声を大にして、何度でも伝えたいのです。\u003C/p>\u003Ch2 id=\"h883f7f65dd\">バックアップの実行と自動化\u003C/h2>\u003Cp>*2025.11.23追記 簡易的なShell Scriptでの実装を紹介していましたが、不備があったため、当該記述を削除しました。\u003C/p>\u003Cp>今回は、以下のDocker Imageを利用します。\u003C/p>\u003Cp>\u003Ca href=\"https://github.com/team-shahu/misskey-backup/pkgs/container/misskey-backup\" target=\"_blank\" rel=\"noopener noreferrer\">https://github.com/team-shahu/misskey-backup/pkgs/container/misskey-backup\u003C/a>\u003Cbr>\u003Ca href=\"https://github.com/team-shahu/misskey-backup\" target=\"_blank\" rel=\"noopener noreferrer\">https://github.com/team-shahu/misskey-backup\u003C/a>\u003C/p>\u003Cp>このコンテナイメージは、定期的なバックアップとファイルの可逆暗号化、R2への転送機能を内包しており、環境変数をいくつか設定するだけで、バックアップからオフサイト転送までの一連のプロセスを、完全に独立したコンテナとして自動実行してくれます。\u003C/p>\u003Cp>具体的には、既存の\u003Ccode>compose.yaml\u003C/code>に、このバックアップコンテナをサービスとして追記するだけで使えます。\u003Cbr>ホストOSはDockerを動かすための最小限の環境に保たれ、アプリケーションに関する全ての責務をコンテナの世界に閉じ込めることができ、より気持ちのいい状態を実現できます。\u003C/p>\u003Cp>詳細な利用方法についてはREADMEに書いてあります。\u003Cbr>よければ使ってください。\u003C/p>\u003Cp>ちなみに、オプションとしてファイルの暗号化に対応していますが、これを利用する場合、リストア時もツールを利用する必要があります。\u003Cbr>コンテナへ入り、\u003Ccode>cd /app &amp;&amp; ./misskey-backup --restore-url &quot;復元するバックアップのファイルを指すURL&quot; --encryption-key &quot;バックアップ時の環境で利用した環境変数BACKUP_ENCRYPTION_KEY&quot;\u003C/code>を実行すると生のdumpが入手可能です。\u003Cbr>リストアが必要になった場合は\u003Ccode>pg_restore\u003C/code>なんかを利用し、DBを再構築しましょう。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/6ba3884288654f2784335c95cdcbd69b/image.png\" alt=\"\" width=\"794\" height=\"375\">\u003C/figure>\u003Cp>* 引数\u003Ccode>--encryption-key\u003C/code>はオプションです。指定がなければ環境変数からロードします。\u003C/p>\u003Ch1 id=\"hbd188c6e62\">ダウン時にメンテナンスページを表示する\u003C/h1>\u003Cp style=\"text-align: start\">サーバの運用において、アプリケーションの更新や予期せぬ障害によるサービスの停止は避けられません。\u003Cbr>その際、利用者にCloudflareの無機質なエラーページを見せてしまうのは、管理者としてあまりにも不親切です。\u003C/p>\u003Cp>ここでは、Misskey本体の前にリバースプロキシとしてCaddyを配置し、Misskeyがダウンしている場合には、あらかじめ別の場所に用意した静的なメンテナンスページへリダイレクトさせる構成を構築します。\u003C/p>\u003Cp>本来、リバースプロキシはアプリケーションサーバとは物理的に別のサーバに設置するのが、可用性やセキュリティの観点から望ましいアーキテクチャです。\u003Cbr>しかし、今回は個人や小規模なコミュニティでの運用を想定しているため、構成の簡潔さを優先し、同一のサーバ上に同居させる構成を採用します。\u003C/p>\u003Cp>メンテナンスページ自体を外部サービスに置くことで、仮にサーバ全体が応答不能になった場合でも、ユーザへの案内が可能になる、より堅牢な構成です。\u003C/p>\u003Ch2 id=\"h0bc4c7ee36\">メンテナンスページの作成\u003C/h2>\u003Cp>まず、メンテナンス中に表示するための簡単なページを作成します。\u003Cbr>何も考えたくない場合は\u003Ca href=\"https://v0.dev/\" target=\"_blank\" rel=\"noopener noreferrer\">v0.dev\u003C/a>あたりを使うといいでしょう。\u003C/p>\u003Cp>サイトが完成したら、VercelやCloudflare Pagesといった、任意の静的ホスティングサービスにデプロイし、そのURLを控えておきます。\u003Cbr>このとき、Misskeyをホストしているドメインのサブドメイン(例: \u003Ccode>maintenance.mi-test.mq1.dev\u003C/code>)に紐づけておくと、後々の管理が少し楽になります。\u003C/p>\u003Ch2 id=\"h9607059ffa\">Caddyfileの作成\u003C/h2>\u003Cp>次に、リバースプロキシの動作を定義するCaddyの設定ファイルを作成します。\u003Ccode>./misskey/config/\u003C/code> ディレクトリ内に \u003Ccode>Caddyfile\u003C/code> という名前で保存してください。\u003C/p>\u003Cdiv data-filename=\"./config/Caddyfile\">\u003Cpre>\u003Ccode>:3000 {\n\tencode gzip\n\theader /assets Cache-Control &quot;public, max-age=31536000, immutable&quot; \n\tfile_server\n\n\treverse_proxy mi:3000 {\n\t\t# Get actual client IP from Cloudflare\n\t\theader_up X-Real-IP {header.CF-Connecting-IP}\n\t\theader_up X-Forwarded-For {header.CF-Connecting-IP}\n\t\theader_up X-Forwarded-Proto {scheme}\n\t\theader_up X-Forwarded-Host {host}\n\n\t\t# health check\n\t\thealth_uri /api/server-info\n\t\thealth_interval 10s\n\t\thealth_timeout 2s\n\t\thealth_status 200\n\t}\n\n\thandle_errors {\n\t\t@backend_down `{err.status_code} in [500, 501, 502, 503, 504, 522]`\n\t\tredir @backend_down メンテナンスページのURL\n\t}\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp>メンテナンスページのURLの部分を、\u003Ccode>https://maintenance.mi-test.mq1.dev/\u003C/code> のような形式で先ほど用意したメンテナンスページのURLに書き換えてください。\u003C/p>\u003Ch2 id=\"hb8d27cfc08\">compose.yamlの更新\u003C/h2>\u003Cp>これまでの\u003Ccode>compose.yaml\u003C/code>を、CaddyをMisskeyの前に置く構成に変更します。\u003Cbr>Tunnel側の設定変更を避けるため、サービス名を\u003Ccode>app\u003C/code>(Caddy)と\u003Ccode>mi\u003C/code>(Misskey)に変更し、依存関係を整理します。\u003C/p>\u003Cdiv data-filename=\"./compose.yaml\">\u003Cpre>\u003Ccode class=\"language-yaml\">services:\n  app:\n    image: caddy:2\n    profiles:\n      - proxy\n    volumes:\n      - ./config/Caddyfile:/etc/caddy/Caddyfile\n      - ./data/caddy/data:/data\n      - ./data/caddy/config:/config\n    networks:\n      - internal_network\n\n  mi:\n    image: misskey/misskey:latest\n    restart: always\n    links:\n      - db\n      - redis\n    depends_on:\n      db:\n        condition: service_healthy\n      redis:\n        condition: service_healthy\n    networks:\n      - internal_network\n    volumes:\n      - ./config/default.yml:/misskey/.config/default.yml:ro\n\n  redis:\n    restart: always\n    image: eqalpha/keydb:alpine\n    networks:\n      - internal_network\n    volumes:\n      - ./data/keydb:/data\n    healthcheck:\n      test: &quot;redis-cli ping&quot;\n      interval: 5s\n      retries: 20\n\n  db:\n    restart: always\n    image: groonga/pgroonga:latest-debian-16\n    ports:\n      - &apos;5430:5432&apos;\n    networks:\n      internal_network:\n    env_file:\n      - ./config/.env\n    volumes:\n      - ./data/db:/var/lib/postgresql/data\n    healthcheck:\n      test: &quot;pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB&quot;\n      interval: 5s\n      retries: 20\n\n  tunnel:\n    restart: always\n    image: cloudflare/cloudflared\n    command: tunnel --no-autoupdate run\n    profiles:\n      - tunnel\n    env_file:\n      - ./config/.env\n    networks:\n      - internal_network\n\nnetworks:\n  internal_network:\u003C/code>\u003C/pre>\u003C/div>\u003Ch2 id=\"hc369bfbfd2\">反映と動作確認\u003C/h2>\u003Cp>設定をすべて保存したら、以下のコマンドでコンテナを再起動します。\u003Cbr>\u003Ccode>--remove-orphans\u003C/code> オプションは、古い定義のコンテナを削除するために付与しています。\u003C/p>\u003Cpre>\u003Ccode class=\"language-shell\">sudo docker compose --profile proxy --profile tunnel up -d --remove-orphans\u003C/code>\u003C/pre>\u003Cp>正常にサイトが表示されることを確認後、\u003Ccode>sudo docker compose stop mi\u003C/code> コマンドで意図的にMisskeyのコンテナを停止させてみてください。\u003Cbr>ブラウザをリロードし、無機質なエラー画面の代わりに、先ほど作成したメンテナンスページへリダイレクトされれば設定は成功です。\u003C/p>\u003Cp>確認後は \u003Ccode>sudo docker compose start mi\u003C/code> で忘れずにコンテナを起動し直しておきましょう。\u003C/p>\u003Ch1 id=\"he04a3454bf\">最低限のセキュリティ\u003C/h1>\u003Cp>サーバを公開するということは、あなたの城を、悪意に満ちたインターネットの荒野に晒すということです。\u003Cbr>城壁を築き、見張りを立てなければ、その城は一夜にして蹂躙されるでしょう。\u003Cbr>セキュリティはオプションではなく、サーバ管理者の最も基本的な責務です。\u003C/p>\u003Ch2 id=\"h32f0498d5a\">ホストの対策\u003C/h2>\u003Cp>攻撃者が最初に狙うのは、あなたのサーバそのものです。\u003Cbr>OSレベルでの防御は、まず全ての入口を閉ざし、信頼できる経路のみを許可することから始まります。\u003C/p>\u003Cp>従来、SSHポートを公開し、公開鍵認証で保護するのが一般的でした。\u003Cbr>しかし、SSHポートをインターネットに晒すこと自体がリスクであり、鍵の管理も煩雑です。\u003Cbr>そこで、当エントリでは\u003Ca href=\"https://tailscale.com/kb/1193/tailscale-ssh\" target=\"_blank\" rel=\"noopener noreferrer\">Tailscale SSH\u003C/a>の利用を推奨します。\u003Cbr>\u003Ca href=\"https://tailscale.com/kb/1193/tailscale-ssh\">https://tailscale.com/kb/1193/tailscale-ssh\u003C/a>\u003C/p>\u003Cp>\u003Ca href=\"https://tailscale.com/\" target=\"_blank\" rel=\"noopener noreferrer\">Tailscale\u003C/a>は、あなたの所有するデバイス間だけで構成されるプライベートなネットワークを構築するサービスです。\u003Cbr>これをサーバに導入することで、SSHポートをインターネットに一切公開することなく、あなたのTailscaleネットワーク内部からのみ、安全にサーバへアクセスできるようになります。\u003Cbr>認証はTailscaleのアカウントで行われ、定義したACLが適用されるため、鍵管理の手間からも解放されます。\u003C/p>\u003Cp>この構成を、ufwによって強制します。\u003C/p>\u003Cpre>\u003Ccode class=\"language-shell\"># デフォルトですべてのインバウンドを拒否\nsudo ufw default deny incoming\n# デフォルトですべてのアウトバウンドを許可\nsudo ufw default allow outgoing\n# Tailscaleのネットワーク(100.64.0.0/10)からのみSSH(ポート22)への着信を許可\nsudo ufw allow from 100.64.0.0/10 to any port 22\n# ufwを有効化\nsudo ufw enable\u003C/code>\u003C/pre>\u003Cp>この設定により、あなたのサーバのSSHポートは、もはや広大なインターネットからは完全に閉ざされたものとなります。\u003C/p>\u003Cp>許可されたサブネット外からのインバウンド通信はすべて破棄されるため、ブルートフォース攻撃の脅威に晒されることはありません。\u003Cbr>そのため、\u003Ccode>fail2ban\u003C/code>のような侵入防止ツールも基本的には不要になります。\u003C/p>\u003Ch2 id=\"h9a58303d83\">Misskeyの管理者アカウントを保護\u003C/h2>\u003Cp>管理者アカウントの二要素認証を有効化してください。\u003C/p>\u003Cp>これは、交渉の余地なく、必須の作業です。\u003Cbr>あなたの管理者アカウントが乗っ取られることは、城の鍵を敵に渡すことと同義です。\u003C/p>\u003Cp>Misskeyにログイン後、\u003Ccode>設定 &gt; セキュリティ &gt; 二要素認証\u003C/code>より、今すぐ設定を行ってください。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/ee528939471e45a6ba6ff9315581315f/image.png\" alt=\"\" width=\"1919\" height=\"906\">\u003C/figure>\u003Cp>\u003C/p>\u003Ch2 id=\"he5e0b0dbc6\">WAF(おまけ)\u003C/h2>\u003Cp>そもそも、Cloudflare Tunnelを利用している時点で、あなたのサーバのIPアドレスは秘匿されており、直接的な攻撃を受けるリスクは大幅に軽減されています。\u003Cbr>さらに金銭的な余裕がある場合には、Cloudflareが提供する有償のセキュリティ機能を活用します。\u003C/p>\u003Cp>Web Application Firewall (WAF)を有効化してください。\u003Cbr>Cloudflareのダッシュボードから\u003Ccode>Security &gt; WAF\u003C/code>を開き、Managed rulesタブからCloudflare Free Managed Rulesetを有効にします。\u003Cbr>これは、SQLインジェクションやクロスサイトスクリプティングといった、ウェブアプリケーションに対する典型的な攻撃パターンを検知し、自動的にブロックしてくれる盾の役割を果たします。\u003C/p>\u003Ch1 id=\"hb7b57b2068\">ドメインブロックの活用\u003C/h1>\u003Cp>スパムの温床となりやすい特定のドメインからの通信を、根本から遮断する設定も重要です。\u003C/p>\u003Cp>直近の顕著な例として、\u003Ccode>*.trycloudflare.com\u003C/code>のドメインを利用したスパムインスタンスからの攻撃が複数件観測されています。\u003C/p>\u003Cp>これは、CloudflareのQuick Tunnels機能を悪用し、使い捨てのインスタンスから無差別にスパムを送りつけるという、極めて悪質な手法です。\u003Cs>これに対抗する手段として、DNSレベルでの名前解決を曲げる等の解決策もいくつか存在しますがここでは説明しません。\u003C/s>\u003C/p>\u003Cp>しかし、幸いなことにMisskey v13以降には、より手軽で確実な手段が標準機能として備わっています。\u003Ccode>コントロールパネル &gt; モデレーション &gt; ブロックしたサーバー\u003C/code>より設定を行います。ここに\u003Ccode>trycloudflare.com\u003C/code>を追加するだけで、そのサブドメインも含めた全ての通信を一律でブロックすることが可能です。\u003C/p>\u003Cp>そもそも、安定したコミュニティ運営を目指すまともな管理者が、永続的でないQuick Tunnels用のドメインを本番環境で使い続けるはずがありません。したがって、このようなドメインからの通信は一律でブロックしてしまって一切問題ないでしょう。\u003C/p>\u003Cp>あなたのサーバのリソースを、使い捨てのスパマーのために消費してやる義理はどこにもありません。\u003C/p>\u003Ch1 id=\"h101a34749f\">CSAM対策\u003C/h1>\u003Cp>インスタンスを運用する以上、避けては通れない問題があります。CSAM、すなわちChild Sexual Abuse Material(児童性的虐待コンテンツ)への対策です。\u003C/p>\u003Cp>「うちは健全なサーバだから関係ない」と思いましたか？ 残念ながら、その認識は甘すぎます。\u003C/p>\u003Cp>あなたがどれほど慎重にサーバを運用していたとしても、倫理観の乏しい愚かなローカルユーザが、ドライブにCSAMをアップロードしてしまう可能性を完全に排除することはできません。リモートメディアのキャッシュを無効にしていようがいまいが、ローカルユーザが直接アップロードしたファイルは、あなたのドメイン配下のオブジェクトストレージやサーバに保存されます。\u003C/p>\u003Cp>そして、たとえ管理者であるあなたに一切の悪意がなかったとしても、自分が運用するドメイン配下にCSAMが存在するという事態は、日本国内においても(運が悪ければ)刑事罰の対象となり得る極めて深刻な問題です。\u003C/p>\u003Cp>Azure AI Content SafetyのようなAPIを利用する手段もありますが、アプリケーション側の実装が必要な上に、一定以上の利用量になるとコストがかかります。\u003Cbr>ここで活用すべきが、CloudflareのCSAM Scanning Toolです。\u003C/p>\u003Cp>\u003Ca href=\"https://developers.cloudflare.com/cache/reference/csam-scanning/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">https://developers.cloudflare.com/cache/reference/csam-scanning/\u003C/a>\u003C/p>\u003Cp>Cloudflareのダッシュボードにログインし、\u003Ccode>Caching &gt; Configuration\u003C/code>からCSAM Scanning Toolを有効にする。\u003Cbr>以上です。\u003C/p>\u003Cp>メールアドレスの入力を求められますが、これはCSAMが実際に検出された際の通知先です。なるべく早く気付けるものを入力してください。\u003C/p>\u003Cp>ここで、いくつか留意すべき点があります。\u003Cbr>まず、当然ながらCloudflareのDNS設定でプロキシが有効になっている必要があります。本エントリの手順に沿っていればCloudflare Tunnelを利用しているため、この点は問題ないはずです。\u003Cbr>日本国内においては、現時点で直接的に該当する法律は見当たりませんが、地域によってはCSAMを発見した場合、NCMEC等の適切な機関への通報義務がWebサイト管理者に課せられる場合があります。\u003C/p>\u003Cp>サーバ管理者として、自分のドメインが犯罪コンテンツの流通経路にならないよう、この程度の設定は最低限の責務として行っておくべきでしょう。\u003C/p>\u003Ch1 id=\"h0139c46456\">サーバの監視\u003C/h1>\u003Cp>インスタンスを建て、動かし始めたらそれで終わり、ではありません。\u003Cbr>むしろ、そこからが本当の始まりです。\u003Cbr>あなたのインスタンスが健全な状態を保っているか、あるいは何らかの脅威に晒されていないかを常に把握し、問題が発生した際には迅速に対応する。\u003Cbr>そのための目となるのが監視です。\u003C/p>\u003Cp>監視を設定するということは、問題が起きてからユーザに指摘される受動的な対応から、問題が深刻化する前、あるいは利用者が気づく前に問題を検知する能動的な管理へと移行することを意味します。\u003Cbr>そして、あなたの平穏な夜が、いつアラートによって叩き起こされるかもしれないという、新たなスリルを受け入れるということです。\u003C/p>\u003Cp>ようこそ、眠れないサーバ管理者の世界へ。\u003C/p>\u003Ch2 id=\"h70bacab598\">外形監視\u003C/h2>\u003Cp>最も基本的で、そして最も重要なのが、外部からあなたのサーバが正常に応答しているかを確認する「外形監視」です。\u003C/p>\u003Cp>これには、高機能な監視SaaSであるBetter Stackの利用を推奨します。\u003Cbr>無料プランでも十分な数の監視項目と、ステータスベージを作成できます。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/52b4965f68f4475c9a92159efc288437/image.png\" alt=\"\" width=\"1916\" height=\"910\">\u003Cfigcaption>ステータスページの例\u003C/figcaption>\u003C/figure>\u003Cp>Better Stackにサインアップし、最低限以下の項目を監視し、異常を検知した際にはあなたに通知が飛ぶように設定してください。\u003C/p>\u003Cul>\u003Cli>\u003Cstrong>HTTP(s)監視\u003C/strong>\u003Cp>MisskeyインスタンスのURL を定期的に監視します。\u003C/p>\u003C/li>\u003Cli>\u003Cstrong>Ping監視\u003C/strong>\u003Cbr>サーバへの基本的な疎通性を確認します。\u003Cbr>FWでICMPを拒否しないようにしておきましょう。\u003C/li>\u003C/ul>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/647de9f0a2c34414939616a5b83960ec/image.png\" alt=\"\" width=\"1918\" height=\"906\">\u003C/figure>\u003Cp>これにより、「急にインスタンスへのアクセスができなくなってしまった！！なにもしてないのに壊れました！！」という最も致命的な事態を即座に把握できます。\u003C/p>\u003Ch2 id=\"hf81b65eb16\">内部監視\u003C/h2>\u003Cp>外から見えていても、サーバ内部が悲鳴を上げていることは頻繁にあります。\u003Cbr>CPU使用率、メモリ使用量、ディスクI/Oといった内部状況を把握するのが内部監視です。\u003C/p>\u003Cp>ここではNew Relicの導入を推奨します。\u003Cbr>無料プランの範囲が非常に広く、個人利用であればほとんどの機能を無償で利用できます。\u003Cbr>他のものでも要件が満たせれば正直何でも構いません。\u003C/p>\u003Cp>New Relicにサインアップし、表示される手順に従って、あなたのVPSに監視エージェントをインストールしてください。\u003Cbr>Dockerコンテナの監視も自動的に認識され、美しいダッシュボードで各コンテナのパフォーマンスを詳細に可視化できます。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/c703d52aa1dd4aeca9a3fc054c1e4725/image.png\" alt=\"\" width=\"1913\" height=\"908\">\u003C/figure>\u003Cp>特に注意深く見るべきは、CPU使用率、メモリ使用量、そしてディスクの空き容量です。\u003C/p>\u003Ch2 id=\"h196a107197\">ログ監視\u003C/h2>\u003Cp>メトリクスはサーバのバイタルサインですが、ログはサーバの声です。\u003Cbr>アプリケーションで何が起きているのか、エラーの原因は何なのか、その全てはログに記録されています。\u003C/p>\u003Cp>\u003Ccode>docker logs {cid}\u003C/code>コマンドでログを確認することもできますが、それは一時的な確認手段に過ぎません。\u003Cbr>恒久的なログの収集と分析のために、New Relicのログ転送機能を設定することを強く推奨します。\u003C/p>\u003Cp>設定は、New Relicのドキュメントに従い、Docker用のログ転送設定をサーバに追加するだけです。\u003Cbr>これにより、Misskey本体やデータベースなど、全てのコンテナから出力されるログが自動的にNew Relicへ集約され、「特定のエラーログが一定数以上記録された場合に、アラートを発報させるー」みたいなことができます。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/1ea11cb275c144c5bf02e7c0ca0ad07a/image.png\" alt=\"\" width=\"1915\" height=\"910\">\u003C/figure>\u003Cp>これらの監視を設定し、サーバの状態を定常的に把握することが管理者としての最低限の責務です。\u003C/p>\u003Ch1 id=\"hf84a935862\">Misskeyをアップデートする\u003C/h1>\u003Cp>Misskey本体の更新頻度はかなり高く、月に1-3回のアップデートが行われています。\u003Cbr>マイグレーション等、手動実行が必要なものがあれば\u003Ca href=\"https://github.com/misskey-dev/misskey/releases/tag/2025.1.0\" target=\"_blank\" rel=\"noopener noreferrer\">このように\u003C/a>リリースノートに書いてあるので、そちらを参照してください。\u003C/p>\u003Cp>一般的には以下のコマンドでアップデートが可能です。\u003C/p>\u003Cpre>\u003Ccode class=\"language-shell\">sudo docker pull misskey/misskey:latest &amp;&amp; sudo docker compose up -d --build\u003C/code>\u003C/pre>\u003Cp>この時、profileを指定してしまうとメンテナンスページが表示されないので、必ず引数に\u003Ccode>--profile\u003C/code>を含めないようにしてください。\u003C/p>\u003Ch1 id=\"hf4d122a789\">MisskeyのアップデートをGitOpsで自動化する\u003C/h1>\u003Cp>Misskeyのアップデートがある度に、毎回サーバへSSHして \u003Ccode>docker image pull\u003C/code> して \u003Ccode>docker compose up -d\u003C/code> を叩くのって、地味に面倒くさいですよね。\u003Cbr>面倒くさいんですよ。\u003C/p>\u003Cp>リリース頻度もそれなりにあるので、放置しているといつの間にか化石になっていたなんてことも起こり得てしまいます。\u003Cs>(そもそも放置するくらいなら初めからインスタンスを建てるべきではないのですが)\u003C/s>\u003C/p>\u003Cp>そこで、Misskey本体の更新を、ほぼ自動で最新化できる仕組みを導入します。\u003Cbr>ArgoCD Image UpdaterのLatest運用に近いイメージですが、今回は\u003Ca href=\"https://github.com/sksat/compose-cd\" target=\"_blank\" rel=\"noopener noreferrer\">compose-cd\u003C/a>とGitHub Actionsを使って、より手元でコントロールしやすい形で実現します。\u003C/p>\u003Cp>実際の挙動は\u003Ca href=\"https://github.com/team-shahu/shahu-docker-provision/pull/2\" target=\"_blank\" rel=\"noopener noreferrer\">こんな感じ\u003C/a>です。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/2dfd14e0b07b4e909768fb73a7623944/image.png\" alt=\"\" width=\"1917\" height=\"927\">\u003C/figure>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/e6365b8e0e9340188963dcddbdc27bf1/image.png\" alt=\"\" width=\"541\" height=\"209\">\u003C/figure>\u003Cp>\u003Cbr>\u003C/p>\u003Cp>私がこれをやろうとした当初、\u003Ca href=\"https://github.com/containrrr/watchtower\" target=\"_blank\" rel=\"noopener noreferrer\">Watchtower\u003C/a>を使う選択肢もありましたが、無駄にオーバーヘッドがあって微妙ですし、かといってCronで定期的にでシェルを叩くのも、杜撰すぎて気持ちのいいやり方とは到底言えません。\u003Cbr>今回は、その中間的な妥協点として、\u003Ca href=\"https://github.com/sksat/compose-cd\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">compose-cd\u003C/a>に寄せる構成にしています。\u003Cs>(compose-cd自体微妙なところもありますが、今回の用途としては十分ですし、妥協することにします)\u003C/s>\u003C/p>\u003Cp>全体の流れは以下の通りです。\u003C/p>\u003Col>\u003Cli>\u003Ccode>compose.yaml\u003C/code> でMisskeyサービスをdiest pinningしておく\u003C/li>\u003Cli>GitHub Actionsが定期的に、最新イメージのダイジェストと、レポジトリ上にあるものの差分をチェックする\u003C/li>\u003Cli>差分があれば、\u003Ccode>compose.yaml\u003C/code>内のイメージ定義を、新しいイメージダイジェストに書き換えたPull Requestを生成する\u003C/li>\u003Cli>人間が確認後、PRをマージすると、compose-cdがレポジトリの変更差分を検知し、自動でデプロイが走る\u003C/li>\u003C/ol>\u003Cp>これで、定期的に起票されるPRを確認してマージするだけといったとても怠惰な運用が可能になります。\u003C/p>\u003Ch2 id=\"h31fc5cbc4a\">事前準備\u003C/h2>\u003Cp>この仕組みの前提として、Misskeyインスタンスの構成ファイル(\u003Ccode>compose.yaml\u003C/code>,\u003Ccode>.config/default.yml\u003C/code>等)が、GitHub上のレポジトリで管理されている必要があります。\u003C/p>\u003Cp>\u003Ccode>./config/.env\u003C/code>ファイルにパスワードなどの秘密情報を全て集約させ、それを\u003Ccode>.gitignore\u003C/code>に追加した上で、Publicなレポジトリとして運用するのが、Private repo環境でPATの取り回しに苦労するよりも、結果的に楽だとわたしは考えます。\u003Cbr>まぁ、お好みでよしなにしてください。\u003C/p>\u003Ch2 id=\"hb35da9580c\">compose-cdの導入\u003C/h2>\u003Cpre>\u003Ccode class=\"language-shell\">wget https://github.com/sksat/compose-cd/releases/latest/download/compose-cd.tar.zst\ntar xvf compose-cd.tar.zst\n./compose-cd install \\\n    --search-root &quot;&lt;compose.yamlが存在しているパス&gt;&quot; \\\n    --git-pull-user &lt;pullするユーザ(メアド)&gt; \\\n    --discord-webhook &quot;&lt;discord webhook URL&gt;&quot;\u003C/code>\u003C/pre>\u003Cp>基本的にはこれで大丈夫です。\u003Cbr>パスの指定は相対パスでも問題ないはずです。\u003Cbr>また、compose-cdは、\u003Ca href=\"https://github.com/sksat/compose-cd/blob/8cff99c98498d88b053dd934f08c045c70aa04c6/compose-cd#L283\" target=\"_blank\" rel=\"noopener noreferrer\">内部的にgit pullを叩いている\u003C/a>ので、当該ディレクトリ内へのrw権限が必要です。\u003Cbr>(rootで実行することで大概は回避可能ですが、推奨はしません)\u003C/p>\u003Ch2 id=\"hcb50ae6e42\">compose-cdの設定ファイルを用意する\u003C/h2>\u003Cp>\u003Ccode>compose.yaml\u003C/code>と同じディレクトリに、\u003Ccode>.compose-cd\u003C/code>という設定ファイルを置き、レポジトリの変更のみを監視するように設定します。\u003C/p>\u003Cdiv data-filename=\".compose-cd\">\u003Cpre>\u003Ccode class=\"language-yaml\">REPO=&quot;&lt;https://github.com/your/repo.git&gt;&quot;\nUPDATE_REPO_ONLY=true\nUPDATE_IMAGE_BY_REPO=true\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp>\u003Ccode>UPDATE_REPO_ONLY=true\u003C/code>にしておくことで、compose-cdが余計なイメージ更新チェックを行わなくなり、余計なことをしなくなります。\u003C/p>\u003Cp>余談ですが、compose-cdの実装を少し覗いてみると分かる通り、\u003Ccode>compose.yaml\u003C/code>が公式に推奨されるようになった令和の現在においても、旧来の\u003Ccode>docker-compose.yaml\u003C/code>ワークフローに寄ったロジックが残っています。\u003Cbr>\u003Ca href=\"https://github.com/sksat/compose-cd/blob/8cff99c98498d88b053dd934f08c045c70aa04c6/compose-cd#L122-L124\">https://github.com/sksat/compose-cd/blob/8cff99c98498d88b053dd934f08c045c70aa04c6/compose-cd#L122-L124\u003C/a>\u003Cbr>\u003Ca href=\"https://github.com/sksat/compose-cd/blob/8cff99c98498d88b053dd934f08c045c70aa04c6/compose-cd#L359\">https://github.com/sksat/compose-cd/blob/8cff99c98498d88b053dd934f08c045c70aa04c6/compose-cd#L359\u003C/a>\u003C/p>\u003Cp>これは、\u003Ca href=\"https://github.com/compose-spec/compose-spec/blob/main/spec.md#compose-file\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">compose-spec\u003C/a>にも以下のようにちゃんと書かれてますからね\u003C/p>\u003Cblockquote>\u003Cp>The default path for a Compose file is \u003Ccode>compose.yaml\u003C/code> (preferred) or \u003Ccode>compose.yml\u003C/code> that is placed in the working directory. Compose also supports \u003Ccode>docker-compose.yaml\u003C/code> and \u003Ccode>docker-compose.yml\u003C/code> for backwards compatibility of earlier versions. If both files exist, Compose prefers the canonical compose.yaml.\u003C/p>\u003C/blockquote>\u003Cblockquote>\u003Cp>Compose ファイルのデフォルトパスは\u003Ccode>compose.yaml\u003C/code>(推奨)または\u003Ccode>compose.yml\u003C/code>で、作業ディレクトリに置かれます。Composeは、以前のバージョンとの後方互換性のために\u003Ccode>docker-compose.yaml\u003C/code>および \u003Ccode>docker-compose.yml\u003C/code>もサポートしています。両方のファイルが存在する場合は、\u003Ccode>compose.yaml\u003C/code>を優先します。(意訳)\u003C/p>\u003C/blockquote>\u003Cp>\u003C/p>\u003Cp>このため、compose-cdが差分を正しく検知できず、想定外の「差分検知 → 更新失敗」を誘発してしまいます。\u003Cbr>実装を修正するのも手間なので、今回は\u003Ccode>UPDATE_REPO_ONLY=true\u003C/code>を設定して、この挙動を回避しています。\u003C/p>\u003Cp>理想的なものはそうそうないので、ツール選定とは概ね常に何らかの妥協を伴うものみたいです。\u003C/p>\u003Ch2 id=\"h5e0913ac92\">更新対象ファイルの一覧を定義する\u003C/h2>\u003Cp>同じディレクトリに\u003Ccode>.compose-apply\u003C/code>を置き、compose-cdが変更を監視すべき対象ファイルを改行区切りで指定します。\u003C/p>\u003Cdiv data-filename=\".compose-apply\">\u003Cpre>\u003Ccode>compose.yaml\nconfig/*\u003C/code>\u003C/pre>\u003C/div>\u003Cp>\u003Ccode>./data/*\u003C/code>や\u003Ccode>.env\u003C/code>のような更新対象外のディレクトリは含めないようにしてください。\u003C/p>\u003Ch2 id=\"h3abd7c9e55\">compose.yamlの編集\u003C/h2>\u003Cp>Misskeyコンテナのイメージをdiest pinningしておきます。\u003C/p>\u003Cdiv data-filename=\"compose.yaml\">\u003Cpre>\u003Ccode class=\"language-yaml\">略\n  app:\n    image: misskey/misskey:2025.10@sha256:e94e565722df11bc2ca85a46a90103c50353313d1a79476d7ee6ee964cd62ae2\n    restart: always\n略\u003C/code>\u003C/pre>\u003C/div>\u003Cp>利用しているイメージやバージョンに合わせて適宜書き換えてください。\u003C/p>\u003Cp>イメージダイジェストが何か分かれなければ、\u003Ca href=\"https://docs.docker.com/dhi/core-concepts/digests/\" target=\"_blank\" rel=\"noopener noreferrer\">これを読んで\u003C/a>おきましょう\u003C/p>\u003Ch2 id=\"he8ea32746e\">GitHub ActionsでPRを自動生成する\u003C/h2>\u003Cp>最後に、イメージの差分を検知してPRを自動生成するGitHub Actionsを設定します。\u003Cbr>レポジトリの\u003Ccode>.github/workflows/\u003C/code>ディレクトリに、以下のワークフローを作成します。\u003C/p>\u003Cdiv data-filename=\".github/workflows/update-misskey-image.yaml\">\u003Cpre>\u003Ccode class=\"language-yaml\">name: Update Misskey Image\n\non:\n  schedule:\n    - cron: &apos;*/10 * * * *&apos;\n  workflow_dispatch:\n\npermissions:\n  contents: write\n  pull-requests: write\n\njobs:\n  check-update:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Docker\n        uses: docker/setup-buildx-action@v3\n\n      - name: Get current image info\n        id: current\n        run: |\n          # compose.yamlから現在のイメージ情報を取得\n          CURRENT_LINE=$(grep &quot;misskey/misskey&quot; compose.yaml)\n          echo &quot;current_line=$CURRENT_LINE&quot; &gt;&gt; $GITHUB_OUTPUT\n          echo &quot;Current line from compose.yaml: $CURRENT_LINE&quot;\n          \n          # 現在のバージョンとダイジェストを抽出\n          CURRENT_VERSION=$(echo &quot;$CURRENT_LINE&quot; | sed -n &apos;s/.*misskey\\/misskey:\\([^@]*\\)@.*/\\1/p&apos;)\n          CURRENT_FULL_DIGEST=$(echo &quot;$CURRENT_LINE&quot; | sed -n &apos;s/.*@\\(sha256:[a-f0-9]*\\).*/\\1/p&apos;)\n          CURRENT_DIGEST=$(echo &quot;$CURRENT_FULL_DIGEST&quot; | sed &apos;s/sha256://&apos;)\n          \n          echo &quot;current_version=$CURRENT_VERSION&quot; &gt;&gt; $GITHUB_OUTPUT\n          echo &quot;current_digest=$CURRENT_DIGEST&quot; &gt;&gt; $GITHUB_OUTPUT\n          echo &quot;current_full_digest=$CURRENT_FULL_DIGEST&quot; &gt;&gt; $GITHUB_OUTPUT\n          echo &quot;Current version: $CURRENT_VERSION&quot;\n          echo &quot;Current digest: $CURRENT_DIGEST&quot;\n          echo &quot;Current full digest: $CURRENT_FULL_DIGEST&quot;\n\n      - name: Get latest image info\n        id: latest\n        run: |\n          # Docker Hubから最新のイメージ情報を取得\n          # まず、利用可能な全てのタグを取得\n          TAGS=$(curl -s &quot;https://hub.docker.com/v2/repositories/misskey/misskey/tags?page_size=100&quot; | jq -r &apos;.results[].name&apos; | grep -E &apos;^[0-9]{4}\\.[0-9]+(\\.[0-9]+)?$&apos; | sort -V | tail -n 1)\n          \n          echo &quot;Latest tag: $TAGS&quot;\n          LATEST_VERSION=&quot;$TAGS&quot;\n          \n          # 最新バージョンの完全なダイジェストを取得\n          FULL_DIGEST=$(docker buildx imagetools inspect &quot;misskey/misskey:$LATEST_VERSION&quot; --format &quot;{{json .Manifest}}&quot; | jq -r &apos;.digest&apos;)\n          LATEST_DIGEST=$(echo &quot;$FULL_DIGEST&quot; | sed &apos;s/sha256://&apos;)\n          \n          echo &quot;latest_version=$LATEST_VERSION&quot; &gt;&gt; $GITHUB_OUTPUT\n          echo &quot;latest_digest=$LATEST_DIGEST&quot; &gt;&gt; $GITHUB_OUTPUT\n          echo &quot;latest_full_digest=$FULL_DIGEST&quot; &gt;&gt; $GITHUB_OUTPUT\n          echo &quot;Latest version: $LATEST_VERSION&quot;\n          echo &quot;Latest digest: $LATEST_DIGEST&quot;\n          echo &quot;Latest full digest: $FULL_DIGEST&quot;\n\n      - name: Check for updates\n        id: check\n        run: |\n          CURRENT_VERSION=&quot;${{ steps.current.outputs.current_version }}&quot;\n          CURRENT_DIGEST=&quot;${{ steps.current.outputs.current_digest }}&quot;\n          LATEST_VERSION=&quot;${{ steps.latest.outputs.latest_version }}&quot;\n          LATEST_DIGEST=&quot;${{ steps.latest.outputs.latest_digest }}&quot;\n          \n          echo &quot;=== Comparing versions and digests ===&quot;\n          echo &quot;  Current version: $CURRENT_VERSION&quot;\n          echo &quot;  Latest version:  $LATEST_VERSION&quot;\n          echo &quot;  Current digest:  $CURRENT_DIGEST&quot;\n          echo &quot;  Latest digest:   $LATEST_DIGEST&quot;\n          echo &quot;&quot;\n          \n          UPDATE_NEEDED=false\n          \n          if [ &quot;$CURRENT_VERSION&quot; != &quot;$LATEST_VERSION&quot; ]; then\n            echo &quot;✓ Version mismatch detected: $CURRENT_VERSION -&gt; $LATEST_VERSION&quot;\n            UPDATE_NEEDED=true\n          else\n            echo &quot;✓ Version is up to date: $CURRENT_VERSION&quot;\n          fi\n          \n          if [ &quot;$CURRENT_DIGEST&quot; != &quot;$LATEST_DIGEST&quot; ]; then\n            echo &quot;✓ Digest mismatch detected&quot;\n            echo &quot;  Current: $CURRENT_DIGEST&quot;\n            echo &quot;  Latest:  $LATEST_DIGEST&quot;\n            UPDATE_NEEDED=true\n          else\n            echo &quot;✓ Digest is up to date&quot;\n          fi\n          \n          echo &quot;&quot;\n          if [ &quot;$UPDATE_NEEDED&quot; = true ]; then\n            echo &quot;update_available=true&quot; &gt;&gt; $GITHUB_OUTPUT\n            echo &quot;🔄 Update available!&quot;\n          else\n            echo &quot;update_available=false&quot; &gt;&gt; $GITHUB_OUTPUT\n            echo &quot;✅ Already up to date&quot;\n          fi\n\n      - name: Update compose.yaml\n        if: steps.check.outputs.update_available == &apos;true&apos;\n        run: |\n          LATEST_VERSION=&quot;${{ steps.latest.outputs.latest_version }}&quot;\n          FULL_DIGEST=&quot;${{ steps.latest.outputs.latest_full_digest }}&quot;\n          \n          # 新しいイメージ行を作成\n          NEW_LINE=&quot;    image: misskey/misskey:${LATEST_VERSION}@${FULL_DIGEST}&quot;\n          \n          # compose.yamlを更新\n          sed -i &quot;s|.*misskey/misskey.*|${NEW_LINE}|&quot; compose.yaml\n          \n          echo &quot;Updated compose.yaml&quot;\n          echo &quot;New image reference: ${NEW_LINE}&quot;\n          \n          # 変更内容を確認\n          echo &quot;--- Git diff ---&quot;\n          git diff compose.yaml\n\n      - name: Create Pull Request\n        if: steps.check.outputs.update_available == &apos;true&apos;\n        uses: peter-evans/create-pull-request@v6\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n          commit-message: &quot;chore: update misskey to ${{ steps.latest.outputs.latest_version }}&quot;\n          title: &quot;chore: Update misskey to ${{ steps.latest.outputs.latest_version }}&quot;\n          body: |\n            ## 🔄 Misskey イメージの更新\n            \n            このPRは自動的に生成されました。\n            \n            ### 📋 変更内容\n            | 項目 | 古いの | 新しいの |\n            |------|------|--------|\n            | バージョン | `${{ steps.current.outputs.current_version }}` | `${{ steps.latest.outputs.latest_version }}` |\n            | ダイジェスト | `${{ steps.current.outputs.current_digest }}` | `${{ steps.latest.outputs.latest_digest }}` |\n            \n            ### 🔍 詳細\n            \n            **以前のイメージ:**\n            ```\n            misskey/misskey:${{ steps.current.outputs.current_version }}@${{ steps.current.outputs.current_full_digest }}\n            ```\n            \n            **新しいイメージ:**\n            ```\n            misskey/misskey:${{ steps.latest.outputs.latest_version }}@${{ steps.latest.outputs.latest_full_digest }}\n            ```\n            \n            ### ✅ 確認事項\n            - [ ] 変更内容を確認しました\n            - [ ] テスト環境で動作確認を行いました（必要に応じて）\n            - [ ] 本番環境への適用準備ができています\n            \n            ### 📚 関連リンク\n            - [Misskey リポジトリ](https://github.com/misskey-dev/misskey)\n            - [Docker Hub イメージ](https://hub.docker.com/r/misskey/misskey)\n            \n            ---\n            \n            🤖 自動生成されたPR | 📅 ${{ github.run_id }}\n          branch: update-misskey-${{ steps.latest.outputs.latest_version }}\n          delete-branch: true\n          labels: |\n            dependencies\n            automated pr\u003C/code>\u003C/pre>\u003C/div>\u003Cp>基本的にはこれで動くはずです。\u003Cbr>独自のフォークを利用している場合は適当に書き換えてください。\u003C/p>\u003Ch2 id=\"h73c11d078e\">実装例\u003C/h2>\u003Cp>参考までに、わたしの環境での実際の実装を以下に示します。\u003Cbr>\u003Ca href=\"https://github.com/team-shahu/shahu-docker-provision/commit/4e34a75ef19403715b17c501f3475c546cd91671#diff-facaded51b7d1c9656ae43b449b69aa398fee9129321a0001d97048c00c8cc1c\" target=\"_blank\" rel=\"noopener noreferrer\">https://github.com/team-shahu/shahu-docker-provision/commit/4e34a75ef19403715b17c501f3475c546cd91671#diff-facaded51b7d1c9656ae43b449b69aa398fee9129321a0001d97048c00c8cc1c\u003C/a>\u003C/p>\u003Cp>使い回しても加工して利用しても構いません。\u003Cbr>煮るなり焼くなり好きにしてください。\u003C/p>\u003Ch1 id=\"hdd244d9218\">人間を招く(Optional)\u003C/h1>\u003Cp>ここまでの手順は、いわば決定論的な世界の話でした。\u003Cbr>正しく設定すれば、システムは期待通りに動きます。\u003Cbr>しかし、人間を招き入れるという行為は、全く異なる、そして遥かに複雑で非合理な問題領域へと足を踏み入れることを意味します。\u003C/p>\u003Cp>サーバを建てる人間が抱きがちな、最も甘美な幻想の一つは、「まともな人間が集まるだろう」というものです。\u003Cbr>しかし、現実は異なります。\u003Cbr>多くの人間は、あなたが期待するような理知的で配慮のある振る舞いは決してしません。 \u003Cbr>むしろ、あなたの善意を食い潰し、絶え間ないストレスの源泉となりかねない存在です。\u003Cbr>その事実を理解した上で、改めて選択肢を見てみましょう。\u003C/p>\u003Cp>\u003Ccode>コントロールパネル &gt; 設定 &gt; モデレーション\u003C/code>より、新規登録の受け入れ設定が可能です。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/52cca5697dc9475b98907c30359731fa/image.png\" alt=\"\" width=\"1918\" height=\"909\">\u003C/figure>\u003Cp>この点について、わたしは語りたいことが山ほどあるのですが、あまりにも長すぎるので興味があれば、わたしの盛大な失敗談を読んで笑ってやってください。\u003Cbr>\u003Ca href=\"https://mq1.dev/entry/a7dv5t3bip\" target=\"_blank\" rel=\"noopener noreferrer\">Misskey鯖缶後悔記\u003C/a>\u003C/p>\u003Cp>あなたがこれから作るのは、あなたの時間と精神を注ぎ込む、あなたの城です。\u003Cbr>その門の鍵を誰に渡すのかは、あなたが決めることです。\u003C/p>\u003Cp>自身の選択を後々後悔することがないよう祈っています。\u003C/p>\u003Ch1 id=\"h5db7379d72\">ユーザが増えてしまった！どうしよう！？\u003C/h1>\u003Cp>おめでとうございます。そして、ご愁傷様です。 \u003Cbr>あなたの手で生まれた小さな世界に人々が定住し始め、サーバは成長という新たな、そして厄介な段階を迎えました。\u003Cbr>これは喜ばしいことであると同時に、あなたの平穏な日々との別れを意味します。\u003C/p>\u003Ch2 id=\"hd844bd9150\">技術的な対処\u003C/h2>\u003Cp>サーバのユーザ数が増加し始めると、運用は新たなフェーズに移行します。これはサーバの成功を示す喜ばしい兆候であると同時に、これまでとは質の異なる、予測可能な技術的課題の始まりでもあります。\u003Cbr>ここからは、たスケーリングのロードマップを、\u003Ca href=\"https://wiki-misskey-operation.7ka.org/ja/misskey/2k-instance\" target=\"_blank\" rel=\"noopener noreferrer\">一般的？な指標\u003C/a>と共に解説します。\u003C/p>\u003Ch3 id=\"he8f3e6c2b5\">トータル300人規模の壁 (同時接続100人〜)\u003C/h3>\u003Cp>最初に直面するのは、多くの場合、単純なリソース不足です。\u003Cbr>LTLが活発化するにつれて、サーバの性能が追いつかなくなってきます。 \u003Cbr>この段階の最も標準的な対策は、スケールアップです。\u003Cbr>最低でも2CPU / 8GBメモリ程度のスペックが推奨されます。\u003C/p>\u003Ch3 id=\"hffd5841bfc\">トータル700人規模の壁 (同時接続200人〜)\u003C/h3>\u003Cp>この規模になると、問題はより専門的になります。\u003Cbr>単純なスケールアップだけでは解決できない、データベースのボトルネックが顕在化し始めます。\u003C/p>\u003Cul>\u003Cli>RDBの接続詰まり\u003Cp>Misskeyの各ワーカーからの大量の接続要求に、PostgreSQLのデフォルト設定が耐えきれなくなります。\u003Cbr>この問題を解決するには、\u003Ccode>pgBouncer\u003C/code>のようなコネクションプーラを導入する必要がありますが、その設定は非自明であり、かなりめんどくさいものです。\u003C/p>\u003C/li>\u003Cli>ジョブキューの詰まり\u003Cp>サーバ内外への投稿を処理する\u003Ccode>Inbox queue\u003C/code>と\u003Ccode>Deliver queue\u003C/code>が詰まり、投稿が遅延するようになります。\u003Cbr>\u003Ccode>.config/default.yml\u003C/code>のワーカー設定の調整が有効です。\u003Cbr>\u003Ccode>clusterLimit\u003C/code>の値を増やすことでワーカープロセスを増やし、処理能力を向上させることができますが、メモリ消費量もその倍数で増加します。\u003Cbr>サーバが実際に処理できる能力と設定値を乖離させすぎると、スワップが発生してしまいます。\u003C/p>\u003C/li>\u003C/ul>\u003Ch3 id=\"h4129f4dba0\">トータル1000人規模の壁 (同時接続300人〜)\u003C/h3>\u003Cp>データベースへの参照が遅くなり、APIの応答が全体的に緩慢になるという問題が発生します。\u003Cbr>この段階では、データベースにリードレプリカを設置し、読み取り処理をそちらに分散させることで、プライマリデータベースの負荷を軽減するアーキテクチャが一般的です。\u003Cbr>同時接続が400人を超えてくると、プライマリDB自体のスペックアップや、リードレプリカの追加増設も視野に入ってきます。\u003C/p>\u003Ch3 id=\"hb085256c33\">マネージドサービスへの移行\u003C/h3>\u003Cp>もしあなたが幸運にもインスタンスのマネタイズに成功したのであれば、これを機にGCP等のマネージドサービスへ完全に移行するのが、賢明な選択でしょう。\u003C/p>\u003Cp>自前でコネクションプーラを管理し、リードレプリカを運用する煩雑さは、計り知れません。\u003Cbr>その複雑な責務を、料金と引き換えに任意のベンダに丸投げすることで、あなたは本来集中すべきコミュニティの運営にリソースを割くことができます。\u003Cbr>規模が大きくなったサーバの管理は、わりと苦行でしかなさそうです。\u003Cbr>しあわせになりましょう。\u003C/p>\u003Ch2 id=\"h37da0415f9\">人間的な対処\u003C/h2>\u003Cp>技術的なスケーリングと並行して、あるいはそれ以上に重要となるのが、人間的な問題への対処、すなわちコミュニティのスケールアップです。\u003C/p>\u003Cp>ユーザ数の増加は、必然的に利用者間の衝突、ルール違反、そして管理者への様々な要求の増大を招きます。\u003Cbr>これらすべてに自分一人の時間と精神力で対応し続けることは、現実的に厳しいかと思います。\u003C/p>\u003Cp>現に、多くの管理者が役目を終えるのは、単純な資金難等が理由ではなく終わりのない人間的な対応からくる精神的なストレスが原因である、という事実に留意しておくべきでしょう。\u003C/p>\u003Cp>そうなる前に対策を講じる必要があります。\u003Cbr>まずは信頼できる人間にモデレーター権限を委譲し、負荷を分散\u003Cs>もとい押し付け\u003C/s>ましょう。\u003Cbr>あなたのサーバの文化をよく理解しているユーザに声をかけ、権限を付与してください。\u003C/p>\u003Cp>サーバ設立時に定めたはずのルールは、公平なモデレーションを行うための揺るぎない基準となります。\u003Cbr>そのため、不備があればこの際まとめて改定しておくといいでしょう。\u003C/p>\u003Cp>もし、なおコミュニティの成長速度があなたの管理能力を上回っていると感じたなら、迷わず新規登録を一時的に停止してしまいましょう。\u003Cbr>登録を招待制に切り替えることで、問題の流入を止め、コミュニティを安定させるための時間を確保できます。\u003C/p>\u003Cp>インスタンスの成長は、あなたが単なる技術的な管理者から、コミュニティの秩序を設計し、維持する運営者へと役割を変えることを要求します。\u003Cbr>残酷ですね。\u003C/p>\u003Ch1 id=\"h8a55dfed1c\">トラブルシューティングの基本\u003C/h1>\u003Cp>サーバが期待通りに動き続けると考えるのは、楽観的すぎる幻想です。\u003C/p>\u003Cp>問題は必ず、そして多くの場合、最も都合の悪いときに発生します。\u003Cbr>その際に重要なのは、パニックに陥らず、論理的に原因を切り分ける、体系的な考え方です。\u003C/p>\u003Cp>まず、あなたのインスタンスにアクセスできなくなった時、外から内へと問題を切り分けていくのが基本です。\u003C/p>\u003Cp>最初に確認すべきは、あなたのサーバの外側、すなわちCloudflareです。\u003Cbr>あなたが設定した外形監視が、おそらく最初の異常を知らせてくれているはずですが、Cloudflare自体のステータスページを確認し、サービスに障害が発生していないかを見ます。\u003Cbr>次に、Zero Trustダッシュボードで、あなたのTunnelが正常に稼働しているものとして認識されているかを確認してください。\u003C/p>\u003Cp>ここまでが正常であれば、問題はあなたのVPS内部にあると判断できます。\u003C/p>\u003Cp>サーバにSSHで接続したら、最初に行うべきはログを読むことではありません。\u003Cbr>まずは、\u003Ccode>sudo docker ps\u003C/code> コマンドを実行し、各コンテナが意図した通りに稼働しているか、その全体像を把握します。\u003Cbr>今回の構成の場合、\u003Ccode>app\u003C/code>, \u003Ccode>mi\u003C/code>, \u003Ccode>db\u003C/code>, \u003Ccode>redis\u003C/code>、いずれかのコンテナが\u003Ccode>Exited\u003C/code>や\u003Ccode>Restarting\u003C/code>といった異常な状態にないかを確認してください。\u003C/p>\u003Cp>例えば、\u003Ccode>db\u003C/code>コンテナが\u003Ccode>Unhealthy\u003C/code>であれば、問題の根源はデータベースにあると、この時点で大きく絞り込むことができます。\u003C/p>\u003Cp>問題のあるコンテナを特定できて初めて、そのコンテナのログの確認に移ります。\u003Cbr>\u003Ccode>sudo docker compose logs --tail 500 mi | grep -E &apos;ERROR|FATAL&apos; \u003C/code> のように、問題が疑われるサービスのログを追跡し、\u003Ccode>ERROR\u003C/code>や\u003Ccode>FATAL\u003C/code>といったキーワードを頼りに、何が起きているのかを読み解きます。\u003Cbr>New Relicのようなログ集約基盤を導入していれば、過去のログを横断的に検索し、「このエラーはいつから発生しているのか」といった時間軸での分析もかなり楽になるでしょう。\u003C/p>\u003Cp>もし、全てのコンテナが正常に稼働しているにも関わらずサイトが極端に遅い、といった場合は、ホストOS自体の健康状態を疑います。\u003Cbr>\u003Ccode>df -h\u003C/code>でディスクの空き容量を確認し(ストレージの逼迫はデータベースを停止させる致命的な原因です）、\u003Ccode>htop\u003C/code>でCPUやメモリを異常に消費しているプロセスがないかを確認します。\u003Cbr>あるいは\u003Ccode>dmesg\u003C/code>で、カーネルがOOM Killerを発動させていないかといった、より低レイヤーの問題を探ります。\u003C/p>\u003Cp>もちろん、あなたが設定したであろう内部監視は、これらの兆候をグラフとして明確に示しているはずです。\u003C/p>\u003Cp>外形監視 → コンテナの状態 → ログ → ホストOSの健康状態。\u003Cbr>この外から内へという一貫した流れで原因を絞り込んでいく思考こそが、トラブルシューティングの要諦です。\u003C/p>\u003Cp>そして、問題の原因は、多くの場合、あなたが最後に行った変更の中にあります。\u003Cbr>冷静に、一つずつ確認していくことが結局のところ一番早い解決方法でしょう。\u003C/p>\u003Ch1 id=\"h1877a38f6d\">Misskeyインスタンスを閉鎖する手順と410 Gone設定\u003C/h1>\u003Cp>すべてのものには終わりがあります。\u003Cbr>Misskeyインスタンスの運用も例外ではありません。\u003C/p>\u003Cp>燃え尽き、時間的制約、あるいは金銭的な理由。\u003Cbr>いずれ訪れるかもしれないその日のために、ここでは責任ある管理者として、サーバの役割を正式に終わらせる爆破手順について解説します。\u003C/p>\u003Cp>無言でサービスを停止するような夜逃げに等しい行いは、最も無責任で、これまであなたのサーバを利用してくれたユーザを裏切る行為です。\u003Cbr>最後まで、管理者としての責務を全うしましょう。\u003C/p>\u003Ch2 id=\"h4366d09a9d\">告知\u003C/h2>\u003Cp>ユーザが心の準備をし、自身のデータを退避させるための時間を十分に確保することが、倫理的なインスタンス爆破の大前提です。\u003Cbr>最低でも1ヶ月、できれば2ヶ月前には、\u003Ccode>コントロールパネル &gt; 管理 &gt; お知らせ\u003C/code>から、全ユーザに閉鎖の意向を告知してください。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/1a9d698a68604b8c90e6e8508f4c338f/image.png\" alt=\"\" width=\"1919\" height=\"905\">\u003C/figure>\u003Cp>\u003C/p>\u003Ch2 id=\"h8ad95409d5\">新規登録の停止\u003C/h2>\u003Cp>告知と同時に、\u003Ccode>コントロールパネル &gt; 設定 &gt; モデレーション\u003C/code>より、招待制に設定し、これ以上ユーザが増えないようにします。\u003C/p>\u003Ch2 id=\"had4604ca89\">410 Gone\u003C/h2>\u003Cp>ActivityPubで連合しているインスタンスを閉鎖する際には、単にコンテナを停止するだけでは不十分です。\u003Cbr>それは、連合していた他のインスタンスに対して、無駄な通信を永遠に試みさせる厄介な行為に他なりません。\u003C/p>\u003Cp>あなたがサーバを停止すると、あなたをフォローしていた、あるいは過去にあなたの投稿をRNしたサーバは、あなたのサーバが応答しないことを検知します。\u003Cbr>しかし、それが一時的なメンテナンスなのか、恒久的な閉鎖なのかを判断できないため、健気にも何度も再接続を試み続けます。\u003Cbr>この無駄な再試行が、他のサーバのリソースを僅かながら、しかし確実に消費させ続けるのです。\u003C/p>\u003Cp>この問題を解決し、各連合先に対して立つ鳥跡を濁さずの礼儀を尽くすのが、HTTPステータスコード \u003Ccode>410 Gone\u003C/code> です。\u003Cbr>これは、「この場所は完全に、そして永久に消滅した」という明確な意思表示であり、これを受け取った他のサーバは、あなたのサーバへの通信を諦め、キューから削除してくれます。\u003C/p>\u003Cp>設定は簡単です。\u003Cbr>Caddyの設定を410を返すだけのシンプルなものに書き換えて、Caddyコンテナを再起動します。\u003C/p>\u003Cdiv data-filename=\"./config/Caddyfile\">\u003Cpre>\u003Ccode>:3000 {\n\trespond &quot;This server is permanently gone.&quot; 410\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cpre>\u003Ccode class=\"language-shell\">sudo docker compose restart app\u003C/code>\u003C/pre>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/2856d44c7bba4789a55ea6326398a754/image.png\" alt=\"\" width=\"1918\" height=\"909\">\u003C/figure>\u003Cp>この状態で、最低でも数週間、可能であれば1ヶ月ほどオリジンを稼働させ続け、連合先のインスタンスに閉鎖を周知させます。\u003C/p>\u003Cp>注意点として、一度410を返したドメインに対して、他のインスタンスは通信を恒久的に停止します。\u003Cbr>もし将来、同じドメイン名でインスタンスを再建する可能性が少しでもあるならば、この手順は慎重に検討しましょう。\u003C/p>\u003Ch2 id=\"h12acfb3b74\">サービスの完全停止\u003C/h2>\u003Cp>周知期間が十分に経過したら、Misskeyを構成する全てのリソースを完全に停止・削除します。\u003Cbr>最後に、VPS等の契約している各種サービスを整理し、すべての工程が終了します。\u003C/p>\u003Cp>お疲れ様でした。\u003Cbr>これにて、あなたのインスタンスの一生は幕を閉じます。\u003C/p>\u003Cp>サーバはデジタルな塵と消え失せましたが、そこでの経験や繋がった人々との記憶は、あなたの内に残ります。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>ここまでお疲れ様でした。\u003Cbr>サーバの契約から始まり、ドメインの取得、Dockerによる構築、各種設定、そして運用と、いつか訪れるかもしれない爆破に至るまで、このマニュアルはMisskeyインスタンス管理の一生を駆け足で巡るものでした。\u003C/p>\u003Cp>このエントリを通じてわたしが一貫して伝えたかったのは、技術的な手順そのものよりも、その先にある「インスタンスを運用し続ける」という行為の重みです。\u003C/p>\u003Cp>技術的な問題は、時間をかければ、あるいはお金で殴ってしまえば、多くの場合解決できます。\u003Cbr>しかし、あなたが本当に向き合うことになるのは、それ以上に複雑で、そして終わりなき人間という存在です。\u003C/p>\u003Cp>あなたは今、自分だけの城を手にしました。\u003Cbr>その城壁の中で何を守り、何を育み、そして何を拒絶するのか。\u003Cbr>その全ての判断と責任は、あなた一人の肩にかかっています。\u003C/p>\u003Cp>当エントリが少しでも道標となれば幸いです。\u003C/p>\u003Cp>なお、このマニュアルはわたしの知見に基づくものであり、全ての情報を網羅できているわけではありません。\u003Cbr>もし内容に不足している点や、「こういう内容も書き足してほしい」といった要望があれば、何らかの方法でご連絡ください。\u003Cbr>今後の改訂の参考にさせていただきます。\u003C/p>\u003Cp>それでは。\u003C/p>",[54,55,56,57],{"id":40,"createdAt":41,"updatedAt":42,"publishedAt":41,"revisedAt":42,"slug":43,"name":44},{"id":34,"createdAt":35,"updatedAt":36,"publishedAt":35,"revisedAt":36,"slug":37,"name":38},{"id":28,"createdAt":29,"updatedAt":30,"publishedAt":29,"revisedAt":30,"slug":31,"name":32},{"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":67,"revisedAt":66,"title":68,"content":69,"tags":70,"is_no_index":45},"88iv1ksd5","2026-01-30T19:08:58.242Z","2026-01-30T20:06:23.765Z","2026-01-30T19:21:46.117Z","SpeedTestで回線品質を語らないでね","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>YouTubeやTwitterを眺めていると、頻繁に目にする光景があります。SpeedTestの計測結果画面を貼り付け、「国内最速級のネットワークを構築した」「自分の回線は最強だ」とドヤ顔で語る、自称コンピュータに詳しい方々の姿です。\u003C/p>\u003Cp>正直に申し上げます。お前らのどこが詳しいんだと。\u003C/p>\u003Cp>それらの投稿からは、基礎的な前提知識の欠如が透けて見えます。彼らの中には、多少コードが書けることや、自作PCを組めることをひけらかしている方もいるようですが、それが何だというのでしょう。多くの先人たちが長い時間をかけて積み上げ、高度に抽象化してくれたレイヤの上でコードが書けることと、ネットワークやコンピュータの本質的な挙動を理解していることは、全くの別物です。\u003C/p>\u003Cp>下に積み重なった技術を軽視し、都合よくブラックボックス化された上澄みだけを啜って「自分は有能だ」と振る舞うその傲慢さは、技術者としてだけでなく、文化人としても風上にも置けない姿勢だと思います。\u003C/p>\u003Ch1 id=\"h7677687bd2\">巨人の肩の上で\u003C/h1>\u003Cp>誤解のないように言っておきますが、私自身が本質を理解した有識者であると言いたいわけではありません。\u003C/p>\u003Cp>普段の私は、インフラからアプリケーションまで設計・開発・運用・保守を広く浅くこなす事を生業としてお賃金を頂いています。しかし、「自分はコンピュータに精通している」などとは、口が裂けても言えません。\u003C/p>\u003Cp>私が日々触れているのは、多くの優秀なエンジニアや研究者たちが残してくれた抽象化レイヤの上にある有象無象に過ぎないからです。さらに下のレイヤ、例えば組み込みシステムやハードウェア設計の話になれば、今の私が詳細に理解することは困難でしょう。\u003C/p>\u003Cp>真の専門性とは、そうした深淵を知ることにあるはずです。にも関わらず、都合よくブラックボックス化された部分を全部すっ飛ばして、表面的な知識だけで有識者ぶって発信する。私自身も大概な無能であるという自覚はありますが、インターネットの海にはそれ以下の蛙が溢れかえっていて、正直かなり気持ち悪く感じます。\u003C/p>\u003Ch1 id=\"h53f7799c9f\">SpeedTestが回線品質ではない理由\u003C/h1>\u003Cp>毒を吐くのはこれくらいにして、今回はそのSpeedTestの話です。\u003C/p>\u003Cp>彼らが信仰するあの数字が、なぜ回線品質の証明にならないのか。技術的な観点から、大きく4つの欺瞞にケチをつけます。\u003C/p>\u003Ch2 id=\"hf3346b6b6e\">測定スコープの不一致\u003C/h2>\u003Cp>まず決定的なのが、評価対象の乖離です。\u003C/p>\u003Cp>本来、回線の実力として評価されるべきは、ユーザー宅内のCPEからアクセス網を抜け、ISPのバックボーン、そしてAS境界ルータに至るまでの区間です。このAS内部において、IGPルーティングがいかに効率的か、設備冗長性が保たれているか、網内帯域に余裕があるか。これこそが回線品質でしょう。\u003C/p>\u003Cp>しかし、SpeedTestが計測しているのは、他社のトランジットやIX、そして測定サーバー内部までを含んだE2Eでの通信結果です。ISP網内がどれほど高品質に保たれていても、トランジット先が輻輳していれば数値は落ちます。つまり、SpeedTestの結果には外部要因のノイズが大きすぎて、被測定物であるはずのISP網の純粋な性能を切り出すことなど困難な構造になっているのです。\u003C/p>\u003Cp>要するに、経路上で最も足を引っ張っている数字が目安程度に出るだけなんですよ。\u003C/p>\u003Cp>ボトルネックがどこにあるのかも分からないのに、その数字を以て回線品質を語るなど、さすが有識者さん(笑)は違いますね。\u003C/p>\u003Ch2 id=\"h8aa307d715\">経路制御の変動性\u003C/h2>\u003Cp>インターネットは宛先によって経路が異なることが大前提ですが、SpeedTestはこの特性を無視した一点観測に過ぎません。\u003C/p>\u003Cp>ISPはコストやポリシに基づいて、経路制御を行います。例えば、測定サーバAへの経路は、たまたま高品質なピアリング先を通る定義されているかもしれません。(ここで言う「定義されている」とは、必ずしも恣意的であると言う意味合いを含みません。)\u003Cbr>しかし、あなたが実際にYouTubeを見たりゲームをしたりする際のサーバBへの経路は、安価で混雑したトランジット先を通るかもしれないのです。\u003C/p>\u003Cp>さらに、経路上のすべてのASはベストエフォートで動いています。ある瞬間に測定サーバーへの経路が確保されたからといって、それが永続的な品質を保証するものでもなければ、他の経路の品質を担保するものでもありません。たまたま空いている裏道を走って「私の車庫は素晴らしい」と主張するのは滑稽です。\u003C/p>\u003Ch2 id=\"h3c6d2eeff5\">測定基準の信頼性\u003C/h2>\u003Cp>任意の何かを測定する場合、その定規は不変である必要があります。\u003C/p>\u003Cp>しかし、測定サーバは定規になり得ません。収容している物理筐体自体のNIC帯域、CPU負荷、ディスクI/O、あるいはそのサーバが収容されているデータセンタの上位回線がボトルネックになるケースは多々あります。\u003C/p>\u003Cp>また、CDNのエッジサーバとSpeedTestのサーバが同じ場所にある保証もありません。「Speedtestサーバへは速いが、GoogleやAWSへは遅い(あるいは逆)」という現象は、技術的に当然起こり得ることです。\u003C/p>\u003Ch2 id=\"h98df36be9e\">スループット偏重\u003C/h2>\u003Cp>最後に、最も見落とされがちなのがトランスポート層の挙動です。\u003C/p>\u003Cp>速度という単一指標への盲信は、回線の本当の姿を隠蔽します。SpeedTestの多くは、複数のTCPセッションを張って帯域を無理やり埋めようとします。TCPは再送制御やウィンドウ制御によって、パケットロスやジッタをある程度吸収し、それをスループットとして出力するプロトコルです。\u003C/p>\u003Cp>つまり、速度は出ているが、パケットロスが多発している低質な回線であっても、再送によって帳尻を合わせ、一見すると高得点が出てしまいます。しかし、VoIPやオンラインゲームのようなUDPでのリアルタイム通信で重要なのは、帯域幅よりもレイテンシの安定性やパケット到達率です。単なる上下○○Mbpsという数字は、これらをもっとも必要とするアプリケーションの快適性を何一つ保証しません。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>ここまで書き連ねてきましたが、私が言いたいのは「SpeedTestを使うな」ということではありません。あくまで目安として、あるいはトラブルシューティングの一環として使う分には有用なツールです。\u003C/p>\u003Cp>私が批判しているのは、その数字が持つ不確かさや多面性を無視し、単一の数値を絶対的な正義として振りかざす姿勢そのものです。\u003C/p>\u003Cp>ネットワークの品質とは、本来もっと静かで、目に見えにくいものです。パケットロスなくデータが届くこと。レイテンシが揺らがないこと。輻輳時にも最低限の公平性が保たれること。そういった地味で計測しづらいパラメータの積み重ねの上に、我々の快適な体験は成り立っているはずです。\u003C/p>\u003Cp>あの派手なメーターが弾き出す数字は、複雑怪奇なインターネットのほんの一瞬を切り取った、ただの現象に過ぎません。\u003C/p>\u003Cp>その数字に一喜一憂してマウントを取り合うよりも、自分がいかに巨大で不確実なシステムの上で遊ばせてもらっているかを自覚する。それが、ブラックボックス化された技術を享受する私たちユーザが持つべき、最低限の節度ではないでしょうか。\u003C/p>",[71,72,73],{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":17,"revisedAt":18,"slug":19,"name":20},{"id":58,"createdAt":59,"updatedAt":60,"publishedAt":59,"revisedAt":60,"slug":61,"name":62},{"id":28,"createdAt":29,"updatedAt":30,"publishedAt":29,"revisedAt":30,"slug":31,"name":32},{"contents":75,"totalCount":112,"offset":113,"limit":114},[76,82,83,89,95,96,97,98,104,105,106],{"id":77,"createdAt":78,"updatedAt":79,"publishedAt":78,"revisedAt":79,"slug":80,"name":81},"riqchoqn8lw","2025-11-06T18:20:17.154Z","2025-12-03T16:06:09.687Z","games","ゲーム",{"id":40,"createdAt":41,"updatedAt":42,"publishedAt":41,"revisedAt":42,"slug":43,"name":44},{"id":84,"createdAt":85,"updatedAt":86,"publishedAt":85,"revisedAt":86,"slug":87,"name":88},"zvbbh2k4o","2025-07-09T17:50:30.110Z","2025-12-03T16:06:32.163Z","cat-life","ネコのいる暮らし",{"id":90,"createdAt":91,"updatedAt":92,"publishedAt":91,"revisedAt":92,"slug":93,"name":94},"1ddfqhefta5p","2025-07-04T15:20:34.250Z","2025-12-03T16:06:41.758Z","politics","政治",{"id":58,"createdAt":59,"updatedAt":60,"publishedAt":59,"revisedAt":60,"slug":61,"name":62},{"id":34,"createdAt":35,"updatedAt":36,"publishedAt":35,"revisedAt":36,"slug":37,"name":38},{"id":28,"createdAt":29,"updatedAt":30,"publishedAt":29,"revisedAt":30,"slug":31,"name":32},{"id":99,"createdAt":100,"updatedAt":101,"publishedAt":100,"revisedAt":101,"slug":102,"name":103},"8q8qyy8sz3","2025-04-29T08:44:23.406Z","2025-12-03T16:07:24.495Z","programming","プログラミング",{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":17,"revisedAt":18,"slug":19,"name":20},{"id":22,"createdAt":23,"updatedAt":24,"publishedAt":23,"revisedAt":24,"slug":25,"name":26},{"id":107,"createdAt":108,"updatedAt":109,"publishedAt":108,"revisedAt":109,"slug":110,"name":111},"sxrwmir1w","2025-04-27T13:01:27.006Z","2025-12-03T16:08:35.042Z","introduction","はじめまして",11,0,50,{"contents":116,"totalCount":271,"offset":113,"limit":272},[117,129,139,149,160,169,180,190,200,213,226,237,246,251,261],{"id":118,"createdAt":119,"updatedAt":120,"publishedAt":120,"revisedAt":120,"title":121,"content":122,"tags":123,"is_no_index":45,"summary":128},"oy69jxht12","2026-06-19T18:38:58.987Z","2026-06-19T18:48:10.255Z","Hide My Emailのドメインが変わるので、また悩んでいる","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>以前、\u003Ca href=\"https://mq1.dev/entry/svgkndwg3ng9\">独自ドメインでのメールアドレス運用をやめ、Hide My Emailに移行した話\u003C/a>という記事を書きました。「自前運用もSESも全部やめて全てをHide My Emailに寄せたよ〜」という話です。\u003C/p>\u003Cp>で、そのHide My Emailなんですが、先日、生成されるアドレスのドメインが\u003Ccode>@icloud.com\u003C/code>から\u003Ccode>@private.icloud.com\u003C/code>に変わると発表されました。すでに発行済みのアドレスは引き続き転送されるらしいので、過去の登録が消えてしまうわけではないのですが、これから作るぶんが別ドメインになる時点で、わたしにとっての価値の大半は消し飛びます。\u003C/p>\u003Cp>\u003Ca href=\"https://developer.apple.com/news/?id=sus6t6ab&amp;7194ef805fa2d04b0f7e8c9521f97343\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">https://developer.apple.com/news/?id=sus6t6ab&amp;7194ef805fa2d04b0f7e8c9521f97343\u003C/a>\u003C/p>\u003Cp>さて、困りました。\u003C/p>\u003Ch1 id=\"ha05bbba395\">なにが困るのか\u003C/h1>\u003Cp>わたしがHide My Emailを愛用していた理由は、大きく二つあります。\u003C/p>\u003Cp>プライマリのアドレスと紐付けずにサービスごとに使い分けられること、そして\u003Ccode>icloud.com\u003C/code>のドメインパワーに肖れることです。\u003C/p>\u003Cp>特に後者が大きくて。\u003Cbr>そもそもHide My Emailを嬉しく思っていたのは、生成されるアドレスが普通のiCloudユーザと見分けがつかなかったからでした。受け手からすればただのiCloudユーザーにしか見えないので、後述する特定の大手ドメインしか受け付けないみたいな邪悪な実装も平然と通過できていました。それが\u003Ccode>@private.icloud.com\u003C/code>になると、受け手がHide My Emailを判別できるようになってしまいます。\u003C/p>\u003Cp>要するに、機能が成立していた前提をApple自身が手放しにきているわけです。プライバシ機能を名乗っておいてこれは、なかなかのものだなと思います。\u003C/p>\u003Cp>で、世の中には、GmailとiCloud(国内だとヘンテコ仕様なキャリアメールなんかも)以外のドメインを弾くようになっているサービスがそこそこあります。\u003Cs>Pi○tLinkなんかがそうですね。\u003C/s>こういう邪悪な実装のところでは、\u003Ccode>icloud.com\u003C/code>が使えること自体が最大にして唯一の利点でした。正直、わたしはこの一点のためだけにiCloud+へ課金していたと言っても過言ではありません。\u003C/p>\u003Cp>プライバシがどうとかいう高尚な理由ではなく、純粋に弾かれないための月額だったので、ここがなくなってしまうのはとても苦しい。\u003C/p>\u003Ch1 id=\"hf58740672c\">そもそも独自ドメインのメールって旨味あるの\u003C/h1>\u003Cp>「じゃあ独自ドメインに戻せば」という話になりそうですが、わたしはこのご時世に個人で独自ドメインのメールを持つ旨味、あんまりないと思っています。理由は二つ。\u003C/p>\u003Cp>一つは、メールサーバを自前で持つハードルが高いこと。\u003Cbr>ここで言うハードルは、構築の手間のことではありません。安定して長期運用できること、そしてレピュテーションを健全に保ち続けられること。実際のところ、メールサーバを立てるだけなら誰でもできるのですが、問題はそこから先で、送信ドメイン認証を整えて、IPを腐らせないように気を遣って、ある日突然どこかにブロックされても対処し続ける維持のほうが本体なんですよね。一度評判を落とすと戻すのも大変だし、これを個人で延々とやる価値があるかというと、正直微妙です。\u003C/p>\u003Cp>そもそもASN単位でブロックリスト入りすることも往々にしてあるので、根本的解決を図るにはASNを取得しIP割当を受けるしかない気がします。あまりにも不毛です。\u003C/p>\u003Cp>もう一つは、結局外部のプロバイダに乗せるなら独自ドメインの旨味は薄いこと。\u003Cbr>SESなりWorkspaceなりに任せるなら、配送やレピュテーションの面倒は向こうが見てくれる代わりに、自分が握ってるのはドメイン名といざとなれば乗り換えられる安心感くらいになります。それはそれで価値がないこともないのですが、Hide My Emailの大手のドメインに乗れることとはそもそも別の話です。\u003C/p>\u003Cp>もちろん利点もあって、スパムが来たときにどのサービスがお漏らしたか分かるとか、普段使いのアドレスを隠せるとか、管理が幾分か楽になるとか。でも正直、どれも過去のHide My Emailを超えることはないように思います。\u003C/p>\u003Ch1 id=\"hba13ad79dc\">Gmailのサブアドレスも微妙\u003C/h1>\u003Cp>Gmailのエイリアス(※1)を使う手もあります。\u003C/p>\u003Cp>ただこれ、同一人物への到達性を保証しなくていいスパムであれば、local partのuser部だけ抽出して(厳密ではないにせよ)送れてしまうんですよね。\u003Cs>少なくとも私ならそういう実装をしますし、多くの人がそう考えると思います。\u003C/s>Separator character sequence以降を捨てれば届いてしまうので、使い捨てアドレスとしては心許ない。かなり微妙。\u003C/p>\u003Ch1 id=\"h287541f080\">で、結局どうするか決まってない\u003C/h1>\u003Cp>整理すると、わたしが欲しいのは三つです。プライマリのアドレスを隠せること、サービスごとに分けられること、そして邪悪な実装を通れること。最初の二つは代替がいくらでもあるのですが、問題は三つめで、これを満たせる選択肢が驚くほど見つかりません。\u003C/p>\u003Cp>一応、候補は一通り眺めてみました。\u003C/p>\u003Cp>\u003Ca href=\"https://addy.io/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">addy.io\u003C/a>や\u003Ca href=\"https://simplelogin.io/ja/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">SimpleLogin\u003C/a>のようなエイリアスサービスは、マスキングも使い分けも完璧。しかし、相手に渡すアドレスは結局そのサービス自身のドメインになるので、\u003Ccode>@addy.io\u003C/code>とか\u003Ccode>@simplelogin.io\u003C/code>をGmail/iCloud/ヘンテコキャリアメールしか通さない許可リストが受け入れてくれるわけがありません。ついでにSimpleLoginはProton傘下で、わたしはProtonが好きではないので却下。\u003C/p>\u003Cp>独自ドメインを自前運用したりWorkspaceに載せたりする手も、同じ壁に当たります。受け手が見てるのはドメイン名の文字列であって、誰がホストしてるかではないため、どれだけ真っ当に運用されてようと、\u003Ccode>icloud.com\u003C/code>でも\u003Ccode>gmail.com\u003C/code>でもない以上、リテラル照合の前では無力です。Googleのインフラに乗せようが、自分のドメインである事実は変わらないので。\u003C/p>\u003Cp>iCloudの素のエイリアス(Hide My Emailではなく、\u003Ccode>@icloud.com\u003C/code>を最大3つ作れるやつ)は、ドメインが本物の\u003Ccode>icloud.com\u003C/code>のままなので許可リストは通ります。これはうれしい。しかし、3つしか作れないうえ、3つ埋まった状態で1つ消すと次を作るまで7日待たされるようで、使い捨てとして回すには微妙です。\u003C/p>\u003Cp>規約上どうかは知りませんが、転送専用のGmailアカウントを量産する手も思いつきました。\u003Cs>(思いついただけでやってないので叩かないでください)\u003C/s>これなら渡すのは正真正銘の\u003Ccode>@gmail.com\u003C/code>で許可リストも確実に通るし、数も稼げそうです。しかし、アカウントごとに電話番号認証だの複数アカウントの取り回しだのが付いてくる上、Googleの機嫌次第でまとめて凍結される可能性も拭えず、得られる体験のわりに管理コストとリスクが釣り合っていません。無念。\u003C/p>\u003Cp>という具合に、どれを取っても過去のHide My Emailには届かないんです。結局わたしが失おうとしてるのは、「大手のドメインに、ほぼ無限に、片手間で乗れる」という、よく考えるとかなり贅沢な状態だったんだなと。\u003Cbr>しかもApple側が仕様としてやめると言ってる以上、こっちの工夫でどうこうできる話でもなく。すごく普通に困ってます。\u003C/p>\u003Cp>そもそもみなさん、メールアドレスの運用ってどうしてるんでしょう？\u003Cbr>いい感じの方法があったら本当に教えてほしいです。\u003C/p>\u003Cp>\u003C/p>\u003Cp>\u003C/p>\u003Chr>\u003Cp>※1 RFC 5233ではSeparator character sequence + Detailであって、これがエイリアスでないことは理解しています。が、(腹立たしいことに)慣習上そう呼ばれることが多い(らしい)のでここでもそう呼んでます。\u003C/p>\u003Cp>蛇足ですが、&quot;Separator character sequence&quot;という言い方をすると、何らかの形で明確に分離された構造になっているように聞こえますが、実際にRFCが言ってるのは「UserにDetailを加えたアドレスをUserにルーティングできる」くらいのことでしかなくて、RFC 5321/5322が「local partの解釈は受け手のソフトウェア次第」ってスタンスなのに対して、RFC 5233はlocal partの形式の例として「local part=userってわけじゃなくてぇ」みたいな話をしてるに過ぎません。広げてるように見えて制限してる規格かと思いきや、その実なにも制限してない用語と用例が出されてるだけのものなので誤解しないようにしてくださいね。\u003C/p>\u003Cp>そもそもエイリアスは原初から全く別の機能の名称として存在してるので、この呼び方にはずっと不満があります。\u003C/p>",[124,125,126,127],{"id":28,"createdAt":29,"updatedAt":30,"publishedAt":29,"revisedAt":30,"slug":31,"name":32},{"id":34,"createdAt":35,"updatedAt":36,"publishedAt":35,"revisedAt":36,"slug":37,"name":38},{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":17,"revisedAt":18,"slug":19,"name":20},{"id":22,"createdAt":23,"updatedAt":24,"publishedAt":23,"revisedAt":24,"slug":25,"name":26},"はじめにどうも、わたしです。以前、独自ドメインでのメールアドレス運用をやめ、Hide My Emailに移行した話という記事を書きました。「自前運用もSESも全部やめて全てをHide My Emailに寄せたよ〜」という話です。で、そのHide My Emailなんですが、先日、生成されるアドレスの",{"id":130,"createdAt":131,"updatedAt":132,"publishedAt":133,"revisedAt":132,"title":134,"content":135,"tags":136,"is_no_index":45,"summary":138},"p7zftgqr_ht","2026-06-03T17:50:57.739Z","2026-06-04T08:52:49.895Z","2026-06-03T18:04:39.359Z","商業登記電子証明書(.p12)まわりの覚書","\u003Ch1 style=\"text-align: start\" id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp style=\"text-align: start\">どうも、わたしです。\u003C/p>\u003Cp style=\"text-align: start\">最近、商業登記電子証明書の証明書ファイル本体(.p12)を扱う機会がありました。かなり苦しんだので、触っていく中で見つけた諸々を備忘録として書き残しておきます。\u003C/p>\u003Cp style=\"text-align: start\">なお、実物には当然ながら法人のクレデンシャルが含まれているので、商号、代表者氏名、シリアル番号、Subject Key Identifierといった同定可能な値はすべて伏せています。本エントリはあくまで仕様の話です。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h4d904280a0\">.p12コンテナを開ける\u003C/h1>\u003Cp style=\"text-align: start\">商業登記電子証明書は、登記・供託オンライン申請システムからPKCS#12コンテナとしてダウンロードされます。OpenSSL 3系で開く場合は\u003Ccode>-legacy\u003C/code>が必要です。\u003C/p>\u003Cpre>\u003Ccode class=\"language-bash\">openssl pkcs12 -in hogefuga.p12 -info -noout -legacy\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">MAC=SHA-1、暗号化=3DES-CBC、Iteration=2000という構成で、2026年の基準では古風な部類です。\u003C/p>\u003Cp style=\"text-align: start\">なお、このPKCS#12の選択は法務省告示第543号の規定ではなく、登記・供託オンライン申請システム側の配布フォーマットの都合です。告示はX.509証明書のフィールド構造とCMP発行プロトコルだけを規定していて、利用者への手渡し方は何も書かれていません。仕様ではなく運用、ということは頭に入れておくとよさそうです。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"hb2cb04a84e\">中の証明書\u003C/h1>\u003Cp style=\"text-align: start\">p12を解体すると、秘密鍵1個と証明書2枚(エンドエンティティ+中間CA)が出てきます。ルートCAは入っていないので、検証側で別途取得する必要があります。\u003C/p>\u003Cp style=\"text-align: start\">エンドエンティティ証明書の基本情報はわりと普通です。\u003C/p>\u003Cul>\u003Cli>X.509 v3\u003C/li>\u003Cli>署名: sha256WithRSAEncryption\u003C/li>\u003Cli>公開鍵: RSA 2048bit\u003C/li>\u003Cli>Issuer: C=JP, O=Japanese Government, OU=Ministry of Justice, CN=Registrar of Tokyo Legal Affairs Bureau\u003C/li>\u003C/ul>\u003Cp style=\"text-align: start\">問題はSubject DNで、\u003C/p>\u003Cpre>\u003Ccode>C=JP\nO=MOJ No.XXXXXXXXXXXX\nCN=0402010000001\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">Oが\u003Ccode>Japanese Government\u003C/code>ではなく\u003Ccode>MOJ No.{会社法人番号}\u003C/code>。\u003Cbr>CNは基本的に\u003Ccode>{役員番号}\u003C/code>で、ローマ字氏名が登録されている場合のみ\u003Ccode>{役員番号}-{氏名のローマ字}\u003C/code>の形式になります。私が触った範囲ではいずれもハイフンなしの役員番号のみでした。\u003C/p>\u003Cp style=\"text-align: start\">いずれにせよ、これを普通のクライアント証明書の感覚で扱うと、O属性を法人名だと思い込んでパースしているライブラリが、ニッコリ笑顔で会社法人番号を法人名として表示してくれます。\u003C/p>\u003Cp style=\"text-align: start\">法人名そのものは、Subject DNではなく証明書の拡張領域に格納されています。私はこれで数時間を溶かしました。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"hd5b6d68703\">本体は1.2.392.100300.1.1.3\u003C/h1>\u003Cp style=\"text-align: start\">商業登記電子証明書のすべてが詰まっているのが、法務省独自OID\u003Ccode>1.2.392.100300.1.1.3\u003C/code>です。中身はこんなASN.1構造になっています。\u003C/p>\u003Cpre>\u003Ccode class=\"language-asn1\">RegisteredCorporationInfoSyntax ::= SEQUENCE {\n    corporateName              [0] DirectoryString,  // 商号\n    registeredNumber           [1] PrintableString,  // 会社法人等番号\n    corporateAddress           [2] DirectoryString,  // 本店所在地\n    representativeDirectorName  [3] DirectoryString,  // 代表者氏名\n    representativeDirectorTitle [4] DirectoryString,  // 代表者役職\n    registryOffice             [6] DirectoryString   // 登記所\n}\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">商号も本店も代表者氏名も役職も法人番号も登記所も、全部証明書本体に埋め込まれているわけです。登記簿照会を都度叩かなくとも、証明書を検証するだけで法人実体を照会できます。これは商業登記電子証明書独特の特徴なようで、海外の一般的なクライアント証明書とは目的がそもそも違います。\u003C/p>\u003Cp style=\"text-align: start\">ちなみに、お気づきの方もいらっしゃるかもしれませんが、タグが\u003Ccode>[0][1][2][3][4][6]\u003C/code>と並んでいて、\u003Ccode>[5]\u003C/code>が欠番になっています。告示付録2のASN.1モジュールにも\u003Ccode>[5]\u003C/code>は定義されていません。\u003C/p>\u003Cp style=\"text-align: start\">何だったんでしょうね、これ。予約とすら書かれていないあたり、運用初期に検討されて消えたフィールドなのかもしれませんが、想像の域を出ません。\u003C/p>\u003Cp style=\"text-align: start\">ついでに、もう2つの独自拡張も紹介しておきます。\u003C/p>\u003Cul>\u003Cli>1.2.392.100300.1.1.1: 日本語のUserNotice(「この証明書は、商業登記法その他の関係法令等に基づき発行されたものです。」)\u003C/li>\u003Cli>1.2.392.100300.1.1.2: 東京法務局登記官 というUTF8文字列。発行者の役職そのもの\u003C/li>\u003C/ul>\u003Cp style=\"text-align: start\">実は標準の\u003Ccode>certificatePolicies\u003C/code>(\u003Ccode>2.5.29.32\u003C/code>)拡張内にも英語のUserNoticeが入っていて、英文と和文が並存しています。同じ趣旨を文字種を変えて二重格納するというのは、ある意味で律儀ですが、実装側からすると少し迷惑な気もします。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h14c781f17c\">発行者の扱い\u003C/h1>\u003Cp style=\"text-align: start\">これは仕様を眺めていて知ったのですが、商業登記電子証明書の発行者は申請者がどこの法務局に申請しても\u003Ccode>Registrar of Tokyo Legal Affairs Bureau\u003C/code>となり、東京法務局登記官で固定なようです。\u003C/p>\u003Cp style=\"text-align: start\">申請者の管轄登記所(例えば長崎地方法務局とか)は、Issuerフィールドではなく独自拡張の\u003Ccode>registryOffice\u003C/code>に入ります。\u003C/p>\u003Cp style=\"text-align: start\">普通のPKIだと「発行者=申請を受け付けた組織」と素朴に対応していることが多いので、気をつけておくといいでしょう。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h7f21a26569\">中間CAが並存\u003C/h1>\u003Cp style=\"text-align: start\">ここもハマる方が多そうですが、商業登記認証局(CRCA1)の中間CA証明書は2世代運用されています。\u003C/p>\u003Ctable>\u003Ctbody>\u003Ctr>\u003Cth colspan=\"1\" rowspan=\"1\">\u003Cp>世代\u003C/p>\u003C/th>\u003Cth colspan=\"1\" rowspan=\"1\">\u003Cp>有効期間\u003C/p>\u003C/th>\u003Cth colspan=\"1\" rowspan=\"1\">\u003Cp>秘密鍵使用期間\u003C/p>\u003C/th>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>旧CRCA(2022)\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>6年\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>3年\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>新CRCA(2026)\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>10年\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>5年\u003C/p>\u003C/td>\u003C/tr>\u003C/tbody>\u003C/table>\u003Cp style=\"text-align: start\">両世代ともCommon Nameは同じ\u003Ccode>Registrar of Tokyo Legal Affairs Bureau\u003C/code>なので、CNだけでは識別できません。\u003Ca href=\"https://crca1.moj.go.jp/toukikan.html\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">シリアル番号\u003C/a>かSubject Key Identifierで判別すべきです。\u003C/p>\u003Cp style=\"text-align: start\">ちなみに、告示第543号の本文では登記官証明書は「72ヶ月/36ヶ月」、つまり6年/3年と規定されています。私が実際に触ったいくつかの証明書に含まれるCRCAは規定の倍近くに延長されていたので、告示の改正があったか、関係法令で別途上書きされているはずですが、本記事執筆時点では出典を特定できていません。詳しい方、誰か教えてください。\u003C/p>\u003Cp style=\"text-align: start\">それと、新世代では\u003Ccode>keyUsage\u003C/code>が\u003Ccode>critical\u003C/code>で付くようになっていたり、CRL Distribution Points拡張が追加されていたりと、告示の最低要件を超えた拡張が行われているようです。告示で定められているのは最低限ラインで、運用側のCP/CPSで上乗せされる感じなのかもしれません。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h7b1b74ce46\">失効確認\u003C/h1>\u003Cp style=\"text-align: start\">失効確認はAIA(\u003Ccode>1.3.6.1.5.5.7.48.1\u003C/code>)に書かれているOCSP URLを叩きます。\u003C/p>\u003Cpre>\u003Ccode class=\"language-plaintext\">http://crca.moj.go.jp/bin/dcwcgi/DC_HUSR/cert/cert\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">新世代CAではCRLのURIも追加されていて、\u003Ccode>http://crca1.moj.go.jp/certificateRevocationList.crl\u003C/code>から取得できます。\u003C/p>\u003Cp style=\"text-align: start\">ここで地味に面白いのが、商業登記電子証明書には失効だけでなく休止届という独自概念があることです。CRLではエントリの\u003Ccode>reasonCode\u003C/code>(\u003Ccode>2.5.29.21\u003C/code>)に\u003Ccode>certificateHold (6)\u003C/code>が入り、OCSPでは\u003Ccode>CertStatus\u003C/code>が\u003Ccode>revoked\u003C/code>で返り、その\u003Ccode>revokedInfo.revocationReason\u003C/code>に\u003Ccode>certificateHold (6)\u003C/code>が入ります。代表者が交代しそうだとか、本店移転の登記が走るだとか、そのような一時停止のためのものです。商業登記電子証明書ならではの特徴と言えるでしょう。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp style=\"text-align: start\">商業登記電子証明書を一言でまとめると、X.509のガワを被った登記簿の抜粋です。証明書としての構造はRFC 5280に準拠しつつ、肝心の登記情報は法務省独自OIDの拡張領域に格納されていて、PKIライブラリで素直に検証すると半分しか機能を引き出せません。\u003C/p>\u003Cp style=\"text-align: start\">ところで、平成26年の告示は本体署名を\u003Ccode>sha1WithRSAEncryption\u003C/code>から\u003Ccode>sha256WithRSAEncryption\u003C/code>に切り替えるアップデート(※1)だったわけですが、p12コンテナのMACが今もSHA-1なのは正直なんとも言えない味わいがあります。中身だけ新しくしてガワが旧式というのは、行政あるあるな気もしますが、いつかはAES+PBKDF2あたりに刷新してほしい気持ちが若干あります。\u003C/p>\u003Cp style=\"text-align: start\">それでは。\u003C/p>\u003Cp style=\"text-align: start\">\u003C/p>\u003Cp style=\"text-align: start\">※1: \u003Ccode>subjectKeyIdentifier\u003C/code>や\u003Ccode>authorityKeyIdentifier\u003C/code>の補助用途では今もSHA-1が使われています\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h3de35099b3\">参考\u003C/h1>\u003Cul>\u003Cli>\u003Ca href=\"https://www.digital.go.jp/assets/contents/node/basic_page/field_ref_resources/d12bde7e-a950-493b-987c-0f8d4bbd1b6b/20211228_notice_article_06.pdf\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">法務省告示第543号(平成26年12月12日)\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://laws.e-gov.go.jp/law/339M50000010023\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">商業登記規則(昭和39年法務省令第23号)\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://laws.e-gov.go.jp/law/338AC0000000125\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">商業登記法(昭和38年法律第125号)\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://www.touki-kyoutaku-online.moj.go.jp/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">登記・供託オンライン申請システム\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://www.moj.go.jp/MINJI/minji06_00028.html\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">商業登記に基づく電子認証制度(法務省)\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://datatracker.ietf.org/doc/html/rfc5280\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">RFC 5280 Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://datatracker.ietf.org/doc/html/rfc7292\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">RFC 7292 PKCS #12: Personal Information Exchange Syntax v1.1\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://datatracker.ietf.org/doc/html/rfc6960\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">RFC 6960 X.509 Internet Public Key Infrastructure Online Certificate Status Protocol - OCSP\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://datatracker.ietf.org/doc/html/rfc4210\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">RFC 4210 Internet X.509 Public Key Infrastructure Certificate Management Protocol (CMP)\u003C/a>\u003C/li>\u003C/ul>\u003Cp>\u003C/p>",[137],{"id":28,"createdAt":29,"updatedAt":30,"publishedAt":29,"revisedAt":30,"slug":31,"name":32},"はじめにどうも、わたしです。最近、商業登記電子証明書の証明書ファイル本体(.p12)を扱う機会がありました。かなり苦しんだので、触っていく中で見つけた諸々を備忘録として書き残しておきます。なお、実物には当然ながら法人のクレデンシャルが含まれているので、商号、代表者氏名、シリアル番号、Subject ",{"id":140,"createdAt":141,"updatedAt":142,"publishedAt":142,"revisedAt":142,"title":143,"content":144,"tags":145,"is_no_index":45,"summary":148},"j7zvrsp48lb","2026-05-04T22:43:43.362Z","2026-05-05T00:07:59.619Z","Tailscaleやめたい","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>Tailscaleはネットワークの知識がない人が使うものだと思っていて、内心ずっと冷笑しているのですが、悪い噂をあまり聞かないので本当に悲しいです。用途に応じて使い分けろという話なんだろうとは思います。思いますが、他人の環境にアクセスしたいときに「まずTailscaleを入れて〜」みたいなことを言われると本当に嫌な気持ちになります。\u003C/p>\u003Cp>\u003Cs>ちなみに私も使っているので、あまり人のことを言えた義理ではありません。\u003C/s>\u003C/p>\u003Cp>便利なのは認めます。\u003Ccode>tailscale up\u003C/code>一発で直接インターネットへの疎通性を持たないホストに外から入れるのは素直に便利ですし、ちょっとしたマネジメント用途にはすごく使えるものだと思います。ただ、この便利は、OSのリゾルバ, netfilter, routing table他諸々に対する全力の侵襲と引き換えに成立しています。問題は、その侵襲の質が悪いことで、本当にストレスが溜まります。\u003C/p>\u003Cp>本エントリでは、これまでTailscaleを使ってきた中で頭にきたことを書いていきます。既に修正されたものもありますし、自分の設定が悪いだけのものも混ざっているかもしれません。それでも踏んだ事実は事実なので、忘れないうちに文句を言っておくことにします。\u003C/p>\u003Ch1 id=\"h89f5d63c3c\">resolv.confの扱い\u003C/h1>\u003Cp>tailscaledは起動するたびに\u003Ccode>/etc/resolv.conf\u003C/code>を書き換え、MagicDNS(100.100.100.100)をnameserverの先頭に挿入します。これがsystemd-resolvedとの相性が破滅的に悪い。\u003C/p>\u003Cp>systemd-resolvedはfollower modeで起動すると\u003Ccode>/run/systemd/resolve/resolv.conf\u003C/code>を生成します。tailscaledが事前に挿した100.100.100.100がここに混ざり、それを起動時の状態として読み戻し、upstreamのDNSの一つだと誤認して自身へ向けてDNSクエリを延々と転送し始めます。結果、内部キューが埋まってDNSが無事に死にます。\u003C/p>\u003Cp>これはGitHub上にもAmazon Linux環境での事象が報告されていて、報告者がtailscaleの中の人。タイトルが｢DNS stops working, and everything is very sad｣。very sadなのはこっちなんですけどね。あなたは知っててなぜ直さないんです？？？\u003C/p>\u003Cp>検出ロジックも杜撰で、systemd-resolvedかどうかを \u003Ccode>resolv.conf\u003C/code>のシンボリックリンク先のファイル名だけで判定しています。\u003Ccode>/etc/resolv.conf\u003C/code>が\u003Ccode>stub-resolv.conf\u003C/code>ではなくlegacy側にリンクされている場合、systemd-resolvedの存在に気づかず、direct モードに落ちてしまいます。諸々の組み合わせ全部を正しくハンドルしようとして、当然のように全部カバーできていません。これについて、tailscale公式ブログが“The Sisyphean Task Of DNS Client Config on Linux”と銘打って解説していますが、タイトルで自虐している時点で察してほしいです。\u003C/p>\u003Ch1 id=\"h77d9c93ff7\">RFC違反のDNS実装\u003C/h1>\u003Cp>resolv.confの話については、LinuxのDNS事情が混沌としてるのである程度同情の余地があります。しかし、tailscaleのDNSプロキシ実装そのものがRFCに準拠できていないのはどうにもなりません。\u003C/p>\u003Cp>EDNSのOPTレコードを完全無視します。クライアントがUDPのbuffer sizeを512 byteと広告していても、690 byte返してきます。RFC 6891がresponder MUST NOT exceed the requestor&apos;s buffer sizeと書いているものを、堂々とMUST NOT違反します。Cloudflareの子はちゃんとTC bitセットしてTCPフォールバックを促すのに、tailscale DNSはそれすらしません。\u003C/p>\u003Cp>DNS Flag Day 2020違反もあって、1232 byteを超えてもtruncateせずIPフラグメンテーションを起こします。上流からTC bit付きで返ってきても自分自身がTCPフォールバックできません。\u003C/p>\u003Cp>おまけに、EDNS Client Subnetを勝手にstripして米国IPに置換する挙動もあります。日本から繋いでるのにCDNが米国エッジに飛ばしてきます。MagicDNSを通すだけでレイテンシが100ms乗ります。VPN通すと遅くなるVPN、本当になんなんですか。\u003C/p>\u003Cp>極めつけがEDNS有無でキャッシュキーが分かれるsplit-brain cacheです。digとnslookupで同じドメインに対して別々の結果が(永続的に)帰ってきます。うーん困った。\u003C/p>\u003Cp>ちなみに文句はまだまだあって、tailscaledは \u003Ccode>resolv.conf\u003C/code>のsearch domainは保持するくせに、\u003Ccode>options ndots:5\u003C/code>を完全に剥がす振る舞いをします。Kubernetes環境なんかだとこれが致命的で、Pod内\u003Ccode>ping kuard.default\u003C/code>がbad addressで死んだ記憶があります。\u003C/p>\u003Ch1 id=\"h6d76dc14aa\">100.64/10をハードコードで全部drop\u003C/h1>\u003Cp>これが一番頭に来てる話です。\u003C/p>\u003Cp>ご存知の通り、tailscaleはtailnetのレンジとして100.64.0.0/10を使っています。曰く、「ISP用に予約された帯だから他のネットワークとぶつからない」らしい。確かにRFC 6598の定める100.64/10はService Provider向けのShared Address Spaceで、RFC 1918のものとは区別されています。とはいえ、インターフェース間のNATを介する用途であれば使用可能と明記されており、実際そう使っている方も多いのではないでしょうか。\u003C/p>\u003Cp>10/8と172.16/12がVPNやKubernetes Pod CIDRやDocker bridgeで埋め尽くされてる現状、まともにアドレス計画を立てたネットワークが100.64/10に逃げるのは普通の選択です。うちもそうしてます。自宅も会社もそう。ISPだけが使えるという前提がそもそも成り立っていません。\u003C/p>\u003Cp>それで、tailscaleを起動するとts-inputチェーンに\u003Ccode>DROP all -- !tailscale0 * 100.64.0.0/10 0.0.0.0/0\u003C/code>が挿入されます。tailscale0以外から来た100.64/10宛のパケットを全部drop。こちらが普通に内部で使ってる100.64/10宛の通信をtailscaleが勝手に殺してきます。困りますね。\u003C/p>\u003Cp>しかもこのDROPルール、tailnet policyで自分の使う範囲を100.65.64.0/22のように狭く指定しても、生成されるルールは依然として100.64.0.0/10全域です。コードを見ると\u003Ccode>util/linuxfw/nftables_runner.go\u003C/code>の\u003Ccode>addDropCGNATRangeRule\u003C/code>と\u003Ccode>createDropOutgoingPacketFromCGNATRangeRuleWithTunname\u003C/code>がそれぞれ別の方法で同じ範囲をルール化していて、片方は文字列正規化、もう片方は生の\u003Ccode>netip.Prefix\u003C/code>を利用しているようで。実装が二箇所に分散してる時点で察してほしいです。\u003C/p>\u003Cp>ちなみに、｢100.100.0.0/20で社内SSH運用してたサーバにtailscaleをインストールしたら、無条件で100.64/10全域DROPルールが入ってSSHを含むLAN通信が全切断、リモートからアクセス不能｣という事例がissueに上がっています。\u003C/p>\u003Cp>\u003Ca href=\"https://github.com/tailscale/tailscale/issues/12829\" target=\"_blank\" rel=\"noopener noreferrer\">https://github.com/tailscale/tailscale/issues/12829\u003C/a>\u003C/p>\u003Cp>これ、2024年7月起票で現在もneeds-triageラベルのままopenです。triageもされてない。1年半以上。\u003C/p>\u003Cp>この回避策が\u003Ccode>--netfilter-mode=off\u003C/code>ですが、これはtailscale自身が公式ドキュメントでセキュリティリスクだと書いているフラグで、もうどうしようもないんじゃないの感があります。苦しい。\u003C/p>\u003Ch1 id=\"h729e032ab6\">iptablesの介入がすごく頭悪い\u003C/h1>\u003Cp>\u003Ca href=\"https://github.com/tailscale/tailscale/issues/320\" target=\"_blank\" rel=\"noopener noreferrer\">tailscaleの中の人が起票したissue\u003C/a>の本文がすごいです。\u003C/p>\u003Cblockquote>\u003Cp>The way we add iptables forwarding rules on Linux was an old hack that works for approximately nobody, except by coincidence. Refers specifically to an interface named ‘eth0’ which is very uncommon. Get re-added (but never deleted) every time the app starts. Creates a wildcard MASQUERADE rule rather than tight settings.\u003C/p>\u003C/blockquote>\u003Cp>これが、tailscale製品のLinuxサポートの土台です。中の人がそう書いています。\u003C/p>\u003Cp>それから、tailscaledは起動時にINPUT/FORWARDの先頭に\u003Ccode>ts-input\u003C/code>, \u003Ccode>ts-forward\u003C/code>を挿入し、POSTROUTINGのNATにts-postroutingを挿入します。これも普通の挙動っぽいですが、周期的に再配置して常に先頭にいるよう書き換えてくるのが本当にお行儀が悪い。iptables-restoreでルール順を組んでも、firewalldで管理しても、ufwで管理しても、tailscaleが定期的に後ろから殴って先頭に出てきてしまいます。\u003Ccode>--netfilter-mode=nodivert\u003C/code>を明示しようと周期再配置は止まりません。ユーザはホストのFWを管理してる気になっていますが、実際の支配権はtailscaleにあります。\u003C/p>\u003Cp>おまけにfwmarkも雑で、Calicoの上位16bitの使用とtailscaleの\u003Ccode>0x40000\u003C/code>, \u003Ccode>0x80000\u003C/code>が衝突し、CalicoのeBPFモードで\u003Ccode>Drop malformed IP packets Final result=DENY\u003C/code>が大量発生します。\u003C/p>\u003Ch1 id=\"hce2d3190fc\">MTU 1280ハードコード\u003C/h1>\u003Cp>\u003Ccode>tailscale0\u003C/code>のMTUは1280でハードコードされています。GCPの1460も、AWSの1500も、ジャンボフレームの9000も、全部関係なく1280です。\u003C/p>\u003Cp>WireGuardはICMP Frag Neededを信用しない実装で (DoS耐性のためらしい？)、まともなPath MTU Discoveryがありません。\u003Ccode>tailscale0\u003C/code>をさらにVXLANやWireGuardで重ねると実効MTUがさらに下がります。CiliumのVXLAN MTU 1280 + WireGuard 60 + tailscale 60で実効MTU ~1140になり、1252 byteのTLS Server Helloがちょうど切れる位置に来てdropされます。原因がMTUだと気付くのに半日。おかげさまで貴重な時間を無駄に過ごすことができました。\u003C/p>\u003Cp>私が知る限り、MTUを変える手段は存在しません。VPN製品でユーザにTCP MSS clampを要求するの、設計としてどうかと思います。\u003C/p>\u003Ch1 id=\"he6e50cfa9d\">ACLでDenyを明示できない\u003C/h1>\u003Cp>tailscaleのACLはdefault-denyを謳いながら、actionはacceptしか書けず、個別ルールでdenyを表現できません。\u003C/p>\u003Cp>一般的なFWアプライアンスであれば、\u003C/p>\u003Cpre>\u003Ccode>allow ssh from trusted_hosts\ndeny ssh\nallow * from any\u003C/code>\u003C/pre>\u003Cp>で「SSHは信頼できるホストからだけ、それ以外のSSHは拒否、他のポートは全公開」のようなものが書けます。しかし、tailscale ACLではこれが書けません。これは、最後の\u003Ccode>allow *\u003C/code>がSSH制限を上書きしてしまうためです。\u003C/p>\u003Cp>「\u003Ccode>tag:A\u003C/code>を\u003Ccode>tag:B\u003C/code>以外からブロック」を表現したいだけで、新タグを追加するたびにsrcリストを全書き換えする必要がでてきてしまい、かなりつらい。\u003C/p>\u003Cp>しかも\u003Ccode>acls\u003C/code>フィールドを省略するとdefault allow all。default-denyを謳いながら、書き忘れたtailnetは実質全許可。default-denyって言葉はどこに行ったんでしょうか？\u003C/p>\u003Cp>capability-basedの設計判断として一貫してるのは分かります。ただ、ACLを名乗りながら一般的なFWの動作と全く別物を提供するのはいただけない。\u003C/p>\u003Ch1 id=\"h507404909b\">SSOを強制\u003C/h1>\u003Cp>これは半分私の好みの話ですが。Tailscaleはアカウント作成にSSOを強制してきます。メールアドレスとパスワードでサインアップという選択肢がそもそも存在していません。\u003C/p>\u003Cp>個人で気軽に使う分にはGoogleで入れば済むので困らない〜みたいな主張をされがちですが、一段冷静に考えるとこれは結構な縛りです。tailnet全体のidentityが特定のIdPに紐付くということは、そのIdPアカウントが消えてしまった瞬間にtailnetから締め出されるということに他なりません。Googleアカウントが何らかの理由でロックされたら自分のホストにすら入られなくなる、というのを許容するかどうかは、判断する余地があって然るべきでしょう。しかしながら、Tailscaleはその判断を許してくれません。\u003C/p>\u003Cp>業務で使う場合だと話はさらに面倒で、「組織のGoogle Workspaceでサインアップしたが、退職時に組織のtailnetから個人を切り離したい」のような普通のユースケースがSSO強制のせいで歪な手順になります。tailnetを組織と個人で分けようとすると、別IdPを用意する必要があり、IdPの運用負担がVPNの運用負担に乗ってきます。VPNにIdPの寿命を縛られる、という構造そのものがおかしい。\u003C/p>\u003Cp>｢OIDC対応があるからセルフホストIdPでもいい｣という反論はあると思います。あると思いますが、家庭でVPNを立てたいだけの人間にKeycloakを建てさせるのは、もはやVPNの話ではありません。「VPNを使うために先にIdPを建てろ」という主張はそもそもの順序が逆です。\u003C/p>\u003Cp>そもそもTailscaleのcontrol planeはクローズドソースで、coordination serverに何を握られているかをユーザは検証できません。その上でidentityまでIdP経由で渡すことを強制される。便利の対価として、tailnetの存在条件をTailscale社とIdP事業者の二者に握られるのが現状です。Headscaleを建てれば回避できるのはそう。そうなんですが、それはTailscaleをやめることとほぼ同義でしょう。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>それでも私はたぶん明日もTailscaleを使います。文句を言いながら使うのが一番楽だからです。wg-quickの設定ファイルを書いて鍵を配って繋がらないと喚いている時間より、Tailscaleに苦しめられる時間の方がまだ短いというだけの話であって、これをTailscaleが優れていると言っていいのかはまた別のお話です。\u003C/p>\u003Cp>Tailscaleが約束する“It just works”は、自分のネットワークのどこにTailscaleが侵入しているかを完全に把握した人にだけ成立する魔法のようなものです。そもそも完全に把握している人は態々Tailscaleを使う理由はないでしょう。Tailscaleの“It just works”は、Tailscaleを使う理由がなくなった人にだけ動作する素晴らしい設計です。よくできていますね。\u003C/p>\u003Cp>いい感じの代替を知っている方がいれば是非教えてほしいです。それでは。\u003C/p>\u003Cp>\u003C/p>",[146,147],{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":17,"revisedAt":18,"slug":19,"name":20},{"id":58,"createdAt":59,"updatedAt":60,"publishedAt":59,"revisedAt":60,"slug":61,"name":62},"はじめにTailscaleはネットワークの知識がない人が使うものだと思っていて、内心ずっと冷笑しているのですが、悪い噂をあまり聞かないので本当に悲しいです。用途に応じて使い分けろという話なんだろうとは思います。思いますが、他人の環境にアクセスしたいときに「まずTailscaleを入れて〜」みたいなこ",{"id":150,"createdAt":151,"updatedAt":152,"publishedAt":152,"revisedAt":152,"title":153,"content":154,"tags":155,"is_no_index":45,"summary":159},"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>",[156,157,158],{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":17,"revisedAt":18,"slug":19,"name":20},{"id":99,"createdAt":100,"updatedAt":101,"publishedAt":100,"revisedAt":101,"slug":102,"name":103},{"id":28,"createdAt":29,"updatedAt":30,"publishedAt":29,"revisedAt":30,"slug":31,"name":32},"はじめにどうも、わたしです。最近、というかここ数年、タイムラインを眺めていると、「プログラミングを始めました！」「LLMでアプリを作りました！」みたいな投稿を頻繁に目にするようになりました。それ自体はとても素晴らしいことだと思いますし、新しく何かを始めようとしている人を否定するつもりは毛頭ありません",{"id":161,"createdAt":162,"updatedAt":163,"publishedAt":163,"revisedAt":163,"title":164,"content":165,"tags":166,"is_no_index":45,"summary":168},"7xdhut0q03","2026-04-20T22:00:47.483Z","2026-04-20T22:06:59.556Z","個を個として","\u003Cp>どうも、わたしです。\u003C/p>\u003Cp style=\"text-align: start\">どうにも眠れない夜です。仕方がないので、思ったことを色々ぼんやりと書いています。\u003C/p>\u003Cp style=\"text-align: start\">先日、誰かと話していて「属性で括るのは良くないよね」と言いかけました。ギリ踏みとどまって、言いませんでした。世の中的には、たぶん正しい主張なのだと思います。\u003C/p>\u003Cp style=\"text-align: start\">私は、偉そうな初学者が嫌いです。傲慢な人間が嫌いです。知性の乏しい人間が嫌いです。これらをこうして並べている時点で、私はそれらを思いっきり広義の属性で括っています。括ることをやめて、これらの嫌悪を表現する方法を、生憎と私は持ち合わせていません。\u003C/p>\u003Ch1 id=\"hc544c60f77\">カテゴライズから逃れられないらしい\u003C/h1>\u003Cp style=\"text-align: start\">聞くに、人間の認知というのは、結局のところカテゴライズを通してしか働かないようにできているらしいです。目の前にいる存在を猫として認識した瞬間、過去に出会ったすべての猫と、これから出会うかもしれない猫たちと、目の前のその子を、同じラベルの中に押し込めている。猫の名を持つラベルなしに、その存在を指し示そうとすると、認知が成り立たなくなってしまう。\u003C/p>\u003Cp style=\"text-align: start\">であるから、｢属性で括るな」という主張を文字通りに受け取ろうとすると、それは概ね「認知をやめろ」と言っているのと大差無いものだと思います。\u003C/p>\u003Ch1 id=\"h7cbc3267a6\">現象に名前を付けているだけ\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\">極論を言ってしまうと、固有名詞ですら例外ではなく、私が私を呼んだ瞬間、私は私を私として、ひとつの同一性に押し込めているという解釈もできます。昨日の私と今日の私は厳密には違う状態にある(べき)はずなのに、ラベルがそれを連続体として扱わせているためです。指示語ですら、世界を指されたものと指されていないものに分けてしまう。\u003C/p>\u003Cp style=\"text-align: start\">言語そのものが世界を切り分けるための都合のいい道具であると私は考えます。だとすれば、属性で括らない表現というものは、原理的に存在し得ないのではないでしょうか。\u003C/p>\u003Ch1 id=\"h057b7d27d3\">多様性\u003C/h1>\u003Cp style=\"text-align: start\">すごく嫌な世の中になったもので、最近、多様性だとか個性の尊重だとか配慮だとか、そういう言葉が粗雑にあちこちで掲げられているのを目にします。\u003Cs>私には到底理解できませんが、\u003C/s>それ自体は多分良いことなのでしょう。\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\">しかし、現実としては殆どそうはなっていません。属性で雑に括った上で「あなたのその属性を尊重します」と不誠実な嘘をついているだけ。そんなものは、個の受容ではなく属性の追認に過ぎないのではないでしょうか。個を個として受け止められない多様性は、その時点でもう破綻しています。破綻したものを、そのまま尊重する必要があるとは、到底私には思えません。\u003C/p>\u003Cp style=\"text-align: start\">私は目の前に立つ人間をひとりのあなたとして受け止めたいと思っています。しかし、それは決して簡単なことではなくて、私自身できているとは到底言えません。その辺の何も考えてないようなヘンテコ人間が偉そうに多様性について講釈を垂れる立場にあるのかというと、本当に疑わしく思います。\u003C/p>\u003Ch1 id=\"h0fb99d999f\">無関心\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\">私の中での認識は概ねこういう整理です。ラベル自体は認知の道具として使い続けるしかない。しかし、そのラベルを根拠に他人を裁定しに行かない、評価しに行かない、わかった気にならない。道具として使うことと、道具を振り回して他人をボコボコに殴ることは別の話だと思っています。\u003C/p>\u003Cp style=\"text-align: start\">紛うことなき自画自賛ですが、これはある種の優しさなのだと思います。少なくとも、雑なラベリングで他人を撫でくり回すよりは、余程マシな振る舞いなのではないかと。\u003C/p>\u003Ch1 id=\"h6799f15c52\">返戻\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\">引き受けた上で、自分のラベルの粗さを省みたり顧みなかったりする。(誤字じゃないですよ)\u003C/p>\u003Cp style=\"text-align: start\">実行可能な範疇で誠実であろうと思います。\u003C/p>",[167],{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":17,"revisedAt":18,"slug":19,"name":20},"どうも、わたしです。どうにも眠れない夜です。仕方がないので、思ったことを色々ぼんやりと書いています。先日、誰かと話していて「属性で括るのは良くないよね」と言いかけました。ギリ踏みとどまって、言いませんでした。世の中的には、たぶん正しい主張なのだと思います。私は、偉そうな初学者が嫌いです。傲慢な人間が",{"id":170,"createdAt":171,"updatedAt":172,"publishedAt":173,"revisedAt":172,"title":174,"content":175,"tags":176,"is_no_index":45,"summary":179},"l_gwwt21az06","2026-04-18T08:31:28.769Z","2026-04-18T18:16:15.075Z","2026-04-18T08:36:42.914Z","20歳になりました","\u003Cp>どうも、わたしです。\u003C/p>\u003Cp style=\"text-align: start\">早いもので、とうとう先日20歳になってしまいました。世間一般で言えば立派な大人の仲間入りということになるのでしょうが、自認はまだまだ5歳児です。時間の流れだけは残酷なほど速く過ぎ去っていくので、ただただ戸惑うばかりです。\u003C/p>\u003Cp style=\"text-align: start\">思い返せば高校時代、卒業した後の自分の姿なんて、微塵も想像していませんでした。当時の私にとって、あの閉鎖的で代わり映えのしない日々の生活が世界の全てでした。その先の未来なんてものは想像すらできず、ぱっと死んでしまうような、そんな刹那的な感覚すら抱いていたのかもしれません。\u003C/p>\u003Cp style=\"text-align: start\">しかし現実は無慈悲に続くもので、時を経て、高校を卒業してからあっという間に2年の月日が経過してしまいました。\u003C/p>\u003Cp style=\"text-align: start\">相変わらず1人怠惰な日々を送っています。20歳になったからといって、急に視界が開けたり、精神的に成熟したり、劇的に何かが変わるわけでもありません。明日からもきっと似たり寄ったりの、良くも悪くもつまんない日々を淡々と消化していくんだと思います。\u003C/p>\u003Cp style=\"text-align: start\">数年前の民法改正によって、成人年齢自体は18歳に引き下げられました。しかし、お酒やタバコ、各種賭博等の規制は依然として20歳がボーダーとして残されています。\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\">選んだのはほろよいの桃っぽい甘いやつ。\u003C/p>\u003Cp style=\"text-align: start\">「初飲酒とはいえ、アルコール3%だしいけるっしょ」と、完全に高を括っていたのですが、結果は無惨な敗北でした。\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\">これは冗談です。あしからず。\u003C/p>\u003Cp style=\"text-align: start\">とまあ、そんなどうしようもない20歳の幕開けを迎えたわけですが、私の日常自体はこれまでと何一つ変わっていません。\u003C/p>\u003Cp style=\"text-align: start\">そういえば、すっかり書き忘れていましたが、またMisskeyインスタンスを建てました。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://misskey.blue/\">https://misskey.blue/\u003C/a>\u003C/p>\u003Cp style=\"text-align: start\">過去に文句を散々書き連ねておきながら、本当に性懲りもない人間だなと自分でも呆れています。喉元過ぎれば熱さを忘れるとはよく言ったもので、ふと手持ち無沙汰になると、あの空気が恋しくなってしまうようです。\u003C/p>\u003Cp style=\"text-align: start\">今回はVultrを借りて、2 vCPUs / 4GiB のインスタンス上にDockerで構築しています。ただ、分かってはいたことですが、Misskeyを動かすにはこのスペックだとリソース的にかなりカツカツで厳しいのが現状です。要求されるリソースがそれなりに重いため、常にswapと睨めっこしながらの綱渡り状態を強いられています。\u003C/p>\u003Cp style=\"text-align: start\">快適さを求めるのであればさっさとスケールすべきなのですが、一つ上のプランに引き上げるとなると、支払い額ほぼ2倍に跳ね上がるようで、十数人程度しかいないインスタンスにその出費を受け入れるかと言われると、どうしても「うーん…」と躊躇してしまうのが正直なところです。\u003C/p>\u003Cp style=\"text-align: start\">現状は、仲のいい子を数人呼んでひっそりと運用しているだけです。ただ、やはり少人数だとどうしてもLTLの動きが鈍く、わちゃわちゃとした楽しさに欠けるというか、純粋にTLが寂しいんですよね。\u003C/p>\u003Cp style=\"text-align: start\">もっと人を呼んでLTLを賑やかにしたいという思いは山々ですが、公開鯖にしてしまうと、かつてと同じ轍をまた踏む未来が容易に想像できてしまいます。\u003C/p>\u003Cp style=\"text-align: start\">人、ほしい。\u003C/p>\u003Cp style=\"text-align: start\">それでは\u003C/p>",[177,178],{"id":22,"createdAt":23,"updatedAt":24,"publishedAt":23,"revisedAt":24,"slug":25,"name":26},{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":17,"revisedAt":18,"slug":19,"name":20},"どうも、わたしです。早いもので、とうとう先日20歳になってしまいました。世間一般で言えば立派な大人の仲間入りということになるのでしょうが、自認はまだまだ5歳児です。時間の流れだけは残酷なほど速く過ぎ去っていくので、ただただ戸惑うばかりです。思い返せば高校時代、卒業した後の自分の姿なんて、微塵も想像し",{"id":181,"createdAt":182,"updatedAt":183,"publishedAt":183,"revisedAt":183,"title":184,"content":185,"tags":186,"is_no_index":45,"summary":189},"jucf8gnoagb4","2026-04-09T04:58:35.980Z","2026-04-09T05:42:14.678Z","LLMがどれだけ優れていても、つまらない人間はつまらない","\u003Cp>最近、タイムラインを眺めていると、「LLMを使いこなせるのは一部の賢い連中だ」のような言説をよく目にします。\u003Cbr>確かにそれは一面の真実なのでしょうが、実際のところそこに存在しているのは、中身が空っぽな人間ほど、LLMによってガワから見た能力が派手に底上げされるという、極めて残酷なまでの非対称性でしょう。世間では知性の民主化などという美辞麗句が踊っていますが、その実態は単なる能力の底上げなどではなく、論理的な思考回路を持たない人間が高度な知性を出力として模倣し、あたかも自分の実力であるかのように偽装できてしまうという、歪な構造的欠陥に他ならないと感じています。\u003C/p>\u003Cp>たとえば、専門知識も論理的訓練も受けていない人間が、洗練された論理構築が可能な大規模言語モデルを使用したとしましょう。その間に生じる知的な解像度の乖離は、もはや対話すら成立しない絶望的な断絶です。認知の深さが一定以上異なれば、前提とする論理のレイヤすら共有できないのが常ですが、その圧倒的な差がある知性をあたかも自分の手足のように操っていると錯覚した人間がどうなるかは、想像に難くありません。本人は、LLMが吐き出したその高度で緻密な内容を、論理的に咀嚼し、真に理解することすら土台不可能なのです。内容を検証する力がない以上、彼らにとってLLMの出力は疑う余地のない神託へと昇格し、それを引き出した自分までもが全知全能の存在にでもなったかのような致命的な自己肥大に陥るわけです。\u003C/p>\u003Cp>一方で、もともと高度な専門性を備えた人間が高度なLLMを使ったところで、そこにある能力の差はそれほど大きくはありません。彼らにとってLLMは24/365で文句ひとつ言わずに稼働し、要求に対して及第点の成果を出す便利な手下程度の認識に収まるでしょう。彼らは出力される情報の裏にある限界や、統計的なもっともらしさの脆さを理解しており、自分の知性と照らし合わせながらその境界線を慎重に引くことができます。\u003C/p>\u003Cp>しかし、思考の基盤を持たない層にとって、LLMの出力は検証不可能な神託そのものとして機能してしまいます。LLMの出力を論理的に検証するだけの批判的思考力も、背景にある膨大な知識体系も欠如しているため、出力された内容を本質的に理解することも、その正当性を疑うことも、ましてや学術的な反証を試みることもできません。結果として彼らは、自分が突然、森羅万象を司る全知全能の存在にでも昇華されたかのような、致命的な自己肥大に陥るわけです。\u003C/p>\u003Cp>これは将棋のルールすら怪しい初心者が、将棋ソフトの最善手と言っている提案をただ無批判に盤上へ再現し、それでプロに勝利して「自分の才能がようやく世界に追いついた」と本気で悦に入っているような、極めて滑稽で厚顔無恥な喜劇です。こうしたLLMによって底上げされた無能たちは、いまやLLMとの対話で得た真理(のようなもの)という名のゴミを誇らしげに掲げ、あらゆる専門分野へ土足で自信満々に侵入を開始しています。彼らの発言は驚くほど定型化されており、正直見ていて反吐が出ます。\u003C/p>\u003Cp>彼らの常套句はこうです。\u003Cbr>「俺はついに、世界を根底から覆す画期的な新理論を発見してしまった。AIがこれは100%本物だと言っている」だとか、「これは複数の最高峰モデルをn時間以上も激論させ、n万円分ものコストを費やしてようやく抽出に成功した究極原理だ」といった具合です。さらに性質の悪いことに、「この理論は常識に縛られた凡人には理解しにくいかもしれないが、AIはこの独創的かつ鋭い着眼点を称賛していた」などと宣い、あたかも自分だけがLLMという高次元の知性と精神的に共鳴できる、選ばれし預言者であるかのように振る舞うのです。\u003C/p>\u003Cp>彼らは「天才たちが最後まで言語化できずにいた核心を、自分だけがついに最も明晰な形で取り出した」と語りますが、その中身を解剖すれば、そこにあるのはLLMが確率論に基づいて繋ぎ合わせた、耳当たりの良い単語のパッチワークに過ぎません。ここ数年、こうしたLLM製のプロパガンダを武器に各所のコミュニティを荒らし回る事例が散見されますが、それは知性に対するこの上ない侮辱であり、文明的な対話の破壊活動に等しいものです。自分の脳内で再構築もできず、論理的な因果関係を自らの言葉で説明すらできないのであれば、その借り物の羽で他者を威圧する行為がどれほど恥べきことか、いい加減自覚すべきでしょう。\u003C/p>\u003Cp>これからのLLM全盛時代において、真に求められる能力とは、出力結果を鵜呑みにして全知全能感に浸ることではありません。分からないことを、分からないままの状態として脳内に保留できる力、自分の理解が及ばない領域を正確に定義するメタ認知そのものです。LLMがどれほど尤もらしい真理を提示したところで、それが自分の論理として肉体化されていないのであれば、それは単なる無意味な記号の羅列に過ぎません。\u003C/p>\u003Cp>結局のところ、彼らは自分たちが楽をすることしか考えておらず、その思考停止のツケを未来の誰かが払わされることを全く想像できていない想像力の欠如こそが、彼らが無能であることの最大の証明ではないでしょうか。全知全能(笑)に酔いしれるのは勝手ですが、その酔いが覚めたときに鏡の前に立っているのが、言葉の重みすら計ることのできない空虚な自分自身であるという事実に、彼らはいつ直面することになるのでしょうか。\u003C/p>\u003Cp>まあ、温室の中で空虚ささえも心地よい万能感として消費し続けるのが、彼らにとってのしあわせなのかもしれませんね。非常に気持ちが悪いので消えてほしいですが。\u003C/p>\u003Cp>それでは。\u003C/p>",[187,188],{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":17,"revisedAt":18,"slug":19,"name":20},{"id":28,"createdAt":29,"updatedAt":30,"publishedAt":29,"revisedAt":30,"slug":31,"name":32},"最近、タイムラインを眺めていると、「LLMを使いこなせるのは一部の賢い連中だ」のような言説をよく目にします。確かにそれは一面の真実なのでしょうが、実際のところそこに存在しているのは、中身が空っぽな人間ほど、LLMによってガワから見た能力が派手に底上げされるという、極めて残酷なまでの非対称性でしょう。",{"id":191,"createdAt":192,"updatedAt":193,"publishedAt":193,"revisedAt":193,"title":194,"content":195,"tags":196,"is_no_index":45,"summary":199},"5nf10o2ud0j","2026-04-02T14:00:30.562Z","2026-04-02T14:01:54.127Z","ねこちゃんをお迎えしました2","\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>以前から検討していたのですが、新しく2匹目のねこをお迎えすることにしました。\u003Cbr>今回お迎えしたのは、ノルウェージャンフォレストキャットの女の子(7ヶ月齢)です。名前は「ラテ」にしました。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/3204c913ca2a4c6b81fcf3660d7af26b/beauty_1774772499640.jpeg\" alt=\"\" width=\"1650\" height=\"928\">\u003C/figure>\u003Cp>一般的にはもっと幼い時期にお迎えするケースが多いかと思いますが、あえてこの月齢の子を選びました。\u003C/p>\u003Cp>7ヶ月ともなると、性格もかなり安定していますし、体調面での不安も少ないです。ノルウェージャンらしい立派な飾り毛や毛吹きも既に出始めており、成猫になった時の完成形がイメージしやすかったのも決め手の一つでした。\u003C/p>\u003Cp>先住猫のモカちゃんは、お迎え当日に添い寝をしてくれるほどの驚異的な適応能力を持っていました。対して今回のラテちゃんは、初日こそ少し慎重でしたが、数時間もすれば部屋の探索を始めるなど、なかなかの図太さを見せてくれています。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/bb54aa22e13446e79786cc85f973296d/beauty_1774942744631.jpeg\" alt=\"\" width=\"1650\" height=\"928\">\u003C/figure>\u003Cp>先住猫のモカちゃんは、ご縁があって譲っていただいた子だったので、実質的な生体価格というものは発生していませんでした。\u003Cbr>しかし、今回は順当な？ルートでお迎えしたため、総額で25万円近くかかりました。\u003C/p>\u003Cp>猫を購入する経験がなかった身からすると、この金額には正直かなりの衝撃を受けています。支払う瞬間に一瞬手が止まるくらいの重みはありましたが、まあ、お迎えしてしまったものは仕方がありません。\u003Cbr>これからこの2匹が仲良く並んで寝てくれる日を目標に、しっかり面倒を見ていこうと思います。\u003C/p>\u003Cfigure style=\"text-align: left;\">\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/106405f266634defae61fc31b18da359/beauty_1774881852261.jpeg\" alt=\"\" width=\"1650\" height=\"928\">\u003C/figure>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/a5212af8ab2548c6b4fd0abe6d7476d0/beauty_1774869951812.jpeg\" alt=\"\" width=\"3840\" height=\"2160\">\u003C/figure>",[197,198],{"id":22,"createdAt":23,"updatedAt":24,"publishedAt":23,"revisedAt":24,"slug":25,"name":26},{"id":84,"createdAt":85,"updatedAt":86,"publishedAt":85,"revisedAt":86,"slug":87,"name":88},"どうも、わたしです。以前から検討していたのですが、新しく2匹目のねこをお迎えすることにしました。今回お迎えしたのは、ノルウェージャンフォレストキャットの女の子(7ヶ月齢)です。名前は「ラテ」にしました。一般的にはもっと幼い時期にお迎えするケースが多いかと思いますが、あえてこの月齢の子を選びました。7",{"id":201,"createdAt":202,"updatedAt":203,"publishedAt":203,"revisedAt":203,"title":204,"content":205,"tags":206,"is_no_index":45,"summary":212},"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>",[207,208,209,210,211],{"id":40,"createdAt":41,"updatedAt":42,"publishedAt":41,"revisedAt":42,"slug":43,"name":44},{"id":99,"createdAt":100,"updatedAt":101,"publishedAt":100,"revisedAt":101,"slug":102,"name":103},{"id":28,"createdAt":29,"updatedAt":30,"publishedAt":29,"revisedAt":30,"slug":31,"name":32},{"id":34,"createdAt":35,"updatedAt":36,"publishedAt":35,"revisedAt":36,"slug":37,"name":38},{"id":58,"createdAt":59,"updatedAt":60,"publishedAt":59,"revisedAt":60,"slug":61,"name":62},"はじめにどうも、わたしです。前回の記事では、Mewkのアーキテクチャやインフラ構成、OGP画像生成、モデレーションまわりの話を書きました。「まあ自分の記録として残しておければいいか」くらいの温度感で書いたもので、読んでくれる人がいるだけで御の字だと思っていましたが、思っていたより反応をもらえました。",{"id":214,"createdAt":215,"updatedAt":216,"publishedAt":216,"revisedAt":216,"title":217,"content":218,"tags":219,"is_no_index":45,"summary":225},"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>",[220,221,222,223,224],{"id":40,"createdAt":41,"updatedAt":42,"publishedAt":41,"revisedAt":42,"slug":43,"name":44},{"id":99,"createdAt":100,"updatedAt":101,"publishedAt":100,"revisedAt":101,"slug":102,"name":103},{"id":28,"createdAt":29,"updatedAt":30,"publishedAt":29,"revisedAt":30,"slug":31,"name":32},{"id":34,"createdAt":35,"updatedAt":36,"publishedAt":35,"revisedAt":36,"slug":37,"name":38},{"id":58,"createdAt":59,"updatedAt":60,"publishedAt":59,"revisedAt":60,"slug":61,"name":62},"はじめにどうも、わたしです。先日、Misskeyユーザ向けの(匿名)質問箱サービスMewkを作りました。https://mewk.app/リリースから1ヶ月少々が経ち、現時点で約2,000ユーザ、7,000件弱の質問が投稿されています。ありがたいことですね。なんでつくったのMisskeyには既存の匿",{"id":227,"createdAt":228,"updatedAt":229,"publishedAt":229,"revisedAt":229,"title":230,"content":231,"tags":232,"is_no_index":45,"summary":236},"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>",[233,234,235],{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":17,"revisedAt":18,"slug":19,"name":20},{"id":28,"createdAt":29,"updatedAt":30,"publishedAt":29,"revisedAt":30,"slug":31,"name":32},{"id":99,"createdAt":100,"updatedAt":101,"publishedAt":100,"revisedAt":101,"slug":102,"name":103},"はじめにどうも、わたしです。最近、自律型LLMエージェントの普及に伴ってか、ローカルの.envファイルから機密情報が漏洩するのではないか、という話題を頻繁に見かけるようになりました。LLMがウェブ検索などを実行した際、悪意のあるサイトからプロンプトインジェクションを受け、意図せずローカルの環境変数を",{"id":238,"createdAt":239,"updatedAt":240,"publishedAt":240,"revisedAt":240,"title":241,"content":242,"tags":243,"is_no_index":45,"summary":245},"tukox0h7-cv","2026-02-22T21:50:32.511Z","2026-02-22T21:53:53.719Z","輪郭","\u003Cp>最近、自分が結局のところ何を考えているのか、よく分からなくなります。考えているようでいて、実は何も考えていないのではないか。そもそも考えるとは何なのだろうか、とふと思うことがあります。\u003C/p>\u003Cp>世の中を見渡すと、多くの人がそれっぽいことを言っています。しかし、どれだけその主張に社会的な妥当性があろうと、そこに本人の実体験や葛藤といった独自の考えが介在していなければ、全部無意味に思えてしまいます。概ねLLMの挙動と大差がありません。\u003C/p>\u003Cp>わたしは、当人の考えが伴わない社会的な正解として消費される主張が嫌いです。例えば、多様性やジェンダーといった文脈で語られる、耳障りのいい言葉で梱包されただけの綺麗事は、非常に浅はかで不快に感じます。勿論、そこに本人の生々しい体験や葛藤が介在していれば話は別なのでしょうが、世の中に溢れる多くのものはそうではないように思えます。\u003C/p>\u003Cp>逆に、仮にそれが社会的に許容されるべきではない極端な主義主張であったとしても、そこに本人の確固たる考えがあるのであれば、わたしはその主張を素敵だと思いますし、耳を傾けたいなと思います。もっとも、こんなことを言っている自分自身も、結局は安全圏から綺麗事を並べているだけなのかもしれないという自覚はあります。これもまた一つの綺麗事なんですけどね。\u003C/p>\u003Cp>自分の浅はかさは、自分がいちばんよく分かっているつもりです。他人を引き合いに出して落とすことでしか自分の優位性を見出せず、他人を見下すことしかできなくなっていて本当に人間性の終わりを感じています。中身に魅力がなさすぎるのでせめて顔くらいはよくありたいと思っています。根本的に視座が低すぎるのかもしれません。\u003C/p>\u003Cp>世に溢れる言葉に違和感を覚えながら、自分の浅さや醜さに葛藤する。綺麗な正解を出せないまま、その自己矛盾や認知の摩擦を抱え続けること自体が、今の自分にとって辛うじて考えている状態を担保しているものなのかもしれません。\u003C/p>\u003Cp>笑い飛ばしてもらえるとうれしいです\u003C/p>",[244],{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":17,"revisedAt":18,"slug":19,"name":20},"最近、自分が結局のところ何を考えているのか、よく分からなくなります。考えているようでいて、実は何も考えていないのではないか。そもそも考えるとは何なのだろうか、とふと思うことがあります。世の中を見渡すと、多くの人がそれっぽいことを言っています。しかし、どれだけその主張に社会的な妥当性があろうと、そこに",{"id":64,"createdAt":65,"updatedAt":66,"publishedAt":67,"revisedAt":66,"title":68,"content":69,"tags":247,"is_no_index":45},[248,249,250],{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":17,"revisedAt":18,"slug":19,"name":20},{"id":58,"createdAt":59,"updatedAt":60,"publishedAt":59,"revisedAt":60,"slug":61,"name":62},{"id":28,"createdAt":29,"updatedAt":30,"publishedAt":29,"revisedAt":30,"slug":31,"name":32},{"id":252,"createdAt":253,"updatedAt":254,"publishedAt":254,"revisedAt":254,"title":255,"content":256,"tags":257,"is_no_index":45,"summary":260},"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>",[258,259],{"id":28,"createdAt":29,"updatedAt":30,"publishedAt":29,"revisedAt":30,"slug":31,"name":32},{"id":99,"createdAt":100,"updatedAt":101,"publishedAt":100,"revisedAt":101,"slug":102,"name":103},"はじめにどうも、わたしです。先日、Twitterを何気なく眺めていたときに相互の子がここのURLを投稿してくれているのが目に留まりました。自分の書いた記事が共有されているのは嬉しいことですが、その投稿を見て一つ気になる点がありました。本来ならアイキャッチ画像が表示されるはずのOGPが、画像なしのグレ",{"id":262,"createdAt":263,"updatedAt":264,"publishedAt":264,"revisedAt":264,"title":265,"content":266,"tags":267,"is_no_index":45,"summary":270},"7pcqwxb_8jvj","2026-01-26T17:51:45.822Z","2026-01-27T11:53:38.813Z","「おっと、何かがうまくいかなかった。」Ad-Shieldをなんとかしたい","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>わたしのおうちネットワーク環境には少々癖がありまして、なぜか広告配信やユーザーを追跡するトラッキングに関連するFQDN全般の名前解決が、ことごとく失敗するようになっています。これらのドメインから配信されるコンテンツは視覚的にも内容的にも不快なものが多いため、個人的にはこの「不具合」のおかげでセキュリティと精神衛生が守られているのですが、最近では色々なところでAd-ShieldなるAd-Blocker対策用のソリューションが採用され、スクリプト読み込みの失敗を検知して閲覧を拒否されることが増えてきました。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/6d68729c516042c0b0f0f0e0b32ecac6/image.png\" alt=\"\" width=\"1919\" height=\"928\">\u003C/figure>\u003Cp>「おっと、何かがうまくいかなかった。」とか言ってくる例のあいつですね。\u003C/p>\u003Ch1 id=\"hab991ce271\">Ad-Shieldの不完全性\u003C/h1>\u003Cp>ただ、このAd-Shieldの挙動を少し掘り下げて確認してみると、その実装品質はお世辞にも高いとは言えません。\u003C/p>\u003Cp>例えばYouTubeの広告検知システムなどは、SSAI技術を含め、コンテンツ配信基盤と密接に統合されており非常に高精度です。誤検知で動画が見られなくなることは稀ですし、そこにはある種の技術的な凄みすら感じます。\u003C/p>\u003Cp>一方で、Ad-Shieldの実装はあまりにも原始的で、クライアントサイドでのスクリプト読み込み成否に過度に依存しています。これが何を意味するかというと、例えば移動中の電車内で通信が不安定なスマホや単に応答速度が遅いパブリックネットワークといった、ごく一般的な環境でインターネットを利用しているだけの順当なユーザーまでもが、無差別に遮断されるリスクがあるということです。\u003C/p>\u003Cp>Ad-Blockerなど一切利用していないのに、たまたまスクリプトの読み込みがタイムアウトしただけで「お前はAd-Blockerを利用しているんだ！！」と決めつけられ、コンテンツを没収される。ユーザー体験としては最悪と言っていいでしょう。\u003C/p>\u003Cp>本来、コンテンツを見てもらうためのWebサイトが、その入り口で正規のユーザーをふるい落として機会損失を生んでいるわけです。品質管理が徹底されたプロダクトとは雲泥の差があり、そもそもまともな技術検証ができている企業であれば、このような粗悪な外部サービスを採用すること自体を躊躇するはずです。\u003C/p>\u003Ch1 id=\"hd7aabd8fbc\">で、どうしよう\u003C/h1>\u003Cp>…と、散々こき下ろしましたが、文句を言っていても画面のオーバーレイは消えてくれません。ただ、この作りの甘さは、裏を返せばとても好都合なことでもあります。\u003C/p>\u003Cp>このシステムの検知ロジックは単純で、そこに付け入る隙があります。通信ログを確認すると、Ad-Shieldは主に\u003Ccode>html-load.com\u003C/code>と、フォールバック用と思われる\u003Ccode>fb.html-load.com\u003C/code>という2つのドメインを使用しています。\u003C/p>\u003Cp>通常は\u003Ccode>html-load.com\u003C/code>からスクリプトを読み込みますが、名前解決に失敗するなどして読み込めないと、即座にフォールバック先の\u003Ccode>fb.html-load.com\u003C/code>へフォールバックする仕様になっているようです。\u003C/p>\u003Cp>ここで残念なのが、サイト側の判定基準です。彼らはどうやら「フォールバックドメインへの接続が成功したかどうか」だけを見て、対策ツールが正常に機能していると判断しているようなのです。実際に広告が表示されているか、DOMが改変されていないかといった本質的な検証は行われていません。\u003C/p>\u003Cp>この挙動を利用すれば、不快なコンテンツの温床である\u003Ccode>html-load.com\u003C/code>の名前解決は失敗させたまま、フォールバック先の\u003Ccode>fb.html-load.com\u003C/code>だけを明示的に許可してあげることで検知を回避できます。\u003C/p>\u003Cp>この環境では\u003Ccode>html-load.com\u003C/code>の読み込みに失敗するため、Ad-Shieldはフォールバック側を読み込みますが、フォールバックドメインの名前解決は許可されているため読み込みに成功します。するとサイト側は「スクリプトが読み込めたから問題ない」と誤認し、警告を解除してコンテンツを表示します。\u003C/p>\u003Cp>もちろん、ユーザー側は広告配信の本体ドメインへの接続に引き続き失敗しているため、画面には広告が表示されないままコンテンツだけが表示されることになります。メインの通信は遮断されているのに、フォールバック側だけが生きているという、明らかに作為的で不自然なネットワーク環境を異常として検出できないあたりに、この技術の悲哀を感じざるを得ません。\u003C/p>\u003Cp>もっとも、この穴も完全に塞がっているわけではないようです。詳細な発生条件までは特定できていませんが、実装が一貫していないおかげで、ごく稀にフォールバックドメイン経由で広告そのものがお漏らしされるケースも観測されています。どうやら純粋な検知用というわけではなく、一部の広告リソースの配信元も兼ねてしまっているのかもしれません。まぁ、許容できる範囲の誤差かと思いますが。\u003C/p>\u003Ch1 id=\"h5f3c23801b\">DNSシンクホールの限界と運用\u003C/h1>\u003Cp>もちろん、DNSレベルでの制御にも限界はあります。あくまでFQDN単位の制御であるため、コンテンツと同じドメインから配信される広告や、直接埋め込まれた広告には無力です。最近はDNSシンクホールを万能なツールのように捉えている方も見かけますが、これは導入が容易で、ある程度の不快なコンテンツが消えればいいという妥協点として運用すべきものです。\u003C/p>\u003Cp>今回の手法もあくまで場当たり的な対策に過ぎません。本来であれば、uBlock Originなどのコンテンツブロッカーを用いてDOMレベルで制御するのがおそらく正しいやり方なのかと思います。現状では\u003Ccode>fb.html-load.com\u003C/code>、あるいは環境によっては\u003Ccode>fb.content-loader.com\u003C/code>といったフォールバックドメインを許可リストに入れることで回避可能ですが、ログに見られる\u003Ccode>report.error-report.com\u003C/code>などはテレメトリ用と思われるため、引き続き名前解決できない状態にしておくのが賢明でしょう。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>今回紹介した手法は、あくまで現時点でのAd-Shieldの仕様を突いたものです。向こうが利用するFQDNを変更したり、検知ロジックを変更したりすれば、すぐに使えなくなるでしょう。所謂いたちごっこに過ぎません。\u003Cbr>しかし、ユーザー体験を損なうようなお粗末な実装でこちらのブラウジングを阻害してくる以上、こちらも快適な環境を維持するために自衛していくしかありません。\u003C/p>\u003Cp>この記事が、同じような「不具合」を抱えるネットワーク環境の管理者の方々にとって、何かの役に立てば幸いです。\u003C/p>",[268,269],{"id":58,"createdAt":59,"updatedAt":60,"publishedAt":59,"revisedAt":60,"slug":61,"name":62},{"id":28,"createdAt":29,"updatedAt":30,"publishedAt":29,"revisedAt":30,"slug":31,"name":32},"はじめにどうも、わたしです。わたしのおうちネットワーク環境には少々癖がありまして、なぜか広告配信やユーザーを追跡するトラッキングに関連するFQDN全般の名前解決が、ことごとく失敗するようになっています。これらのドメインから配信されるコンテンツは視覚的にも内容的にも不快なものが多いため、個人的にはこの",66,15,1782052981529]