[{"data":1,"prerenderedAt":170},["ShallowReactive",2],{"tag-server":3,"tag-server-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},"fwj_nwhj1-s","2025-04-30T04:55:28.953Z","2025-12-03T16:07:03.591Z","server","サーバー",1,0,{"contents":14,"totalCount":168,"offset":12,"limit":169},[15,43,70,82,94,105,122,133,145,158],{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":19,"revisedAt":18,"title":20,"content":21,"tags":22,"is_no_index":42},"oy69jxht12","2026-06-19T18:38:58.987Z","2026-06-26T14:48:16.179Z","2026-06-19T18:48:10.255Z","Hide My Emailのドメインが変わるので、また悩んでいる","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>以前、\u003Ca href=\"https://mq1.dev/entry/svgkndwg3ng9\">独自ドメインでのメールアドレス運用をやめ、Hide My Emailに移行した話\u003C/a>という記事を書きました。「自前運用もSESも全部やめて全てをHide My Emailに寄せたよ〜」という話です。\u003C/p>\u003Cp>で、そのHide My Emailなんですが、先日、生成されるアドレスのドメインが\u003Ccode>@icloud.com\u003C/code>から\u003Ccode>@private.icloud.com\u003C/code>に変わると発表されました。すでに発行済みのアドレスは引き続き転送されるらしいので、過去の登録が消えてしまうわけではないのですが、これから作るぶんが別ドメインになる時点で、わたしにとっての価値の大半は消し飛びます。\u003C/p>\u003Cp>\u003Ca href=\"https://developer.apple.com/news/?id=sus6t6ab&amp;7194ef805fa2d04b0f7e8c9521f97343\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">https://developer.apple.com/news/?id=sus6t6ab&amp;7194ef805fa2d04b0f7e8c9521f97343\u003C/a>\u003C/p>\u003Cp>さて、困りました。\u003C/p>\u003Ch1 id=\"ha05bbba395\">なにが困るのか\u003C/h1>\u003Cp>わたしがHide My Emailを愛用していた理由は、大きく二つあります。\u003C/p>\u003Cp>プライマリのアドレスと紐付けずにサービスごとに使い分けられること、そして\u003Ccode>icloud.com\u003C/code>のドメインパワーに肖れることです。\u003C/p>\u003Cp>特に後者が大きくて。\u003Cbr>そもそもHide My Emailを嬉しく思っていたのは、生成されるアドレスが普通のiCloudユーザと見分けがつかなかったからでした。受け手からすればただのiCloudユーザーにしか見えないので、後述する特定の大手ドメインしか受け付けないみたいな邪悪な実装も平然と通過できていました。それが\u003Ccode>@private.icloud.com\u003C/code>になると、受け手がHide My Emailを判別できるようになってしまいます。\u003C/p>\u003Cp>要するに、機能が成立していた前提をApple自身が手放しにきているわけです。プライバシ機能を名乗っておいてこれは、なかなかのものだなと思います。\u003C/p>\u003Cp>で、世の中には、GmailとiCloud(国内だとヘンテコ仕様なキャリアメールなんかも)以外のドメインを弾くようになっているサービスがそこそこあります。\u003Cs>Pi○tLinkなんかがそうですね。\u003C/s>こういう邪悪な実装のところでは、\u003Ccode>icloud.com\u003C/code>が使えること自体が最大にして唯一の利点でした。正直、わたしはこの一点のためだけにiCloud+へ課金していたと言っても過言ではありません。\u003C/p>\u003Cp>プライバシがどうとかいう高尚な理由ではなく、純粋に弾かれないための月額だったので、ここがなくなってしまうのはとても苦しい。\u003C/p>\u003Ch1 id=\"hf58740672c\">そもそも独自ドメインのメールって旨味あるの\u003C/h1>\u003Cp>「じゃあ独自ドメインに戻せば」という話になりそうですが、わたしはこのご時世に個人で独自ドメインのメールを持つ旨味、あんまりないと思っています。理由は二つ。\u003C/p>\u003Cp>一つは、メールサーバを自前で持つハードルが高いこと。\u003Cbr>ここで言うハードルは、構築の手間のことではありません。安定して長期運用できること、そしてレピュテーションを健全に保ち続けられること。実際のところ、メールサーバを立てるだけなら誰でもできるのですが、問題はそこから先で、送信ドメイン認証を整えて、IPを腐らせないように気を遣って、ある日突然どこかにブロックされても対処し続ける維持のほうが本体なんですよね。一度評判を落とすと戻すのも大変だし、これを個人で延々とやる価値があるかというと、正直微妙です。\u003C/p>\u003Cp>そもそもASN単位でブロックリスト入りすることも往々にしてあるので、根本的解決を図るにはASNを取得しIP割当を受けるしかない気がします。あまりにも不毛です。\u003C/p>\u003Cp>もう一つは、結局外部のプロバイダに乗せるなら独自ドメインの旨味は薄いこと。\u003Cbr>SESなりWorkspaceなりに任せるなら、配送やレピュテーションの面倒は向こうが見てくれる代わりに、自分が握ってるのはドメイン名といざとなれば乗り換えられる安心感くらいになります。それはそれで価値がないこともないのですが、Hide My Emailの大手のドメインに乗れることとはそもそも別の話です。\u003C/p>\u003Cp>もちろん利点もあって、スパムが来たときにどのサービスがお漏らしたか分かるとか、普段使いのアドレスを隠せるとか、管理が幾分か楽になるとか。でも正直、どれも過去のHide My Emailを超えることはないように思います。\u003C/p>\u003Ch1 id=\"hba13ad79dc\">Gmailのサブアドレスも微妙\u003C/h1>\u003Cp>Gmailのエイリアス(※1)を使う手もあります。\u003C/p>\u003Cp>ただこれ、同一人物への到達性を保証しなくていいスパムであれば、local partのuser部だけ抽出して(厳密ではないにせよ)送れてしまうんですよね。\u003Cs>少なくとも私ならそういう実装をしますし、多くの人がそう考えると思います。\u003C/s>Separator character sequence以降を捨てれば届いてしまうので、使い捨てアドレスとしては心許ない。かなり微妙。\u003C/p>\u003Ch1 id=\"h287541f080\">で、結局どうするか決まってない\u003C/h1>\u003Cp>整理すると、わたしが欲しいのは三つです。プライマリのアドレスを隠せること、サービスごとに分けられること、そして邪悪な実装を通れること。最初の二つは代替がいくらでもあるのですが、問題は三つめで、これを満たせる選択肢が驚くほど見つかりません。\u003C/p>\u003Cp>一応、候補は一通り眺めてみました。\u003C/p>\u003Cp>\u003Ca href=\"https://addy.io/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">addy.io\u003C/a>や\u003Ca href=\"https://simplelogin.io/ja/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">SimpleLogin\u003C/a>のようなエイリアスサービスは、マスキングも使い分けも完璧。しかし、相手に渡すアドレスは結局そのサービス自身のドメインになるので、\u003Ccode>@addy.io\u003C/code>とか\u003Ccode>@simplelogin.io\u003C/code>をGmail/iCloud/ヘンテコキャリアメールしか通さない許可リストが受け入れてくれるわけがありません。ついでにSimpleLoginはProton傘下で、わたしはProtonが好きではないので却下。\u003C/p>\u003Cp>独自ドメインを自前運用したりWorkspaceに載せたりする手も、同じ壁に当たります。受け手が見てるのはドメイン名の文字列であって、誰がホストしてるかではないため、どれだけ真っ当に運用されてようと、\u003Ccode>icloud.com\u003C/code>でも\u003Ccode>gmail.com\u003C/code>でもない以上、リテラル照合の前では無力です。Googleのインフラに乗せようが、自分のドメインである事実は変わらないので。\u003C/p>\u003Cp>iCloudの素のエイリアス(Hide My Emailではなく、\u003Ccode>@icloud.com\u003C/code>を最大3つ作れるやつ)は、ドメインが本物の\u003Ccode>icloud.com\u003C/code>のままなので許可リストは通ります。これはうれしい。しかし、3つしか作れないうえ、3つ埋まった状態で1つ消すと次を作るまで7日待たされるようで、使い捨てとして回すには微妙です。\u003C/p>\u003Cp>規約上どうかは知りませんが、転送専用のGmailアカウントを量産する手も思いつきました。\u003Cs>(思いついただけでやってないので叩かないでください)\u003C/s>これなら渡すのは正真正銘の\u003Ccode>@gmail.com\u003C/code>で許可リストも確実に通るし、数も稼げそうです。しかし、アカウントごとに電話番号認証だの複数アカウントの取り回しだのが付いてくる上、Googleの機嫌次第でまとめて凍結される可能性も拭えず、得られる体験のわりに管理コストとリスクが釣り合っていません。無念。\u003C/p>\u003Cp>という具合に、どれを取っても過去のHide My Emailには届かないんです。結局わたしが失おうとしてるのは、「大手のドメインに、ほぼ無限に、片手間で乗れる」という、よく考えるとかなり贅沢な状態だったんだなと。\u003Cbr>しかもApple側が仕様としてやめると言ってる以上、こっちの工夫でどうこうできる話でもなく。すごく普通に困ってます。\u003C/p>\u003Cp>そもそもみなさん、メールアドレスの運用ってどうしてるんでしょう？\u003Cbr>いい感じの方法があったら本当に教えてほしいです。\u003C/p>\u003Cp>\u003C/p>\u003Cp>\u003C/p>\u003Chr>\u003Cp>※1 RFC 5233のsubaddressingは、Separator character sequence + Detailであって、これがエイリアスでないことは理解しています。が、(腹立たしいことに)慣習上そう呼ばれることが多い(らしい)のでここでもそう呼んでます。\u003C/p>\u003Cp>蛇足ですが、&quot;Separator character sequence&quot;という言い方をすると、何らかの形で明確に分離された構造になっているように聞こえますが、実際にRFCが言ってるのは「UserにDetailを加えたアドレスをUserにルーティングできる」くらいのことでしかなくて、RFC 5321/5322が「local partの解釈は受け手のソフトウェア次第」ってスタンスなのに対して、RFC 5233はlocal partの形式の例として「local part=userってわけじゃなくてぇ」みたいな話をしてるに過ぎません。広げてるように見えて制限してる規格かと思いきや、その実なにも制限してない用語と用例が出されてるだけのものなので誤解しないようにしてくださいね。\u003C/p>\u003Cp>そもそもエイリアスは原初から全く別の機能の名称として存在してるので、この呼び方にはずっと不満があります。\u003C/p>",[23,29,30,36],{"id":24,"createdAt":25,"updatedAt":26,"publishedAt":25,"revisedAt":26,"slug":27,"name":28},"qfey3yw0z1","2025-04-29T08:44:41.687Z","2025-12-03T16:07:14.622Z","technology","テクノロジー",{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":31,"createdAt":32,"updatedAt":33,"publishedAt":32,"revisedAt":33,"slug":34,"name":35},"wia4slp55q","2025-04-28T07:14:14.541Z","2025-12-03T16:08:13.798Z","thoughts","独り言",{"id":37,"createdAt":38,"updatedAt":39,"publishedAt":38,"revisedAt":39,"slug":40,"name":41},"4oy2jc8mdg","2025-04-28T07:14:01.179Z","2025-12-03T16:08:24.495Z","diary","日記",false,{"id":44,"createdAt":45,"updatedAt":46,"publishedAt":46,"revisedAt":46,"title":47,"content":48,"tags":49,"is_no_index":42},"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>",[50,56,62,63,64],{"id":51,"createdAt":52,"updatedAt":53,"publishedAt":52,"revisedAt":53,"slug":54,"name":55},"vmhb23sq4","2025-07-29T12:56:07.884Z","2025-12-03T16:06:19.140Z","misskey","Misskey",{"id":57,"createdAt":58,"updatedAt":59,"publishedAt":58,"revisedAt":59,"slug":60,"name":61},"8q8qyy8sz3","2025-04-29T08:44:23.406Z","2025-12-03T16:07:24.495Z","programming","プログラミング",{"id":24,"createdAt":25,"updatedAt":26,"publishedAt":25,"revisedAt":26,"slug":27,"name":28},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":65,"createdAt":66,"updatedAt":67,"publishedAt":66,"revisedAt":67,"slug":68,"name":69},"lte0t59xm8sb","2025-05-02T16:12:38.708Z","2025-12-03T16:06:52.753Z","network","ネットワーク",{"id":71,"createdAt":72,"updatedAt":73,"publishedAt":73,"revisedAt":73,"title":74,"content":75,"tags":76,"is_no_index":42},"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>",[77,78,79,80,81],{"id":51,"createdAt":52,"updatedAt":53,"publishedAt":52,"revisedAt":53,"slug":54,"name":55},{"id":57,"createdAt":58,"updatedAt":59,"publishedAt":58,"revisedAt":59,"slug":60,"name":61},{"id":24,"createdAt":25,"updatedAt":26,"publishedAt":25,"revisedAt":26,"slug":27,"name":28},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":65,"createdAt":66,"updatedAt":67,"publishedAt":66,"revisedAt":67,"slug":68,"name":69},{"id":83,"createdAt":84,"updatedAt":85,"publishedAt":85,"revisedAt":85,"title":86,"content":87,"tags":88,"is_no_index":42},"881vyfdq_5","2026-01-12T19:08:19.276Z","2026-01-12T19:14:08.297Z","さようならVercel, こんにちはGCP","\u003Cp>こんばんは。あるいはこんにちは。\u003C/p>\u003Cp>実はお気づきの方もいらっしゃるかもしれませんが、このサイト、数ヶ月前にインフラのお引越しをしています。暫くお世話になっていたVercel基盤を離れ、現在はGCPの上で稼働しています。移行作業からしばらく経ち、運用も安定していて特に問題なさそうなので、ここらで重い腰を上げて移行の経緯でも書き残しておこうかなと思います。\u003C/p>\u003Cp>今回、以降に踏み切った理由はいろいろとあるのですが、きっかけとして大きかったのは、単純にVercelの無料枠を使い果たしてしまったことです。個人の趣味サイトとはいえ、トラヒックが増えればリソースも食いますし、記事数が増えれば(移行当時は部分的にSSG構成だったので)ビルド時間も伸びます。もちろん、お金で殴ってプランを上げてしまえばそのまま使い続けることは造作もないことなのですが、そこでふと損得勘定が働いてしまいました。毎月10USDを固定で払うくらいなら、必要な分だけ課金されるCloud Runで動かした方が、結果的にはお財布に優しいしお得なんですよね。\u003C/p>\u003Cp>そして2つ目。実はこっちの方が心情的な割合としてはかなり大きいのですが、VercelのCEOであるGuillermo Rauch氏の倫理観に対して、どうしても拭えない不信感を抱いてしまったという点です。ご存じの方も多いかと思いますが、彼がイスラエルのネタニヤフ首相と会談し、ML技術について議論を交わし、その後に満面の笑みで撮影したセルフィーをTwitterに投稿したあの一件です。\u003C/p>\u003Cp>相手は国際刑事裁判所から指名手配を受け、ガザでのジェノサイド容疑もかけられている人物です。政治的なスタンスは人それぞれあるにせよ、公人として、tech企業のトップとして、そのセルフィーを世界に発信することの意味をどう捉えているのか。本当にこの人の倫理観はどうなっているんだろうと、背筋が寒くなるような思いでした。\u003C/p>\u003Cp>「たかが個人の趣味サイトなんだから、プラットフォームのCEOの思想なんて関係ないじゃん」と言われればそれまでですし、別にわたしのサイトは法人の看板を背負っているわけでも、社会的な信用を切り売りしている商売でもありません。実害は全然ないんです。でも、それはそれとして、一度すこしでも嫌悪感を感じてしまうと、それ以外の部分まで全部が色褪せて見えてしまうのものです。所謂、坊主憎けりゃ袈裟まで憎いというやつです。\u003C/p>\u003Cp>そうなると不思議なもので、元々は妥協していた小さな不満が、急に許せなくなってきます。例えば、NuxtImgを使った画像最適化の取り回しについて、Vercelプロバイダを利用するときだけ妙に設定が面倒だったり、挙動に癖があったりして、結局対応するのが億劫でipxを利用して回避していた事なんかが思い出されます。\u003C/p>\u003Cp>あとはあれですね、令和の現代においても未だにIPv6をネイティブでサポートしていない点とか。先進的なフロントエンド体験を謳っているプラットフォームなのに、足回りのネットワークに関しては意外と適当なんだなと、以前から感じていた違和感が嫌悪感に変わりました。そういう小さな「うーん」という澱みのようなものが積み重なって、最終的に「もう全部嫌だ、引っ越そう」という決断に至ったわけです。\u003C/p>\u003Cp>移行先にGCPを選んだ理由は至ってシンプルで、安くて使いやすくて、なんとなくいい感じだからです。ありがたいことに無料枠もそれなりに充実していますし、貧乏開発者には優しい環境が整っています。\u003C/p>\u003Cp>ちなみに、このサイトを作り始めたちょうど1年くらい前は、Cloudflare Workersを利用していました。なので今回もWorkersに出戻りすることを真っ先に検討したのですが、いざ検証してみると壁にぶつかりました。現在の構成がNode.js依存の実装を含むライブラリとズブズブの関係になってしまっており、Workersのランタイム環境では動かすのが厳しかったのです。加えて、仮にPaidプランにしたとしても、ランタイムサイズの制限を大幅に超過してしまうことは明白でした。\u003C/p>\u003Cp>あれこれ悩んだ結果、CockroachDBがちょうどGCP上にあったこともあり、それならアプリケーションも同じGCPに置いてしまった方が合理的だという結論に達しました。結果的に、GCPへの集約は正解だったと思います。\u003C/p>\u003Cp>インフラの構築に関しては、例によって私はGUIの管理画面をポチポチして手動で設定するのが大嫌いなので、今回も全てTerraformを使ってIaC化しています。人の温もりを感じるインフラなんて、いつ壊れるかわからない時限爆弾みたいなものですからね。\u003C/p>\u003Cp>あ、そうそう、ついでにGoogle Fontsもやめました。Lighthouseのパフォーマンススコアの足を引っ張っていて邪魔だったので思い切って削除したのですが、代わりのシステムフォントはどうにも気持ち悪くて、納得できませんでした。結局、Adobe Fontsを導入してFutura-PTを利用することにしました。そのせいで削除前よりもLighthouseのスコアは低下してしまったのですが、見た目が圧倒的にかわいくなったのでよしとしています。\u003C/p>\u003Cp>最適化については、またいつか気が向いた時にでも頑張ろうと思います。\u003C/p>\u003Cp>それでは。\u003C/p>",[89,90,91,92,93],{"id":24,"createdAt":25,"updatedAt":26,"publishedAt":25,"revisedAt":26,"slug":27,"name":28},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":57,"createdAt":58,"updatedAt":59,"publishedAt":58,"revisedAt":59,"slug":60,"name":61},{"id":31,"createdAt":32,"updatedAt":33,"publishedAt":32,"revisedAt":33,"slug":34,"name":35},{"id":37,"createdAt":38,"updatedAt":39,"publishedAt":38,"revisedAt":39,"slug":40,"name":41},{"id":95,"createdAt":96,"updatedAt":97,"publishedAt":97,"revisedAt":97,"title":98,"content":99,"tags":100,"is_no_index":42},"e-3nu72af97k","2025-12-29T18:01:40.332Z","2025-12-30T10:20:45.448Z","非公式Misskeyサーバーリストをつくった","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>Misskeyのサーバー探しで困ったことはありませんか？公式のリストが止まっていたり、実態のわからないサーバーが混ざっていたり。そんな状況に業を煮やし、最新の状況を自動で反映し続けるサーバーリストを作成しました。\u003C/p>\u003Cp>\u003Ca href=\"https://servers.misskey.ink/\" target=\"_blank\" rel=\"noopener noreferrer\">https://servers.misskey.ink/\u003C/a>\u003C/p>\u003Cp>本当は今年のMisskey Advent Calendarのネタにしようかとも思っていたのですが、どうせ技術的な記事は他の方々がたくさん書かれているだろうし、何より当時のUIはちょっと見せびらかすには残念な出来だったので見送っていました。 そんなわけで、諸々がいい感じに仕上がってきたので、少し遅れての登場です。\u003C/p>\u003Cp>私が (Unofficial) Misskey Server List を作成しようと思い立った直接のきっかけは、Misskey公式のMisskeyHubに掲載されていたインスタンス一覧が、長期メンテナンスに入ったまま復旧しなくなってしまったことでした。 開発者からは「ユーザーの混乱を避けるため」といった趣旨の説明がなされていましたが、その裏にある実情は十中八九、Misskeyの派生プロジェクトであるCherryPickによる実装変更が引き金でしょう。\u003C/p>\u003Cp>現行のCherryPickは、\u003Ca href=\"https://github.com/yunfie-twitter/cherrypick/commit/98ae8b5d869bac470aad2b8f025318f2c222e432#diff-c99e10daaf6c2db57012b73a31a76d7e3bbbf9e2599ed226e2f1651cdff40c20\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">User-AgentにJoinMisskeyの文字列を含むリクエストに対してのみ、メタデータ上で自身をMisskeyであると偽装して返すという、極めて行儀の悪い実装\u003C/a>を行っています。JoinMisskeyとは、aqz氏が個人でメンテナンスを行っているインスタンス一覧のデータソースであり、MisskeyHubも内部的にこれを利用していました。その結果、本来掲載されるべきではないCherryPickのインスタンスが、Misskeyの顔をして公式リストに混入してしまう事態が発生していました。\u003C/p>\u003Cp>私自身、新しいインスタンスが生えていないか定期的に見に行っていたため、この信頼できない状況には困っていました。 「ならば、自分で納得できるクリーンなリストを作ろう」というのが、開発の出発点です。\u003C/p>\u003Ch1 id=\"h89265527fa\">本物を識別する\u003C/h1>\u003Cp>「Misskeyのインスタンスだけを集めたい。」言葉にするのは簡単ですが、いざ実装しようとすると、すぐに多様性とスプーフィングの壁にぶち当たります。ActivityPubのおかげで、MastodonもFirefishも、そして悪意ある偽装サーバーも、外向きには同じような顔をして通信してくるからです。加えて、フォーク文化が盛んであり、独自機能を追加した派生版も無数に存在します。そんな混沌としたFediverseの中から、どうやって本物と偽物を分別しているのかと言えば、幸いなことにCherryPickはJoinMisskeyに対してのみMisskeyを名乗るような仕草をしていたので、これを逆手に取りました。\u003C/p>\u003Cp>私が実装した検出機構では、単にリクエストを投げるだけでなく、意図的に異なるUAでリクエストを投げています。まず通常のブラウザなどを模したUAと、JoinMisskeyを模したUAの二種類を用意し、その双方から対象サーバーの\u003Ccode>/api/meta\u003C/code>および\u003Ccode>/nodeinfo/[2.0|2.1]\u003C/code>にリクエストを投げます。もし、ここで返ってきたJSONレスポンスの内容、特に\u003Ccode>softwareName\u003C/code>やバージョン情報に矛盾がある場合、あるいは相手によって応答を変えるような挙動が見られた場合は、その時点で信頼できないノードとして即座に弾く仕組みにしました。\u003C/p>\u003Cp>また、なるべく全ての工程を自動化したかったので、Misskey以外のソフトウェアを機械的に判定し、排除する機構が必要でした。対象のホストがMisskeyを名乗っており、JoinMisskeyが公開している\u003Ccode>ignorehosts.yml\u003C/code>に含まれていないことは最低条件ですが、それだけでは足りません。正常なレスポンスを返すかどうかの監視に加え、レポジトリ情報の検証も徹底しています。具体的には、APIで返却されるレポジトリURLが公式のものなのか、あるいは信頼できないフォークを含まないかを厳密にチェックします。\u003C/p>\u003Cp>中にはType-4nyのように過度な独自修正が加えられて独自のブランディングを行っていてもなお、nodeinfo上ではMisskeyを自称するようなフォークも存在します。そういったものを判定するため、データベース上に保持した除外リストや、既知のレポジトリパターン判定ロジックを用いて検知を行っています。このフィルタリングロジックは適宜更新し、常に概ね純粋なMisskeyを提供しているサーバーのみを抽出できるよう調整をしています。\u003C/p>\u003Cp>中規模以上のサーバーだいたい何かしらのカスタマイズを施したMisskeyを利用しているのもあって、結局のところ「どの程度までをMisskeyと見なすか」は難しいラインではあります。しかし、そこに明瞭な指標もないので、取り敢えずは私の主観とコード上のロジックで決定しています。「これこそが公平な判定だ！」といういい感じのアイデアがある方がいれば、GitHubにPRを投げてくれれば採用するかもしれません。\u003C/p>\u003Ch1 id=\"hde3dc27fcf\">連合探索\u003C/h1>\u003Cp>静的なリストではなく、各インスタンスの現状をリアルタイムに反映するため、いくつかの定期タスクを実装しています。\u003C/p>\u003Cp>まず\u003Ccode>update\u003C/code>タスクが6時間おきに実行され、登録済みのインスタンスを検証します。ユーザー数やノート数、登録開放状況といったメタデータを最新の状態に更新し、応答がないサーバーや410を返すサーバーはリストから除外します。\u003C/p>\u003Cp>そして、新規開拓を行うのが\u003Ccode>discovery\u003C/code>タスクです。既存のリストの中で\u003Ccode>is_alive=true\u003C/code>状態のMisskeyインスタンスをランダムに抽出し、そのサーバーが持つ連合先インスタンス一覧を取得します。そこに含まれるFQDNが未登録であれば、検証プロセスへ回します。\u003C/p>\u003Cp>ActivityPubの特性上、稼働しているサーバーは高い確率で他のサーバーと連合しています。\u003Cbr>つまり、既知のサーバーを起点にネットワークを辿ることで、主要なリレーやリストに登録されていない小規模な個人サーバーであっても自動的に検出が可能になります。これにより、手動登録に頼らずともある程度網羅的なリストを作成できるエコシステムを構築しました。\u003C/p>\u003Ch1 id=\"hd04cfc55ee\">アーキテクチャ\u003C/h1>\u003Cp>システムの構成についてですが、当初はCloudflare Workers / D1 / Queuesを中心に、APIだけを提供するつもりで開発を進めていました。しかしながら、数千件に及ぶ大量の連合情報を数時間に1度収集・解析する都合上、WorkersのCPU Time Limitに引っかかり、当然耐えられませんでした。特にJSONのパースやバリデーションといったCPUインテンシブな処理が重なり、タイムアウトが頻発しました。その致命的な問題に気付いたのは、コードを書き始め、実装の半分以上が完成してからでした。\u003C/p>\u003Cp>どうせ構成を見直すなら、ついでにフロントも付けて目に見える形で多くの人に使って貰えたらなと思い立ったのもこのタイミングです。ここで技術選定を根本からやり直し、フレームワークにはNuxt4を採用、データベースにはCockroachDBを使用してアプリケーションを再構築しました。インフラ基盤はGoogle Cloud Platformで統一し、Cloud Run / Cloud Build / Cloud Scheduler / Cloud Tasks / Secrets Managerで構成しています。\u003C/p>\u003Cp>この構想から設計、実装、そしてデプロイに至るまでの全ての工程は、丸2日程度で行いました。\u003C/p>\u003Cp>全体的なアーキテクチャのイメージとしては、Cloud Schedulerが定期的にトリガーを引き、Cloud Tasksがジョブを管理し、Cloud Run上のアプリケーションがCockroachDBと連携しながら外部のMisskeyインスタンス群やGitHub APIを巡回する形になります。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/4248fb5b4e75471ab42e22d4d653b993/mi-server-list.png\" alt=\"\" width=\"1324\" height=\"878\">\u003C/figure>\u003Cp>また、Cloud Runをasia-northeast1を含む特定のリージョンで利用している際、Custom Domain Mappings を利用するとレイテンシが顕著に上昇する既知の問題がありました。これに対し Cloud Load Balancingを利用したルーティングと比較検証を行いましたが、結果として40ms 程度の違いしか見られなかったため、LB の維持コストとの兼ね合いでそのままマッピングを行っています。検索クエリを叩かなければレスポンスはキャッシュされる前提で実装をしているおかげか、ユーザー体験に顕著な差は現れませんでした。\u003C/p>\u003Cp>これらの基盤は全て Terraform でコード化して管理しています。\u003Cbr>会社でも普段はAWS基盤ばかり利用していますが、GCPの開発体験はAWSのそれと比べてだいぶ分かりやすく、直感的であるという印象を覚えます。無料枠も太っ腹なので嫌いじゃないです。\u003C/p>\u003Cp>\u003C/p>\u003Ch1 id=\"h38757ef838\">非同期分散処理\u003C/h1>\u003Cp>タスク実行基盤として、当初はCloud SchedulerからCloud Runのエンドポイントを直接呼び出していましたが、処理量の増加に伴いタイムアウトで処理が中断されるリスクが顕在化し、Cloud Tasksを導入しました\u003C/p>\u003Cp>現在のアーキテクチャでは、Cloud Schedulerはタスクの実行をエンキューする軽量な処理のみを行います。具体的には、Cloud Schedulerが\u003Ccode>/api/tasks/:name\u003C/code>を叩くと、アプリケーションは処理を直接実行せず、Google Cloud Tasksのキューにタスクを積みます。\u003C/p>\u003Cp>ここの重要な点として、キューの\u003Ccode>max_concurrent_dispatches\u003C/code>(最大同時実行数)を1に制限しています。これは、外部インスタンスへの過度なリクエストを防ぐ行儀の良さの担保と、自身のデータベース接続数の枯渇を防ぐためです。タスク名にタイムスタンプを含めて重複実行を排除し、失敗時にはCloud Tasksのリトライポリシーによって自動的に再試行される堅牢な設計としています。\u003C/p>\u003Cp>また、Cloud TasksからCloud RunへのリクエストにはOIDCトークンが付与されており、Cloud Run側でリクエストが正当な権限を持ったサービスアカウントからのものであるかを厳格に検証しています。つまり、\u003Ccode>/tasks/workers/[:name]\u003C/code>エンドポイントはインターネットに公開されていますが、実質的には内部からしか叩けない設計となっています。人間が手動でキックするためのエンドポイントには独自のBearerトークン制限をかけ、システムからの自動実行にはIAM認証を用いるいい感じのセキュリティを実現しました。\u003C/p>\u003Ch1 id=\"h08ac57933c\">言語の判定\u003C/h1>\u003Cp>実装において最もかつ苦労したのはインスタンスの主要言語判定です。ユーザーが新しいサーバーを探す際、「そこで何語が話されているか」はサーバーのテーマと同じくらい重めな条件になります。しかし、Misskeyの仕様上、言語設定は必須ではなく、説明文も英語と日本語が混在している文章や極端に短い文章で構成されているといったケースが多々あります。APIから得られる情報だけでは、そのサーバーが実際にどの言語圏のコミュニティなのかを正確に断定することが困難でした。\u003C/p>\u003Cp>既存の言語判定ライブラリもいくつか試しましたが、短文やノイズの多いテキストに対してはどれも単体では実用に耐えうる精度が出ませんでした。そこで、複数のロジックを組み合わせた合議制アルゴリズムを実装して解決することにしました。\u003C/p>\u003Cp>判定プロセスは三段階に分かれており、第一段階として、テキスト内の文字コードの範囲を走査するヒューリスティック判定を行います。日本語や中国語、韓国語のように特徴的な文字種が含まれていれば、重い処理を回すまでもなく高速に言語を確定できます。ここで判別がつかない場合は第二段階として、特性の異なる3つの言語判定ライブラリに判定を委ね、多数決投票を行います。アルゴリズムの異なるライブラリに投票をさせることで、個々の誤判定を相互に補完させる狙いです。\u003Cbr>さらに、それでも結果が割れて怪しい場合は、実際にそのサーバーのLTLから直近の投稿20件を取得し、実データのサンプリングに基づいて解析を行っています。\u003C/p>\u003Cp>最近だとLLMのAPIもだいぶ安くて、Geminiあたりを使ってもよかったのですが、自動で収集と判定を延々と繰り返している、かつMisskeyを利用する全てのインスタンス数が把握できない以上、ランニングコストの試算ができなかったため無謀すぎてやめました。もし対象が小規模かつ試算ができる状態であれば、実装コスト諸々との天秤にかけて利用していたと思います。\u003C/p>\u003Cp>また、どれだけ収集能力が高くても、ノイズだらけでは意味がありません。そのため、除外リストの運用には細心の注意を払っています。ベースとなるのはJoinMisskeyが公開している\u003Ccode>ignorehosts.yml\u003C/code>ですが、これはあくまで最低ラインです。当リストでは、これに加えて独自の検疫プロセスを導入しています。例えば、一度は正常にMisskeyと判定されたサーバーであっても、その後の定期観測でソフトウェア名の偽装や、Misskey以外の実装への変更などといった不審な変更が見られた場合、自動的にシステム側の除外テーブルに追加され、リストから排除されます。また、Misskey以外のAP実装が誤って検出された場合も、同様にフィルタリングされます。このプロセスは完全に自動化されており、私の主観が入り込む余地を排除しています。\u003C/p>\u003Ch1 id=\"hfa9126a93a\">透明性がどうのこうの\u003C/h1>\u003Cp>幸か不幸か、インターネット人間として長年活動している中で、私を嫌う方々に粘着されてしまっていて、こういうものを作ると必ずいちゃもんを付けられることが懸念されました。具体的には、「私の気に食わないサーバーを意図的に排除している」だとか、「公平性に欠く基準で恣意的にレコメンドしている」なんかがそうですね。\u003C/p>\u003Cp>まぁ、個人運営のリストに対する主張としては妥当だと思うのですが、これに関して言えば私に限らず公式だろうと似たり寄ったりだろうと思うわけです。それにしても、こういうことをイチイチ言われるのも気分がいいものではありません。\u003C/p>\u003Cp>こうした懸念を払拭するため、私は全ての情報を公開する方針をとりました。ソースコードはGitHub上にAGPL-3.0ライセンスのもと公開しており、収集した統計データ、除外リスト、そしてその除外理由に至るまで、全てAPIで取得可能です。\u003C/p>\u003Cp>特にRecommendedスコアの算出ロジックについても、コード内の\u003Ccode>calculateRecommendationScore\u003C/code>関数を見ていただければ分かる通り、完全に数式です。ユーザー数や投稿数といった規模感に加え、GitHub Releasesから取得した最新バージョンに対する追随度や、活動率を係数として掛け合わせ、機械的にスコアを弾き出しています。たとえば、バージョンが古いサーバーはセキュリティリスクと見なし、指数関数的にスコアを減衰させる処理を入れています。そこに私の主観やお気持ちが入る余地は当然1ミリもありません。\u003C/p>\u003Cp>実際、この懸念は杞憂ではなくて、公開直後にある方から直々に文句を言われてしまいました。こういうの言ってくる人って、自身の主張に妥当性があるかどうかすら考えないんですかね。コードも公開データも見ずに、イメージだけでいちゃもんをつけて来るのはかなり無理があります。文句があるならGitHubでIssueでも建ててもらえば、こちらとしても対等な議論として対応できるので幾分建設的だとは思うのですが、残念ながら彼女にその考えはなかったようです。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>そんなわけで、年の瀬ギリギリの滑り込み記事でした。\u003C/p>\u003Cp>元々は自分が不便だからという理由だけで作ったツールですが、公開してみると予想以上の反響があり、私としてはうれしい限りです。非中央集権の海は広大で、自由で、そして少しだけ不親切です。そんな中で、結果として誰かの役に立てたのなら、それはとても嬉しいことだと思います。\u003C/p>\u003Cp>収集したデータはWebで見れるだけじゃなく、登録不要のAPIとして全開放しています。Botを作るもよし、統計を取ってニヤニヤするもよし。私のお財布が吹き飛ばない程度に、自由に叩き倒してください。ドキュメントも一応用意してあるので、バグや要望があればGitHubまで。PRはいつでも歓迎します。\u003C/p>\u003Cp>それでは、よいお年を。\u003C/p>",[101,102,103,104],{"id":51,"createdAt":52,"updatedAt":53,"publishedAt":52,"revisedAt":53,"slug":54,"name":55},{"id":24,"createdAt":25,"updatedAt":26,"publishedAt":25,"revisedAt":26,"slug":27,"name":28},{"id":57,"createdAt":58,"updatedAt":59,"publishedAt":58,"revisedAt":59,"slug":60,"name":61},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":106,"createdAt":107,"updatedAt":108,"publishedAt":109,"revisedAt":108,"title":110,"content":111,"tags":112,"is_no_index":42},"5ybjx06von0","2025-11-06T18:20:06.910Z","2025-11-06T18:26:20.273Z","2025-11-06T18:24:32.966Z","RustのゲームサーバーをDockerで建てた","\u003Cp>どうも、わたしです。 \u003C/p>\u003Cp>先日、Rust(軽量でメモリ安全性が担保されているものではない方)が安くなっていたので、仲間内でサーバーを建ててマルチプレイをしよう、ということになりました。\u003Cbr>ただ生憎と、現状私の手元には自由に使えるIPv4アドレスの在庫が枯渇していまして、仕方がないので適当なVPSを契約し、そこにサーバーを構築しようとしました。\u003C/p>\u003Cp>導入方法を調べてみたところ、大筋としてはSteamCMDを使い、サーバーデータをダウンロードして必要な引数を渡して起動するだけ、と手順自体はシンプルなようでした。\u003Cbr>ところが、検索して出てくる記事の多くは、なぜかsnapでSteamCMDをインストールする方法だったり、無駄にsystemdのサービスを増やすようなものだったりと、本当に気持ちが悪いものばかり。\u003Cbr>何も考えずに色んなところからコピペしてコマンドを並べているだけなのかと疑うレベルの記事ばかりで、正直うんざりしていました。\u003C/p>\u003Cp>さらに、手軽に使えるDockerイメージも現存していないようでしたので、今回作成するに至りました。\u003C/p>\u003Cp>私の普段使いの環境がM4 Maxなのですが、ここで一つ問題にぶつかりました。\u003Cbr>SteamCMDがARMアーキに対応していなかったのです。\u003C/p>\u003Cp>このご時世においてARMのサポートをしていないなんて前時代的ですよね。有り得ない。\u003C/p>\u003Cp>とはいえ、嘆いても仕方がないので、ちゃっちゃとDockerfileを書き、互換レイヤを挟んで試みるもうまくいかず。\u003Cbr>結局面倒くさくなって適当な8664環境を作って解決しました。\u003C/p>\u003Cp>ちなみに、今回の成果物は、全てGitHub上で公開しています。\u003Cbr>\u003Ca href=\"https://github.com/chan-mai/docker-rust-gameserver\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">https://github.com/chan-mai/docker-rust-gameserver\u003C/a> \u003C/p>\u003Cp>リポジトリ上に置いてある \u003Ccode>compose.yaml\u003C/code> をコピーしてきてもらい、適宜、環境変数を変えていただければ、そのまま使えます。\u003Cbr>詳しいことはREADMEに書いているので、そちらをご参照ください。\u003C/p>\u003Cp>これで面倒なことを考えずにRustで遊べますね。\u003C/p>\u003Cp>\u003C/p>",[113,114,115,116],{"id":37,"createdAt":38,"updatedAt":39,"publishedAt":38,"revisedAt":39,"slug":40,"name":41},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":24,"createdAt":25,"updatedAt":26,"publishedAt":25,"revisedAt":26,"slug":27,"name":28},{"id":117,"createdAt":118,"updatedAt":119,"publishedAt":118,"revisedAt":119,"slug":120,"name":121},"riqchoqn8lw","2025-11-06T18:20:17.154Z","2025-12-03T16:06:09.687Z","games","ゲーム",{"id":123,"createdAt":124,"updatedAt":125,"publishedAt":126,"revisedAt":125,"title":127,"content":128,"tags":129,"is_no_index":42},"svgkndwg3ng9","2025-09-29T13:06:03.316Z","2026-06-26T14:50:56.142Z","2025-09-29T13:16:44.358Z","独自ドメインでのメールアドレス運用をやめ、Hide My Emailに移行した話","\u003Cp>どうも、わたしです。\u003Cbr>今日は、長らく頭を悩ませていたメールアドレスの運用について、その移行経緯と最終的な結論を記録しておこうと思います。\u003C/p>\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>かつては自宅サーバでメールサーバを運用していましたが、自宅サーバ群の完全撤廃に伴い、その運用も当然ながら終了しました。\u003Cbr>以前はHomeNOCより割り当ていただいたIPで運用していましたが、自宅の一般回線はOP25Bの影響を避けられないため、代替としてAmazon Simple Email Service (SES)を暫定的に利用していた、というのがこれまでの経緯です。\u003C/p>\u003Cp>SESのコストや機能に大きな不満はなかったものの、ごく稀に発生するGmailへの転送失敗という、再現性の低い問題が看過できなくなり、本格的に移行先を探すことにしました。\u003C/p>\u003Ch1 id=\"h408cdaab63\">検討と検討と検討\u003C/h1>\u003Cp>まず、国内でよく名前が挙がる安価なメールサービス、例えばConoHaやさくらのメールボックスあたりから検討を始めましたが、これらはその仕様のお粗さから、候補にすら入りませんでした。\u003C/p>\u003Cp>特にさくらのメールボックスに関しては、価格の安さからスパム業者の温床になっているという話は有名で、実際にAS単位でメールサービスからブロックされることも珍しくありません。\u003Cbr>私のような真っ当な利用者が、なぜスパマーのせいでメールが届かないリスクを負わなければならないのか。\u003Cbr>サービス設計としてあり得ない話です。\u003Cbr>たまったものではありません。\u003C/p>\u003Cp>そもそも私の要件は極めてシンプルです。\u003C/p>\u003Col>\u003Cli>独自ドメインで、大量のアドレスを生成・受信できること。\u003C/li>\u003Cli>それらを普段使いのメールボックスに転送できること。\u003C/li>\u003Cli>生成した大量のアドレスからメールを送信できること。\u003C/li>\u003C/ol>\u003Cp>メインで利用しているメールアドレスを、素性の知れないウェブサービスに登録するのが生理的に受け付けない、というだけの理由で、サービスごとにアドレスを使い分けるためだけに独自ドメインを利用しています。\u003Cbr>プライバシがどうこうという崇高な理念は、あいにくと持ち合わせていません。\u003Cbr>現代において、ビッグテックから完全に情報を秘匿することが、現代においてどれほど非現実的か理解しているつもりですので。\u003C/p>\u003Cp>Tech系の界隈を見渡すと、Zoho MailやProtonMailを利用している層がかなり観測できます。\u003Cbr>しかし、私のユースケースはサービス登録の都度、数百から数千といった単位でアドレスを量産することにあるため、エイリアス数に課金されるこれらのサービスはコスト的に全く見合いません。\u003C/p>\u003Cp>コストだけを考えればCloudflare Email Routingも候補でしたが、基本的に受信専用のものでした。\u003Cbr>送信もそれなりに行うため、機能的に片手落ちであり、これも除外。(これを書いている少し前に\u003Ca href=\"https://blog.cloudflare.com/email-service/\" target=\"_blank\" rel=\"noopener noreferrer\">Cloudflare Email Service’s\u003C/a>なるものがプライベートベータとしてリリースされたようですが、検討していた時点では存在しなかったため、考慮していません)\u003C/p>\u003Cp>正直なところ、VPSを借りて再びメールサーバを構築することも一瞬考えました。\u003Cbr>しかし、あの不毛な設定と、セキュリティアップデートやスパム対策に追われる運用保守に、再び貴重なリソースを投下するのはあまりにも非合理的だと判断し、即座に却下しました。\u003Cbr>あんな面倒なこと、二度とやりたくありません。\u003C/p>\u003Ch1 id=\"h046bdabcec\">Apple Hide My Email、お前だったのか\u003C/h1>\u003Cp>ここで私はiCloud+に含まれるHide My Email機能の存在に行き着きます。\u003C/p>\u003Cp>最近の私の身の回りは比較的Apple製品で固められており、そのエコシステムとの親和性は言うまでもありません。\u003Cbr>そして何より、私の目的はアドレスを使い分けることであり、独自ドメインの利用は、その目的を達成するための単なる手段に過ぎませんでした。\u003Cbr>その手段へのこだわりを捨てることに、何のためらいもありません。\u003C/p>\u003Cp>むしろ、独自ドメインを手放すことには予期せぬメリットすらありました。\u003Cbr>PictLinkのような、特定のドメイン以外からの登録を受け付けない、前時代的で愚かな仕様を持つサービスが未だに存在しますが、\u003Ccode>icloud.com\u003C/code>というドメインパワーの前では、そうした問題も発生しにくい。\u003Cbr>これはうれしい。我ながらかなり合理的な判断です。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/eae3d94adc674e3b89835222a24fd6ef/D510F5C4-01C7-4CC9-966C-29245F32CE0D.JPG?w=600&amp;h=1303\" alt=\"\" width=\"600\" height=\"1303\">\u003C/figure>\u003Cp>一つ問題だったのは、パスワード管理に利用しているVaultwarden(Bitwarden互換)のクライアントから、直接Hide My Emailのアドレスを生成できない点でした。\u003Cbr>サービス登録のたびに手動でアドレスを生成し、コピペするのは普通にしんどいですし、UXとして最悪です。\u003C/p>\u003Cp>しかし、この問題は\u003Ca href=\"https://misskey.vip/@n\" target=\"_blank\" rel=\"noopener noreferrer\">お友達のまくらくら\u003C/a>が作っていたブリッジツールによって解決しました。\u003Cbr>\u003Ca href=\"https://github.com/lqvp/hme_bridge\" target=\"_blank\" rel=\"noopener noreferrer\">https://github.com/lqvp/hme_bridge\u003C/a>\u003C/p>\u003Cp>外向きにはSimpleLoginのAPIとして振る舞うことで、Bitwardenクライアントを騙し、内部でiCloudのAPIを叩いているようです。\u003Cbr>Rustで書かれていたのでコードの詳細は読んでいませんが、きちんと動くので多分問題ないでしょう。\u003Cbr>ありがたく利用させてもらうことにしました。\u003C/p>\u003Cp>各種サービスに登録していたメールアドレスの変更も概ね完了し、現在はiCloud基盤で安定して稼働しています。\u003Cbr>SES + Lambdaの環境で発生していたようなメールのロストもなく、不満はありません。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>最近のサービスはSSOを前提とし、ユーザにメールアドレスの入力すらさせず、GoogleアカウントやiCloudアカウントでログインさせるものが増えているように感じます。\u003Cbr>この風潮の背景には、「原則としてユーザは馬鹿で愚かで無能なものなので、まともにアカウント管理など到底できる訳がない」という、サービス提供者側の諦観にも似た思想があるのでしょう。\u003Cbr>それ自体は否定しませんし、私も同感です。\u003C/p>\u003Cp>しかし、例えばGoogleのような単一の認証基盤に、あらゆるサービスの認証を依存させるアーキテクチャには、強い違和感を覚えます。\u003Cbr>これは、すこし考え方を変えて見れば、SPOFを意図的に作り出しているに等しいあまりにも稚拙で傲慢な設計です。\u003Cbr>一つのサービスから情報が流出しただけで、他の全アカウントに影響が及びかねない構造など、本来あってはならない。\u003Cbr>どうやら世の中の多くの人間に、まともなセキュリティ感覚はないらしいようです。\u003C/p>\u003Cp>GoogleがDon&apos;t be evilを捨てて久しいですが、そのGoogleアカウントに生活の全てを預けることを、なぜ誰も疑問に思わないのでしょうか。\u003Cbr>なーにがゼロトラストだ。口だけでフルトラストじゃないか。\u003C/p>",[130,131,132],{"id":24,"createdAt":25,"updatedAt":26,"publishedAt":25,"revisedAt":26,"slug":27,"name":28},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":37,"createdAt":38,"updatedAt":39,"publishedAt":38,"revisedAt":39,"slug":40,"name":41},{"id":134,"createdAt":135,"updatedAt":136,"publishedAt":137,"revisedAt":136,"title":138,"content":139,"tags":140,"is_no_index":42},"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>",[141,142,143,144],{"id":51,"createdAt":52,"updatedAt":53,"publishedAt":52,"revisedAt":53,"slug":54,"name":55},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":24,"createdAt":25,"updatedAt":26,"publishedAt":25,"revisedAt":26,"slug":27,"name":28},{"id":65,"createdAt":66,"updatedAt":67,"publishedAt":66,"revisedAt":67,"slug":68,"name":69},{"id":146,"createdAt":147,"updatedAt":148,"publishedAt":149,"revisedAt":148,"title":150,"content":151,"tags":152,"is_no_index":42},"a7dv5t3bip","2025-06-21T17:10:41.140Z","2026-02-10T15:43:19.260Z","2025-06-21T17:17:01.575Z","Misskey鯖缶後悔記","\u003Cp>お久しぶりです。\u003Cbr>先日ふとした想いたちから自動車学校に通いはじめた挙句、運転が下手すぎて技能教習を増やされてしまった私です。\u003C/p>\u003Cp>今日は、そんな私が始めてしまった、ある小さなインスタンスについて語り散らかしていきたい。\u003Cbr>ちなみにこれは、誰かの心を照らすような成功譚ではありません。\u003C/p>\u003Ch1 id=\"h432d4192e0\">全ての始まり、小さな寂しさと淡い理想\u003C/h1>\u003Cp>今から遡ること、およそ3年。\u003Cbr>まだ入学したばかりの高校生だった私は、「しゃふすきー」という、少し変わった名前のMisskeyインスタンスを広大なインターネットの片隅にぽつんと構築しました。\u003C/p>\u003Cp>なぜ、数あるSNSの中から、わざわざ自前でサーバーを立てるなんていう面倒な選択をしたのか。\u003Cbr>それは、TwitterやInstagramといった巨大なプラットフォームに渦巻く、常に誰かの視線を意識し、他人からの評価に一喜一憂するような空気感に、少しだけ息苦しさを覚えていたからです。\u003Cbr>お金儲けのためのレコメンドアルゴリズムによって最適化され、差し出される情報ではなく、もっと属人的で、手触りのある、自分のためだけの場所が欲しかった。\u003Cbr>最初は、誰にも見つからない日記帳のような、自分だけのおひとり様インスタンスとして、静かに運営していくつもりでした。\u003C/p>\u003Cp>しかしながら、たった一人きりのLTLは、私が想像していた以上に静かでした。\u003Cbr>投稿しても、ローカルの誰からも反応がない。\u003Cbr>聞こえるのは、自分の思考が壁に当たって跳ね返ってくる、その反響音だけ。\u003Cbr>その完璧なまでの静寂が、私の心の奥底に横たわる、名前のつかない寂しさの輪郭を、あまりにもありありと映し出す鏡のように感じられて、次第に怖くなっていきました。\u003Cbr>あるいは、心のどこかに巣食っていた「誰かに見つけてほしい」「私の存在を肯定してほしい」という、承認欲求が、むくむくと鎌首をもたげたのかもしれません。\u003C/p>\u003Cp>「誰かが、この場所に居てくれたら」。\u003Cbr> 「私と同じように、既存のSNSの喧騒に少しだけ疲れた人が、その羽を休められるような、ささやかな止まり木になれたら」。\u003C/p>\u003Cp>そんな淡い期待と、今にして思えば少しばかり傲慢な理想を抱いて、私はインスタンスの重い扉を開け、誰でも自由に登録できるように設定を変更しました。\u003Cbr>この、ほんの数クリックの、しかし後戻りのできない操作が、私のその後の3年間を決定づける、全ての始まりでした。\u003C/p>\u003Cp>開設して間もなく、本当にありがたいことに、数十人の方々が私の小さなサーバーにアカウントを作ってくださいました。\u003Cbr>中には、LTLに定住し、日々の出来事をこまめに投稿してくれる方も現れました。\u003C/p>\u003Cp>「おはよう」という、一日の始まりを告げる挨拶。\u003Cbr>今日食べたランチの、少しピントのずれた写真。\u003Cbr>好きな音楽や映画の話。\u003Cbr>深夜の、誰に聞かせるともない、とりとめのない悩み事。\u003Cbr>自分の知らない誰かが、自分の作った場所で笑ったり、怒ったり、悩みを打ち明けたりしている。\u003Cbr>その事実の一つひとつが、まるで乾いた土に染み込む水のように、私の心をじんわりと温め、満たしていきました。\u003C/p>\u003Cp>LTLを眺めていると、まるで自分が神様にでもなったような、不思議な全能感がありました。\u003Cbr>私が維持しているこの場所が、誰かの日常の一部になっている。\u003Cbr>私の存在が、誰かのささやかな居場所になっているのかもしれない。\u003Cbr>その感覚は、現実の生活では得難い、抗いがたいほどに甘美なものでした。\u003Cbr>人の気配がそこにあるというだけで、私の慢性的な寂しさは、確かに少しだけ、その輪郭をぼやかしてくれるような気がしていたのです。\u003C/p>\u003Ch1 id=\"h659334fa2a\">楽しかった\u003C/h1>\u003Cp>しかし、現実は残酷なもので、あの夢のように穏やかで、希望に満ちていた日々は、残念ながら永遠ではありませんでした。\u003Cbr>人が増え、タイムラインの流量が増え、コミュニティがゆっくりと拡大していくにつれて、水面下に潜んでいた様々な問題が、一つ、また一つと、ぬるりとした感触で顕在化してきたのです。\u003C/p>\u003Cp>もちろん、技術的な問題は常にありました。\u003Cbr>GCPのプロジェクトが突然凍結されたり、深夜にPostgreSQLクラスタ間のデータ不整合を見つけてしまい、半泣きで復旧作業にあたったこともあります。\u003Cbr>急激なユーザー増で特定ノードへの負荷が高くなり、慌ててスケールしたことも一度や二度ではありません。\u003Cbr>でも、そういった技術的なトラブルは、ある意味で解決がシンプルです。\u003Cbr>エラーログと向き合い、ドキュメントを漁り、試行錯誤を繰り返せば、大体はなんとかなってしまう。\u003Cbr>むしろ、困難な問題を解決したときには、明確な達成感さえありました。\u003Cbr>というか、私ですらできるようなそんなことができないようなら、そもそも自前でインスタンスなんて建てるべきではない、とさえ思っています。\u003C/p>\u003Cp>本当に私の心を蝕んでいったのは、そういった技術的・金銭的に解決できる問題ではありませんでした。\u003Cbr>もっとアナログで、複雑で、正解がなく、そして終わりもない、人間という存在そのものが孕む問題です。\u003C/p>\u003Cp>インスタンスの開設から1年半か、あるいは2年が経った頃でしょうか。\u003Cbr>それまで保たれていた牧歌的な雰囲気に、少しずつ、しかし確実に、不協和音が混じり始めました。\u003Cbr>最初は些細な違和感でした。\u003Cbr>あるのユーザーが、LTLを自分の日記帳かのように使い、延々と自分語りを続ける。\u003Cbr>会話の流れを無視して、唐突に自分の話にすり替える。\u003Cbr>そういった、コミュニケーションの僅かなズレが、日に日に目立つようになってきたのです。\u003C/p>\u003Cp>他者への配慮が決定的に欠けている、人格に難のあるユーザーの存在が、タイムライン上で無視できない大きさになってくると、負の連鎖が静かに、しかし着実に始まります。\u003Cbr>もともといた良識的なユーザーたち、あの穏やかな空気を作ってくれていた人々は、その居心地の悪さに耐えかねたのか、あるいは単純に飽きたのか、一人、また一人と、何も言わずに姿を消していきました。\u003Cbr>お気に入りのユーザーのアイコンが、ある日を境にデフォルトの画像に戻っているのを見つけるたび、胸の奥が冷たくなるのを感じました。\u003C/p>\u003Cp>彼らが去った分だけLTLは静かになり、その空白を埋めるように、さらに似たような性質の、声の大きい人たちが居座るようになる。\u003Cbr>結果として、コミュニティはゆっくりと、でも確実に、私が望んでいたものとは全く違う、歪で、息苦しい姿へと変貌してしまいました。\u003C/p>\u003Cp>私の運営しているインスタンスは「しゃふすきー」という名前です。\u003Cbr>社会不適合者の「しゃふ」と、Misskeyの「すきー」を組み合わせた、安直な名前。\u003Cbr>余っていたドメイン(当時はgenkaishahu.com)で、本当に深く考えずに名付けてしまいました。\u003Cbr>その自虐的な名前の響きからか、開設当初から、メンタルヘルスに何かしらの問題を抱えている方が多く登録される傾向がありました。\u003Cbr>私自身もその一人ですから、同じような境遇の人たちが、社会の喧騒から離れて安らげる場所になれば、という気持ちがなかったわけではありません。\u003C/p>\u003Cp>これは今でこそ思えるのですが、あまりにもナイーブで、独りよがりで、そして何よりも傲慢な考えでした。\u003Cbr>同じ傷を持つ者同士が、必ずしも寄り添い合えるとは限らない。\u003Cbr>むしろ、互いの傷口を舐め合い、膿を啜りあうような、共依存的な関係に陥りやすい。\u003Cbr>メンタルに問題を抱える人たち「だけ」が集まる場所を、健全なコミュニティとして成り立たせることは、もしかしたら元より不可能に近い、あまりにも過酷な挑戦だったのかもしれないのです。\u003C/p>\u003Ch1 id=\"h4860e8be57\">地獄とか\u003C/h1>\u003Cp>現状を言葉を選ばずに言えば、割と地獄のようなものです。\u003C/p>\u003Cp>「自分はこんなに可哀想」「誰も分かってくれない」「世界が私をいじめる」と、常に被害者としての立場から世界を語る人々。\u003Cbr>しかし、その言動の端々からは、他者への攻撃性や、「自分だけが注目されるべきだ」「私を慰め、肯定しなさい」という強烈な支配欲が、全く隠しきれていない。\u003Cbr>そういう人たちが、無数に点在しています。\u003C/p>\u003Cp>「誰も私を助けてくれない」という嘆きは、いつしか「だからあなたは私を助けるべきだ」という、他者への一方的な要求に変わります。\u003Cbr>「辛い」という一言は、他者の時間や感情を際限なく奪うための、万能の呪文になります。\u003Cbr>そして、その要求が満たされないと分かると、今度はその相手を「冷たい人だ」と断じ、新たな攻撃の対象にするのです。\u003C/p>\u003Cp>そんな言葉で埋め尽くされた投稿を眺めていると、本当に、心がじりじりと焼かれていくような、耐え難い感覚に襲われるのです。\u003C/p>\u003Cp>管理者として、「こんなつもりではなかった」なんて言う資格は、私には微塵もありません。\u003Cbr>サーバーの扉を開け、誰でもどうぞと招き入れたのは、他の誰でもない私自身なのですから。\u003Cbr>全ての責任は、この私にあります。\u003C/p>\u003Cp>でも、その責任論とは全く別の話として、今のこの状況は、一人の人間として、本当に耐え難く、辛い。\u003Cbr>めっちゃしんどい。\u003Cbr>まるで、自分の善意や寂しさが産み出してしまった化け物たちに、自分の手で築いた城の中で、じわじわと四方から追い詰められ、精神的な逃げ場を失っていくような気分です。\u003C/p>\u003Cp>ここからは、私自身が精神疾患の当事者だからこそ言える、とても残酷で、差別的な内容です。\u003Cbr>もし、あなたの心をざわつかせてしまったら、本当に申し訳ない。\u003C/p>\u003Cp>メンタルヘルスに問題を抱えている人間は、悲しいかな、大概において、コミュニケーションの面で碌でもない側面を持っています。\u003Cbr>もちろん、私自身を棚に上げるつもりは全くありません。\u003Cbr>むしろ、私こそがその筆頭なのかもしれない。\u003Cbr>だから、どんなに崇高な理想を掲げたとしても、他人に安易に手を差し伸べようとするべきではない。\u003Cbr>それが、この3年間で私が心と身体に深く刻み込んだ、最も痛みを伴う教訓でした。\u003C/p>\u003Cp>私のような層は、多くの場合、社会性が決定的に欠けています。\u003Cbr>だからこそ、大衆に向けられた「誰でもどうぞ」という無償の善意を、「自分だけを見てくれている」「この人は私を理解してくれるはずだ」という、特別な好意だと勘違いしてしまう。\u003Cbr>そして、際限なく甘え、依存し、もっと注目してくれと要求し、思い上がっていく。\u003Cbr>彼らの言動の中に、私が必死で蓋をしている自分自身の醜い部分、認めたくない側面を見てしまうからこそ、これほどまでに苦しいのかもしれません。\u003C/p>\u003Cp>彼女ら(そして私たち)は、理由があって社会という大きな輪の中から弾き出され、他人からまともに相手にされていない人たちなのです。\u003Cbr>そう考えると、そのような歪んだコミュニケーションしか取れないのは、ある意味で当然の帰結とも言えます。\u003C/p>\u003Cp>もっとも、私の場合は「誰かを救いたい」なんていう崇高な動機があったわけではありません。\u003Cbr>ただ、自分のどうしようもない寂しさと、肥大した承認欲求を満たしたかっただけ。\u003Cbr>その結果として、同じような空虚を抱えた人たちを、磁石のように引き寄せてしまった。\u003Cbr>ただ、それだけのことだったのです。\u003C/p>\u003Cp>社会に適合できない者たちを集めて、小さな社会を作ろうとしたら、そこではより純度が高く、煮詰められた社会不適合が生まれるだけ。\u003Cbr>そんな単純な道理に、私は気づくのがあまりにも遅すぎました。\u003C/p>\u003Ch1 id=\"h092c6dc24d\">静かな隠居\u003C/h1>\u003Cp>私は、もうそろそろ限界です。\u003Cbr>朝、目が覚めてPWAからの通知を見た瞬間に、ずしりとした疲労感が全身を覆う。\u003Cbr>TLを開くのが怖い。\u003Cbr>誰かの投稿に反応するのが億劫。\u003Cbr>あの場所は、もはや私にとって、安らぎの場所ではなく、心をすり減らすだけの苦役になってしまいました。\u003C/p>\u003Cp>サーバーを完全に閉鎖することも、考えなかったわけではありません。\u003Cbr>全てを投げ出して、この忌まわしい人間関係から逃げ出してしまえたら、どれだけ楽だろうかと、この数ヶ月、夜ごと何度も考えました。\u003C/p>\u003Cp>でも、こんな場所になってしまっても、変わらずに使い続けてくれている数少ない、本当に良識的なユーザーさんたちがいます。\u003Cbr>私が落ち込んでいるときに、そっと優しい言葉をかけてくれた人。\u003Cbr>「この場所が好きです」と、言ってくれた人。\u003Cbr>そして、サーバーの維持のために、毎月貴重なお金を寄付という形で支援し続けてくれている方々もいます。\u003C/p>\u003Cp>私の個人的な「もう無理」という感情だけで、その人たちのささやかな居場所や、これまでの思い出、そして寄せてくれた善意を、全て無碍に消し去ってしまうのは、あまりにも無責任で、身勝手だと思いました。\u003C/p>\u003Cp>だから、私は少しだけ、この場所との関わり方を変えることにします。\u003C/p>\u003Cp>もちろん、管理者として必要な最低限のモデレーションや、サーバーのメンテナンスは、責任をもって続けるつもりです。\u003Cbr>でも、TLを常に監視して心をすり減らすような関わり方は、もうやめます。\u003Cbr>当面は全く関係のない、インターネットの片隅でひっそりと、ただのユーザーとして隠居生活を送るつもりです。\u003Cbr>誰のことも気にせず、ただ好きなことを呟ける、あの最初の頃のような自由を取り戻したいのです。\u003C/p>\u003Cp>結局のところ、私には、一つのコミュニティを健全に管理し、そこに集う人々の人生の断片を預かり、その責任を最後まで背負い続けられるほどの器がなかった。\u003Cbr>ただ、それだけのことなのでしょう。\u003Cbr>\u003Cs>もっと規模が大きく、個々人の投稿が目に止まらないほどのTLを構築できていたらまた話は違ってきたのかもしれません。\u003C/s>\u003C/p>\u003Cp>美しい夢だったと思います。\u003Cbr>そして、その夢は、私の手には余るほど、重たく、そしてあまりにも壊れやすいものだったのです。\u003C/p>\u003Cp>自分の未熟さと無力さ、そして人間関係のどうしようもなさを、これでもかというほど思い知らされた、長くて短い3年間でした。\u003C/p>\u003Cp>それでは。\u003C/p>",[153,154,155,156,157],{"id":31,"createdAt":32,"updatedAt":33,"publishedAt":32,"revisedAt":33,"slug":34,"name":35},{"id":37,"createdAt":38,"updatedAt":39,"publishedAt":38,"revisedAt":39,"slug":40,"name":41},{"id":24,"createdAt":25,"updatedAt":26,"publishedAt":25,"revisedAt":26,"slug":27,"name":28},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":51,"createdAt":52,"updatedAt":53,"publishedAt":52,"revisedAt":53,"slug":54,"name":55},{"id":159,"createdAt":160,"updatedAt":161,"publishedAt":161,"revisedAt":161,"title":162,"content":163,"tags":164,"is_no_index":42},"v74px0p4w","2025-04-30T04:28:12.247Z","2025-04-30T08:00:40.810Z","自宅サーバーやめた","\u003Cp>わたしが長年続けてきたことについて、一つの区切りがついたので、その経緯と考えを少し整理してみようと思います。\u003Cbr>小学生の頃に初めて構築したPMMPのサーバーから数えて、十年ちょっと続けてきた自宅サーバーの運用をやめる決断をしました。\u003C/p>\u003Ch1 id=\"h68eaf13a9e\">自宅サーバーという存在\u003C/h1>\u003Cp>そもそも、自宅サーバーがどのようなものか、という点に触れておく必要があるでしょう。\u003Cbr>その名の通り、自宅に設置して運用するサーバーのことです。\u003Cbr>一般的にサーバーは、コンシューマ向けの製品と比較して耐久性などが考慮されており、24時間連続で稼働させることを想定して設計されています。\u003C/p>\u003Cp>わたしの場合、FUJITSU PRIMERGY RX2530 M4というサーバーを自宅に4台、そして別荘にもそれぞれ3台ずつ設置していました。\u003Cbr>それらの拠点をNGN網内VPNで接続し、一つのネットワークとして機能させていたのです。\u003Cbr>その上では、\u003Ca href=\"https://shahu.ski/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">現在も公開しているMisskeyサーバー\u003C/a>や、パスワード管理のためのvaultwarden、ファイル同期用のnextcloud、あるいはDNS用のbindなど、公開サービスから個人的な利用用途のものまで、多岐にわたるソフトウェアをホストしていました。\u003Cbr>一時期はHomeNOCと接続し、おうちのルータがフルルートを食っているという少し特殊な環境を構築していたこともありました。\u003C/p>\u003Ch1 id=\"h339b805da6\">やめた理由\u003C/h1>\u003Cp>自宅サーバーを始めたきっかけは、当時運営していた公開Minecraftサーバーのためでした。\u003Cbr>ごく単純な動機だったように思います。\u003Cbr>その後も、形を変えながら様々なものを動かし続けてきましたが、その背景には、自分の手で環境を所有しているという感覚があったのかもしれません。\u003C/p>\u003Cp>しかし、エンタープライズ向けとされる機器を用いたとしても、結局のところ設置場所は所詮自宅でしかありません。\u003Cbr>データセンターのような専門施設とは異なり、信頼性や稼働の安定性という点では、自ずと限界が見えてきます。\u003C/p>\u003Cp>それに加えて、「自宅サーバーはコストパフォーマンスが良い」という当初抱いていたイメージは、運用を続けるうちに少しずつ変化していきました。\u003Cbr>維持管理にかかるコストを考慮に入れると、一概にそうとは断言できないと考えるようになったからです。\u003Cbr>冗長化、万が一に備えたオフサイトバックアップ、故障時のための保守用パーツの常備、定期的なメンテナンス作業。これらには相応の手間と費用がかかります。 \u003Cbr>加えて、\u003Ca href=\"https://shahu.ski/notes/a36ms6lpk0sf0q3d\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">電気代もそれなりの金額\u003C/a>がかかりますし、何よりも「もしサービスがダウンしたらどうしよう」という心配が、常に頭の片隅にありました。\u003C/p>\u003Cp>自宅サーバーでは当然、サーバーマシンが故障した際の対応も自分で行わなければなりません。\u003Cbr>特に起こりやすい問題は、データが失われることでしょう。\u003Cbr>コンシューマ向けのものより高耐久とはいえ、ストレージは消耗品であり、突然壊れてしまうこともあります。\u003Cbr>S.M.A.R.T.情報である程度の状態は把握できますが、それでも故障を完璧に予見することは困難で、常に故障のリスクを念頭に置く必要がありました。\u003C/p>\u003Cp>ストレージの故障に備えるというのは、ただバックアップを作成するだけでは足りません。\u003Cbr>手順を明確にしておき、実際に故障が発生した際には、迅速に復旧に取り掛かれる準備をしておくことが求められます。\u003Cbr>これを怠ると、サービスが使えない時間が延びるだけでなく、最悪の場合、復旧を試みて初めてバックアップが役に立たないことに気づく、という苦い経験をすることにもなりかねません(1敗)。\u003C/p>\u003Cp>できる限りサービスを継続させ、データを失わないようにするために、RAID構成を採用したり、冗長構成を用意したりしていましたが、絶えずサーバーの調子を気にし続けることは、精神的な負担が大きかったです。\u003Cbr>所謂OSI参照モデルにおけるL1からL7まで、その全ての面倒を見る必要があり、完璧を目指そうとした結果、少々消耗してしまったのかもしれません。\u003C/p>\u003Ch1 id=\"hfb33b72b1b\">得られたもの\u003C/h1>\u003Cp>それでも、自宅サーバーを運用してきたことは、わたしにとって無駄ではなかったと考えています。\u003Cbr>自宅に運用基盤を構築し、サーバーを管理するプロセスを通じて、多くの知識や技術を習得できたからです。\u003C/p>\u003Cp>広く利用されている主要なパブリッククラウドでは、低いレイヤの詳細を知らなくても利用できる場合が多いですが、逆に言えば、そうした知識を実践的に学ぶ機会が得にくいということでもあります。\u003Cbr>自宅でのサーバー運用を通じて、このような知識を実際に手を動かしながら身につけられたという点において、これまでの時間は有意義だったと、そう思えるのです。\u003C/p>\u003Ch1 id=\"h3dfff7ab3d\">自宅サーバーをやめてどうすんの\u003C/h1>\u003Cp>現在は、ローカルネットワークで稼働させる必要のあるbindを除き、他のサービスはほぼ全てクラウド上に移行し、自宅以外の場所に設置していたサーバーは全て停止させました。\u003Cbr>Misskeyや定期実行しているバッチ処理などは、GKEやCloud Run、あるいはCloudflare Workersといったサービスへ移行しました。\u003Cbr>Nextcloudのようなソフトウェアについては、自身でホストする形態をやめ、それらの機能を提供するSaaSを利用することにしました。\u003C/p>\u003Cp>近年、手軽に使えるクラウドサービスが普及したことで選択肢も増え、自宅サーバーという選択肢は、以前ほどは魅力的な選択肢とは見なされなくなってきているかもしれません。\u003Cbr>しかし、私としては、自宅サーバーは一度試してみる価値のあるものだと今でも思います。\u003Cbr>大規模なものでなくても、小さくても何かサービスを自分で動かしてみる経験は、知識を深める上で非常に良い経験になるはずです。\u003C/p>\u003Cp>思い返してみれば、わたしが自宅サーバーをやり続けていた理由の一つに、「所有感」があったように思います。\u003Cbr>普段作業する場所のすぐ近くに実体のあるサーバーが存在し、そこで動いているアプリケーションへ絶えずトラヒックが飛んできているという実感です。\u003Cbr>事業者が提供するパブリッククラウドを利用することは、どこか自分の作ったものやデータが、手の届かない場所に行ってしまうようで、少し寂しさや不安を感じていました。\u003C/p>\u003Cp>しかし、運用に伴う負担や困難を経験する中で、考え方が徐々に変わってきました。\u003Cbr>むしろ、物理的な制約がなく、安定稼働し、規模の変更も容易で、従量課金の柔軟性に、大きな利点を感じるようになったのです。\u003C/p>\u003Cp>\u003C/p>\u003Cp>これは余談ですが、可能な限りセルフホストしたいという気持ちと、利便性を追求して全てをSaaSに委ね、先進的なサービスを享受したいという気持ちが、交互にやってくる感覚は、わたしだけのものでしょうか。\u003Cbr>この辺りの感覚について、皆さんはどうお考えになるか、少し気になるところです。\u003C/p>\u003Cp>長くなりましたが、今回はこの辺で。最後まで読んでくださり、ありがとうございました。\u003C/p>",[165,166,167],{"id":37,"createdAt":38,"updatedAt":39,"publishedAt":38,"revisedAt":39,"slug":40,"name":41},{"id":24,"createdAt":25,"updatedAt":26,"publishedAt":25,"revisedAt":26,"slug":27,"name":28},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},10,12,1783975282173]