رفتن به محتوا
LoopX

WebSocket

توی این درس یاد می‌گیری اتصال‌های WebSocket (اتصال دوطرفه و ماندگار بین مرورگر و سرور، که چت، اعلان زنده، بازی آنلاین و داشبورد لحظه‌ای با آن ساخته می‌شوند) را از Nginx عبور بدهی. یک اپ چت واقعی می‌سازی و می‌بینی با proxy_pass ساده کار نمی‌کند؛ دلیلش را در دست‌دادن (handshake) WebSocket و هدرهای Upgrade و Connection پیدا می‌کنی، با سه خط درستش می‌کنی، الگوی استاندارد map $http_upgrade $connection_upgrade را یاد می‌گیری، و مشکل «اتصال بعد از یک دقیقه قطع می‌شود» را با proxy_read_timeout و ping حل می‌کنی.

مسئله: HTTP برای «زنده» ساخته نشده

Section titled “مسئله: HTTP برای «زنده» ساخته نشده”

HTTP معمولی «درخواست و پاسخ» است: مرورگر می‌پرسد، سرور جواب می‌دهد، تمام. برای یک چت، سرور باید هر وقت پیام تازه‌ای رسید خودش به مرورگر بگوید، بدون اینکه مرورگر هر ثانیه بپرسد «پیام جدید داری؟». WebSocket همین را حل می‌کند: مرورگر یک بار یک درخواست HTTP خاص می‌فرستد که می‌گوید «بیا این اتصال را به WebSocket ارتقا (upgrade) بدهیم»، و اگر سرور قبول کند، همان اتصال TCP باز می‌ماند و هر دو طرف هر وقت خواستند پیام می‌فرستند. مشکل اینجاست که Nginx به‌صورت پیش‌فرض این درخواست «ارتقا» را به اپ نمی‌رساند.

تشبیه: تماس تلفنی به‌جای نامه

Section titled “تشبیه: تماس تلفنی به‌جای نامه”

HTTP معمولی مثل نامه است: می‌فرستی، جواب می‌آید، تمام. WebSocket مثل تماس تلفنی است: یک بار شماره می‌گیری و می‌پرسی «می‌توانیم صحبت کنیم؟» (دست‌دادن)، طرف می‌گوید «بله» (پاسخ 101)، و از آن به بعد خط باز می‌ماند و هر دو هر وقت خواستند حرف می‌زنند. Nginx مثل اپراتور تلفن‌خانه است که تماس را وصل می‌کند. اپراتور معمولی فقط نامه جابه‌جا می‌کند؛ باید به او یاد بدهی وقتی کسی گفت «تماس می‌خواهم» (هدر Upgrade)، خط را وصل و باز نگه دارد، و اگر چند دقیقه سکوت شد، گوشی را نگذارد (timeout).

دست‌دادن WebSocket: مرورگر یک GET معمولی با هدرهای Upgrade: websocket و Connection: Upgrade و یک کلید تصادفی می‌فرستد. اگر سرور قبول کند، با 101 Switching Protocols و یک Sec-WebSocket-Accept (ساخته‌شده از همان کلید) جواب می‌دهد. از آن لحظه همان اتصال TCP برای پیام‌های دوطرفه باز می‌ماند. Nginx باید هم هدرهای ارتقا را به اپ برساند و هم بعد از 101، اتصال را تونل کند.

این درس روی ماشین آزمایشی server (Ubuntu 24.04، Nginx 1.24، Python 3.12) با کاربر root اجرا شده. اپ چت با کتابخانه‌ی websockets پایتون (بسته‌ی python3-websockets در Ubuntu) ساخته شده؛ در دنیای واقعی همین را با Node (ws یا Socket.IO)، Go یا هر زبان دیگری می‌سازند و تنظیم Nginx دقیقاً همین است.

نصب کتابخانه و نوشتن سرور چت: هر پیامی که یک کاربر بفرستد، برای همه‌ی کاربران متصل پخش (broadcast) می‌شود.

