[{"data":1,"prerenderedAt":76},["ShallowReactive",2],{"article-8-d49ulp-r":3,"prev-article-8-d49ulp-r":33,"next-article-8-d49ulp-r":45},{"contents":4,"totalCount":31,"offset":32,"limit":31},[5],{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":8,"revisedAt":8,"title":9,"content":10,"tags":11,"is_no_index":30},"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>",[12,18,24],{"id":13,"createdAt":14,"updatedAt":15,"publishedAt":14,"revisedAt":15,"slug":16,"name":17},"wia4slp55q","2025-04-28T07:14:14.541Z","2025-12-03T16:08:13.798Z","thoughts","独り言",{"id":19,"createdAt":20,"updatedAt":21,"publishedAt":20,"revisedAt":21,"slug":22,"name":23},"qfey3yw0z1","2025-04-29T08:44:41.687Z","2025-12-03T16:07:14.622Z","technology","テクノロジー",{"id":25,"createdAt":26,"updatedAt":27,"publishedAt":26,"revisedAt":27,"slug":28,"name":29},"8q8qyy8sz3","2025-04-29T08:44:23.406Z","2025-12-03T16:07:24.495Z","programming","プログラミング",false,1,0,{"contents":34,"totalCount":44,"offset":32,"limit":31},[35],{"id":36,"createdAt":37,"updatedAt":38,"publishedAt":38,"revisedAt":38,"title":39,"content":40,"tags":41,"is_no_index":30,"summary":43},"tukox0h7-cv","2026-02-22T21:50:32.511Z","2026-02-22T21:53:53.719Z","輪郭","\u003Cp>最近、自分が結局のところ何を考えているのか、よく分からなくなります。考えているようでいて、実は何も考えていないのではないか。そもそも考えるとは何なのだろうか、とふと思うことがあります。\u003C/p>\u003Cp>世の中を見渡すと、多くの人がそれっぽいことを言っています。しかし、どれだけその主張に社会的な妥当性があろうと、そこに本人の実体験や葛藤といった独自の考えが介在していなければ、全部無意味に思えてしまいます。概ねLLMの挙動と大差がありません。\u003C/p>\u003Cp>わたしは、当人の考えが伴わない社会的な正解として消費される主張が嫌いです。例えば、多様性やジェンダーといった文脈で語られる、耳障りのいい言葉で梱包されただけの綺麗事は、非常に浅はかで不快に感じます。勿論、そこに本人の生々しい体験や葛藤が介在していれば話は別なのでしょうが、世の中に溢れる多くのものはそうではないように思えます。\u003C/p>\u003Cp>逆に、仮にそれが社会的に許容されるべきではない極端な主義主張であったとしても、そこに本人の確固たる考えがあるのであれば、わたしはその主張を素敵だと思いますし、耳を傾けたいなと思います。もっとも、こんなことを言っている自分自身も、結局は安全圏から綺麗事を並べているだけなのかもしれないという自覚はあります。これもまた一つの綺麗事なんですけどね。\u003C/p>\u003Cp>自分の浅はかさは、自分がいちばんよく分かっているつもりです。他人を引き合いに出して落とすことでしか自分の優位性を見出せず、他人を見下すことしかできなくなっていて本当に人間性の終わりを感じています。中身に魅力がなさすぎるのでせめて顔くらいはよくありたいと思っています。根本的に視座が低すぎるのかもしれません。\u003C/p>\u003Cp>世に溢れる言葉に違和感を覚えながら、自分の浅さや醜さに葛藤する。綺麗な正解を出せないまま、その自己矛盾や認知の摩擦を抱え続けること自体が、今の自分にとって辛うじて考えている状態を担保しているものなのかもしれません。\u003C/p>\u003Cp>笑い飛ばしてもらえるとうれしいです\u003C/p>",[42],{"id":13,"createdAt":14,"updatedAt":15,"publishedAt":14,"revisedAt":15,"slug":16,"name":17},"最近、自分が結局のところ何を考えているのか、よく分からなくなります。考えているようでいて、実は何も考えていないのではないか。そもそも考えるとは何なのだろうか、とふと思うことがあります。世の中を見渡すと、多くの人がそれっぽいことを言っています。しかし、どれだけその主張に社会的な妥当性があろうと、そこに",55,{"contents":46,"totalCount":75,"offset":32,"limit":31},[47],{"id":48,"createdAt":49,"updatedAt":50,"publishedAt":50,"revisedAt":50,"title":51,"content":52,"tags":53,"is_no_index":30,"summary":74},"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>",[54,60,61,62,68],{"id":55,"createdAt":56,"updatedAt":57,"publishedAt":56,"revisedAt":57,"slug":58,"name":59},"vmhb23sq4","2025-07-29T12:56:07.884Z","2025-12-03T16:06:19.140Z","misskey","Misskey",{"id":25,"createdAt":26,"updatedAt":27,"publishedAt":26,"revisedAt":27,"slug":28,"name":29},{"id":19,"createdAt":20,"updatedAt":21,"publishedAt":20,"revisedAt":21,"slug":22,"name":23},{"id":63,"createdAt":64,"updatedAt":65,"publishedAt":64,"revisedAt":65,"slug":66,"name":67},"fwj_nwhj1-s","2025-04-30T04:55:28.953Z","2025-12-03T16:07:03.591Z","server","サーバー",{"id":69,"createdAt":70,"updatedAt":71,"publishedAt":70,"revisedAt":71,"slug":72,"name":73},"lte0t59xm8sb","2025-05-02T16:12:38.708Z","2025-12-03T16:06:52.753Z","network","ネットワーク","はじめにどうも、わたしです。先日、Misskeyユーザ向けの(匿名)質問箱サービスMewkを作りました。https://mewk.app/リリースから1ヶ月少々が経ち、現時点で約2,000ユーザ、7,000件弱の質問が投稿されています。ありがたいことですね。なんでつくったのMisskeyには既存の匿",13,1783975274074]