リアルタイム共同編集の運用
Crowi 2.0 のリアルタイム共同編集 (RFC-0003) を本番環境にデプロイするためのオペレーターガイド
Crowi 2.0 のリアルタイム共同編集機能は、Hocuspocus を @crowi/api
プロセス内にライブラリとして同居 attach する構成で出荷されています。
別プロセスや別ポートを起動する必要はなく、既存の api コンテナを動かせば
そのままリアルタイム共同編集が有効になります。
設計の詳細は RFC-0003 を、利用者向けの使い方は リアルタイム共同編集 を 参照してください。
概要
- Hocuspocus は
packages/api/src/collab/attach.ts経由で api プロセスのhttp.Serverにws noServerモードで attach されます。 - WebSocket エンドポイントは
wss://<crowi-host>/collab/:pageId?token=…で、 api と同じホスト・ポートを使います。リバースプロキシで別プロセスに 振り分ける必要はありません。 - マルチインスタンス構成では
@hocuspocus/extension-redisがREDIS_URL設定時に自動 attach され、Yjs アップデートを Redis pub/sub で全レプリカへ伝搬します。スティッキーセッションは不要です。 - ページ単位の同時編集者上限 (デフォルト 20) は Redis カウンタで管理され、 クラスタ全体で正しくカウントされます。
シングルインスタンスデプロイ
最小構成は api + web + MongoDB の 3 サービスです。
# docker-compose.yml (抜粋)
services:
api:
image: ghcr.io/crowi/api:2.0
environment:
MONGO_URI: mongodb://mongo:27017/crowi
WS_TOKEN_SECRET: ${WS_TOKEN_SECRET}
CROWI_ENCRYPTION_KEY: ${CROWI_ENCRYPTION_KEY}
# REDIS_URL は任意 (セッション/Socket.IO 共用)
REDIS_URL: redis://redis:6379
ports:
- "3000:3000"
web:
image: ghcr.io/crowi/web:2.0
environment:
NEXT_PUBLIC_API_URL: https://crowi.example.com
ports:
- "3001:3000"
mongo:
image: mongo:7このとき:
REDIS_URLは省略可ですが、セッション保存と Socket.IO アダプタの 共用先として通常は設定済みのはずです。- 共同編集自体は Redis なしでも動作します (extension-redis は attach されず、編集者上限 counter は fail-open になります)。
- ブラウザは
NEXT_PUBLIC_API_URLから/collab/への WebSocket URL を 自動導出するため、NEXT_PUBLIC_COLLAB_URLの指定は不要です。 WS_TOKEN_SECRETはシングルインスタンスでは省略可です — 未設定のまま でもプロセスごとにランダム鍵が生成され (起動時に警告ログ)、1 レプリカなら 無害です。api ノードを 2 台以上に増やすときにのみCROWI_MULTI_INSTANCEとあわせて設定してください。
マルチインスタンスデプロイ
api を複数レプリカで運用する場合の追加要件:
REDIS_URLを全レプリカで同じ Redis に向ける — extension-redis が 自動 attach され、レプリカ間で Yjs アップデートが pub/sub されます。CROWI_MULTI_INSTANCEを宣言し、WS_TOKEN_SECRETを全レプリカで同一値に 揃える — wsToken は任意のレプリカで発行されて任意のレプリカで検証される ため、値がずれると接続が確立できません (詳細は 関連環境変数 参照)。各レプリカにCROWI_MULTI_INSTANCE=1(またはレプリカ数) を設定すると、WS_TOKEN_SECRETが未設定 (または プレースホルダ) のままだと api は起動時にエラーで停止します (プロセスごとのランダム鍵では他レプリカが wsToken を検証できないため)。 シングルインスタンス構成 (CROWI_MULTI_INSTANCE未設定) ではWS_TOKEN_SECRET未設定でも起動できます。REDIS_URLの有無だけでは マルチインスタンスとは判定しません (Redis はシングルレプリカでも使われる ため)。- スティッキーセッション不要 — クライアントは任意の api レプリカに 接続できます。Yjs 更新は Redis pub/sub 経由で他レプリカへ即座に 伝搬します。
Page.yjsStateが真実の源 — レプリカ全体の再起動時も MongoDB に 永続化された yjsState から復元されます。Redis はライブのルーティング 専用です。
重要 (データ消失リスク・推奨はシングルインスタンス): マルチインスタンス 構成には、まれにですが編集内容が失われる既知のリスクがあります。
extension-redisはライブの pub/sub のみを担い、切断中レプリカの Y.Doc を 再構築しません。あるレプリカが古いPage.yjsStateから Y.Doc を materialise し、その状態でチェックポイント (compaction) を書き戻すと、別レプリカが書いた 新しい状態を上書きしうるためです。compaction にはクロスインスタンスの ロックがありません (cross-instance ロック + last-writer 調停は現時点で スコープ外)。@crowi/collabはこれをサーバドキュメント保存ロックで 緩和します。ページの revision ポインタは、ライブのサーバ側ドキュメントが materialise された revision を基底とする単一のアトミックな compare-and-set で前進させるため、別経路の編集 (または別レプリカ) がポインタを動かした後の 保存はCONFLICTで拒否され、クライアントは再読み込みします (revisionとcurrentRevisionが乖離することはありません)。自動チェックポイントには desync ガードを追加しています。非空ページに対して空ドキュメントを 書き込もうとするチェックポイント (= 読み込み失敗で materialise された ドキュメントの兆候) はスキップし、スキップ時は既存の状態と保留中デルタの 両方を保持するため編集は失われません。エディタからの意図的な削除は (ページの 全消去を含め) 明示的なユーザー操作として通常どおり保存されます (削除は短命の 行ではなくPage.yjsStateに永続化されます)。これらにより single/multi 両方で最悪ケースを緩和しますが、完全には防げません。このため Crowi は 当面シングルインスタンス (例: Lightsail 1 台) を推奨します。マルチ インスタンスは同時編集の負荷分散が必要な場合のみ、このリスクを理解した上で 採用してください。
# 2 instance 例
services:
api:
image: ghcr.io/crowi/api:2.0
deploy:
replicas: 2
environment:
MONGO_URI: mongodb://mongo:27017/crowi
REDIS_URL: redis://redis:6379 # マルチ構成では必須
WS_TOKEN_SECRET: ${WS_TOKEN_SECRET} # 全レプリカで揃える
CROWI_ENCRYPTION_KEY: ${CROWI_ENCRYPTION_KEY}リバースプロキシ設定
WebSocket の Upgrade ヘッダを api までフォワードする必要があります。
nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream crowi_api {
server api1:3000;
server api2:3000;
# マルチインスタンスでもスティッキー指定不要
}
server {
listen 443 ssl http2;
server_name crowi.example.com;
# /api/v2/* と /collab を同じ upstream へ
location /api/v2/ {
proxy_pass http://crowi_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 末尾スラッシュ無しの `/collab` も含む prefix match。HocuspocusProvider は
# bare `ws[s]://<host>/collab` で接続し、page id は handshake 後の protocol
# で送るため、`location /collab/` (末尾スラッシュ付き) だと bare upgrade が
# マッチしません。`location /collab` (末尾なし) は `/collab` と `/collab/...`
# の両方をカバーします。
location /collab {
proxy_pass http://crowi_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 3600s; # 接続は長時間 keep-alive
proxy_send_timeout 3600s;
}
location / {
proxy_pass http://crowi_web:3000;
}
}Traefik
# docker-compose.yml の api service に
labels:
- "traefik.enable=true"
- "traefik.http.routers.crowi-api.rule=Host(`crowi.example.com`) && (PathPrefix(`/api/v2`) || PathPrefix(`/collab`))"
- "traefik.http.services.crowi-api.loadbalancer.server.port=3000"
# スティッキーセッション無効 (デフォルト)ポイント:
/api/v2/pages/:id/yjs-token(HTTP) と/collab(WebSocket。HocuspocusProvider が bare/collabで接続し、page id は protocol 経由で送る) を同じ api アップ ストリームに向ける必要があります。- WebSocket 接続のアイドルタイムアウトは長め (1 時間程度) に設定して ください。長時間の閲覧/編集セッションが prematurely 切断されると ユーザー体験が悪化します。
- スティッキーセッションは不要です。
Note (Crowi 2.0 / RFC-0005): ページプレゼンス機能の WebSocket は
/presence/:pageIdに attach されており、/collab/*とまったく同じ 構成です。リバースプロキシでは/presenceロケーションを/collabと同じ要領で api アップストリームへ向け、Upgradeヘッダを転送して ください (nginx なら上記location /collabと同じブロックをパスだけ 変えて複製、Traefik ならruleのPathPrefixに/presenceを追加)。 プレゼンスのビューア状態とマルチインスタンス間の伝搬はREDIS_URLの Redis を共用します (専用の追加インフラはありません)。プレゼンスの WebSocket が届かない場合でも、ライブプレゼンス行が静かに隠れるだけで ページ閲覧自体には影響しません。
関連環境変数
| 変数名 | 必須 | 用途 |
|---|---|---|
WS_TOKEN_SECRET | マルチでは必須 (全レプリカで揃える) | wsToken (短命 JWT) の HMAC 署名鍵。マルチインスタンス構成では全レプリカで同じ値を指定する必要があります。CROWI_MULTI_INSTANCE が設定されているのに WS_TOKEN_SECRET が未設定 (または既知のプレースホルダ) の場合、api は起動を中止します (プロセスごとのランダム鍵では他レプリカが検証できないため)。シングルインスタンス (CROWI_MULTI_INSTANCE 未設定) では未設定でも起動でき、プロセス起動ごとにランダム生成され警告ログが出ます。同梱の .env.example は意図的に空にしています (世界中で既知の鍵をテンプレートに入れないため)。設定する場合は最低 32 文字必要です — 32 文字未満の値は NODE_ENV=production (未設定時のデフォルトも production 扱い) では起動時エラーで停止し、それ以外の NODE_ENV では起動ログの警告レポートに含まれます (既知のプレースホルダ値は対象外)。openssl rand -base64 32 で生成してください。 |
CROWI_MULTI_INSTANCE | いいえ (既定はシングル) | マルチインスタンス構成を宣言します (1 / true、またはレプリカ数 >= 2)。WS_TOKEN_SECRET 起動ガードはこの値を基準に判定します (REDIS_URL の有無ではありません)。そのため Redis をセッション/Socket.IO 用途のみに使う 1 レプリカ構成は共有鍵なしで起動できます。api ノードを 2 台以上に増やすときに全レプリカへ設定してください。 |
COLLAB_MAX_EDITORS_PER_PAGE | いいえ (デフォルト 20) | ページごとの同時編集者上限。21 人目以降は read-only でリアルタイム表示は受けられますが編集できません。 |
NEXT_PUBLIC_COLLAB_URL | いいえ | ブラウザが WebSocket 接続する URL。未指定時は window.location.host + NEXT_PUBLIC_API_URL から /collab/ を derive します。リバースプロキシで /collab/* を api 同居ホストに向けているなら設定不要。 |
REDIS_URL | マルチでは必須 | セッション/Socket.IO アダプタ + 共同編集の pub/sub + 編集者上限 counter。未設定時の挙動は次節参照。 |
CROWI_ENCRYPTION_KEY | 推奨 | 機密 Config (OAuth secret 等) を AES-256-GCM で暗号化。共同編集には直接影響しませんが、pnpm --filter @crowi/api crypto:gen-key で生成して全レプリカで揃えてください。 |
Redis 接続数の見積もり
@hocuspocus/extension-redis は pub + sub の 2 接続 を api インスタンス
ごとに張ります。既存のセッション/Socket.IO 用接続と合算して Redis 側の
maxclients を見積もる必要があります。
例: 10 api レプリカ + 1 web レプリカの構成
- 共同編集 extension-redis: 10 × 2 = 20 接続 (pub + sub の専用 ioredis client)
- セッション/Socket.IO: 10 × 1 = 10 接続 (node-redis client)
- 編集者上限 counter: セッション/Socket.IO と同じ node-redis client に相乗り (追加接続 0)
- web 側からの直接 Redis 接続: なし (api 経由)
合計約 30 接続が常時消費されます。デフォルトの maxclients=10000 内で
十分まかなえますが、Redis Cluster や ElastiCache の低リソースインスタンス
(cache.t3.micro / cache.t4g.micro 等) では余裕を持って割り当ててください。
2 インスタンス smoke テスト手順
実機での multi-instance 検証は docker compose --scale で簡単に組めます:
# 1. 既存の compose 構成にスケール指示を加えて起動
docker compose --profile app up -d --build --scale api=2
# 2. 2 つの api コンテナが立っていることを確認
docker compose ps api
# crowi-api-1 Up 3000/tcp
# crowi-api-2 Up 3000/tcp
# 3. 各 api コンテナのログをそれぞれ tail
docker compose logs -f api
# extension-redis (identifier=…) の attach ログを 2 行確認検証手順:
- 2 つのブラウザウィンドウ (Chrome 通常 + シークレット、または異なる ブラウザ) で別ユーザーとしてログイン。
- 同じページの
/_edit?page_id=<pageId>を 2 ウィンドウで開く。 - 一方で文字を入力 → もう一方にリアルタイムで反映されることを確認。
- nginx などのロードバランサーが手前にある場合は、各ウィンドウが
別レプリカに振り分けられていることを確認 (api 側ログの
onConnect出力でinstance=識別子が異なれば OK)。 - 一方の api コンテナを
docker compose stop api-1で停止 → 残った レプリカに自動再接続し、編集が継続できることを確認。
障害時の挙動 (Failure modes)
- Redis 障害 (
REDIS_URL設定済み、接続不可): extension-redis の ioredis 再接続ループが動き続けます。新規 Yjs 更新はローカルの Hocuspocus engine 内では伝搬しますが、他レプリカへの pub/sub は 途切れます。編集者上限 counter は fail-open (上限チェックを 通す) に倒れます。Redis 復旧後は自動で pub/sub が再開します。 WS_TOKEN_SECRET不一致 / 未設定: あるレプリカが発行した wsToken を別の レプリカが検証できず、onAuthenticateで reject されます。クライアントは WebSocket connect 後すぐに切断され、エディタが "接続できませんでした" 状態のままになります。対処: 全レプリカで同じ secret を再設定して ローリング再起動。なおCROWI_MULTI_INSTANCEを宣言しつつWS_TOKEN_SECRETを未設定 (または既知のプレースホルダ) にした場合、api は起動時にエラーで 停止してこの問題を未然に防ぎます。シングルインスタンス構成 (CROWI_MULTI_INSTANCE未設定) は secret なしで問題なく起動します。これらの 診断 (wsToken 検証失敗 / 編集者上限到達) は本番ログにサンプリングされた警告 として出力されるため、"接続できませんでした" の真因 (期限切れ / secret 不一致 / クロック skew / 上限到達) を切り分けられます。WS_TOKEN_SECRETが短すぎる (32 文字未満): 値は設定されているものの 32 文字に満たない場合 (既知のプレースホルダは対象外)、NODE_ENV=production(未設定時のデフォルトも production 扱い) では api が起動時にエラーで停止します。 エラーメッセージには変数名・現在の長さ・要求 (32 文字以上)・openssl rand -base64 32での生成方法が含まれます。development/testなど production 以外のNODE_ENVでは起動を止めず、他の env 検証と同じ 起動時の警告レポートに 1 回まとめて出力されます。entropy や辞書のような 推測的なチェックは行わず、文字数のみの単純なチェックです。- マルチインスタンスでの編集消失 (既知のリスク): 上記「マルチインスタンス
デプロイ」の重要注記を参照。stale なレプリカが古い
Page.yjsStateから materialise した Y.Doc で compaction を書き戻すと、新しい状態を上書きしうる 既知のリスクです。サーバドキュメント保存ロックと desync ガードで緩和されますが 完全には防げず、シングルインスタンス運用を推奨します。 - WebSocket Upgrade 失敗: リバースプロキシで
Upgradeヘッダが 落とされていると/collab/接続が 400/426 で失敗します。nginx設定でproxy_set_header Upgrade $http_upgradeを確認してください。 - ライブ編集セッション中の外部編集: 共同編集セッションが開いている
ページに対して、REST API・MCP の
crowi_update_page・プロセス内のPage.updatePageなどの外部経路から本文が更新されると、Crowi はcurrentRevisionを新しい revision に張り替えPage.yjsStateを null に します。このとき同一 api プロセス内にライブな Y.Doc があれば、Crowi は 保存コミット後にその Y.Doc を無効化します — 接続中の全クライアントへcrowi:force-reloadを broadcast し、ドキュメントの基底 revision を tombstone して進行中の古い保存をCONFLICTで拒否し、短い猶予の後に ライブ接続を閉じます。これにより、先に reload したクライアントが古い ライブ Y.Doc に再アタッチしてCONFLICTループに陥ることを防ぎます。 クライアント側は警告ダイアログを出してページを reload し、未保存の ローカル編集は復旧バッファから復元できます。次回接続時は新しいcurrentRevisionの本文から再構築されます。- 既知の制約 (マルチインスタンス / プロセス外): 無効化のハンドルは
その api プロセス内のライブ Y.Doc にしか届きません。別レプリカで
開いているライブ Y.Doc や、プロセス外の書き込み (DB を直接叩く
admin CLI の
Page.updatePage等) には届かないため、マルチインスタンス 構成では外部編集後に接続中クライアントが古い内容のまま編集を続ける 可能性があり、手動で再接続するまで収束しません。クロスインスタンスの 無効化チャネル (Redis pub/sub による全レプリカへの伝搬) は将来対応であり 現時点ではスコープ外です (RFC-0003 §5b)。これもシングルインスタンス 運用を推奨する理由のひとつです。
- 既知の制約 (マルチインスタンス / プロセス外): 無効化のハンドルは
その api プロセス内のライブ Y.Doc にしか届きません。別レプリカで
開いているライブ Y.Doc や、プロセス外の書き込み (DB を直接叩く
admin CLI の
Page.yjsState破損:onLoadDocumentで yjsState のY.applyUpdateが失敗した場合、Crowi は revision の Markdown 本文から Y.Doc を再構築し、接続中の全クライアントへ force-reload 通知を送ります。 クライアント側は警告ダイアログを出してページを reload します。renderedAst不整合 (RFC-0002 連動): 共同編集の保存はRevision.prepareRevisionを呼び出して renderedAst を自動再生成 します。何らかの理由でPage.bodyと最新Revision.bodyがずれた 場合は、crowi-admin renderer:rebuild(RFC-0002) で再ビルドして ください。- api プロセスダウン: 接続中の全クライアントが reconnect ループに 入ります。ヘルスチェックで他レプリカへフェイルオーバーする構成なら クライアントは自動的に他レプリカへ繋ぎ直し、Y.Doc は MongoDB の yjsState から復元されます。
キャパシティチューニング
COLLAB_MAX_EDITORS_PER_PAGEで同時編集者上限を変更できます。20 は 経験則的な目安で、awareness の cursor 描画コストが大きくなる前の 上限です。これを大きく上げる場合は、editor 側の同時 awareness 配信に 対する描画負荷も検証してください。- Y.Doc の差分は
debounce: 2000ms でonChangeに流れます。これは Hocuspocus のデフォルトを継承していて、変更が小さいうちは無駄なPageYjsUpdate書き込みを抑えます。 PageYjsUpdateは TTL 1 時間で自動 expire し、ベースラインのPage.yjsStateへの compaction も定期的に走ります。1 ページに対して 延々と編集が続いても DB は線形には肥大化しません。
Save の SLO
- 保存処理は通常 100-500ms で完了します。
- サーバ側には 5 秒のタイムアウトがあり、5 秒を超えると 408 相当の エラーをクライアントへ返します。
- ただしタイムアウトしても実際には保存が成功している可能性が あります (DB は書き込み済みで応答が遅延しただけのケース)。 クライアント側は自動リトライしません。ページを reload して最新 revision を確認するのが正しい対処です。
- 5 秒タイムアウトはあくまで extreme slow への安全網であり、通常の ネットワーク条件では十分余裕があります。タイムアウトが恒常的に 発生する場合は MongoDB のレイテンシ / renderer 処理時間 / 接続 プールサイズを疑ってください。