Terminal window
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-websockets > /dev/null 2>&1
python3 -c 'import websockets; print("websockets", websockets.__version__)'
خروجی
websockets 10.4
/srv/chat/chat_server.py
# chat_server.py: broadcast every message to all connected clients
import asyncio
import websockets
CLIENTS = set()
async def handler(ws, path=None):
CLIENTS.add(ws)
name = f"user{len(CLIENTS)}"
websockets.broadcast(CLIENTS, f"* {name} joined ({len(CLIENTS)} online)")
try:
async for message in ws:
websockets.broadcast(CLIENTS, f"{name}: {message}")
finally:
CLIENTS.discard(ws)
websockets.broadcast(CLIENTS, f"* {name} left ({len(CLIENTS)} online)")
async def main():
async with websockets.serve(handler, "127.0.0.1", 8765, ping_interval=20):
await asyncio.Future() # run forever
asyncio.run(main())

و یک کلاینت آزمایشی که وصل می‌شود، (اختیاری) پیامی می‌فرستد، چند ثانیه هر چه می‌رسد را چاپ می‌کند و می‌رود:

/srv/chat/chat_client.py
# chat_client.py URL SECONDS [MESSAGE]: connect, maybe send, print what arrives
import asyncio
import sys
import websockets
async def main(url, seconds, message):
try:
async with websockets.connect(url, ping_interval=None) as ws:
if message:
await asyncio.sleep(0.5)
await ws.send(message)
end = asyncio.get_running_loop().time() + seconds
while (left := end - asyncio.get_running_loop().time()) > 0:
try:
print(" got:", await asyncio.wait_for(ws.recv(), left), flush=True)
except asyncio.TimeoutError:
break
except Exception as e:
print(" ERROR:", type(e).__name__, e, flush=True)
asyncio.run(main(sys.argv[1], float(sys.argv[2]), sys.argv[3] if len(sys.argv) > 3 else ""))
Terminal window
(exec python3 /srv/chat/chat_server.py) > /srv/chat/server.log 2>&1 &
sleep 1
ss -tln | grep ':8765'
echo "=== دو کاربر مستقیم به اپ؛ کاربر دوم سلام می‌کند:"
python3 /srv/chat/chat_client.py ws://127.0.0.1:8765/ 2 > /tmp/alice.txt &
alice=$!
sleep 0.3
python3 /srv/chat/chat_client.py ws://127.0.0.1:8765/ 1.5 "hello from bob"
wait "$alice"
echo "--- کاربر اول دید:"
cat /tmp/alice.txt
خروجی
LISTEN 0 100 127.0.0.1:8765 0.0.0.0:*
=== دو کاربر مستقیم به اپ؛ کاربر دوم سلام می‌کند:
got: * user2 joined (2 online)
got: user2: hello from bob
got: * user1 left (1 online)
--- کاربر اول دید:
got: * user1 joined (1 online)
got: * user2 joined (2 online)
got: user2: hello from bob

چت مستقیم کار می‌کند: کاربر اول پیوستن کاربر دوم و پیامش را بدون اینکه چیزی بپرسد دریافت کرد؛ سرور خودش فرستاد. حالا باید از پشت Nginx هم همین کار کند.

مثال ۲: دست‌دادن را با curl ببینیم

Section titled “مثال ۲: دست‌دادن را با curl ببینیم”

دست‌دادن WebSocket در واقع یک درخواست HTTP است، پس با curl می‌شود دیدش. -H هدرهای ارتقا را می‌فرستد و -m 2 بعد از ۲ ثانیه قطع می‌کند (چون اتصال باز می‌ماند):

Terminal window
curl -s -i -m 2 \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
http://127.0.0.1:8765/ | tr -d '\r' | head -n 6
خروجی
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Date: Sun, 04 Oct 2026 13:15:55 GMT
Server: Python/3.12 websockets/10.4

