AWSLab

Difyの「データ同期中…」が消えない【1.16の共同編集とRedis】

スポンサーラベル
Difyの「データ同期中」の詰まりどころは2箇所。ブラウザの接続先がlocalhostになっている問題と、api_websocketのRedis pub/subがsocket_timeoutで5秒ごとに切れる問題。両方直しても消えないときは共同編集を切る AWS

当サイトはアフィリエイト広告を利用しています。

Dify 1.16 のワークフロー編集画面で、画面中央に「データ同期中…」と出たまま、ノードの設定パネルが一切反応しなくなることがあります。

LLM ノードを開いてもモデルの選択肢が出ない、プロンプト欄が現れない、という状態です。エラーは何も出ません。保存自体は動いていて「自動保存済み」と表示されるので、壊れているようにも見えません。

原因は2つ重なっていました。しかもどちらも、Dify が配布している .env.example の既定値のまま起きます。

先に結論を書いておくと、この2つを直しても最終的には解決しませんでした。 ただし2つとも実在するバグで、直せば症状は変わります。切り分けの過程がそのまま同じ症状の役に立つはずなので、順を追って書きます。

Difyの「データ同期中」の詰まりどころは2箇所。ブラウザの接続先がlocalhostになっている問題と、api_websocketのRedis pub/subがsocket_timeoutで5秒ごとに切れる問題。両方直しても消えないときは共同編集を切る

1. Dify 1.16 の編集画面は socket.io に依存している

Dify 1.16 では、ワークフローの編集画面に共同編集(collaboration)が入りました。複数人が同じフローを同時に開いても壊れないよう、CRDT で状態を持ち合う仕組みです。

この通信に socket.io を使います。docker compose ps を見ると、専用のコンテナが増えています。

docker compose ps --format '{{.Service}}\t{{.State}}'
api                running
api_websocket      running    ← これが共同編集のサーバー
nginx              running
redis              running

重要なのは、この接続が確立するまで、Dify はノードの設定パネルを操作させないことです。読み取りはできるので画面は出ますが、クリックしても何も起きません。「触れないだけでエラーは出ない」ため、権限やブラウザの問題と勘違いしやすいのがこの問題の厄介なところです。

なお ENABLE_COLLABORATION_MODE=true が既定です。1人で使っていても有効になります。

2. まずサーバー側が生きているか確かめる

ブラウザの開発者ツールには、次のエラーが数秒おきに出ていました。

WebSocket connection error: B: websocket error

これだけでは、サーバーが悪いのかブラウザが悪いのか分かりません。先にサーバー側を潰しておくと切り分けが早くなります。

socket.io は、いきなり WebSocket にはなりません。最初に HTTP のポーリングで握手し、そこで得た sid を使って WebSocket に切り替えます。この2段階をそれぞれ確認します。

# ① 握手(sid が返れば nginx とサーバーは生きている)
curl -s "http://203.0.113.10/socket.io/?EIO=4&transport=polling"
0{"sid":"X1EQseSTBwf1o62WAAAB","upgrades":["websocket"],"pingTimeout":20000,...}
# ② WebSocket への切り替え(101 が返れば nginx は Upgrade を通している)
curl -s -i \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  "http://203.0.113.10/socket.io/?EIO=4&transport=websocket&sid=X1EQseSTBwf1o62WAAAB"
HTTP/1.1 101 Switching Protocols
Upgrade: websocket

両方通りました。つまり nginx の WebSocket プロキシ設定は正しく、サーバーも応答しています。 ここで「サーバー側は無罪」と確定できます。

私は最初、ブラウザがプロキシ経由で出ていくため、プロキシが WebSocket を落としていると考えました。これは外れでした。ブラウザのコンソールから同じ URL を直接開いたら、あっさり OPEN になったためです。

// ブラウザのコンソールで実行する。サーバーまで届くかを直接確かめる
const r = await fetch('/socket.io/?EIO=4&transport=polling');
const sid = JSON.parse((await r.text()).replace(/^0/, '')).sid;
const ws = new WebSocket(`ws://${location.host}/socket.io/?EIO=4&transport=websocket&sid=${sid}`);
ws.onopen  = () => console.log('OPEN');
ws.onerror = () => console.log('ERROR');

手で繋ぐと繋がるのに、アプリだけ失敗する。 ここで初めて「アプリは別の宛先へ行っている」と疑えました。

3. 原因①:ブラウザが localhost へ繋ぎに行っている

決め手は nginx のアクセスログでした。

docker compose logs --tail 300 nginx | grep -i "socket.io"

ブラウザからの socket.io のリクエストが1件もありません。 ログに残っていたのは、私が手で投げた検証用のリクエストだけでした。届いていないのではなく、そもそも送っていないわけです。

