[{"data":1,"prerenderedAt":172},["ShallowReactive",2],{"tag-technology":3,"tag-technology-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},"qfey3yw0z1","2025-04-29T08:44:41.687Z","2025-12-03T16:07:14.622Z","technology","テクノロジー",1,0,{"contents":14,"totalCount":170,"offset":12,"limit":171},[15,30,52,69,78,93,102,119,131,141,152,161],{"id":16,"createdAt":17,"updatedAt":18,"publishedAt":18,"revisedAt":18,"title":19,"content":20,"tags":21,"is_no_index":29},"0s5wahzwf4","2026-07-13T20:36:41.461Z","2026-07-13T20:40:28.103Z","知性","\u003Cp>嫌な時代になったもので、わたしが知性を感じていたもの達は、軒並みLLMによって6割くらいの完成度で再現可能になってしまいました。\u003C/p>\u003Cp>文章も、コードも、設計も、映像も。おかげさまで世の中はslopまみれです。検索結果を開けばLLM製のまとめ記事、技術記事サイトを開けばどこかで見たような解説の劣化版焼き直し。SEO汚染で瀕死だったインターネットに、とどめの一撃が入った格好です。\u003C/p>\u003Cp>この6割という数字が、また嫌らしい。2割なら誰も相手にしませんし、10割なら諦めもつく。6割は、素人目には十分に見えて、よく見ると粗が見える感じの、ちょうど厄介なラインです。そして残念なことに、世の中の大半の場面は6割で十分だったようです。残念で仕方がありません。\u003C/p>\u003Cp>極端な話、LLMの出力する任意の成果物は単なるパターンマッチに過ぎません。確率的に尤もらしいトークンを並べているだけで、そこに理解と呼べるものがあるのかは怪しいところです。\u003C/p>\u003Cp>ただ、残念なことに、わたし達が知的作業と呼んでいたモノの多くは、そこまで崇高なものではなく、パターンマッチで代替可能でした。とりあえず動くものをつくる、目の前のエラーに場当たり的なパッチを当てる。そういうある種の短絡的な思考で解決できる問題は殆どLLMで解決可能になっているように感じています。認めたくないですがね。\u003C/p>\u003Cp>正直、コーディングや設計で現行のLLMに負ける気は更々ありません。が、これも今だけでしょう。そのうち負ける気がしています。というか、Fable 5相手ですら怪しいですからね。1年前のモデルには余裕を感じていたというのに。\u003C/p>\u003Cp>本当に負けた日には、わたしも「オデ、Fableノウデ、クウ。オデ、ツヨイエンジニアニ、ナル」とか言い出しかねません。まぁ、相手に腕は無いんですが。\u003C/p>\u003Cp>以前、LLMがどれだけ優れていても、つまらない人間はつまらないという記事を書きました。今回は、その矛先を自分に向けてみようと思います。\u003C/p>\u003Cp>私は幼い頃から気持ちの悪いコンピュータのオタクでした。それ自体はただただ気持ち悪いだけなのでいいのですが、いつの日からか、技術は好奇心の対象というより、無価値な自分を価値あるものに見せるための粉飾の材料になっていたようにも思います。技術の話をしている間だけは、何者かでいられるためです。\u003C/p>\u003Cp>念のため書いておくと、これは私が特別高度な事を理解していることを示唆するものではありません。\u003C/p>\u003Cp>とは言え、世の中の大半は(詳しいとされている人でさえ)基礎的な部分を碌に理解していません。TCPの輻輳制御を説明できないままインフラを語り、ベジェの制御点の意味も知らないままモーションを売っています。だから、尤もらしいことを言うだけで相対的に浮上できてしまう。ただそれは、周囲の無理解に支えられた非本質的な虚構でしかないとも思うのです。\u003C/p>\u003Cp>白状すると、私は自分を大きく見せようとする無知な人が苦手です。それは私自身がそういう側面を持ち合わせているからなのかもしれませんし、そうではないのかもしれません。実際のところ、周りが私のことをどう思っているかなんて知りようがないので、どちらとも言えないのですが。\u003C/p>\u003Cp>そして今、尤もらしいことを言う能力なら人間よりよほど上手い計算機が現れてしまいました。虚構で嵩上げしていた部分から順に価値が剥がれ落ちていきます。これは困ったことになりました。\u003C/p>\u003Cp>では、何が残るのでしょうか。\u003C/p>\u003Cp>私は、分からないことを分からないと自分で認識して、批判的に内省できることだと考えています。視座が高い話をしたいわけではありません。むしろ逆で、それすら出来ないメタ認知で、現代社会に適応できるのか？という純粋な疑問です。\u003C/p>\u003Cp>LLMは自分が何を分かっていないのかを分かっていません。知識の穴に差し掛かっても速度を落とさず、同じ自信で虚偽を出力してくる。あれだけ流暢に喋る計算機が、自分の限界の認識だけは絶望的に下手なわけです。裏を返せば、自分の理解の輪郭を把握していること, どこまでが検証済みでどこからが受け売りかを区別できること, 分からないと言えること。今のところ、ここあたりだけは辛うじて人間の役割として残っています。\u003C/p>\u003Cp>むしろ、それを持たない人間には価値がないので、LLMでいいです。分かっていないことを分かっていないまま断言する人間は、品質でも再現性でもコストでもその辺の計算機に劣ります。存在意義がありません。\u003C/p>\u003Cp>偉そうなことを書きましたが、真っ先に価値が剥がれる側は多分わたしです。コーディングで負ける日が来たとき、身の程を知る知性とやらが飯の種になる保証はどこにもありません。\u003C/p>\u003Cp>とはいえ、他にやれることも特に思いつかないので、当面は自分がどこから分かっていないのかの確認だけ続けることになりそうです。それすら粉飾の一部だと言われたら、返す言葉もありませんが。\u003C/p>",[22,28],{"id":23,"createdAt":24,"updatedAt":25,"publishedAt":24,"revisedAt":25,"slug":26,"name":27},"wia4slp55q","2025-04-28T07:14:14.541Z","2025-12-03T16:08:13.798Z","thoughts","独り言",{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},true,{"id":31,"createdAt":32,"updatedAt":33,"publishedAt":34,"revisedAt":33,"title":35,"content":36,"tags":37,"is_no_index":51},"hp0fd0z-gnnt","2026-07-04T12:48:34.820Z","2026-07-04T13:57:28.343Z","2026-07-04T13:11:31.078Z","塩漬けのMisskey v12を最新版にアップグレードする","\u003Ch1 style=\"text-align: start\" id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp style=\"text-align: start\">どうも、わたしです。\u003C/p>\u003Cp style=\"text-align: start\">先日、お友達の運営する2つのMisskeyインスタンスを、v12.119.1ベースのフォークから最新の2026.6.0ベースの\u003Ca href=\"https://github.com/lqvp/misskey-tempura\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">別フォーク(tempura)\u003C/a>へ移行しました。およそ3年半の塩漬けインスタンスです。\u003Cbr>バックアップはありませんでしたし、FWもなく、inbound全解放の状態でした。よく今まで無事だったなと。\u003C/p>\u003Cp style=\"text-align: start\">旧サーバはConoHa VPS上のUbuntu 22.04にMisskey install shell scriptで構築された環境でした。これをKAGOYA VPSのUbuntu 26.04へDocker構成で丸ごと引っ越します。DBはPGroonga入りのPostgreSQL18にし、\u003Ccode>files/\u003C/code>配下にあったメディア類はR2へ、デプロイはcompose-cdでGitOps化するところまでやりました。当エントリはその備忘録です。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h8aea7b1b68\">移行前と移行後\u003C/h1>\u003Ctable>\u003Ctbody>\u003Ctr>\u003Cth colspan=\"1\" rowspan=\"1\">\u003Cp>\u003C/p>\u003C/th>\u003Cth colspan=\"1\" rowspan=\"1\">\u003Cp>旧\u003C/p>\u003C/th>\u003Cth colspan=\"1\" rowspan=\"1\">\u003Cp>新\u003C/p>\u003C/th>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>サーバ\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>ConoHa VPS(Ubuntu 22.04)\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>KAGOYA VPS(Ubuntu 26.04LTS)\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>Misskey\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>v12.119.1フォーク(systemd)\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>tempura 2.0.9(Docker)\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>DB\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>PostgreSQL15\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>PostgreSQL18.4+PGroonga\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>全文検索\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>無し\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>PGroonga\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>メディア\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>ローカル\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>Cloudflare R2\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>リバースプロキシ\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>nginx+certbot\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>Caddy+Cloudflare Origin証明書\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>バックアップ\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>無し\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>R2へ1日2回\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>デプロイ\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>compose-cd\u003C/p>\u003C/td>\u003C/tr>\u003C/tbody>\u003C/table>\u003Cp style=\"text-align: start\">compose一式は、以前\u003Ca href=\"https://misskey.blue/\" target=\"_blank\" rel=\"noopener noreferrer\">misskey.blue\u003C/a>用に組んだ\u003Ca href=\"https://github.com/chan-mai/misskey.blue-docker-provision\" target=\"_blank\" rel=\"noopener noreferrer\">provisionレポジトリ\u003C/a>を下敷きにしています。 概ね中身は\u003Ca href=\"https://mq1.dev/entry/krpvl5itbr9h\" target=\"_blank\" rel=\"noopener noreferrer\">以前書いた構築記事\u003C/a>のものです。\u003Cbr>成果物は\u003Ca href=\"https://github.com/miel-misskey/rochka.club-docker-provision\" target=\"_blank\" rel=\"noopener noreferrer\">ここ\u003C/a>に置いてあります。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://github.com/miel-misskey/rochka.club-docker-provision\" target=\"_blank\" rel=\"noopener noreferrer\">\u003Cu>https://github.com/miel-misskey/rochka.club-docker-provision\u003C/u>\u003C/a>\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"hd196191eab\">アップグレードの方針\u003C/h1>\u003Cp style=\"text-align: start\">フォークのまま多段アップグレードはしません。というより、できませんでした。\u003C/p>\u003Cp style=\"text-align: start\">旧サーバが使っていたv12.119.1ベースのフォークは元レポジトリ自体がすでに消滅していて、差分をたどって移行パスを検証するという選択肢がなかったためです。なのでDBをバニラ相当とみなし、上流の通常アップグレード手順にそのまま追従させて最新版まで上げてから、最後にtempuraフォーク差分を適用することにしました。\u003C/p>\u003Cp style=\"text-align: start\">手順としては、旧サーバで\u003Ccode>pg_dump\u003C/code>したものを新サーバのpostgres:15コンテナへ\u003Ccode>pg_restore\u003C/code>し、あとはappイメージのtagを差し替えて起動、マイグレーションの完走を見届けてまた次のtagへ。\u003C/p>\u003Cp style=\"text-align: start\">ここで\u003Ccode>docker compose exec -T\u003C/code>がstdinを消費することにハマりました。スクリプトを流し込む段階で、execが残りのスクリプトをstdinとして食ってしまい、以降のコマンドが実行されなかったためです。restoreやインデックス作成がエラーを出さずに飛ぶので気づきにくいですが、SQLは\u003Ccode>&lt; file\u003C/code>、参照系は\u003Ccode>&lt;/dev/null\u003C/code>で明示的にリダイレクトするといいでしょう。\u003C/p>\u003Cp style=\"text-align: start\">今回は、12.119.1 → 13.14.2 → 2023.12.2 → 2024.10.0 → 2025.1.0 → 2026.6.0 → tempura 2.0.9の順でアップグレードしました。最大の難所はv12→v13で、かなり大規模な差分が入っています。マイグレーション数でいうと350本前後、tempura独自の57本を足して最終的に400本ちょっとでした。 \u003C/p>\u003Cp style=\"text-align: start\">v12からの移行で苦しんだことも記憶に新しいですが、もう3年前なんですね、あれ。\u003C/p>\u003Cp style=\"text-align: start\">移行に際して、\u003Ccode>default.yml\u003C/code>の項目も幾分か変わっているのですが、id方式(aid)だけは全世代を通して変えないことが必要です。これはデータの整合性を担保するために必須です。\u003C/p>\u003Cp style=\"text-align: start\">また、PG15からPG18への移行はメジャーバージョン跨ぎなのでデータディレクトリの流用はできず、ここもdump/restoreで行いました。PGDATAが\u003Ccode>/var/lib/postgresql/18/docker\u003C/code>にあるので、親ディレクトリごとマウントすれば永続化できます。今回は合わせてPGroongaの導入も行っているので、\u003Ccode>CREATE EXTENSION pgroonga\u003C/code>して\u003Ccode>note.text\u003C/code>にインデックスを張り、\u003Ccode>fulltextSearch: sqlPgroonga\u003C/code>を有効にしました。v12には無かった全文検索が使えるようになりました。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h19474baa06\">メディア移行とMIME\u003C/h1>\u003Cp style=\"text-align: start\">\u003Ccode>drive_file\u003C/code>は53,534行ありましたが、ローカルに実体を持つのは4,007件だけで、残り49,527件はリモートのリンク参照でした。ファイルそのものは9,564個(原本4,007+サムネイル3,898+webpublic1,659)で計8.8GiBほどありました。これらをまとめて旧サーバからrcloneでR2へ直接上げました。\u003C/p>\u003Cp style=\"text-align: start\">DB側の既存メディアのURL書き換えは、\u003C/p>\u003Cpre>\u003Ccode class=\"language-sql\">UPDATE drive_file SET url = replace(url, &apos;https://&lt;domain&gt;/files/&apos;, &apos;https://media.&lt;domain&gt;/local/&apos;) WHERE ...;\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">で行い、空のフィールドは空のまま残るので404は出ません。非空URLの合計(4,007+3,898+1,659=9,564)が物理ファイル数とぴったり一致したことを確認しました。\u003C/p>\u003Cp style=\"text-align: start\">また、リモートには\u003Ccode>files/\u003C/code>配下のファイルを参照するデータが残っていることを考慮し、アプリケーションの前段に置いているCaddyでリダイレクトする経路をあわせて用意しています。\u003C/p>\u003Cpre>\u003Ccode>example.com {\n\ttls /etc/ssl/certs/certificate.pem /etc/ssl/private/key.pem\n\tencode gzip\n\theader /assets Cache-Control &quot;public, max-age=31536000, immutable&quot;\n\tfile_server\n\n\tlog {\n\t\toutput file /var/log/caddy/access.log {\n\t\t\troll_size 500mb\n\t\t\troll_keep 10\n\t\t}\n\t\tformat json {\n\t\t\ttime_format iso8601\n\t\t}\n\t}\n\n\t# 旧メディアURLをR2へ\n\t@legacyfiles path_regexp legacyfiles ^/files/(.*)$\n\tredir @legacyfiles https://media.example.com/local/{re.legacyfiles.1} permanent\n\n\treverse_proxy app:3000 {\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\thealth_uri /\n\t\thealth_interval 10s\n\t\thealth_timeout 2s\n\t\thealth_status 200\n\t}\n\n\thandle_errors {\n\t\t# appコンテナ停止時などのバックエンドエラー\n\t\t@backend_down `{err.status_code} in [500, 501, 502, 503, 504, 522]`\n\t\thandle @backend_down {\n\t\t\t# /api系は外形監視のためメンテナンスを返さず本来のエラー(5xx)を通す\n\t\t\t@api path /api/*\n\t\t\thandle @api {\n\t\t\t\trespond {err.status_code}\n\t\t\t}\n\t\t\t# それ以外はメンテナンスページを200で返す\n\t\t\thandle {\n\t\t\t\treverse_proxy maintenance:80 {\n\t\t\t\t\thandle_response {\n\t\t\t\t\t\tcopy_response_headers\n\t\t\t\t\t\tcopy_response 200\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">R2への転送で一つ問題があり、Misskeyのローカルファイルはファイル名に拡張子がなく(accessKeyがそのままファイル名)、rcloneは拡張子からしかMIMEを判定しないため、全部\u003Ccode>application/octet-stream\u003C/code>でアップロードされていました。画像はブラウザのスニッフィングで表示されてしまうため気づきにくいのですが、動画がインライン再生できない等の問題が発生します。 修正として、S3のCopyObject(metadata REPLACE)で、Content-Typeだけ貼り替えました。\u003Ccode>file --mime-type\u003C/code>の実判定値を9,564件流し込んで完了しました。ちなみに、並列に\u003Ccode>aws-cli\u003C/code>を立ち上げるやり方はメモリ2GiBの子には荷が重すぎたようでOOMしてしまいました。boto3+スレッドプールの単一プロセスでやるのが速くて安全そうです。\u003C/p>\u003Cp style=\"text-align: start\">余談ですが、アップロード中に止めようとして打った\u003Ccode>pkill -f &quot;rclone copy&quot;\u003C/code>は、自分がsshで送ったコマンド文字列そのものにもマッチするようでセッションごと死んでしまい、exit 255が返ってきて数秒固まりました。\u003Ccode>pkill -x rclone\u003C/code>を使いましょう。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"hc07c39fafc\">全員のアイコンがidenticonになる\u003C/h1>\u003Cp style=\"text-align: start\">v12からv13へのアップグレードで、既存ユーザの\u003Ccode>avatarUrl\u003C/code>/\u003Ccode>bannerUrl\u003C/code>がnullになりました。Misskeyの挙動として、identiconにフォールバックされるので、移行直後は全ユーザーのアイコンがidenticon表示になってしまいました。失敗したのかとちょっと焦りましたね。\u003C/p>\u003Cp style=\"text-align: start\">解決策として、\u003Ccode>drive_file\u003C/code>自体は無事(なはず)なので、そこから再構築しました。\u003C/p>\u003Cpre>\u003Ccode class=\"language-sql\">UPDATE &quot;user&quot; SET &quot;avatarUrl&quot; = COALESCE(NULLIF(f.&quot;webpublicUrl&quot;, &apos;&apos;), f.&quot;url&quot;) FROM drive_file f WHERE ...;\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">これで9,507人のアバターと7,238件のバナーが正常に表示されます。\u003Cbr>注意点として、ユーザ情報はin-memoryでキャッシュされているので、DBを直しただけでは反映されませんので、Misskeyのアプリケーション本体を再起動するところまでやりましょう。\u003C/p>\u003Cp style=\"text-align: start\">ちなみに、絵文字も似たような問題を踏んでいて、\u003Ccode>emoji.publicUrl\u003C/code>が旧URLのまま残り、ローカルの絵文字だけダミー画像にフォールバックしていました。URLを\u003Ccode>drive_file\u003C/code>と同じ直R2形式に置換し、Redisの\u003Ccode>&lt;domain&gt;:singlecache:localEmojis\u003C/code>を消して再起動することで事なきを得ました。 ここで安直に\u003Ccode>FLUSHALL\u003C/code>してしまうとジョブキューごと消えてしまうのでやってはいけません。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h61e6f6ae5a\">管理者がいない\u003C/h1>\u003Cp style=\"text-align: start\">今回利用しているフォークのtempuraでは、管理者/モデレータの判定を\u003Ccode>permissionGroup\u003C/code>という独自カラム(Admin/MainModerator/Normal/Community)で行っており、バニラにある\u003Ccode>isAdministrator\u003C/code>のbool値を見ていません。管理者ロールをDBで作って割り当てても、\u003Ccode>permissionGroup=&apos;Admin&apos;\u003C/code>でなければ権限は付かないことになります。\u003C/p>\u003Cp style=\"text-align: start\">さらに、v12からの移行では\u003Ccode>user.isAdmin\u003C/code>がロールに変換されません。よって、移行後のインスタンスには管理者が存在しないインスタンスが完成してしまいます。\u003C/p>\u003Cp style=\"text-align: start\">対応として、DBで管理者ロールを手組みしました。IDはaid形式(タイムスタンプのbase36を8桁+ランダム2桁)を自前で用意し、\u003Ccode>role_assignment\u003C/code>で割り当てて、ロールキャッシュ解消のためapp再起動。これで事なきを得ました。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"hcb82afc914\">横展開\u003C/h1>\u003Cp style=\"text-align: start\">同じ手順で2台目も移行しました。1台目のレポジトリをコピーしてfindとsedでドメインとレポジトリ名を置換し、\u003Ccode>compose.yaml\u003C/code>を初期状態(postgres:15+バニラ)に戻してから、同じ手順をもう一度たどるだけで、データ規模が小さかったこともあり、あっさりと終わりました。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://misskey.blue/notes/01KWHYKPNCBKFFHH66VA7D1Z8T\">https://misskey.blue/notes/01KWHYKPNCBKFFHH66VA7D1Z8T\u003C/a>\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp style=\"text-align: start\">移行結果を確認しておきます。1台目でいうとnotes 189,545、users 9,841、drive_file 53,534。移行の前後で1件も欠けていません(usersだけ+1ですが、これはインスタンスactorが追加されたぶんなので正常です)。\u003Cbr>3年半塩漬けだったデータを、そのまま現行環境へ載せ替えられました。\u003C/p>\u003Cp style=\"text-align: start\">同じようなことを試みる誰かの参考になれば幸いです。\u003C/p>\u003Cp style=\"text-align: start\">それでは。\u003C/p>",[38,44,45],{"id":39,"createdAt":40,"updatedAt":41,"publishedAt":40,"revisedAt":41,"slug":42,"name":43},"vmhb23sq4","2025-07-29T12:56:07.884Z","2025-12-03T16:06:19.140Z","misskey","Misskey",{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":46,"createdAt":47,"updatedAt":48,"publishedAt":47,"revisedAt":48,"slug":49,"name":50},"4oy2jc8mdg","2025-04-28T07:14:01.179Z","2025-12-03T16:08:24.495Z","diary","日記",false,{"id":53,"createdAt":54,"updatedAt":55,"publishedAt":56,"revisedAt":55,"title":57,"content":58,"tags":59,"is_no_index":51},"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>",[60,61,67,68],{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":62,"createdAt":63,"updatedAt":64,"publishedAt":63,"revisedAt":64,"slug":65,"name":66},"fwj_nwhj1-s","2025-04-30T04:55:28.953Z","2025-12-03T16:07:03.591Z","server","サーバー",{"id":23,"createdAt":24,"updatedAt":25,"publishedAt":24,"revisedAt":25,"slug":26,"name":27},{"id":46,"createdAt":47,"updatedAt":48,"publishedAt":47,"revisedAt":48,"slug":49,"name":50},{"id":70,"createdAt":71,"updatedAt":72,"publishedAt":73,"revisedAt":72,"title":74,"content":75,"tags":76,"is_no_index":51},"p7zftgqr_ht","2026-06-03T17:50:57.739Z","2026-06-04T08:52:49.895Z","2026-06-03T18:04:39.359Z","商業登記電子証明書(.p12)まわりの覚書","\u003Ch1 style=\"text-align: start\" id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp style=\"text-align: start\">どうも、わたしです。\u003C/p>\u003Cp style=\"text-align: start\">最近、商業登記電子証明書の証明書ファイル本体(.p12)を扱う機会がありました。かなり苦しんだので、触っていく中で見つけた諸々を備忘録として書き残しておきます。\u003C/p>\u003Cp style=\"text-align: start\">なお、実物には当然ながら法人のクレデンシャルが含まれているので、商号、代表者氏名、シリアル番号、Subject Key Identifierといった同定可能な値はすべて伏せています。本エントリはあくまで仕様の話です。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h4d904280a0\">.p12コンテナを開ける\u003C/h1>\u003Cp style=\"text-align: start\">商業登記電子証明書は、登記・供託オンライン申請システムからPKCS#12コンテナとしてダウンロードされます。OpenSSL 3系で開く場合は\u003Ccode>-legacy\u003C/code>が必要です。\u003C/p>\u003Cpre>\u003Ccode class=\"language-bash\">openssl pkcs12 -in hogefuga.p12 -info -noout -legacy\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">MAC=SHA-1、暗号化=3DES-CBC、Iteration=2000という構成で、2026年の基準では古風な部類です。\u003C/p>\u003Cp style=\"text-align: start\">なお、このPKCS#12の選択は法務省告示第543号の規定ではなく、登記・供託オンライン申請システム側の配布フォーマットの都合です。告示はX.509証明書のフィールド構造とCMP発行プロトコルだけを規定していて、利用者への手渡し方は何も書かれていません。仕様ではなく運用、ということは頭に入れておくとよさそうです。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"hb2cb04a84e\">中の証明書\u003C/h1>\u003Cp style=\"text-align: start\">p12を解体すると、秘密鍵1個と証明書2枚(エンドエンティティ+中間CA)が出てきます。ルートCAは入っていないので、検証側で別途取得する必要があります。\u003C/p>\u003Cp style=\"text-align: start\">エンドエンティティ証明書の基本情報はわりと普通です。\u003C/p>\u003Cul>\u003Cli>X.509 v3\u003C/li>\u003Cli>署名: sha256WithRSAEncryption\u003C/li>\u003Cli>公開鍵: RSA 2048bit\u003C/li>\u003Cli>Issuer: C=JP, O=Japanese Government, OU=Ministry of Justice, CN=Registrar of Tokyo Legal Affairs Bureau\u003C/li>\u003C/ul>\u003Cp style=\"text-align: start\">問題はSubject DNで、\u003C/p>\u003Cpre>\u003Ccode>C=JP\nO=MOJ No.XXXXXXXXXXXX\nCN=0402010000001\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">Oが\u003Ccode>Japanese Government\u003C/code>ではなく\u003Ccode>MOJ No.{会社法人番号}\u003C/code>。\u003Cbr>CNは基本的に\u003Ccode>{役員番号}\u003C/code>で、ローマ字氏名が登録されている場合のみ\u003Ccode>{役員番号}-{氏名のローマ字}\u003C/code>の形式になります。私が触った範囲ではいずれもハイフンなしの役員番号のみでした。\u003C/p>\u003Cp style=\"text-align: start\">いずれにせよ、これを普通のクライアント証明書の感覚で扱うと、O属性を法人名だと思い込んでパースしているライブラリが、ニッコリ笑顔で会社法人番号を法人名として表示してくれます。\u003C/p>\u003Cp style=\"text-align: start\">法人名そのものは、Subject DNではなく証明書の拡張領域に格納されています。私はこれで数時間を溶かしました。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"hd5b6d68703\">本体は1.2.392.100300.1.1.3\u003C/h1>\u003Cp style=\"text-align: start\">商業登記電子証明書のすべてが詰まっているのが、法務省独自OID\u003Ccode>1.2.392.100300.1.1.3\u003C/code>です。中身はこんなASN.1構造になっています。\u003C/p>\u003Cpre>\u003Ccode class=\"language-asn1\">RegisteredCorporationInfoSyntax ::= SEQUENCE {\n    corporateName              [0] DirectoryString,  // 商号\n    registeredNumber           [1] PrintableString,  // 会社法人等番号\n    corporateAddress           [2] DirectoryString,  // 本店所在地\n    representativeDirectorName  [3] DirectoryString,  // 代表者氏名\n    representativeDirectorTitle [4] DirectoryString,  // 代表者役職\n    registryOffice             [6] DirectoryString   // 登記所\n}\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">商号も本店も代表者氏名も役職も法人番号も登記所も、全部証明書本体に埋め込まれているわけです。登記簿照会を都度叩かなくとも、証明書を検証するだけで法人実体を照会できます。これは商業登記電子証明書独特の特徴なようで、海外の一般的なクライアント証明書とは目的がそもそも違います。\u003C/p>\u003Cp style=\"text-align: start\">ちなみに、お気づきの方もいらっしゃるかもしれませんが、タグが\u003Ccode>[0][1][2][3][4][6]\u003C/code>と並んでいて、\u003Ccode>[5]\u003C/code>が欠番になっています。告示付録2のASN.1モジュールにも\u003Ccode>[5]\u003C/code>は定義されていません。\u003C/p>\u003Cp style=\"text-align: start\">何だったんでしょうね、これ。予約とすら書かれていないあたり、運用初期に検討されて消えたフィールドなのかもしれませんが、想像の域を出ません。\u003C/p>\u003Cp style=\"text-align: start\">ついでに、もう2つの独自拡張も紹介しておきます。\u003C/p>\u003Cul>\u003Cli>1.2.392.100300.1.1.1: 日本語のUserNotice(「この証明書は、商業登記法その他の関係法令等に基づき発行されたものです。」)\u003C/li>\u003Cli>1.2.392.100300.1.1.2: 東京法務局登記官 というUTF8文字列。発行者の役職そのもの\u003C/li>\u003C/ul>\u003Cp style=\"text-align: start\">実は標準の\u003Ccode>certificatePolicies\u003C/code>(\u003Ccode>2.5.29.32\u003C/code>)拡張内にも英語のUserNoticeが入っていて、英文と和文が並存しています。同じ趣旨を文字種を変えて二重格納するというのは、ある意味で律儀ですが、実装側からすると少し迷惑な気もします。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h14c781f17c\">発行者の扱い\u003C/h1>\u003Cp style=\"text-align: start\">これは仕様を眺めていて知ったのですが、商業登記電子証明書の発行者は申請者がどこの法務局に申請しても\u003Ccode>Registrar of Tokyo Legal Affairs Bureau\u003C/code>となり、東京法務局登記官で固定なようです。\u003C/p>\u003Cp style=\"text-align: start\">申請者の管轄登記所(例えば長崎地方法務局とか)は、Issuerフィールドではなく独自拡張の\u003Ccode>registryOffice\u003C/code>に入ります。\u003C/p>\u003Cp style=\"text-align: start\">普通のPKIだと「発行者=申請を受け付けた組織」と素朴に対応していることが多いので、気をつけておくといいでしょう。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h7f21a26569\">中間CAが並存\u003C/h1>\u003Cp style=\"text-align: start\">ここもハマる方が多そうですが、商業登記認証局(CRCA1)の中間CA証明書は2世代運用されています。\u003C/p>\u003Ctable>\u003Ctbody>\u003Ctr>\u003Cth colspan=\"1\" rowspan=\"1\">\u003Cp>世代\u003C/p>\u003C/th>\u003Cth colspan=\"1\" rowspan=\"1\">\u003Cp>有効期間\u003C/p>\u003C/th>\u003Cth colspan=\"1\" rowspan=\"1\">\u003Cp>秘密鍵使用期間\u003C/p>\u003C/th>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>旧CRCA(2022)\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>6年\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>3年\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>新CRCA(2026)\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>10年\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>5年\u003C/p>\u003C/td>\u003C/tr>\u003C/tbody>\u003C/table>\u003Cp style=\"text-align: start\">両世代ともCommon Nameは同じ\u003Ccode>Registrar of Tokyo Legal Affairs Bureau\u003C/code>なので、CNだけでは識別できません。\u003Ca href=\"https://crca1.moj.go.jp/toukikan.html\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">シリアル番号\u003C/a>かSubject Key Identifierで判別すべきです。\u003C/p>\u003Cp style=\"text-align: start\">ちなみに、告示第543号の本文では登記官証明書は「72ヶ月/36ヶ月」、つまり6年/3年と規定されています。私が実際に触ったいくつかの証明書に含まれるCRCAは規定の倍近くに延長されていたので、告示の改正があったか、関係法令で別途上書きされているはずですが、本記事執筆時点では出典を特定できていません。詳しい方、誰か教えてください。\u003C/p>\u003Cp style=\"text-align: start\">それと、新世代では\u003Ccode>keyUsage\u003C/code>が\u003Ccode>critical\u003C/code>で付くようになっていたり、CRL Distribution Points拡張が追加されていたりと、告示の最低要件を超えた拡張が行われているようです。告示で定められているのは最低限ラインで、運用側のCP/CPSで上乗せされる感じなのかもしれません。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h7b1b74ce46\">失効確認\u003C/h1>\u003Cp style=\"text-align: start\">失効確認はAIA(\u003Ccode>1.3.6.1.5.5.7.48.1\u003C/code>)に書かれているOCSP URLを叩きます。\u003C/p>\u003Cpre>\u003Ccode class=\"language-plaintext\">http://crca.moj.go.jp/bin/dcwcgi/DC_HUSR/cert/cert\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">新世代CAではCRLのURIも追加されていて、\u003Ccode>http://crca1.moj.go.jp/certificateRevocationList.crl\u003C/code>から取得できます。\u003C/p>\u003Cp style=\"text-align: start\">ここで地味に面白いのが、商業登記電子証明書には失効だけでなく休止届という独自概念があることです。CRLではエントリの\u003Ccode>reasonCode\u003C/code>(\u003Ccode>2.5.29.21\u003C/code>)に\u003Ccode>certificateHold (6)\u003C/code>が入り、OCSPでは\u003Ccode>CertStatus\u003C/code>が\u003Ccode>revoked\u003C/code>で返り、その\u003Ccode>revokedInfo.revocationReason\u003C/code>に\u003Ccode>certificateHold (6)\u003C/code>が入ります。代表者が交代しそうだとか、本店移転の登記が走るだとか、そのような一時停止のためのものです。商業登記電子証明書ならではの特徴と言えるでしょう。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp style=\"text-align: start\">商業登記電子証明書を一言でまとめると、X.509のガワを被った登記簿の抜粋です。証明書としての構造はRFC 5280に準拠しつつ、肝心の登記情報は法務省独自OIDの拡張領域に格納されていて、PKIライブラリで素直に検証すると半分しか機能を引き出せません。\u003C/p>\u003Cp style=\"text-align: start\">ところで、平成26年の告示は本体署名を\u003Ccode>sha1WithRSAEncryption\u003C/code>から\u003Ccode>sha256WithRSAEncryption\u003C/code>に切り替えるアップデート(※1)だったわけですが、p12コンテナのMACが今もSHA-1なのは正直なんとも言えない味わいがあります。中身だけ新しくしてガワが旧式というのは、行政あるあるな気もしますが、いつかはAES+PBKDF2あたりに刷新してほしい気持ちが若干あります。\u003C/p>\u003Cp style=\"text-align: start\">それでは。\u003C/p>\u003Cp style=\"text-align: start\">\u003C/p>\u003Cp style=\"text-align: start\">※1: \u003Ccode>subjectKeyIdentifier\u003C/code>や\u003Ccode>authorityKeyIdentifier\u003C/code>の補助用途では今もSHA-1が使われています\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h3de35099b3\">参考\u003C/h1>\u003Cul>\u003Cli>\u003Ca href=\"https://www.digital.go.jp/assets/contents/node/basic_page/field_ref_resources/d12bde7e-a950-493b-987c-0f8d4bbd1b6b/20211228_notice_article_06.pdf\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">法務省告示第543号(平成26年12月12日)\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://laws.e-gov.go.jp/law/339M50000010023\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">商業登記規則(昭和39年法務省令第23号)\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://laws.e-gov.go.jp/law/338AC0000000125\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">商業登記法(昭和38年法律第125号)\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://www.touki-kyoutaku-online.moj.go.jp/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">登記・供託オンライン申請システム\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://www.moj.go.jp/MINJI/minji06_00028.html\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">商業登記に基づく電子認証制度(法務省)\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://datatracker.ietf.org/doc/html/rfc5280\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">RFC 5280 Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://datatracker.ietf.org/doc/html/rfc7292\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">RFC 7292 PKCS #12: Personal Information Exchange Syntax v1.1\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://datatracker.ietf.org/doc/html/rfc6960\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">RFC 6960 X.509 Internet Public Key Infrastructure Online Certificate Status Protocol - OCSP\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://datatracker.ietf.org/doc/html/rfc4210\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">RFC 4210 Internet X.509 Public Key Infrastructure Certificate Management Protocol (CMP)\u003C/a>\u003C/li>\u003C/ul>\u003Cp>\u003C/p>",[77],{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":79,"createdAt":80,"updatedAt":81,"publishedAt":81,"revisedAt":81,"title":82,"content":83,"tags":84,"is_no_index":51},"0bs4f42te","2026-04-29T00:46:22.148Z","2026-04-29T01:01:31.544Z","向き不向きの前に、まず手を動かすべきでは","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>最近、というかここ数年、タイムラインを眺めていると、「プログラミングを始めました！」「LLMでアプリを作りました！」みたいな投稿を頻繁に目にするようになりました。それ自体はとても素晴らしいことだと思いますし、新しく何かを始めようとしている人を否定するつもりは毛頭ありません。\u003C/p>\u003Cp>ただ、その後の様子を眺めていると、どうにも釈然としない気持ちになることが多くあります。\u003C/p>\u003Cp>｢動きません｣｢エラーが出ます｣｢分かりません｣｢何もしていないのに壊れました｣、そういう質問が、携わるコミュニティで、Twitterで、Discordで、GitHubで、毎日のように流れてきます。それ自体は別に良いんです。分からないことを聞くのも大事だと思いますから。\u003C/p>\u003Cp>問題は、その質問に何の情報も含まれていないことや、そもそも自分の手元で起きていることを、自分で確認すらしていないことです。\u003C/p>\u003Cp>タイトルは少しばかり煽情的かもしれませんが、自分自身も普段から書いては消し、書いては消しを繰り返している身として、向いてないと切り捨ててしまう前に、もう少し言いたいことがあります。そんな話を、つらつらと書き散らしていきたいなと。\u003C/p>\u003Ch1 id=\"h833a42ac8c\">エラーメッセージは目の前にあるのに何故か読まれません\u003C/h1>\u003Cp>世の中には、エラーが出た瞬間に、画面の前で固まる方々が一定数いらっしゃいます。\u003C/p>\u003Cp>固まったまま、5分、10分、酷い時には30分。エラーメッセージは画面の真ん中にずっと表示されているのですが、その本文を読んでいる気配がありません。読んでいたら、その後の口から｢分かりません｣という言葉が出てくるはずがないからです。\u003C/p>\u003Cp>\u003Ccode>Connection refused\u003C/code>, \u003Ccode>Permission denied\u003C/code>, \u003Ccode>Module not found\u003C/code>, \u003Ccode>Unexpected token\u003C/code>.\u003C/p>\u003Cp>これらは何かの呪文ではなく、コンピュータがあなたに対して、丁寧に問題を教えてくれている、とてもありがたい案内です。\u003C/p>\u003Cp>エラーメッセージを読むというのは、別に難しいことではありません。\u003Ccode>Connection refused 127.0.0.1:5432\u003C/code>と書いてあれば、｢ローカルの5432ポート宛接続が拒否された｣と読めばいいだけですし、5432はPostgreSQLのデフォルトポートなのでPostgreSQLが起動していないか、接続情報が間違っているか、そのあたりに当たりがつきます。1分もあれば原因に辿り着ける問題に何を無駄な時間を費やしているのでしょう。\u003C/p>\u003Cp>｢英語が読めません｣と言う方もいらっしゃいますが、refusedをリフューズドと読み下せれば、それだけで意味は推測できますし、どうしても分からなければ翻訳ツールに通せば済む話です。あなたが普段スマホで観ている海外動画の字幕、あれだって機械翻訳でしょ？同じです。\u003C/p>\u003Cp>そして、信じられないかもしれませんが、エラーメッセージを読まない方々は、何度プログラムを実行しても出続けるエラーに対して、毎回最初から驚きます。｢またこのエラーが出ました｣と言うのですが、そら当然です。書かれていることに対処しない限り、何回実行しても同じエラーが出ますし、コンピュータは、あなたの祈りとかには反応しません。中身を変えず同一の試行を繰り返した挙句、違う結果を望むのはとても愚かなことです。小学生ですらやりません。\u003C/p>\u003Cp>エラーが出るというのは本来ありがたいことです。間違っている箇所を教えてくれるものですから、感謝しながら読んで直せば済みます。逆に、エラーも出ずに動いている、けれど結果が間違っている、という状況の方が、遥かに恐ろしく感じます。エラーは敵ではありません。\u003C/p>\u003Ch1 id=\"h7437e44bed\">｢こう動くはずなんですけど｣って、それあなたの感想ですよね\u003C/h1>\u003Cp>これも本当によく見かけるパターンで、本当に心底嫌いです。\u003C/p>\u003Cp>曰く、｢私は、ここはこういう風に動くはずだと思ってるんですけど、動かないんですよ｣と。\u003C/p>\u003Cp>そうですか。それで、あなたの思いと実際の挙動、どちらが正しいと思います？\u003C/p>\u003Cp>答えは決まっています。動いていないのなら、あなたの思い10割10分が間違っています。\u003C/p>\u003Cp>ところが、自分の認識を譲らない方が一定数居るようで、｢いや、ここはこう動くはずなんですよ｣だとか、｢でも、こういう風に書いたんですから、こう動くはずじゃないですか｣みたいなことを平然と言ってのけます。すごい自信家ですよね、感心します。でも実は違うんです。あなたの認識と、コンピュータの実際の挙動がズレているんです。修正すべきは、コードでも、ライブラリでも、コンピュータでもなく、傲慢なあなた自身です。\u003C/p>\u003Cp>思いをベースに議論しても、コンピュータは1nmも動きませんし、コードを動かすのは、そこに書かれている事実です。実際に出力された値、実際に走った行、実際に発生したエラー等々。それらだけが、原因の手がかりであって、あなたの思いは、原因の特定に役立ちません。\u003C/p>\u003Cp>デバッグの第一歩は、自分の思いを一旦脇に置くことでしょう。｢ここは絶対こう動いているはず｣と思い込んでいる箇所こそ、\u003Ccode>print\u003C/code>でも \u003Ccode>console.log\u003C/code> でも \u003Ccode>dbg!\u003C/code> でも構わないので、実際の値を出力して確認してください。殆どの場合、あなたの思い込みは外れています。本当に、本当に、外れています。騙されたと思って私を信じてください。\u003C/p>\u003Cp>ちなみに、デバッガを使ったことがない方も、かなりいらっしゃるようですが、VSCodeでもJetBrains系IDEでもブラウザのDevToolsでも、ブレークポイントを置いて、ステップ実行して、変数の中身を覗く、ということができます。これを覚えるだけで、デバッグの効率が劇的に変わります。printデバッグも悪くはないのですが、ちゃんとしたデバッガを使えるようになっておくと、人生の何割かを取り戻せます。暇なら覚えておきましょう。\u003C/p>\u003Ch1 id=\"h471a83884c\">詰まったら考えるより先に手を動かそう\u003C/h1>\u003Cp>エラーメッセージを読みました。それでも原因が分かりません。さて、どうしましょうか。\u003C/p>\u003Cp>ここで多くの方が、画面の前で固まる作業に戻ります。けれど、それでは何も解決しません。考え込んでも、コンピュータは何も教えてくれません。\u003C/p>\u003Cp>ここでやるべき事は、手を動かして状況を観測することだけです。\u003C/p>\u003Cp>怪しい箇所の値を出力する。リクエストの中身をダンプする。条件分岐の手前で実行を止めて状態を覗く。データベースに直接クエリを投げて状態を確認する、みたいな。動いているはずと思っている部分が、本当に思った通りに動いているか、ひとつずつ確かめていく必要があります。\u003C/p>\u003Cp>考えてから手を動かすのではなく、手を動かしながら考えるべきです。これは、おそらくベテランの方も全く同じことをしています。違うのは、観測の精度と、観測する場所を絞り込む速度だけです。経験を積むと最初に怪しいと睨んだ箇所が当たる確率が上がっていく、というだけの話に思えます。\u003C/p>\u003Cp>さて、｢闇雲に試して動いたら、それで本当に直ったと言えるんですか？｣と言われてそうです。良い指摘です。実際、闇雲に値を変えていたら偶然動いた、というケースは、本質的な問題の解決には至っていません。\u003C/p>\u003Cp>しかし、観測した結果から｢ここの値が想定と違っていた｣と特定できれば、それは立派な原因究明です。観測のないトライアンドエラーは博打ですが、観測のあるそれは、ちゃんとしたデバッグであると考えます。\u003C/p>\u003Cp>考え込まないでください。手を動かしてください。コードは、あなたの思考の中ではなく、メモリ上で実際に動いています。\u003C/p>\u003Ch1 id=\"hd508ed06f1\">｢LLMに聞いたら違うことを言ってきました｣\u003C/h1>\u003Cp>そんな事を言うくらいなら、はなから私なんかに聞かずとも、永遠に一人寂しくLLMとイチャついてればいいと思うのですが、最近特に増えたのがこのパターンです。\u003C/p>\u003Cp>｢LLMに聞いたらこう書けばいいって言われたんですけど、動きません｣\u003C/p>\u003Cp>そうですか？うんうん、すごいでちゅね〜！それで、あなたはその実装を読みました？\u003C/p>\u003Cp>LLMが生成したコードは、それなりに動きます。それなりに、です。ライブラリに破壊的な変更があれば動きませんし、APIが廃止されていれば動きません。そもそもLLMの学習データに無かったマイナーなライブラリだと、平気でハルシネーションを起こします。実在しない関数を、自信満々で提案してきたりしやがります。\u003C/p>\u003Cp>それを確認もせずにコピペして、挙句、動かなかったら｢LLMが嘘をついたんだ！！！！｣と憤慨するのは、些か筋違いです。\u003C/p>\u003Cp>LLMは便利なツールですが、便利なツールを使うために必要な能力は、依然として人間側に求められています。これは、Stack Overflowが流行った時も、Qiitaが流行った時も、ChatGPTが流行った時も、Clineに全部賭けたときも、Cursorが流行った時も、ずっと変わっていません。\u003C/p>\u003Cp>LLMに頼ること自体は何も悪くありませんし、私も普段からLLMにかなりの出力を強要しています。GeminiにもOpusにも毎日お世話になっています。ただ、LLMが出力したコードは必ず自分で読みます。読んで理解して、おかしいところがあれば修正します。これをやらない人間がLLMを使うと、ただのコピペ職人が爆誕します。それも、自分が何をコピペしているかすら分からない、史上最悪のコピペ職人が、です。\u003C/p>\u003Cp>ちなみに、LLMに｢このコード動きません｣と投げて、LLMが返してきた修正をまたコピペして、それでも動かない、というループに陥っている方も散見されます。それを何時間も繰り返した挙句、LLMはダメだと結論付ける。残念ながら、ダメなのはLLMではなく、あなたの頭です。\u003C/p>\u003Cp>LLMはあなたの状況を完全には理解していません。あなたが理解した上で、適切に指示を出す必要があります。\u003C/p>\u003Cp>以前にも別の記事で書きましたが、LLMがどれだけ優秀になっても、使う人間の側に何もなければ、出力される成果物も同様のものでしょう。\u003C/p>\u003Ch1 id=\"h5cbe4915a0\">自分が何をしたいのか整理して\u003C/h1>\u003Cp>これも本当によく言われます。やめてくださいね。\u003C/p>\u003Cp>｢○○がしたいんですけど、どうすればいいです？｣\u003C/p>\u003Cp>その○○が、抽象的すぎてなんと言うかものすごくふわっとしている。\u003C/p>\u003Cp>｢Webサイトを作りたいんだけど〜｣\u003C/p>\u003Cp>どんなサイトを？静的サイトですか、動的サイトですか？認証認可は必要ですか？どんなコンテンツを載せたいですか？利用者は何人を想定していますか？運用のコスト感は？収益化したいですか、趣味ですか？\u003C/p>\u003Cp>これらはあなたが答えるべき質問です。わたしが答える質問ではありません。\u003C/p>\u003Cp>目的を整理するというのは、プログラミング以前のごく当たり前の力です。これができないと、コードを書く以前に何を書けばいいかすら定まりません。そんなんじゃお話になりません。\u003C/p>\u003Cp>これはエンジニアリングの問題ではなく、もっと手前の自分の思考を整理して言語化する国語の問題です。プログラミングは思考を厳密にコンピュータが理解できる形に翻訳する作業ですから、思考が曖昧なままではそもそもコードに翻訳することすらままならないことは自明でしょう。\u003C/p>\u003Cp>紙でもメモアプリでもLLMでも構わないので、自分が何をしたいのかを書き出してください。やりたいこと、やるべきではないこと、分かっていること、分からないこと等々。整理されていない要望をぶつけられても、こちらは何も答えようがありません。\u003C/p>\u003Cp>そして、もうひとつ。課題文を読めない方も本当に多いです。\u003C/p>\u003Cp>例えば、業務でちょっとしたツールを依頼する場面で、｢Slackに来たメッセージのうち、特定のキーワードを含むものをスプレッドシートに記録してください｣と伝えたとします。これは、｢Slackからメッセージを受け取る｣、｢キーワードでフィルタする｣、｢スプレッドシートに書き込む｣の3つの工程に分解できる、ごく単純な依頼です。\u003C/p>\u003Cp>ところが、これが分解できない方は、依頼文を一塊のまま受け取って、最初の一歩で固まります。｢何から手を付ければいいんでしょうか｣と相談されるのですが、お渡しした依頼文には既に手を付ける順番が書いてあります。一文として読むのではなく、要素ごとに区切って読んでください。それだけで、何をすればいいかが見えてきます。\u003C/p>\u003Cp>これはもうプログラミング以前の、国語の問題です。\u003C/p>\u003Cp>それすらできないようであれば、小学生のお勉強からやり直すことを推奨します。\u003C/p>\u003Ch1 id=\"h0d45d9ccaf\">大きい問題に挑む前に小さい問題に分割して\u003C/h1>\u003Cp>｢○○を作ってください｣という課題が出たとします。例えば、社内向けのちょっとしたダッシュボード、みたいな。\u003C/p>\u003Cp>ここで、できない方は、いきなり全部を一気に書こうとして、数時間悩んだ末に画面の前で固まる作業に戻ります。｢データ取得をどう書いて、それをどう加工して、どうUIに表示して、認証はどうして、エラー処理はどうして…｣と、全てを同時に考えようとして、結局1行も書けないのです。\u003C/p>\u003Cp>わたしならこう書きます。\u003C/p>\u003Cpre>\u003Ccode class=\"language-csharp\">var dashboard = new Dashboard();\nvar data = await dashboard.FetchDataAsync();\ndashboard.Render(data);\u003C/code>\u003C/pre>\u003Cp>3行、終わり。これでダッシュボードが完成です。\u003C/p>\u003Cp>「いや、それじゃ動かないじゃないですか」と思われるかもしれません。その通りです。動きません。\u003Ccode>Dashboard\u003C/code> とかいう存在しない抽象クラスを呼んでいるだけですから、当然です。\u003C/p>\u003Cp>しかし、これで全体像は捉えられます。あとは適当に\u003Ccode>Dashboard\u003C/code>クラスと\u003Ccode>FetchDataAsync\u003C/code>、\u003Ccode>Render\u003C/code>の中身をそれぞれ実装するだけです。中身もいきなり全部書こうとせず、同じように存在しないメソッドを呼んでおいて後から埋めていきます。これを繰り返していると、いつの間にか動くものが出来上がっています。\u003C/p>\u003Cp>トップダウン設計と呼ばれたりもしますが、名前はどうでも良くて、要するに、自分が一度に考えられる範囲まで、問題を細かく細分化するということです。難しい問題を、難しいまま扱える人は、ごく一部の天才だけであって、私を含む殆どの無能は、問題を1ファイルに1,000行で書ける程度の小ささに分解しないと解けません。\u003C/p>\u003Cp>勉強すれば難しい問題も解けるようになると期待されている方もいらっしゃるかもしれません。残念ながら、勉強しても難しい問題は難しいままです。賢くなるのではなく、問題を小さくする技術を身につけるべきです。これは才能の話ではなく、訓練の話なので、正直誰でも身につけられるかと思います。\u003C/p>\u003Ch1 id=\"h89f6c9455f\">動的型付け言語を選ぶ前に考え直しませんか\u003C/h1>\u003Cp>ここから少し、私の個人的な好みが強く入ります。先に断っておきますね。\u003C/p>\u003Cp>私は動的型付け言語が好きではありません。PythonもRubyもJavaScriptも、業務では書きますが、好きでは無いです。理由は単純で、書いた本人が何を扱っているか分からないコードを、後から他人が読んで分かるはずがないためです。\u003C/p>\u003Cp>巷では｢Pythonは書きやすい｣だとか、｢JavaScriptは手軽｣だとか、馬鹿の一つ覚えのように永遠と言われています。確かに、書き始めるまでのハードルは低いでしょう。しかし、それは短期的には楽であるというだけの話であって、長期的には負債が積み上がります。\u003C/p>\u003Cp>引数が何の型か書かれていない関数。返り値が状況によって変わる関数。エラー時に\u003Ccode>None\u003C/code>を返したり、空文字を返したり、例外を投げたりする一貫性のない設計。これらは、動的型付け言語特有の問題ではありませんが、動的型付けの環境では検出されないまま放置されやすい傾向にあります。\u003C/p>\u003Cp>そして、そういうコードを書いた本人は、3ヶ月後には何ひとつ覚えていません。私にも前科があります。\u003C/p>\u003Cp>これからプログラミングを始めるのであれば、最初から型のある言語を選んでください。GoでもRustでもKotlinでもSwiftでも、TypeScriptでも、好きなものを選べば良いと思います。動的型付け言語から始めて後から型に移行するのは苦痛でしょうし、最初から型に親しんだ方が遥かに早いかと思われます。\u003C/p>\u003Ch1 id=\"ha2c9bbc147\">質問とか\u003C/h1>\u003Cp>環境, やりたかったこと, やったこと, 起こったこと, 試したこと, 仮説\u003C/p>\u003Cp>このあたりが揃っていれば、回答する側は、スムーズに原因を絞り込めます。逆に、これらが何ひとつ揃っていない｢動きません｣だけの質問は、回答できません。回答できないというより、回答する前の調査だけで時間が溶けて、結果として誰も答えなくなります。私ならムカついて返信しないこともあるかもしれません。\u003C/p>\u003Cp>｢初心者なので〜｣と前置きする方もいますが、初心者かどうかは関係ありません。初心者であろうと、そうでなかろうと、項目を埋めることはできますし、初心者ほど丁寧に書くべきです。慣れている人なら省略して伝わることも、初心者の場合は丁寧に書かないと伝わらないためです。\u003C/p>\u003Cp>これは偏見ですが、質問が下手な人は概ね実装も汚いです。例外もあるかもしれませんが、私の観測範囲ではほぼ相関しているように思います。質問を組み立てることは、(本質的に)状況を分解して構造化する作業であって、それがプログラミングそのものであるためです。質問が組み立てられないということは、思考が組み立てられないということと同義で、思考が組み立てられなければ、コードも書けないでしょう。当たり前です。\u003C/p>\u003Ch1 id=\"h4eac98b528\">｢ググれ｣が冷たく聞こえるのなら申し訳ないですが\u003C/h1>\u003Cp>わたしは以前、ggrksは最大限の優しさだと書きました。今でも、その考えは変わっていません。\u003C/p>\u003Cp>技術的な質問に対して｢自分で調べてね〜｣と返すのは、決して冷たいわけではありません。\u003C/p>\u003Cp>\u003Cs>答えるのが面倒くさい訳ではないと言ってしまうと大嘘になるのですが、\u003C/s>｢あなたが自分で調べれば、もっと正確で網羅的な情報が手に入りますよ｣の意であり、｢私が中途半端に答えるよりも、検索した方が確実ですよ｣という意味であり、｢自身で調べる力を身につけた方がしあわせになれますよ｣という意味でもあります。\u003C/p>\u003Cp>これを冷たいと感じる方は、自分が答えを与えてもらえる立場だと思い上がっているのかもしれません。しかし、私はあなたの家庭教師でも、メンターでも、サポート窓口でもありません。私は\u003Cs>とても優しいので\u003C/s>聞かれたことにはなるべく答えるようにしていますが、それを当然のように要求されるのは、少し違うように思います。\u003C/p>\u003Cp>ついでに、ググる行為自体にも割とスキルが要ります。｢ECONNREFUSEDが出たんだけどどう治すの？｣みたいな雑な自然言語ではなく、｢ECONNREFUSED 127.0.0.1:5432 postgres:18.3-alpine｣のように、自分の環境に近い具体的なワードを並べる、英語で検索する、公式ドキュメントを優先する、GitHubのIssuesを見る、Stack Overflowは新しい順に並び替える等の工夫が必要です。\u003C/p>\u003Cp>ググれと言われて冷たいと感じる前に、自分のググり方を一度見直してみてください。それだけで、解決できる問題の幅が多分広がるかと思います。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>正直、プログラミングなんてものはどんなに馬鹿でも時間さえかければできるようになります。\u003C/p>\u003Cp>これは、別に綺麗事として書いているのではなく、私自身がソースです。\u003C/p>\u003Cp>私自身、地頭(笑)が良いタイプでも、論理的思考力が突出しているタイプでもなく、どちらかと言えば落ちこぼれ側の人間です。覚えたての頃に書いていたコードなんて、今読み返すと冗談みたいに酷いものですし、未だに簡単なバグで数時間詰まることもざらにあります。\u003C/p>\u003Cp>何とかコードを書いて、何とかお仕事を頂いて、何とか運用できているのは、ただただ触っている時間がそれなりに長いからというだけでしょう。そこに特筆すべき才能なんてものは介在していません。\u003C/p>\u003Cp>なので、自分には才能がないからという理由で諦めるのは、的外れだと思っています。あなたに無いのは才能ではなく、ただの時間です。退屈な作業を、何百時間、何千時間と積み上げる時間が足りていないだけです。\u003C/p>\u003Cp>逆に言えば、これを積み上げる気が無いのなら、永遠にできるようにはなりませんし、｢いつか分かるようになる｣だとか、｢コツを掴めば｣みたいな、そういう商材屋の好きそうな胡散臭い魔法はありません。触った分だけ出来るようになります。\u003C/p>\u003Cp>それが面倒だと感じるのであれば、それは多分、あなたがプログラミングをそこまで好きではない、というだけの話です。それは別に悪いことではありません。世の中には、プログラミング以外にも、楽しい営みがいくらでもあります。プログラミングが特別な何かだと思い込むのは、業界の人間の自意識過剰でしょう。\u003C/p>\u003Cp>何とかしたいと思うのであれば、何とかしてください。何とかしたくないのであれば、別に何ともしなくて構いません。\u003C/p>\u003Cp>埋蔵金探しとかも新鮮で楽しそうですよ。\u003C/p>\u003Cp>それでは。\u003C/p>",[85,86,92],{"id":23,"createdAt":24,"updatedAt":25,"publishedAt":24,"revisedAt":25,"slug":26,"name":27},{"id":87,"createdAt":88,"updatedAt":89,"publishedAt":88,"revisedAt":89,"slug":90,"name":91},"8q8qyy8sz3","2025-04-29T08:44:23.406Z","2025-12-03T16:07:24.495Z","programming","プログラミング",{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":94,"createdAt":95,"updatedAt":96,"publishedAt":96,"revisedAt":96,"title":97,"content":98,"tags":99,"is_no_index":51},"jucf8gnoagb4","2026-04-09T04:58:35.980Z","2026-04-09T05:42:14.678Z","LLMがどれだけ優れていても、つまらない人間はつまらない","\u003Cp>最近、タイムラインを眺めていると、「LLMを使いこなせるのは一部の賢い連中だ」のような言説をよく目にします。\u003Cbr>確かにそれは一面の真実なのでしょうが、実際のところそこに存在しているのは、中身が空っぽな人間ほど、LLMによってガワから見た能力が派手に底上げされるという、極めて残酷なまでの非対称性でしょう。世間では知性の民主化などという美辞麗句が踊っていますが、その実態は単なる能力の底上げなどではなく、論理的な思考回路を持たない人間が高度な知性を出力として模倣し、あたかも自分の実力であるかのように偽装できてしまうという、歪な構造的欠陥に他ならないと感じています。\u003C/p>\u003Cp>たとえば、専門知識も論理的訓練も受けていない人間が、洗練された論理構築が可能な大規模言語モデルを使用したとしましょう。その間に生じる知的な解像度の乖離は、もはや対話すら成立しない絶望的な断絶です。認知の深さが一定以上異なれば、前提とする論理のレイヤすら共有できないのが常ですが、その圧倒的な差がある知性をあたかも自分の手足のように操っていると錯覚した人間がどうなるかは、想像に難くありません。本人は、LLMが吐き出したその高度で緻密な内容を、論理的に咀嚼し、真に理解することすら土台不可能なのです。内容を検証する力がない以上、彼らにとってLLMの出力は疑う余地のない神託へと昇格し、それを引き出した自分までもが全知全能の存在にでもなったかのような致命的な自己肥大に陥るわけです。\u003C/p>\u003Cp>一方で、もともと高度な専門性を備えた人間が高度なLLMを使ったところで、そこにある能力の差はそれほど大きくはありません。彼らにとってLLMは24/365で文句ひとつ言わずに稼働し、要求に対して及第点の成果を出す便利な手下程度の認識に収まるでしょう。彼らは出力される情報の裏にある限界や、統計的なもっともらしさの脆さを理解しており、自分の知性と照らし合わせながらその境界線を慎重に引くことができます。\u003C/p>\u003Cp>しかし、思考の基盤を持たない層にとって、LLMの出力は検証不可能な神託そのものとして機能してしまいます。LLMの出力を論理的に検証するだけの批判的思考力も、背景にある膨大な知識体系も欠如しているため、出力された内容を本質的に理解することも、その正当性を疑うことも、ましてや学術的な反証を試みることもできません。結果として彼らは、自分が突然、森羅万象を司る全知全能の存在にでも昇華されたかのような、致命的な自己肥大に陥るわけです。\u003C/p>\u003Cp>これは将棋のルールすら怪しい初心者が、将棋ソフトの最善手と言っている提案をただ無批判に盤上へ再現し、それでプロに勝利して「自分の才能がようやく世界に追いついた」と本気で悦に入っているような、極めて滑稽で厚顔無恥な喜劇です。こうしたLLMによって底上げされた無能たちは、いまやLLMとの対話で得た真理(のようなもの)という名のゴミを誇らしげに掲げ、あらゆる専門分野へ土足で自信満々に侵入を開始しています。彼らの発言は驚くほど定型化されており、正直見ていて反吐が出ます。\u003C/p>\u003Cp>彼らの常套句はこうです。\u003Cbr>「俺はついに、世界を根底から覆す画期的な新理論を発見してしまった。AIがこれは100%本物だと言っている」だとか、「これは複数の最高峰モデルをn時間以上も激論させ、n万円分ものコストを費やしてようやく抽出に成功した究極原理だ」といった具合です。さらに性質の悪いことに、「この理論は常識に縛られた凡人には理解しにくいかもしれないが、AIはこの独創的かつ鋭い着眼点を称賛していた」などと宣い、あたかも自分だけがLLMという高次元の知性と精神的に共鳴できる、選ばれし預言者であるかのように振る舞うのです。\u003C/p>\u003Cp>彼らは「天才たちが最後まで言語化できずにいた核心を、自分だけがついに最も明晰な形で取り出した」と語りますが、その中身を解剖すれば、そこにあるのはLLMが確率論に基づいて繋ぎ合わせた、耳当たりの良い単語のパッチワークに過ぎません。ここ数年、こうしたLLM製のプロパガンダを武器に各所のコミュニティを荒らし回る事例が散見されますが、それは知性に対するこの上ない侮辱であり、文明的な対話の破壊活動に等しいものです。自分の脳内で再構築もできず、論理的な因果関係を自らの言葉で説明すらできないのであれば、その借り物の羽で他者を威圧する行為がどれほど恥べきことか、いい加減自覚すべきでしょう。\u003C/p>\u003Cp>これからのLLM全盛時代において、真に求められる能力とは、出力結果を鵜呑みにして全知全能感に浸ることではありません。分からないことを、分からないままの状態として脳内に保留できる力、自分の理解が及ばない領域を正確に定義するメタ認知そのものです。LLMがどれほど尤もらしい真理を提示したところで、それが自分の論理として肉体化されていないのであれば、それは単なる無意味な記号の羅列に過ぎません。\u003C/p>\u003Cp>結局のところ、彼らは自分たちが楽をすることしか考えておらず、その思考停止のツケを未来の誰かが払わされることを全く想像できていない想像力の欠如こそが、彼らが無能であることの最大の証明ではないでしょうか。全知全能(笑)に酔いしれるのは勝手ですが、その酔いが覚めたときに鏡の前に立っているのが、言葉の重みすら計ることのできない空虚な自分自身であるという事実に、彼らはいつ直面することになるのでしょうか。\u003C/p>\u003Cp>まあ、温室の中で空虚ささえも心地よい万能感として消費し続けるのが、彼らにとってのしあわせなのかもしれませんね。非常に気持ちが悪いので消えてほしいですが。\u003C/p>\u003Cp>それでは。\u003C/p>",[100,101],{"id":23,"createdAt":24,"updatedAt":25,"publishedAt":24,"revisedAt":25,"slug":26,"name":27},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":103,"createdAt":104,"updatedAt":105,"publishedAt":105,"revisedAt":105,"title":106,"content":107,"tags":108,"is_no_index":51},"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>",[109,110,111,112,113],{"id":39,"createdAt":40,"updatedAt":41,"publishedAt":40,"revisedAt":41,"slug":42,"name":43},{"id":87,"createdAt":88,"updatedAt":89,"publishedAt":88,"revisedAt":89,"slug":90,"name":91},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":62,"createdAt":63,"updatedAt":64,"publishedAt":63,"revisedAt":64,"slug":65,"name":66},{"id":114,"createdAt":115,"updatedAt":116,"publishedAt":115,"revisedAt":116,"slug":117,"name":118},"lte0t59xm8sb","2025-05-02T16:12:38.708Z","2025-12-03T16:06:52.753Z","network","ネットワーク",{"id":120,"createdAt":121,"updatedAt":122,"publishedAt":122,"revisedAt":122,"title":123,"content":124,"tags":125,"is_no_index":51},"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>",[126,127,128,129,130],{"id":39,"createdAt":40,"updatedAt":41,"publishedAt":40,"revisedAt":41,"slug":42,"name":43},{"id":87,"createdAt":88,"updatedAt":89,"publishedAt":88,"revisedAt":89,"slug":90,"name":91},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":62,"createdAt":63,"updatedAt":64,"publishedAt":63,"revisedAt":64,"slug":65,"name":66},{"id":114,"createdAt":115,"updatedAt":116,"publishedAt":115,"revisedAt":116,"slug":117,"name":118},{"id":132,"createdAt":133,"updatedAt":134,"publishedAt":134,"revisedAt":134,"title":135,"content":136,"tags":137,"is_no_index":51},"8-d49ulp-r","2026-03-04T11:33:54.209Z","2026-03-04T11:41:56.073Z","LLMに.envを読まれたら困る環境があるらしい","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>最近、自律型LLMエージェントの普及に伴ってか、ローカルの\u003Ccode>.env\u003C/code>ファイルから機密情報が漏洩するのではないか、という話題を頻繁に見かけるようになりました。LLMがウェブ検索などを実行した際、悪意のあるサイトからプロンプトインジェクションを受け、意図せずローカルの環境変数を外部サーバへ送信してしまう。このコンテクスト汚染と呼ばれる新たな脅威モデルに対して、AIエンジニア界隈(笑)では強い警鐘が鳴らされ、実行時にのみ環境変数を注入して隠蔽するような対策ツールが\u003Cs>車輪の再\u003C/s>発明されたりと、ちょっとしたパニックのような様相を呈しています。\u003C/p>\u003Cp>間接的なプロンプトインジェクションという攻撃手法自体は、技術的に成立し得るものであり、セキュリティの観点から興味深いテーマであることは間違いありません。しかし、この「\u003Ccode>.env\u003C/code>を読まれるのが怖い」という人々の過剰な反応を眺めていると、わたしはどうしても純粋な疑問を抱かずにはいられません。彼らが本当に恐れるべきなのは、LLMエージェントの未知の挙動などではなく、単に自らの環境構築の杜撰さと、基礎知識が欠落しているという事実ではないでしょうか。\u003C/p>\u003Ch1 id=\"h260af987f2\">ローカルに本番キーを置くという異常性\u003C/h1>\u003Cp>まず、この話題において最も根本的な前提として問うべきなのは、「なぜ手元環境に、Productionのクリティカルなクレデンシャルが存在しているのか」という点です。\u003C/p>\u003Cp>「LLMに\u003Ccode>.env\u003C/code>を読み取られて、本番DBや決済APIのSecretsが外部に抜かれたらどうするんだ」と声高に叫んでいる人々は、そもそも開発者が普段持ち歩き、様々なネットワークに接続するラップトップ端末の平文ファイルに、システムの中枢へアクセスするための鍵が鎮座している状況の異常性に気づいていません。LLMエージェントの脅威を論じる以前の問題として、そのシステム運用と開発フローは根底から破綻しています。\u003C/p>\u003Cp>現代のWeb開発において、まともなインフラ設計とCI/CDパイプラインが構築されていれば、本番環境のシークレット情報はクラウドプロバイダが提供するセキュアなシークレットマネージャ等で一元管理されるべきものです。それらの情報は、デプロイ実行時や、あるいは実行環境の立ち上げ時にのみ、安全な経路で注入されるのが妥当なアーキテクチャでしょう。\u003C/p>\u003Cp>ローカルの\u003Ccode>.env\u003C/code>に記述されるべきは、ローカル環境で立ち上げたモックのエンドポイントや、使い捨ての開発用クレデンシャルのみであるはずです。自らの手で作り出した旧態依然とした巨大な技術的負債を放置したまま、新しいツールの危険性を嘆く姿は、控えめに言って滑稽に映ります。\u003C/p>\u003Ch1 id=\"h2ea620ba31\">開発用キーは使い捨て\u003C/h1>\u003Cp>百歩譲って、「Productionのキーではなく、開発環境で利用しているLLMのAPI Secretや、サードパーティのテストキーが漏洩するのも困るのだ」という反論があるかもしれません。しかし、これについても権限管理とライフサイクルの基本に立ち返れば、夜も眠れなくなるほど大騒ぎするような事態にはなり得ません。\u003C/p>\u003Cp>外部のAPIを開発用途で利用する場合、そのアクセスキーは万が一漏洩したとしても、Revokeしてローテーションするという前提で運用されて然るべき、単なる使い捨てのものに過ぎません。仮に、LLMエージェントがコンテクスト汚染を受け、その開発用キーがどこかの悪意あるサーバに送信されてしまったとしても、該当キーをRevokeし、新しいものを再発行するだけの、ほんの数分の作業で事態は収束します。\u003C/p>\u003Cp>事前に利用金額のハードリミットを適切に設定しておき、スコープを最小化しておくという最小権限の原則を守っていれば、大した金銭的な被害も生じません。もし、開発用のキーが漏洩しただけでリカバリ不能なダメージを受けるような設計になっていたり、キーをRevokeする運用フローすら確立されていないのだとすれば、それはLLMの暴走のせいではなく、単なる設計の怠慢です。\u003C/p>\u003Ch1 id=\"h4b5fc8ef25\">抽象化のツケと無知の露呈\u003C/h1>\u003Cp>さらに言えば、この騒動は、システム全体における脅威モデルを著しく見誤っています。彼らは\u003Ccode>.env\u003C/code>ファイルの内容が外部に送信されることばかりを恐れていますが、LLMエージェントがローカル環境で任意のコマンドを実行できる権限を持っている状態で乗っ取られた場合、想定される被害は環境変数の流出程度にとどまりません。\u003C/p>\u003Cp>もしLLMがターミナルを自由に操作できるのであれば、攻撃者は\u003Ccode>.env\u003C/code>など見向きもせず、ホームディレクトリの隠しフォルダに直接アクセスし、本番サーバへのアクセス権を持つSSHの秘密鍵をごっそり抜き取ることでしょう。あるいは、ブラウザのプロファイルからセッションファイルやCookieを抽出し、あらゆるクラウドサービスへのアクセス権を奪取することすら容易に想像がつきます。\u003C/p>\u003Cp>つまり、平文の\u003Ccode>.env\u003C/code>ファイルを隠すためだけの小手先のツールを導入して安堵したところで、それは根本的な解決には全くなっていません。真にこの脅威に向き合うのであれば、LLMエージェントそのものをdevcontainerのような完全に隔離されたサンドボックス環境内で実行し、ホストOSのファイルシステムへのアクセス権限を根本から制限するアーキテクチャを組むのが本筋というものです。\u003C/p>\u003Cp>少し前に、「LLMにGitを爆破された」「大事な未コミットのコードを消された」と騒いでいた人たちを見かけましたが、今回の騒動も全く同じ構図です。多くの先人たちが積み上げ、高度に抽象化してくれた便利なツールの表層だけを啜り、バージョン管理や権限分離といった土台となる基本原則を理解しようとしない。ツールが賢くなる速度に対して、それを使う側のレベルが低すぎるだけな気もしますけどね。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>LLMエージェントは、私たちの開発体験を劇的に向上させてくれる便利な道具ですが、同時に、私たちがこれまで見て見ぬふりをしてきた運用上の手抜きや、基礎的な理解の欠如を容赦なく炙り出す試薬でもあります。\u003C/p>\u003Cp>粗探しをして無闇に恐れる前に、まずは自分自身の足元の開発環境と、技術者としての基本作法をもう一度見直してみてはいかがでしょうか。\u003C/p>\u003Cp>それでは。\u003C/p>",[138,139,140],{"id":23,"createdAt":24,"updatedAt":25,"publishedAt":24,"revisedAt":25,"slug":26,"name":27},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":87,"createdAt":88,"updatedAt":89,"publishedAt":88,"revisedAt":89,"slug":90,"name":91},{"id":142,"createdAt":143,"updatedAt":144,"publishedAt":145,"revisedAt":144,"title":146,"content":147,"tags":148,"is_no_index":51},"88iv1ksd5","2026-01-30T19:08:58.242Z","2026-01-30T20:06:23.765Z","2026-01-30T19:21:46.117Z","SpeedTestで回線品質を語らないでね","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>YouTubeやTwitterを眺めていると、頻繁に目にする光景があります。SpeedTestの計測結果画面を貼り付け、「国内最速級のネットワークを構築した」「自分の回線は最強だ」とドヤ顔で語る、自称コンピュータに詳しい方々の姿です。\u003C/p>\u003Cp>正直に申し上げます。お前らのどこが詳しいんだと。\u003C/p>\u003Cp>それらの投稿からは、基礎的な前提知識の欠如が透けて見えます。彼らの中には、多少コードが書けることや、自作PCを組めることをひけらかしている方もいるようですが、それが何だというのでしょう。多くの先人たちが長い時間をかけて積み上げ、高度に抽象化してくれたレイヤの上でコードが書けることと、ネットワークやコンピュータの本質的な挙動を理解していることは、全くの別物です。\u003C/p>\u003Cp>下に積み重なった技術を軽視し、都合よくブラックボックス化された上澄みだけを啜って「自分は有能だ」と振る舞うその傲慢さは、技術者としてだけでなく、文化人としても風上にも置けない姿勢だと思います。\u003C/p>\u003Ch1 id=\"h7677687bd2\">巨人の肩の上で\u003C/h1>\u003Cp>誤解のないように言っておきますが、私自身が本質を理解した有識者であると言いたいわけではありません。\u003C/p>\u003Cp>普段の私は、インフラからアプリケーションまで設計・開発・運用・保守を広く浅くこなす事を生業としてお賃金を頂いています。しかし、「自分はコンピュータに精通している」などとは、口が裂けても言えません。\u003C/p>\u003Cp>私が日々触れているのは、多くの優秀なエンジニアや研究者たちが残してくれた抽象化レイヤの上にある有象無象に過ぎないからです。さらに下のレイヤ、例えば組み込みシステムやハードウェア設計の話になれば、今の私が詳細に理解することは困難でしょう。\u003C/p>\u003Cp>真の専門性とは、そうした深淵を知ることにあるはずです。にも関わらず、都合よくブラックボックス化された部分を全部すっ飛ばして、表面的な知識だけで有識者ぶって発信する。私自身も大概な無能であるという自覚はありますが、インターネットの海にはそれ以下の蛙が溢れかえっていて、正直かなり気持ち悪く感じます。\u003C/p>\u003Ch1 id=\"h53f7799c9f\">SpeedTestが回線品質ではない理由\u003C/h1>\u003Cp>毒を吐くのはこれくらいにして、今回はそのSpeedTestの話です。\u003C/p>\u003Cp>彼らが信仰するあの数字が、なぜ回線品質の証明にならないのか。技術的な観点から、大きく4つの欺瞞にケチをつけます。\u003C/p>\u003Ch2 id=\"hf3346b6b6e\">測定スコープの不一致\u003C/h2>\u003Cp>まず決定的なのが、評価対象の乖離です。\u003C/p>\u003Cp>本来、回線の実力として評価されるべきは、ユーザー宅内のCPEからアクセス網を抜け、ISPのバックボーン、そしてAS境界ルータに至るまでの区間です。このAS内部において、IGPルーティングがいかに効率的か、設備冗長性が保たれているか、網内帯域に余裕があるか。これこそが回線品質でしょう。\u003C/p>\u003Cp>しかし、SpeedTestが計測しているのは、他社のトランジットやIX、そして測定サーバー内部までを含んだE2Eでの通信結果です。ISP網内がどれほど高品質に保たれていても、トランジット先が輻輳していれば数値は落ちます。つまり、SpeedTestの結果には外部要因のノイズが大きすぎて、被測定物であるはずのISP網の純粋な性能を切り出すことなど困難な構造になっているのです。\u003C/p>\u003Cp>要するに、経路上で最も足を引っ張っている数字が目安程度に出るだけなんですよ。\u003C/p>\u003Cp>ボトルネックがどこにあるのかも分からないのに、その数字を以て回線品質を語るなど、さすが有識者さん(笑)は違いますね。\u003C/p>\u003Ch2 id=\"h8aa307d715\">経路制御の変動性\u003C/h2>\u003Cp>インターネットは宛先によって経路が異なることが大前提ですが、SpeedTestはこの特性を無視した一点観測に過ぎません。\u003C/p>\u003Cp>ISPはコストやポリシに基づいて、経路制御を行います。例えば、測定サーバAへの経路は、たまたま高品質なピアリング先を通る定義されているかもしれません。(ここで言う「定義されている」とは、必ずしも恣意的であると言う意味合いを含みません。)\u003Cbr>しかし、あなたが実際にYouTubeを見たりゲームをしたりする際のサーバBへの経路は、安価で混雑したトランジット先を通るかもしれないのです。\u003C/p>\u003Cp>さらに、経路上のすべてのASはベストエフォートで動いています。ある瞬間に測定サーバーへの経路が確保されたからといって、それが永続的な品質を保証するものでもなければ、他の経路の品質を担保するものでもありません。たまたま空いている裏道を走って「私の車庫は素晴らしい」と主張するのは滑稽です。\u003C/p>\u003Ch2 id=\"h3c6d2eeff5\">測定基準の信頼性\u003C/h2>\u003Cp>任意の何かを測定する場合、その定規は不変である必要があります。\u003C/p>\u003Cp>しかし、測定サーバは定規になり得ません。収容している物理筐体自体のNIC帯域、CPU負荷、ディスクI/O、あるいはそのサーバが収容されているデータセンタの上位回線がボトルネックになるケースは多々あります。\u003C/p>\u003Cp>また、CDNのエッジサーバとSpeedTestのサーバが同じ場所にある保証もありません。「Speedtestサーバへは速いが、GoogleやAWSへは遅い(あるいは逆)」という現象は、技術的に当然起こり得ることです。\u003C/p>\u003Ch2 id=\"h98df36be9e\">スループット偏重\u003C/h2>\u003Cp>最後に、最も見落とされがちなのがトランスポート層の挙動です。\u003C/p>\u003Cp>速度という単一指標への盲信は、回線の本当の姿を隠蔽します。SpeedTestの多くは、複数のTCPセッションを張って帯域を無理やり埋めようとします。TCPは再送制御やウィンドウ制御によって、パケットロスやジッタをある程度吸収し、それをスループットとして出力するプロトコルです。\u003C/p>\u003Cp>つまり、速度は出ているが、パケットロスが多発している低質な回線であっても、再送によって帳尻を合わせ、一見すると高得点が出てしまいます。しかし、VoIPやオンラインゲームのようなUDPでのリアルタイム通信で重要なのは、帯域幅よりもレイテンシの安定性やパケット到達率です。単なる上下○○Mbpsという数字は、これらをもっとも必要とするアプリケーションの快適性を何一つ保証しません。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>ここまで書き連ねてきましたが、私が言いたいのは「SpeedTestを使うな」ということではありません。あくまで目安として、あるいはトラブルシューティングの一環として使う分には有用なツールです。\u003C/p>\u003Cp>私が批判しているのは、その数字が持つ不確かさや多面性を無視し、単一の数値を絶対的な正義として振りかざす姿勢そのものです。\u003C/p>\u003Cp>ネットワークの品質とは、本来もっと静かで、目に見えにくいものです。パケットロスなくデータが届くこと。レイテンシが揺らがないこと。輻輳時にも最低限の公平性が保たれること。そういった地味で計測しづらいパラメータの積み重ねの上に、我々の快適な体験は成り立っているはずです。\u003C/p>\u003Cp>あの派手なメーターが弾き出す数字は、複雑怪奇なインターネットのほんの一瞬を切り取った、ただの現象に過ぎません。\u003C/p>\u003Cp>その数字に一喜一憂してマウントを取り合うよりも、自分がいかに巨大で不確実なシステムの上で遊ばせてもらっているかを自覚する。それが、ブラックボックス化された技術を享受する私たちユーザが持つべき、最低限の節度ではないでしょうか。\u003C/p>",[149,150,151],{"id":23,"createdAt":24,"updatedAt":25,"publishedAt":24,"revisedAt":25,"slug":26,"name":27},{"id":114,"createdAt":115,"updatedAt":116,"publishedAt":115,"revisedAt":116,"slug":117,"name":118},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":153,"createdAt":154,"updatedAt":155,"publishedAt":155,"revisedAt":155,"title":156,"content":157,"tags":158,"is_no_index":51},"qxerlbel9l1j","2026-01-28T14:05:23.242Z","2026-01-29T12:09:47.600Z","TwitterではOGPにWebPが使えないみたい","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>先日、Twitterを何気なく眺めていたときに相互の子がここのURLを投稿してくれているのが目に留まりました。\u003C/p>\u003Cp>自分の書いた記事が共有されているのは嬉しいことですが、その投稿を見て一つ気になる点がありました。本来ならアイキャッチ画像が表示されるはずのOGPが、画像なしのグレーの表示になっていたのです。\u003C/p>\u003Cp>環境の問題かと思い何度かリロードしてみたり、別の端末で確認してみたりしましたが、状況は変わりません。どうやら、私のサイトのOGP設定に何か不具合が起きているようでした。\u003C/p>\u003Ch1 id=\"h1f37091dcb\">WebP移行の落とし穴\u003C/h1>\u003Cp>実はこのサイト、かなり前の改修でパフォーマンス向上を目的として、配信する画像のフォーマットを全面的にWebPへ統一していました。WebPは軽量で取り回しやすく、現代においては事実上のスタンダードと言える形式です。\u003C/p>\u003Cp>その際、主要なブラウザでの表示確認は行っていましたが、TwitterでのOGP表示に関しては特に個別の検証をしていませんでした。というのも、\u003Ca href=\"https://developer.x.com/en/docs/x-for-websites/cards/overview/markup#:~:text=URL%20of%20image%20to%20use,SVG%20is%20not%20supported.\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">Twitterの公式ドキュメント\u003C/a>にはサポートされる画像形式としてWebPが明記されていたため、「公式が言ってるんだから大丈夫だろう」と高を括っていたのです。\u003C/p>\u003Cp>おそらく、その改修を行った時点からずっと、Twitter上では画像が表示されない状態が続いていたのだと思われます。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/8b9ca34d125749b48cc329940674b108/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88%202026-01-28%2022.21.16.png\" alt=\"\" width=\"589\" height=\"252\">\u003C/figure>\u003Cp>\u003C/p>\u003Ch1 id=\"h63fb55d34f\">記事ページだけ表示されない\u003C/h1>\u003Cp>状況を詳しく確認してみると、サイト内のすべてのページで画像が表示されないわけではありませんでした。\u003C/p>\u003Cp>サイト内にある\u003Ccode>/\u003C/code>, \u003Ccode>/about\u003C/code>のような固定ページは、Twitter上でも問題なく画像が表示されています。一方で、ブログのメインコンテンツである記事ページやタグページのURLを投稿すると、画像が表示されません。\u003C/p>\u003Cp>特定のページだけが表示されないという挙動から、metaタグの記述ミスではなく、画像ファイルそのものや配信方法に原因があるのではないかと推測しました。そこで、正常に表示されているページと、表示されないページの違いを比較してみることにしました。\u003C/p>\u003Ch1 id=\"h9c1f22bd6d\">原因\u003C/h1>\u003Cp>調査の結果、両者の決定的な違いは画像のファイル形式にありました。\u003C/p>\u003Cp>表示されている固定ページのOGP画像は、サーバー上に配置された静的なPNGファイルを指定していました。対して、表示されない記事ページの画像は、記事ごとにアイキャッチを生成するAPIを経由しており、動的に生成されたWebP画像を指定していました。\u003C/p>\u003Cp>このサイトの仕様として、APIはリクエストに応じてWebP形式で画像を返し、レスポンスヘッダーのContent-Typeも \u003Ccode>image/webp\u003C/code> を正しく設定しています。Webの標準仕様に準拠した実装であり、ブラウザで直接URLを開けば画像は問題なく表示されます。\u003C/p>\u003Cp>\u003Ca href=\"https://github.com/chan-mai/mq1-web/blob/33aabbbebed9ab839ce6d6759753be2cbcd06a3e/server/api/og/article/%5BcontentId%5D.ts#L49-L55\">https://github.com/chan-mai/mq1-web/blob/33aabbbebed9ab839ce6d6759753be2cbcd06a3e/server/api/og/article/%5BcontentId%5D.ts#L49-L55\u003C/a>\u003C/p>\u003Cp>このサイトでは、通常のブラウザ向けにはWebPを標準としていますが、どうやらTwitterのクローラーに対しては、WebP形式の画像は正しく認識されないようです。私のサイト環境においては、動的・静的問わず、WebPを指定している箇所でOGPが表示されるケースはありませんでした。\u003C/p>\u003Ch1 id=\"h9ebabf7874\">公式ドキュメントと実挙動の乖離\u003C/h1>\u003Cp>納得がいかないのは、\u003Ca href=\"https://developer.x.com/en/docs/x-for-websites/cards/overview/markup#:~:text=URL%20of%20image%20to%20use,SVG%20is%20not%20supported.\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">Twitter公式のDeveloper Platform\u003C/a>にある\u003Ccode>twitter:image\u003C/code>の仕様記述です。そこには明確にこう書かれています。\u003C/p>\u003Cblockquote>\u003Cp>URL of image to use in the card. Images must be less than 5MB in size. JPG, PNG, WEBP and GIF formats are supported. Only the first frame of an animated GIF will be used. SVG is not supported.\u003C/p>\u003C/blockquote>\u003Cp>「JPG, PNG, WEBP and GIF formats are supported」と、はっきりと書かれているのです。\u003C/p>\u003Cp>しかし現実は、ドキュメント通りにWebPを設定しても表示されません。この現象について相互の子に話を聞いたところ、全く同じ挙動に悩まされた経験があり、結局はWebPを諦めてPNG配信に切り替えることで解決したそうです。個別の環境依存というよりは、プラットフォーム側でWebPへの対応が不完全、あるいは不安定である可能性が高そうです。\u003C/p>\u003Cp>ドキュメント通りの正しい実装をしているにもかかわらず、プラットフォーム側の都合で弾かれてしまうというのは納得しづらい部分ですが、外部サービスに依存している以上、こちらが合わせるしかありません。\u003C/p>\u003Ch1 id=\"hd6164b4a9e\">PNG形式に戻して解決\u003C/h1>\u003Cp>原因がプラットフォーム側のWebP対応状況にあると判断したため、OGP画像の生成ロジックを変更することにしました。\u003C/p>\u003Cp>\u003Ca href=\"https://github.com/chan-mai/mq1-web/blob/8899159ec6a9901fcdc70933b572cf63a4f47ddc/server/api/og/article/%5BcontentId%5D.ts#L49-L55\">https://github.com/chan-mai/mq1-web/blob/8899159ec6a9901fcdc70933b572cf63a4f47ddc/server/api/og/article/%5BcontentId%5D.ts#L49-L55\u003C/a>\u003C/p>\u003Cp>バックエンドの処理を修正し、OGP用画像のリクエストに対しては、PNGを強制して返すように変更しました。本来であればファイルサイズの小さいWebPを使いたいところですが、OGPが表示されないデメリットの方が大きいため、ここはやむを得ない対応です。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/91b8bee5f7384b60b7ba80ee88c24a37/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88%202026-01-28%2022.22.13.png\" alt=\"\" width=\"589\" height=\"430\">\u003C/figure>\u003Cp>修正をデプロイして確認したところ、PNGになった画像はTwitterに認識され、カードが正常に表示されるようになりました💩\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>公式ドキュメントには「WebP対応」と明記されているのに、実際には動かない。開発者としてはモヤっとしますが、プラットフォーム側の都合には逆らえません。\u003C/p>\u003Cp>同じようにTwitterのOGP問題で頭を抱えている方の参考になれば嬉しいです。\u003C/p>",[159,160],{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},{"id":87,"createdAt":88,"updatedAt":89,"publishedAt":88,"revisedAt":89,"slug":90,"name":91},{"id":162,"createdAt":163,"updatedAt":164,"publishedAt":164,"revisedAt":164,"title":165,"content":166,"tags":167,"is_no_index":51},"7pcqwxb_8jvj","2026-01-26T17:51:45.822Z","2026-01-27T11:53:38.813Z","「おっと、何かがうまくいかなかった。」Ad-Shieldをなんとかしたい","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>わたしのおうちネットワーク環境には少々癖がありまして、なぜか広告配信やユーザーを追跡するトラッキングに関連するFQDN全般の名前解決が、ことごとく失敗するようになっています。これらのドメインから配信されるコンテンツは視覚的にも内容的にも不快なものが多いため、個人的にはこの「不具合」のおかげでセキュリティと精神衛生が守られているのですが、最近では色々なところでAd-ShieldなるAd-Blocker対策用のソリューションが採用され、スクリプト読み込みの失敗を検知して閲覧を拒否されることが増えてきました。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/6d68729c516042c0b0f0f0e0b32ecac6/image.png\" alt=\"\" width=\"1919\" height=\"928\">\u003C/figure>\u003Cp>「おっと、何かがうまくいかなかった。」とか言ってくる例のあいつですね。\u003C/p>\u003Ch1 id=\"hab991ce271\">Ad-Shieldの不完全性\u003C/h1>\u003Cp>ただ、このAd-Shieldの挙動を少し掘り下げて確認してみると、その実装品質はお世辞にも高いとは言えません。\u003C/p>\u003Cp>例えばYouTubeの広告検知システムなどは、SSAI技術を含め、コンテンツ配信基盤と密接に統合されており非常に高精度です。誤検知で動画が見られなくなることは稀ですし、そこにはある種の技術的な凄みすら感じます。\u003C/p>\u003Cp>一方で、Ad-Shieldの実装はあまりにも原始的で、クライアントサイドでのスクリプト読み込み成否に過度に依存しています。これが何を意味するかというと、例えば移動中の電車内で通信が不安定なスマホや単に応答速度が遅いパブリックネットワークといった、ごく一般的な環境でインターネットを利用しているだけの順当なユーザーまでもが、無差別に遮断されるリスクがあるということです。\u003C/p>\u003Cp>Ad-Blockerなど一切利用していないのに、たまたまスクリプトの読み込みがタイムアウトしただけで「お前はAd-Blockerを利用しているんだ！！」と決めつけられ、コンテンツを没収される。ユーザー体験としては最悪と言っていいでしょう。\u003C/p>\u003Cp>本来、コンテンツを見てもらうためのWebサイトが、その入り口で正規のユーザーをふるい落として機会損失を生んでいるわけです。品質管理が徹底されたプロダクトとは雲泥の差があり、そもそもまともな技術検証ができている企業であれば、このような粗悪な外部サービスを採用すること自体を躊躇するはずです。\u003C/p>\u003Ch1 id=\"hd7aabd8fbc\">で、どうしよう\u003C/h1>\u003Cp>…と、散々こき下ろしましたが、文句を言っていても画面のオーバーレイは消えてくれません。ただ、この作りの甘さは、裏を返せばとても好都合なことでもあります。\u003C/p>\u003Cp>このシステムの検知ロジックは単純で、そこに付け入る隙があります。通信ログを確認すると、Ad-Shieldは主に\u003Ccode>html-load.com\u003C/code>と、フォールバック用と思われる\u003Ccode>fb.html-load.com\u003C/code>という2つのドメインを使用しています。\u003C/p>\u003Cp>通常は\u003Ccode>html-load.com\u003C/code>からスクリプトを読み込みますが、名前解決に失敗するなどして読み込めないと、即座にフォールバック先の\u003Ccode>fb.html-load.com\u003C/code>へフォールバックする仕様になっているようです。\u003C/p>\u003Cp>ここで残念なのが、サイト側の判定基準です。彼らはどうやら「フォールバックドメインへの接続が成功したかどうか」だけを見て、対策ツールが正常に機能していると判断しているようなのです。実際に広告が表示されているか、DOMが改変されていないかといった本質的な検証は行われていません。\u003C/p>\u003Cp>この挙動を利用すれば、不快なコンテンツの温床である\u003Ccode>html-load.com\u003C/code>の名前解決は失敗させたまま、フォールバック先の\u003Ccode>fb.html-load.com\u003C/code>だけを明示的に許可してあげることで検知を回避できます。\u003C/p>\u003Cp>この環境では\u003Ccode>html-load.com\u003C/code>の読み込みに失敗するため、Ad-Shieldはフォールバック側を読み込みますが、フォールバックドメインの名前解決は許可されているため読み込みに成功します。するとサイト側は「スクリプトが読み込めたから問題ない」と誤認し、警告を解除してコンテンツを表示します。\u003C/p>\u003Cp>もちろん、ユーザー側は広告配信の本体ドメインへの接続に引き続き失敗しているため、画面には広告が表示されないままコンテンツだけが表示されることになります。メインの通信は遮断されているのに、フォールバック側だけが生きているという、明らかに作為的で不自然なネットワーク環境を異常として検出できないあたりに、この技術の悲哀を感じざるを得ません。\u003C/p>\u003Cp>もっとも、この穴も完全に塞がっているわけではないようです。詳細な発生条件までは特定できていませんが、実装が一貫していないおかげで、ごく稀にフォールバックドメイン経由で広告そのものがお漏らしされるケースも観測されています。どうやら純粋な検知用というわけではなく、一部の広告リソースの配信元も兼ねてしまっているのかもしれません。まぁ、許容できる範囲の誤差かと思いますが。\u003C/p>\u003Ch1 id=\"h5f3c23801b\">DNSシンクホールの限界と運用\u003C/h1>\u003Cp>もちろん、DNSレベルでの制御にも限界はあります。あくまでFQDN単位の制御であるため、コンテンツと同じドメインから配信される広告や、直接埋め込まれた広告には無力です。最近はDNSシンクホールを万能なツールのように捉えている方も見かけますが、これは導入が容易で、ある程度の不快なコンテンツが消えればいいという妥協点として運用すべきものです。\u003C/p>\u003Cp>今回の手法もあくまで場当たり的な対策に過ぎません。本来であれば、uBlock Originなどのコンテンツブロッカーを用いてDOMレベルで制御するのがおそらく正しいやり方なのかと思います。現状では\u003Ccode>fb.html-load.com\u003C/code>、あるいは環境によっては\u003Ccode>fb.content-loader.com\u003C/code>といったフォールバックドメインを許可リストに入れることで回避可能ですが、ログに見られる\u003Ccode>report.error-report.com\u003C/code>などはテレメトリ用と思われるため、引き続き名前解決できない状態にしておくのが賢明でしょう。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>今回紹介した手法は、あくまで現時点でのAd-Shieldの仕様を突いたものです。向こうが利用するFQDNを変更したり、検知ロジックを変更したりすれば、すぐに使えなくなるでしょう。所謂いたちごっこに過ぎません。\u003Cbr>しかし、ユーザー体験を損なうようなお粗末な実装でこちらのブラウジングを阻害してくる以上、こちらも快適な環境を維持するために自衛していくしかありません。\u003C/p>\u003Cp>この記事が、同じような「不具合」を抱えるネットワーク環境の管理者の方々にとって、何かの役に立てば幸いです。\u003C/p>",[168,169],{"id":114,"createdAt":115,"updatedAt":116,"publishedAt":115,"revisedAt":116,"slug":117,"name":118},{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":7,"revisedAt":8,"slug":9,"name":10},30,12,1783975282261]