[{"data":1,"prerenderedAt":132},["ShallowReactive",2],{"tag-network":3,"tag-network-articles-1":13},{"contents":4,"totalCount":11,"offset":12,"limit":11},[5],{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},"lte0t59xm8sb","2025-05-02T16:12:38.708Z","2025-12-03T16:06:52.753Z","network","ネットワーク",1,0,{"contents":14,"totalCount":130,"offset":12,"limit":131},[15,30,62,74,85,94,106,120],{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":18,"revisedAt":18,"title":19,"content":20,"tags":21,"is_no_index":29},"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>",[22,28],{"id":23,"createdAt":24,"updatedAt":25,"publishedAt":24,"revisedAt":25,"slug":26,"name":27},"wia4slp55q","2025-04-28T07:14:14.541Z","2025-12-03T16:08:13.798Z","thoughts","独り言",{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},false,{"id":31,"createdAt":32,"updatedAt":33,"publishedAt":33,"revisedAt":33,"title":34,"content":35,"tags":36,"is_no_index":29},"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>",[37,43,49,55,61],{"id":38,"createdAt":39,"updatedAt":40,"publishedAt":39,"revisedAt":40,"slug":41,"name":42},"vmhb23sq4","2025-07-29T12:56:07.884Z","2025-12-03T16:06:19.140Z","misskey","Misskey",{"id":44,"createdAt":45,"updatedAt":46,"publishedAt":45,"revisedAt":46,"slug":47,"name":48},"8q8qyy8sz3","2025-04-29T08:44:23.406Z","2025-12-03T16:07:24.495Z","programming","プログラミング",{"id":50,"createdAt":51,"updatedAt":52,"publishedAt":51,"revisedAt":52,"slug":53,"name":54},"qfey3yw0z1","2025-04-29T08:44:41.687Z","2025-12-03T16:07:14.622Z","technology","テクノロジー",{"id":56,"createdAt":57,"updatedAt":58,"publishedAt":57,"revisedAt":58,"slug":59,"name":60},"fwj_nwhj1-s","2025-04-30T04:55:28.953Z","2025-12-03T16:07:03.591Z","server","サーバー",{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":63,"createdAt":64,"updatedAt":65,"publishedAt":65,"revisedAt":65,"title":66,"content":67,"tags":68,"is_no_index":29},"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>",[69,70,71,72,73],{"id":38,"createdAt":39,"updatedAt":40,"publishedAt":39,"revisedAt":40,"slug":41,"name":42},{"id":44,"createdAt":45,"updatedAt":46,"publishedAt":45,"revisedAt":46,"slug":47,"name":48},{"id":50,"createdAt":51,"updatedAt":52,"publishedAt":51,"revisedAt":52,"slug":53,"name":54},{"id":56,"createdAt":57,"updatedAt":58,"publishedAt":57,"revisedAt":58,"slug":59,"name":60},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":75,"createdAt":76,"updatedAt":77,"publishedAt":78,"revisedAt":77,"title":79,"content":80,"tags":81,"is_no_index":29},"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>",[82,83,84],{"id":23,"createdAt":24,"updatedAt":25,"publishedAt":24,"revisedAt":25,"slug":26,"name":27},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":50,"createdAt":51,"updatedAt":52,"publishedAt":51,"revisedAt":52,"slug":53,"name":54},{"id":86,"createdAt":87,"updatedAt":88,"publishedAt":88,"revisedAt":88,"title":89,"content":90,"tags":91,"is_no_index":29},"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>",[92,93],{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":50,"createdAt":51,"updatedAt":52,"publishedAt":51,"revisedAt":52,"slug":53,"name":54},{"id":95,"createdAt":96,"updatedAt":97,"publishedAt":98,"revisedAt":97,"title":99,"content":100,"tags":101,"is_no_index":29},"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>",[102,103,104,105],{"id":38,"createdAt":39,"updatedAt":40,"publishedAt":39,"revisedAt":40,"slug":41,"name":42},{"id":56,"createdAt":57,"updatedAt":58,"publishedAt":57,"revisedAt":58,"slug":59,"name":60},{"id":50,"createdAt":51,"updatedAt":52,"publishedAt":51,"revisedAt":52,"slug":53,"name":54},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":107,"createdAt":108,"updatedAt":109,"publishedAt":109,"revisedAt":109,"title":110,"content":111,"tags":112,"is_no_index":29},"4z-279y7dn5","2025-05-12T04:39:36.333Z","2025-05-12T04:41:16.263Z","UDM-Pro環境で一部のサイトがタイムアウトしていた","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>私の家ではUnifi Dream Machine ProでPPPoE接続をしているのですが、特定のトラヒック(主にGitHubとLINE)が著しく遅延し、タイムアウトする現象が発生していました。\u003C/p>\u003Ch1 id=\"hed900658dc\">環境\u003C/h1>\u003Cp>UniFi Dream Machine Pro(UniFi OS 4.1.22)\u003C/p>\u003Ch1 id=\"h9da7265cb8\">事象の発生と初期対応\u003C/h1>\u003Cp>前述のとおり、わたしの自宅ではUDM-Proを利用しています。\u003Cbr>ある時期からGitHubへのアクセス時に顕著な速度低下が発生するようになりました。\u003C/p>\u003Cp>初期対応として、広告ブロックに利用しているNextDNSが影響している可能性を疑い、一時的に無効化しましたが、状況に変化は見られませんでした。\u003Cbr>原因の特定には至らず、いくつかの設定変更を試みましたが、解決の糸口が見えない状況が続いていました。\u003C/p>\u003Cp>解決策の調査を進める中で、Ubiquitiの公式コミュニティフォーラムにて、\u003Ca href=\"https://community.ui.com/questions/MTU-on-UDM-PRO/10f1a26a-3c0b-4ae8-94c2-cea8054255f5\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">このような投稿\u003C/a>を発見し、MSS Clampingの設定値を見直すことに至りました。\u003Cbr>\u003Ca href=\"https://community.ui.com/questions/MTU-on-UDM-PRO/10f1a26a-3c0b-4ae8-94c2-cea8054255f5\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">https://community.ui.com/questions/MTU-on-UDM-PRO/10f1a26a-3c0b-4ae8-94c2-cea8054255f5\u003C/a>\u003C/p>\u003Ch1 id=\"he843e3454b\">作業内容\u003C/h1>\u003Cp>UDM-ProのMSS Clampingを有効化、Max TCP Connection Segment Sizeの値を1414に固定しました。\u003Cbr>(設定項目は\u003Ccode>Console &gt; UniFi Devices &gt; UDM Pro &gt; Settings &gt; MSS Clamping\u003C/code>にあります)\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/0996cd769d4249da872d8fa19e969196/image.png\" alt=\"\" width=\"391\" height=\"546\">\u003C/figure>\u003Cp>この1414という値は、一般的にフレッツ光のPPPoE接続におけるMTU値である1454バイトから、IPヘッダ(20Byte)とTCPヘッダ(20Byte)のサイズを引いたものです。\u003C/p>\u003Cpre>\u003Ccode class=\"language-vim\">MTU(1454) - IP Header(20) - TCP Header(20) = MSS(1414)\u003C/code>\u003C/pre>\u003Cp>このMSS値を適切に設定することで、パケットの不要なフラグメンテーションを防ぎ、通信効率の低下を避けることが期待できます。\u003C/p>\u003Cp>上記の設定変更を実施した結果、問題となっていた遅延は解消され、安定した通信速度が確保できるようになりました。\u003C/p>\u003Cp>今回の経験から、ネットワーク機器の細かなパラメータ設定が、特定の条件下で通信品質に大きな影響を与える可能性があることを改めて認識しました。\u003Cbr>特にPPPoEのようなトンネリングプロトコルを利用する場合、MTUやMSSの扱いは重要であり、環境に応じた適切な設定が必要となります。\u003C/p>\u003Cp>同様の環境で特定の通信に問題を抱えている場合、このMSS Clamping設定の見直しが一つの有効な対策となるかもしれません。\u003Cbr>ただし、ネットワーク環境は多岐にわたるため、これが全てのケースに当てはまるわけではない点は留意が必要です。\u003C/p>",[113,114],{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":115,"createdAt":116,"updatedAt":117,"publishedAt":116,"revisedAt":117,"slug":118,"name":119},"4oy2jc8mdg","2025-04-28T07:14:01.179Z","2025-12-03T16:08:24.495Z","diary","日記",{"id":121,"createdAt":122,"updatedAt":123,"publishedAt":123,"revisedAt":123,"title":124,"content":125,"tags":126,"is_no_index":29},"b8aofcdnju","2025-05-02T15:50:32.591Z","2025-05-02T16:15:42.637Z","NextDNSで広告とばいばいする","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>私の家では、\u003Cs>広告を排除することはまったく意識していないのですが、\u003C/s>bindを用いた独自のDNSキャッシュサーバーを使っていると、なぜか特定のドメインからの応答が遮断される環境を構築しています。\u003C/p>\u003Cp>現代において、GAFAMやMagnificent7といった巨大企業に、わたしたちの情報がある程度提供されてしまうのは、もはや避けられない現実なのかもしれませんし、その事実自体を今さら争うつもりはありません。\u003Cbr>しかし、それでもなお、「どの主体に、どの程度の情報を、どのような形で提供するのか」という選択の余地は、利用者側に残されているべきだと考えます。\u003Cbr>そして願わくば、どのような情報を受け入れ、あるいは拒否するのか、その選択肢もまた、わたしたちの手元にあるべきではないでしょうか。\u003C/p>\u003Cp>昨今、特に懸念を覚えるのは、わたしたちが日常的に利用するPCやスマートフォン、あるいはIoTデバイスが、利用者の認識しないところで、様々な外部ドメインと通信を行っているという事実です。\u003Cbr>所謂トラッカーやテレメトリと呼ばれるものがそれに該当します。\u003C/p>\u003Cp>情報を何らかの形で提供すること自体は、現代のサービスを利用する上で避けられない側面もあるかもしれません。しかし、その情報の出入りが不透明であるという状況は、決して好ましいものではないとわたしは感じています。\u003C/p>\u003Cp>先日、ありがたいことに\u003Ca href=\"https://misskey.vip/@n\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">友人のまくらくら\u003C/a>からNextDNSを奢ってもらえることになり、試してみました。\u003Cbr>近年巧妙化していると言われるCNAME Cloakingなどといった手法にも対応しているらしく、技術的な関心も惹かれました。\u003C/p>\u003Ch1 id=\"h6ae33e7349\">設定\u003C/h1>\u003Cp>今回のNextDNSの設定は、自宅ネットワークの基幹となっているIX3110に対して行いました。\u003Cbr>Tailscaleの機能でDNS設定を上書きすることも可能ですが、PCやスマホだけでなく、ネットワークに接続されている全てのIoTデバイスを配下に置きたかったため、ルータ自体に設定を施すことにしました。\u003C/p>\u003Cp>設定内容は、概ね以下の通りです。\u003C/p>\u003Col>\u003Cli>proxy-dns server設定を、これまで利用していた自前のbindサーバーのIPアドレスから、NextDNSから指定されたIPアドレスに変更。\u003C/li>\u003Cli>わたしの利用しているインターネット回線は動的IPアドレスであるため、現在のIPアドレスをNextDNS側に継続的に通知するためのDDNS設定を追加。\u003C/li>\u003C/ol>\u003Cp>詳細なコマンドや手順については、NextDNSの公式サイトに用意されているセットアップガイドが非常に参考になりました。\u003C/p>\u003Cp>以下は実際に投入しているconfigです。\u003C/p>\u003Cpre>\u003Ccode>hostname rt-001.ngs.nr1q.me\ntimezone +09 00\n!\nlogging buffered 131072\nlogging subsystem all warn\nlogging timestamp datetime\n!\nno syslog ip enable\n!\nusername mq1 password plain ************** administrator\n!\naccess-list mflt-list permit src any dest any type ipv6\naccess-list mflt-list permit src any dest any type ip\n!\n!\nip ufs-cache max-entries 20000\nip ufs-cache enable\nip route default GigaEthernet0.1\nip dhcp enable\nip access-list web-http-acl permit ip src any dest 192.168.0.0/24\n!\n!\nipv6 ufs-cache max-entries 10000\nipv6 ufs-cache enable\nipv6 dhcp enable\nipv6 access-list block-list deny ip src any dest any\nipv6 access-list dhcpv6-list permit udp src any sport any dest any dport eq 546\nipv6 access-list dhcpv6-list permit udp src any sport any dest any dport eq 547\nipv6 access-list icmpv6-list permit icmp src any dest any\nipv6 access-list permit-list permit ip src any dest any\nipv6 access-list dynamic dflt-list access permit-list\n!\n!\n!\n!\nbridge irb enable\n!\n!\n!\ndns host rt-001.ngs.nr1q.me ip 192.168.0.1\n!\nproxy-dns ip enable\nproxy-dns ip request both\n!\nproxy-dns ip query-response 20\nproxy-dns ip query-retries 3\nproxy-dns ip query-interval 2\n!\nproxy-dns server 2a07:a8c0::bd:49d7 priority 90\nproxy-dns server 2a07:a8c1::bd:49d7 priority 80\nproxy-dns server 45.90.28.174 priority 60\nproxy-dns server 45.90.30.174 priority 50\nproxy-dns server 1.1.1.1 priority 20\nproxy-dns interface GigaEthernet0.0 priority 11\nproxy-dns interface GigaEthernet0.1 priority 10\n!\n!\nssh-server ip enable\n!\nhttp-server username mq1\nhttp-server ip access-list web-http-acl\nhttp-server ip enable\n!\n!\n!\n!\nddns enable\nddns profile nextdns-ddns\n  url https://link-ip.nextdns.io/bd49d7/***********\n  transport ip\n  source-interface GigaEthernet0.1\n  update-interval 1\n!\n!\n!\n!\nppp profile ppp-v4\n  authentication myname user@domain\n  authentication password user@domain password\n!\nip dhcp profile dhcpv4\n  assignable-range 192.168.0.2 192.168.0.254\n  default-gateway 192.168.0.1\n  dns-server 192.168.0.1\n  domain-name ngs.nr1q.me\n  lease-time 7200\n!\nipv6 dhcp client-profile dhcpv6-cl\n  information-request\n  option-request dns-servers\n  ia-pd subscriber GigaEthernet1.0\n!\nipv6 dhcp server-profile dhcpv6-sv\n  dns-server dhcp\n!\ndevice GigaEthernet0\n!\ndevice GigaEthernet1\n!\ndevice GigaEthernet2\n!\ndevice GigaEthernet3\n!\ninterface GigaEthernet0.0\n  filter mflt-list 1 in\n  no ip address\n  ipv6 enable\n  ipv6 address autoconfig receive-default\n  ipv6 dhcp client dhcpv6-cl\n  ipv6 tcp adjust-mss auto\n  ipv6 nd ra enable\n  ipv6 filter dhcpv6-list 1 in\n  ipv6 filter icmpv6-list 2 in\n  ipv6 filter block-list 100 in\n  ipv6 filter dhcpv6-list 1 out\n  ipv6 filter icmpv6-list 2 out\n  ipv6 filter dflt-list 100 out\n  bridge-group 1\n  no shutdown\n!\ninterface GigaEthernet1.0\n  description LAN\n  filter mflt-list 1 in\n  ip address 192.168.0.1/24\n  ip dhcp binding dhcpv4\n  ipv6 enable\n  ipv6 dhcp server dhcpv6-sv\n  ipv6 nd ra enable\n  ipv6 nd ra other-config-flag\n  ipv6 nd proxy GigaEthernet1.0\n  no shutdown\n!\ninterface GigaEthernet2.0\n  bridge-group 1\n  no shutdown\n!\ninterface GigaEthernet3.0\n  bridge-group 1\n  no shutdown\n!\ninterface GigaEthernet0.1\n  encapsulation pppoe\n  auto-connect\n  ppp binding ppp-v4\n  ip address ipcp\n  ip tcp adjust-mss auto\n  ip napt enable\n  no shutdown\n!\n!\u003C/code>\u003C/pre>\u003Cp>一点、補足ですが、IXシリーズのルーターは、現時点ではDNS over TLS (DoT)に対応していません。\u003Cbr>そのため、NextDNS側で提供されている、デバイスごとのクエリ分析といった詳細な機能は利用できないという制約があります。\u003C/p>\u003Cp>最後に、NextDNSの管理画面上で、利用するブロックリストを選択して設定は完了です。\u003Cbr>私は以下のような構成にしました。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/be395fb8391241058faade1003502ad0/my.nextdns.io_bd49d7_setup.png\" alt=\"\" width=\"1397\" height=\"2196\">\u003C/figure>\u003Cp>設定完了後、動作確認のために\u003Ccode>userlocation.googleapis.com\u003C/code>へdigを実行しました。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/db62007a5e874831b416445bf3c66de5/image.png\" alt=\"\" width=\"899\" height=\"437\">\u003C/figure>\u003Cp>結果は、意図した通り、NextDNSによってブロックされていることが確認できます。\u003Cbr>わたしの設定ではブロック時に特定のページへ誘導するオプションを有効にしているため、\u003Ccode>103.170.232.254\u003C/code>が応答として返されましたが、通常は\u003Ccode>0.0.0.0\u003C/code>が返されるようです。\u003Cbr>(\u003Ccode>103.170.232.254\u003C/code>がNextDNSの保有するIPであることはwhoisで確認済みです。)\u003C/p>\u003Ch1 id=\"h93dc9cd2e6\">実際につかってみて\u003C/h1>\u003Cp>運用を開始してから約1週間半が経過した時点で、NextDNSのダッシュボードで統計情報を確認してみました。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/4c5c6cfa5c124f3a8365e8d3847e76b2/image.png\" alt=\"\" width=\"1249\" height=\"904\">\u003C/figure>\u003Cp>その結果、総DNSクエリ数が1,091,869件であったのに対し、ブロックされたクエリ数は209,022件にのぼっていました。\u003Cbr>これは、全クエリのおよそ19%に相当します。\u003C/p>\u003Cp>この数字は、わたしが日常的にインターネットを利用する中で、意識しないうちに発生していた通信の約2割が、広告配信やトラッキングといった目的のものであった可能性を示唆しています。\u003Cbr>私は、固定回線を利用しているため、通信量自体が問題になるわけではありませんが、これまで不可視であった不要なあるいは望まない通信がこれほどの割合を占めていたという事実が可視化されたことで、インターネットという空間における情報の流れについて、改めて考えさせられることになりました。\u003C/p>\u003Cp>思った以上にいい感じだったのですが、誤爆ブロックもかなりあったで、ログを見て自力で調整できる方にとってはかなりいいのではないかと思います。\u003Cbr>私はたぶん使い続けるでしょう。\u003C/p>\u003Ch1 id=\"h6a5297362b\">追記\u003C/h1>\u003Cp>はてブやipinfo、ニコニコがそのままでは見られなかったので以下のFQDNを明示的に許可しました。\u003C/p>\u003Cul>\u003Cli>\u003Ccode>*.b.hatena.ne.jp\u003C/code>\u003C/li>\u003Cli>\u003Ccode>*.ipinfo.io\u003C/code>\u003C/li>\u003Cli>\u003Ccode>*.nicovideo.jp\u003C/code>\u003C/li>\u003C/ul>",[127,128,129],{"id":115,"createdAt":116,"updatedAt":117,"publishedAt":116,"revisedAt":117,"slug":118,"name":119},{"id":50,"createdAt":51,"updatedAt":52,"publishedAt":51,"revisedAt":52,"slug":53,"name":54},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},8,12,1783975282130]