.env を見ると、接続先が入っていました。

grep -n "NEXT_PUBLIC_SOCKET_URL" .env
26:NEXT_PUBLIC_SOCKET_URL=ws://localhost

NEXT_PUBLIC_ で始まる値は、ビルド時にブラウザ側のコードへ埋め込まれます。 サーバーの中から見た localhost ではなく、ブラウザを動かしている利用者のPC自身を指します。だから永久に届きません。

サーバーの URL に直します。

cp -a .env .env.bak.$(date +%s)
sed -i 's|^NEXT_PUBLIC_SOCKET_URL=.*|NEXT_PUBLIC_SOCKET_URL=ws://203.0.113.10|' .env
docker compose up -d --force-recreate web

ここで注意があります。この値を書いた時点で、Dify の画面はその IP でしか使えなくなります。 EC2 のパブリック IP は停止・起動のたびに変わるので、Elastic IP のように固定されたアドレスが前提です。付け方はCloudFormationのEC2にElastic IPを後付けするにまとめています。

再起動すると、画面の右上に在席を示すアイコンが出ました。socket.io は繋がっています。

ただし「データ同期中…」は消えませんでした。

4. 原因②:Redis の待ち受けが5秒で切れる

サーバー側のログを見ると、クライアントが接続した瞬間からエラーが出続けていました。

docker compose logs --tail 30 api_websocket
"GET /socket.io/?EIO=4&transport=polling HTTP/1.1" 200 276
Cannot receive from redis... retrying in 1 secs
ERROR [redis_manager.py:188] - Cannot receive from redis... retrying in 1 secs
ERROR [redis_manager.py:188] - Cannot receive from redis... retrying in 1 secs

誰も接続していない間は1行も出ません。 接続して初めて Redis の購読を始めるためです。「起動直後のログはきれいだから正常」と判断すると、ここで見落とします。私も一度そう読み違えました。

4.1 例外の中身がログに出ない

まず困ったのが、理由が書いていないことです。python-socketio の該当箇所はこうなっています。

except Exception as exc:
    self._get_logger().error(
        'Cannot receive from redis... '
        f'retrying in {retry_sleep} secs',
        # 例外の中身は extra に入るだけ。既定のログ書式では出力されない
        extra={"redis_exception": str(exc)})

extra= はログのフォーマットに %(redis_exception)s を書いて初めて出ます。既定では捨てられます。ログを眺めているだけでは永遠に原因に辿り着けません。

Redis 自体は生きています。同じコンテナから接続すると通ります。

docker compose exec api_websocket python -c "
import os, redis
r = redis.Redis(host='redis', port=6379, password=os.environ['REDIS_PASSWORD'])
print('PING:', r.ping())
"
PING: True

4.2 手で再現して初めて分かる

そこで、Dify が socket.io に渡しているのと同じ条件で購読してみました。

import redis, time

# Dify が渡しているオプションをそのまま再現する
r = redis.Redis.from_url(url, socket_timeout=5.0, socket_connect_timeout=5.0,
                         health_check_interval=30, protocol=3)
p = r.pubsub(ignore_subscribe_messages=True)
p.subscribe("probe")

t0 = time.time()
try:
    for _ in p.listen():   # メッセージが来るまで待つ
        break
except Exception as e:
    print("RAISED after %.1fs  %s: %s" % (time.time() - t0, type(e).__name__, e))
RAISED after 5.0s  TimeoutError: Timeout reading from redis:6379

socket_timeout が犯人です。 Dify の ext_socketio.py は、pub/sub 用のオプションを次のように組み立てています。

def _build_redis_options(redis_url: str) -> dict[str, Any]:
    options: dict[str, Any] = {
        # 読み書きのタイムアウト。ここが待ち受けにもそのまま渡ってしまう
        "socket_timeout": dify_config.REDIS_SOCKET_TIMEOUT,
        "socket_connect_timeout": dify_config.REDIS_SOCKET_CONNECT_TIMEOUT,
        ...
    }

pubsub.listen()メッセージが来るまで待ち続けるのが正しい動作です。そこに「5秒で読み取りを打ち切る」設定を渡せば、メッセージが無いという正常な状態が、そのままタイムアウト例外になります

socket.io はその例外を掴んで再購読し、また5秒で落ちます。結果として一度もイベントを配れません。 初期同期が終わらないので、画面は「データ同期中…」のままです。

そして .env.example には、こう書かれています。

114:REDIS_SOCKET_TIMEOUT=5.0

配布されている既定値のままで踏みます。

5. 直し方:api_websocket だけ上書きする

REDIS_SOCKET_TIMEOUTapiworker も使います。API の呼び出しが応答不能なまま固まるのを防ぐ設定なので、全体から消すのは適切ではありません。