101 Switching Protocols: سرور قبول کرد که پروتکل را عوض کند. Sec-WebSocket-Accept اثبات می‌کند سرور واقعاً WebSocket می‌فهمد (پشت پرده می‌گوییم از کجا آمده). بعد از این خط، دیگر HTTP نیست؛ فریم‌های WebSocket است.

مثال ۳: پشت Nginx با proxy_pass ساده، شکست

Section titled “مثال ۳: پشت Nginx با proxy_pass ساده، شکست”
/etc/nginx/sites-available/chat.test
server {
listen 80;
server_name chat.test;
location /ws/ {
proxy_pass http://127.0.0.1:8765;
}
location /raw/ {
proxy_pass http://127.0.0.1:8799;
}
}

(location /raw/ با همان تنظیم ساده، درخواست را به یک nc می‌فرستد که فقط درخواست خام را ثبت می‌کند؛ تا ببینیم Nginx دقیقاً چه چیزی به اپ می‌دهد.)

Terminal window
ln -s /etc/nginx/sites-available/chat.test /etc/nginx/sites-enabled/
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
echo "=== دست‌دادن از میان Nginx:"
curl -s -i -m 2 \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
http://chat.test/ws/ | tr -d '\r' | head -n 1
echo "=== کلاینت چت از میان Nginx:"
python3 /srv/chat/chat_client.py ws://chat.test/ws/ 1
echo "=== درخواستی که Nginx واقعاً به اپ می‌فرستد:"
timeout 3 nc -l 127.0.0.1 8799 > /tmp/raw.txt &
ncpid=$!
sleep 0.5
curl -s -m 1 -o /dev/null \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
http://chat.test/raw/
wait "$ncpid"
tr -d '\r' < /tmp/raw.txt
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
=== دست‌دادن از میان Nginx:
HTTP/1.1 400 Bad Request
=== کلاینت چت از میان Nginx:
ERROR: InvalidStatusCode server rejected WebSocket connection: HTTP 400
=== درخواستی که Nginx واقعاً به اپ می‌فرستد:
GET /raw/ HTTP/1.0
Host: 127.0.0.1:8799
Connection: close
User-Agent: curl/8.5.0
Accept: */*
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

به‌جای 101، اپ با 400 Bad Request رد کرد. درخواست خامی که nc گرفت دلیلش را نشان می‌دهد: Nginx درخواست را با HTTP/1.0 و Connection: close فرستاد و هدر Upgrade اصلاً در آن نیست؛ هدرهای Sec-WebSocket-* رسیدند، ولی درخواستِ «ارتقا» گم شد.

Terminal window
cat > /etc/nginx/sites-available/chat.test <<'EOF'
server {
listen 80;
server_name chat.test;
location /ws/ {
proxy_pass http://127.0.0.1:8765;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
EOF
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
echo "=== دست‌دادن از میان Nginx:"
curl -s -i -m 2 \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
http://chat.test/ws/ | tr -d '\r' | grep -E '^(HTTP|Upgrade|Connection|Sec-WebSocket-Accept)'
echo "=== دو کاربر از میان Nginx:"
python3 /srv/chat/chat_client.py ws://chat.test/ws/ 2 > /tmp/alice.txt &
alice=$!
sleep 0.3
python3 /srv/chat/chat_client.py ws://chat.test/ws/ 1.5 "hi through nginx"
wait "$alice"
cat /tmp/alice.txt
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
=== دست‌دادن از میان Nginx:
HTTP/1.1 101 Switching Protocols
Connection: upgrade
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
=== دو کاربر از میان Nginx:
got: * user2 joined (2 online)
got: user2: hi through nginx
got: * user1 left (1 online)
got: * user1 joined (1 online)
got: * user2 joined (2 online)
got: user2: hi through nginx

حالا 101 از میان Nginx آمد و چت کار می‌کند. سه خط کلیدی:

  • proxy_http_version 1.1;: ارتقای پروتکل در HTTP/1.1 تعریف شده؛ پیش‌فرض Nginx برای اپ HTTP/1.0 است (درس reverse proxy).
  • proxy_set_header Upgrade $http_upgrade;: هدر Upgrade کاربر (websocket) را به اپ برسان.
  • proxy_set_header Connection "upgrade";: به اپ بگو این اتصال برای ارتقاست.

(Host و X-Real-IP را هم گذاشتیم؛ چون این location proxy_set_header خودش را دارد، هیچ هدری از بیرون به ارث نمی‌رسد.)

مثال ۵: الگوی map برای یک location مشترک

Section titled “مثال ۵: الگوی map برای یک location مشترک”

خیلی وقت‌ها یک اپ هم صفحه‌ی HTTP معمولی دارد و هم WebSocket، روی همان مسیرها (مثلاً Socket.IO که اول با HTTP شروع می‌کند). گذاشتن Connection "upgrade" روی همه‌ی درخواست‌ها درست نیست. الگوی استاندارد مستندات Nginx: یک map که اگر کاربر Upgrade فرستاده بود، upgrade و وگرنه close بدهد:

Terminal window
cat > /etc/nginx/conf.d/connection-upgrade.conf <<'EOF'
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
EOF
sed -i 's|proxy_set_header Connection "upgrade";|proxy_set_header Connection $connection_upgrade;|' /etc/nginx/sites-available/chat.test
grep -n 'Connection' /etc/nginx/sites-available/chat.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
echo "=== WebSocket:"
python3 /srv/chat/chat_client.py ws://chat.test/ws/ 0.5
echo "=== HTTP معمولی به همان مسیر:"
curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://chat.test/ws/
خروجی
9: proxy_set_header Connection $connection_upgrade;
nginx: configuration file /etc/nginx/nginx.conf test is successful
=== WebSocket:
got: * user1 joined (1 online)
=== HTTP معمولی به همان مسیر:
HTTP 426

WebSocket کار می‌کند و درخواست HTTP معمولی هم (که اپ چت ما به آن جواب 426 Upgrade Required می‌دهد، چون فقط WebSocket می‌فهمد) بدون هدر ارتقای جعلی به اپ رسید. map در conf.d (context http) است تا در همه‌ی سایت‌ها قابل استفاده باشد.

مثال ۶: قطع شدن بعد از سکوت، و راه‌حل

Section titled “مثال ۶: قطع شدن بعد از سکوت، و راه‌حل”

proxy_read_timeout (پیش‌فرض ۶۰ ثانیه) برای WebSocket هم معتبر است: اگر در این مدت هیچ داده‌ای از اپ نیاید، Nginx اتصال را می‌بندد. برای اینکه آزمایش طول نکشد، آن را روی ۳ ثانیه می‌گذاریم و یک کلاینت ۶ ثانیه ساکت متصل می‌ماند. برای اینکه اثر ping را جدا ببینیم، یک نسخه‌ی دوم از سرور چت بدون ping روی پورت ۸۷۶۶ هم بالا می‌آوریم:

Terminal window
sed 's/8765, ping_interval=20/8766, ping_interval=None/' /srv/chat/chat_server.py > /srv/chat/chat_server_noping.py
(exec python3 /srv/chat/chat_server_noping.py) > /dev/null 2>&1 &
sed -i 's| location /ws/ {| location /ws-noping/ {\n proxy_pass http://127.0.0.1:8766;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection $connection_upgrade;\n proxy_read_timeout 3s;\n }\n\n location /ws/ {|' /etc/nginx/sites-available/chat.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
echo "=== بدون ping، timeout سه ثانیه، ۶ ثانیه سکوت:"
start=$SECONDS
python3 - <<'EOF'
import asyncio, websockets
async def main():
async with websockets.connect("ws://chat.test/ws-noping/", ping_interval=None) as ws:
print(" got:", await ws.recv())
try:
await asyncio.wait_for(ws.recv(), 6)
except asyncio.TimeoutError:
print(" still connected after 6s of silence")
except websockets.ConnectionClosed as e:
print(" CLOSED by the other side:", e)
asyncio.run(main())
EOF
echo " (took $((SECONDS - start))s)"
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
=== بدون ping، timeout سه ثانیه، ۶ ثانیه سکوت:
got: * user1 joined (1 online)
CLOSED by the other side: no close frame received or sent
(took 3s)

اتصال بعد از حدود ۳ ثانیه سکوت بسته شد (CLOSED؛ Nginx بست، نه اپ). در چت واقعی یعنی «یک دقیقه کسی چیزی نگفت، چت قطع شد». دو راه‌حل که معمولاً با هم به کار می‌روند:

۱. timeout بزرگ‌تر برای location WebSocket: proxy_read_timeout 1h;. ۲. ping منظم: اپ (یا کلاینت) هر چند ثانیه یک فریم کوچک ping می‌فرستد تا اتصال هیچ‌وقت «ساکت» نباشد. سرور اصلی ما ping_interval=20 دارد؛ برای دیدن اثرش، timeout /ws/ را ۳ ثانیه و ping سرور را روی ۱ ثانیه می‌گذاریم:

Terminal window
pkill -f 'chat_server.py'
sed -i 's/ping_interval=20/ping_interval=1/' /srv/chat/chat_server.py
(exec python3 /srv/chat/chat_server.py) > /srv/chat/server.log 2>&1 &
sed -i '/location \/ws\/ {/,/}/ s| proxy_set_header X-Real-IP $remote_addr;| proxy_set_header X-Real-IP $remote_addr;\n proxy_read_timeout 3s;|' /etc/nginx/sites-available/chat.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
echo "=== با ping هر ۱ ثانیه، timeout سه ثانیه، ۶ ثانیه سکوت:"
python3 - <<'EOF'
import asyncio, websockets
async def main():
async with websockets.connect("ws://chat.test/ws/", ping_interval=None) as ws:
print(" got:", await ws.recv())
try:
await asyncio.wait_for(ws.recv(), 6)
except asyncio.TimeoutError:
print(" still connected after 6s of silence")
except websockets.ConnectionClosed as e:
print(" CLOSED by the other side:", e)
asyncio.run(main())
EOF
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
=== با ping هر ۱ ثانیه، timeout سه ثانیه، ۶ ثانیه سکوت:
got: * user1 joined (1 online)
still connected after 6s of silence

با اینکه هیچ پیام چتی رد و بدل نشد، فریم‌های ping هر ثانیه از اپ گذشتند و Nginx اتصال را «ساکت» ندید. (در عمل ping_interval حدود ۲۰ تا ۳۰ ثانیه و proxy_read_timeout چند دقیقه یا یک ساعت است.)

پشت پرده: hop-by-hop و Sec-WebSocket-Accept

Section titled “پشت پرده: hop-by-hop و Sec-WebSocket-Accept”

چرا Nginx خودش هدر Upgrade را رد نمی‌کند؟ در HTTP، هدرهای Connection و Upgrade از نوع hop-by-hop هستند: یعنی فقط برای همان یک اتصال (مثلاً مرورگر ← Nginx) معنی دارند و طبق استاندارد، proxy نباید آن‌ها را به اتصال بعدی (Nginx ← اپ) منتقل کند. برای همین باید صریح بگویی «این بار، ارتقا را جلو ببر». بعد از 101، Nginx دیگر محتوا را تفسیر نمی‌کند و فقط بایت‌ها را بین دو اتصال جابه‌جا می‌کند (تونل).

و Sec-WebSocket-Accept؟ سرور کلید کلاینت را با یک رشته‌ی ثابت جهانی (258EAFA5-E914-47DA-95CA-C5AB0DC85B11، تعریف‌شده در استاندارد RFC 6455) می‌چسباند، SHA-1 می‌گیرد و base64 می‌کند. خودمان حساب کنیم و با جواب سرور مقایسه کنیم:

Terminal window
key='dGhlIHNhbXBsZSBub25jZQ=='
printf '%s' "${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11" | openssl dgst -sha1 -binary | base64
curl -s -i -m 2 -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' \
-H "Sec-WebSocket-Key: $key" http://chat.test/ws/ | tr -d '\r' | grep '^Sec-WebSocket-Accept'
خروجی
s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

یکسان‌اند. این مکانیزم ثابت می‌کند پاسخ را یک سرور WebSocket واقعی داده، نه یک کش یا proxy قدیمی که فقط درخواست را تکرار کرده.

تنظیم کامل یک location WebSocket:

خط چرا
proxy_pass http://127.0.0.1:8765; آدرس اپ
proxy_http_version 1.1; ارتقا فقط در HTTP/1.1
proxy_set_header Upgrade $http_upgrade; رساندن هدر hop-by-hop Upgrade
proxy_set_header Connection $connection_upgrade; upgrade برای WebSocket، close برای HTTP (با map)
proxy_set_header Host $host; و X-Real-IP اطلاعات کاربر (درس reverse proxy)
proxy_read_timeout 1h; اتصال ساکت قطع نشود

کدهایی که در این درس دیدیم:

کد معنی
101 Switching Protocols دست‌دادن موفق؛ از این به بعد WebSocket
400 Bad Request / 426 Upgrade Required اپ WebSocket درخواست بدون هدر ارتقا گرفته
502 / 504 مثل هر proxy دیگری: اپ در دسترس نیست / دیر جواب داد

۱) proxy_pass ساده برای WebSocket

Section titled “۱) proxy_pass ساده برای WebSocket”

مثال ۳. راه‌حل: سه خط proxy_http_version 1.1، Upgrade، Connection.

۲) Connection "upgrade" روی همه‌چیز

Section titled “۲) Connection "upgrade" روی همه‌چیز”

اگر یک location هم HTTP معمولی دارد و هم WebSocket، Connection: upgrade برای درخواست‌های معمولی غلط است. راه‌حل: الگوی map $http_upgrade $connection_upgrade (مثال ۵).

۳) قطع شدن چت بعد از یک دقیقه سکوت

Section titled “۳) قطع شدن چت بعد از یک دقیقه سکوت”

مثال ۶: پیش‌فرض proxy_read_timeout ۶۰ ثانیه است. راه‌حل: timeout بزرگ‌تر برای location WebSocket و ping در اپ یا کلاینت.

۴) WebSocket پشت load balancer با چند نسخه‌ی اپ

Section titled “۴) WebSocket پشت load balancer با چند نسخه‌ی اپ”

اگر اپ وضعیت را در حافظه نگه دارد (مثل CLIENTS در اپ ما) و چند نسخه از آن پشت Nginx باشد، کاربرانی که به نسخه‌های مختلف وصل‌اند پیام هم را نمی‌بینند. راه‌حل: اپ وضعیت را در یک سرویس مشترک نگه دارد (مثلاً Redis pub/sub) یا (موقت) ip_hash در upstream تا هر کاربر همیشه به یک نسخه برسد (درس load balancing).

۵) proxy_set_header ناقص با ارث‌بری

Section titled “۵) proxy_set_header ناقص با ارث‌بری”

اگر include proxy_params در سطح server باشد و location WebSocket فقط Upgrade و Connection را بگذارد، Host و X-Real-IP در آن location از دست می‌روند (قاعده‌ی ارث‌بری آرایه‌ای؛ درس ساختار تنظیمات). راه‌حل: همه‌ی هدرها را در همان location بنویس (یا آنجا هم include کن).

✎ تمرینآسان

بدون کلاینت پایتون و فقط با curl، ثابت کن مسیر /ws/ روی chat.test دست‌دادن WebSocket را با 101 جواب می‌دهد، و همان درخواست بدون هدر Upgrade جواب دیگری می‌گیرد.

دیدن جواب
Terminal window
echo "=== با هدرهای ارتقا:"
curl -s -i -m 2 -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: SGVsbG8gTG9vcFggMTIzNA==' http://chat.test/ws/ | tr -d '\r' | head -n 1
echo "=== بدون هدرهای ارتقا:"
curl -s -i -m 2 http://chat.test/ws/ | tr -d '\r' | head -n 1
خروجی
=== با هدرهای ارتقا:
HTTP/1.1 101 Switching Protocols
=== بدون هدرهای ارتقا:
HTTP/1.1 426 Upgrade Required

با هدرها 101 Switching Protocols؛ بدون آن‌ها اپ با 426 Upgrade Required گفت «من فقط WebSocket می‌فهمم». کلید (Sec-WebSocket-Key) هر رشته‌ی ۱۶ بایتی base64 شده است.

✎ تمرینمتوسط

تمرین اصلی درس: اپ چت را پشت Nginx راه بینداز، به‌شکلی که سه کاربر از میان Nginx متصل شوند، دو نفر پیام بدهند و نفر سوم (که فقط گوش می‌دهد) هر دو پیام را ببیند. تنظیم نهایی location WebSocket باید proxy_read_timeout 1h داشته باشد.

دیدن جواب
Terminal window
sed -i 's/ping_interval=1/ping_interval=20/' /srv/chat/chat_server.py
pkill -f 'chat_server'
(exec python3 /srv/chat/chat_server.py) > /srv/chat/server.log 2>&1 &
cat > /etc/nginx/sites-available/chat.test <<'EOF'
server {
listen 80;
server_name chat.test;
location /ws/ {
proxy_pass http://127.0.0.1:8765;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 1h;
}
}
EOF
sleep 1
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
python3 /srv/chat/chat_client.py ws://chat.test/ws/ 3 > /tmp/listener.txt &
listener=$!
sleep 0.5
python3 /srv/chat/chat_client.py ws://chat.test/ws/ 1 "who is on support today?" > /dev/null &
sleep 0.3
python3 /srv/chat/chat_client.py ws://chat.test/ws/ 1 "Sara is, ask her" > /dev/null
wait "$listener"
echo "--- کاربری که فقط گوش داد:"
cat /tmp/listener.txt
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
--- کاربری که فقط گوش داد:
got: * user1 joined (1 online)
got: * user2 joined (2 online)
got: * user3 joined (3 online)
got: user2: who is on support today?
got: user3: Sara is, ask her
got: * user2 left (2 online)
got: * user3 left (1 online)

کاربر شنونده پیوستن و رفتن دو نفر دیگر و هر دو پیام را دید. چون هر کلاینت در یک پروسه‌ی جداست، ترتیب دقیق پیام‌ها (پیوستن‌ها و پیام‌ها) ممکن است کمی فرق کند؛ مهم این است که همه از میان Nginx رسیدند.

✎ تمرینسخت

یک صفحه‌ی HTML ساده برای چت (فایل استاتیک) و WebSocket را روی یک دامنه سرو کن: / صفحه‌ی HTML را از دیسک بدهد و /ws/ به اپ برود. در صفحه، آدرس WebSocket را نسبی به خود صفحه بساز (ws:// یا wss:// بر اساس پروتکل صفحه) تا هم روی HTTP و هم بعد از HTTPS کار کند. با curl ثابت کن هر دو بخش درست جواب می‌دهند.

دیدن جواب
Terminal window
mkdir -p /var/www/chat.test
cat > /var/www/chat.test/index.html <<'EOF'
<!doctype html>
<meta charset="utf-8">
<title>LoopX chat</title>
<ul id="log"></ul>
<input id="msg" placeholder="message"><button id="send">send</button>
<script>
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${location.host}/ws/`);
const log = (t) => { const li = document.createElement("li"); li.textContent = t; document.getElementById("log").append(li); };
ws.onmessage = (e) => log(e.data);
document.getElementById("send").onclick = () => ws.send(document.getElementById("msg").value);
</script>
EOF
sed -i 's| location /ws/ {| root /var/www/chat.test;\n index index.html;\n\n location /ws/ {|' /etc/nginx/sites-available/chat.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
curl -s -o /dev/null -w "/ -> HTTP %{http_code} %{content_type}\n" http://chat.test/
curl -s http://chat.test/ | grep -o 'new WebSocket([^)]*)'
curl -s -i -m 2 -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: SGVsbG8gTG9vcFggMTIzNA==' http://chat.test/ws/ | tr -d '\r' | head -n 1 | sed 's|^|/ws/ -> |'
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
/ -> HTTP 200 text/html
new WebSocket(`${proto}//${location.host}/ws/`)
/ws/ -> HTTP/1.1 101 Switching Protocols

صفحه با location.protocol می‌فهمد خودش روی HTTP است یا HTTPS و ws: یا wss: انتخاب می‌کند؛ location.host هم دامنه و پورت فعلی است. پس همین فایل بدون تغییر بعد از فعال کردن HTTPS (درس بعد) کار می‌کند، و Nginx هر دو بخش (فایل و WebSocket) را روی یک دامنه سرو می‌کند (بدون مشکل‌های CORS).

⚡ بررسی سریع

کدام سه خط در location، WebSocket را از Nginx عبور می‌دهد؟

؟ آزمونک
  1. پاسخ موفق دست‌دادن WebSocket چه کدی دارد؟

  2. چرا Nginx هدر Upgrade کاربر را خودکار به اپ نمی‌رساند؟

  3. چت پشت Nginx بعد از یک دقیقه سکوت قطع می‌شود. علت محتمل؟

  4. map $http_upgrade $connection_upgrade { default upgrade; '' close; } برای چیست؟

  5. روی سایت HTTPS، مرورگر به چه آدرسی برای WebSocket وصل می‌شود؟

  6. سه نسخه از اپ چت (که کاربران را در حافظه نگه می‌دارد) پشت upstream با round robin هستند. چه مشکلی پیش می‌آید؟

  • WebSocket با یک درخواست HTTP (Upgrade: websocket، Connection: Upgrade، Sec-WebSocket-Key) شروع می‌شود و با 101 Switching Protocols به یک اتصال دوطرفه‌ی ماندگار تبدیل می‌شود.
  • proxy_pass ساده کافی نیست؛ Upgrade و Connection هدرهای hop-by-hop‌اند. سه خط لازم: proxy_http_version 1.1;، proxy_set_header Upgrade $http_upgrade;، proxy_set_header Connection "upgrade"; (یا $connection_upgrade با map).
  • بعد از 101، Nginx فقط تونل است. proxy_read_timeout (پیش‌فرض ۶۰ ثانیه) اتصال ساکت را می‌بندد: timeout بزرگ‌تر + ping در اپ.
  • هدرهای Host و X-Real-IP را در همان location WebSocket بنویس (ارث‌بری).
  • روی HTTPS، wss:// از بیرون و همان تنظیم داخل server ۴۴۳. با چند نسخه‌ی اپ، به وضعیت مشترک یا ip_hash فکر کن.
برگه‌ی تقلب این درس
دستورکاری که می‌کند
proxy_http_version 1.1;لازم برای ارتقای پروتکل
proxy_set_header Upgrade $http_upgrade;رساندن هدر Upgrade به اپ
proxy_set_header Connection $connection_upgrade;upgrade یا close (با map)
map $http_upgrade $connection_upgrade { default upgrade; '' close; }در context http (conf.d)
proxy_read_timeout 1h;قطع نشدن اتصال ساکت
curl -i -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: ...' URLآزمایش دست‌دادن (انتظار: 101)
new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws/`)آدرس WebSocket نسبی در مرورگر
sudo apt install python3-websocketsکتابخانه‌ی WebSocket پایتون (برای آزمایش)