docker-compose.override.yaml を置いて、共同編集のサーバーだけを対象にします。Docker Compose が自動で読み込むファイルなので、Dify 本体のファイルには手を入れずに済みます。

# docker-compose.override.yaml
# 共同編集の pub/sub は「メッセージが来るまで待つ」のが正しい動作。
# .env の REDIS_SOCKET_TIMEOUT=5.0 がその待ち受けにも渡ると、
# 5秒ごとに TimeoutError になって初期同期が完了しない。
# このサービスだけ実質無効化する。api / worker は 5.0 のままにする。
services:
  api_websocket:
    environment:
      REDIS_SOCKET_TIMEOUT: "86400"

空文字では通りません。 設定の型が PositiveFloat | None で、空文字は数値として解釈できずに弾かれます。

pydantic_core.ValidationError: 1 validation error for DifyConfig
REDIS_SOCKET_TIMEOUT
  Input should be a valid number, unable to parse string as a number

そのため、実質無効となる大きな値を入れています。

docker compose up -d --force-recreate api_websocket
docker compose exec api_websocket env | grep "^REDIS_SOCKET_TIMEOUT"

反映を確認したら、接続してからログを見ます。

# セッションを1つ作ってから数えるのがポイント。接続していないと0件で当たり前
curl -s -o /dev/null "http://203.0.113.10/socket.io/?EIO=4&transport=polling"
sleep 20
docker compose logs --since 3m api_websocket | grep -c "Cannot receive from redis"
0

修正前は6秒ごとに1件出ていたので、0件なら直っています。 画面を開き直すと「データ同期中…」は消え、モデルの選択もプロンプトの入力もできるようになりました。

……のですが、しばらく使っているうちに再発しました。

6. それでも再発する

2つとも直したのに、また「データ同期中…」が出ました。今度はサーバー側が全部きれいなままです。

nginx のアクセスログには、ブラウザからの接続がちゃんと残っています。

"GET /socket.io/?EIO=4&transport=websocket HTTP/1.1" 101 801

api_websocket の Redis エラーも0件のままです。原因①も原因②も再発していません。

6.1 Redis に残る共同編集の状態を見る

共同編集の状態は Redis に置かれています。アプリごとに2つのキーがあります。

docker compose exec redis sh -c \
  'redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS "workflow_*"'
workflow_leader:3f2a1c8e-7b40-4d19-9c62-0ae5138b7d44
workflow_online_users:3f2a1c8e-7b40-4d19-9c62-0ae5138b7d44

workflow_leader は、そのフローの編集を主導しているセッションです。中身を見ます。

leader:  Kf7Qa2LmZ0xVb9dTAAAB
online_users: {"username":"(略)","sid":"Kf7Qa2LmZ0xVb9dTAAAB",
               "server_id":"a1b2c3d4e5f6:16:(略)",
               "graph_active":false}

リーダーは正しく登録されていました。 sid は一致し、server_id のプロセス番号も現在動いているコンテナのものです。それでも graph_activefalse のまま変わりません。

つまり接続もリーダー登録も成功しているのに、グラフの初期同期だけが完了しない状態です。ここから先はアプリケーション側の話で、設定では届きません。

6.2 死んだセッションが残ることもある

途中、workflow_leaderすでに存在しないコンテナのセッションを指している状態も見つけました。

server_id: a1b2c3d4e5f6:20   ← 20 は再起動前のプロセス番号

コンテナを再作成すると切断が Redis に伝わらず、リーダーの席を死んだセッションが押さえたままになります。この状態なら、キーを消せば次の接続が新しいリーダーになります。

# 消しても失うのは「いま誰が編集中か」だけ。フローの中身とは無関係
docker compose exec redis sh -c \
  'redis-cli -a "$REDIS_PASSWORD" --no-auth-warning DEL \
   workflow_leader:<アプリID> workflow_online_users:<アプリID>'

これは今回の決め手ではありませんでしたが、リーダーが死んだセッションを指しているときは効きます。確認する価値はあります。

7. 最終的に共同編集を切った

1人でしか使わない検証環境です。深追いをやめて、共同編集そのものを無効にしました。

cd /opt/dify/docker
cp -a .env .env.bak.collab.$(date +%s)

sed -i 's|^ENABLE_COLLABORATION_MODE=.*|ENABLE_COLLABORATION_MODE=false|' .env
# api_websocket は collaboration プロファイルに属している。外すと起動対象から消える
sed -i 's|^COMPOSE_PROFILES=.*|COMPOSE_PROFILES=weaviate,postgresql|' .env

docker compose up -d --force-recreate web api
docker compose stop api_websocket

api_websocket コンテナごと不要になるので、メモリも少し空きます。 データには一切触れないので、.env を戻して docker compose up -d すれば元通りです。

7.1 効いたかどうかはコンソールで数える

画面が直ったように見えても、開いたままのタブは古い設定のまま再接続を試み続けます。私はこれで一度混乱しました。ブラウザのコンソールに数百件のエラーが残っていて、まだ壊れていると勘違いしたのです。

WebSocket connection error: B: websocket error    ← 600件以上

開発者ツールのコンソールを一度クリアし、それからページを再読み込みして数え直します。 古いエラーが混ざったままだと、直っているのに直っていないように見えます。

リロード後の新規エラーは0件になりました。「データ同期中…」も出ません。ノードの設定パネルも普通に開きます。

7.2 何を失うか

共同編集は、Dify が標準構成として進めている方向です。切ったままにすると、今後の機能から外れていく可能性はあります。複数人で同じフローを同時に編集する予定があるなら、この選択は取れません。

逆に言えば、1人で使う検証環境では失うものがありません。 .env まわりの考え方はDifyの.envを理解するにまとめています。

8. まとめ

Dify 1.16 で編集画面が「データ同期中…」から進まないときの要点です。

  • 1.16 の編集画面は socket.io が繋がるまで設定パネルを操作させない。 エラーは出ない
  • サーバー側は curl で握手(polling)と切り替え(101)の2段階を確認する。ここが通れば無罪
  • nginx のアクセスログに socket.io が来ていなければ、ブラウザは送ってすらいない
  • NEXT_PUBLIC_SOCKET_URL はブラウザに埋め込まれる。ws://localhost は利用者のPC自身を指す
  • 書き換えるなら Elastic IP のような固定アドレスが前提になる
  • Cannot receive from redis接続した瞬間から出る。起動直後のログを見ても分からない
  • 例外の中身は extra= に入るだけで、既定のログ書式には出ない。手で再現するしかない
  • socket_timeout を pub/sub の待ち受けに渡すと、正常な待機がタイムアウトになる
  • .env.example の既定値(5.0)のまま踏む。docker-compose.override.yaml で対象を絞って外す
  • 状態は Redis の workflow_leader / workflow_online_users に入っている。graph_active が見どころ
  • 1人で使う検証環境なら ENABLE_COLLABORATION_MODE=false が最短COMPOSE_PROFILES からも外す

一番の教訓は、「ログにエラーが出ている」ことと「ログに原因が書いてある」ことは別だという点でした。同じ1行が数千回並んでいても、そこに理由が無ければ情報量はゼロです。同じ条件を手元で再現して例外を素通しさせるのが、結局いちばん速い道でした。

もうひとつ。原因を2つ見つけて2つとも直したのに、症状は残りました。 途中で見つけたバグが本物であることと、それが目の前の症状の原因であることは別です。「直したのだから直ったはず」と思い込むと、次の一手が遅れます

そして、機能を切るのは負けではありません。 検証環境で1人で使う分には失うものがなく、コンテナが1つ減る分だけ軽くなります。原因究明そのものが目的でないなら、どこで打ち切るかを先に決めておくほうが結果的に早く進みます。

AWSを効率的に学習する方法

私が効率的にAWSを学習するために実施した方法は以下の通りです。
 ①最初に書籍(ハンズオンができる)を購入、座学でAWSの基礎を学習
 ②AWS資格試験を取得ための学習

※私の場合は①と②を合わせて2か月でソリューションアーキテクトを取得できました。

①AWS基礎学習

最初に購入した書籍は「Amazon Web Services 基礎からのネットワーク&サーバー構築」です。

Amazon Web Services 基礎からのネットワーク&サーバー構築 改訂4版

この本では、AWSの基本サービスを利用したハンズオンを通じて、AWSの基礎を学習することができます。

また、タイトル通りAWSのネットワークやインフラに関しても網羅しているため、もともとインフラ系の技術者ではない人たちにとっても分かりやすい内容だと思います。

とりあえずAWS上にサーバーを設定して開発を行うための準備までするには最良の一冊です。

Amazon Web Services 基礎からのネットワーク&サーバー構築 改訂4版

②AWS資格取得

AWSの基礎をある程度学習することができたら、次はAWS資格を取得しましょう。まずはAWSソリューションアーキテクトを目指しましょう。

資格勉強のための問題集をひたすら解きながら、AWSの知識を積み重ねて習得していきましょう!

苦行ではありますがこの問題集を3周ほどすればAWS用語や構成に関しても習得できているはずです。

AWS認定ソリューションアーキテクト-アソシエイト問題集

独学だと手が止まってしまう場合は、ササエル のようなインフラエンジニア向けのオンラインスクールを使う手もあります。ネットワークやサーバーの分野に絞ったカリキュラムなので、業務でインフラを触る人が学び直すのにも使えます

あわせて読みたい

コメント

タイトルとURLをコピーしました