Django ChannelsをdockerのNginxでWSGIとASGIをsupervisorで共存利用する方法

目次

WSGIとASGIを共存させるとは?

この記事では、Nginxに来たリクエストを以下のように振り分けて共存させたいと思います。

  • HTTP通信:WSGI
  • 非同期通信(Websocket):ASGI

WSGIとASGIのプロセス管理はDjango Channelsのドキュメントにも乗っていたためsupervisorを使用します。
Django Channelsをデプロイする:https://channels.readthedocs.io/en/latest/deploying.html

なお、Docker上で実現させるがもちろんサーバで分けることができます。

ここからはWSGIとASGIの簡単な説明をします。
※ すでに理解している人は読み飛ばしてOKです。

WSGIについて

  • WSGIはWeb Server Gateway Interfaceが正式名称
  • WSGIはWebサーバ(ApacheやNginx)とPythonアプリケーション(DjangoやFlask)の橋渡しを行うインターフェイスである
  • Socket通信とTCP/IPの通信がサポートされており、Webとアプリを分けたり、同一サーバ上に乗せたりできる。
  • DjangoではNginxやApacheを使用する時に最もよく利用させる
  • WSGIをコールするためには環境変数を含む辞書(environ)とコールバック関数を渡して実行

ASGIについて

  • ASGIはAsynchronous Server Gateway Interfaceが正式名称。
  • ASGIはWSGIの精神的な後継仕様(要は後継仕様)であり、asyncioを介して非同期で動作する。
  • 非同期通信に対応しているため、Websocket通信をサポート。
  • Django ChannelsではASGIを利用する。
  • ASGIは受信したリクエストに関する情報を含む辞書(scope)、ASGI イベントメッセージを受信するために使用される非同期関数(receive)、ASGI イベントメッセージを送信するために使用する非同期関数(send)が必要。

構成イメージ図

今回の構成イメージは以下とする。

なお、すでにChannelsのChatアプリ作成チュートリアルは完了+理解している前提で書いています。
また各ライブラリの使用するバージョンは以下とします。

  • Django:3.2.6
  • uwsgi:2.0.19.1
  • channels:3.0.4
  • channels_redis:3.3.0

ここからの説明には重要な箇所のみ行います。

Docker+Docker-composeの構成

上記の構成図のdocker-compose.ymlは以下になります。
pythonを提供するコンテナ内supervisorを提供するのでpython3コンテナだけ構成しております。

version: '3'

services:
  python3:
    build: ./python3
    command: bash -c "supervisord && uwsgi --socket :8001 --module project.wsgi --py-autoreload 1"
    volumes:
      - ./src:/src
      - ./src/static:/src/static
    expose:
      - "8001"
      - "8002"
    environment:
      PYTHONPATH: /src
    env_file:
      - .env

  nginx:
    build: ./nginx
    ports:
      - "${NGINX_PORT}:8000"
    volumes:
      - ./nginx/conf:/etc/nginx/conf.d
      - ./nginx/uwsgi_params:/etc/nginx/uwsgi_params
      - ./src/static:/src/static
    depends_on:
      - python3

  redis:
    build:
      context: ./redis
    ports:
    - ${REDIS_PORT}:6379
    volumes:
      - redis_data:/redis_data
    restart: always

volumes:
  redis_data:

Nginx構成

Nginxは以下のように構成します。

  • リバースプロキシでWebサービスを提供
  • 8001ポートでwsgiをListenし、HTTPをリクエストを受け取る
  • 8002ポートでwsgiをListenし、Websocketのリクエストを受け取る

Nginxの設定ファイルは以下になります。

upstream http {
    ip_hash;
    server wsgi:8001;
}

upstream websocket {
    ip_hash;
    server asgi:8002;
}

server {
    listen 8000;
    server_name 127.0.0.1;
    charset utf-8;

    # client upload size
    client_max_body_size 75M;

    location /ws {
        proxy_pass http://websocket;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        proxy_redirect off;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Host $server_name;
    }

    location / {
        uwsgi_pass http;
        include    /etc/nginx/uwsgi_params;
    }

    location /static {
        alias /src/static;
    }

}

Supervisorの構成

python3コンテナのsupervisorの構成するためにpython3/supervisor/daphne.conf に以下を追記します。

[program:daphne]
command=/usr/local/bin/daphne -b 0.0.0.0 -p 8002 project.asgi:application
directory=/src
autorestart=true
stdout_logfile=/var/log/supervisor/daphne.log
stdout_logfile_maxbytes=1MB
stdout_logfile_backups=10
redirect_stderr=true

Djangoの構成

今回はサンプルとしてchatアプリを作成します。
settings.pyにasgiのアプリケーションを追加します。

ASGI_APPLICATION = "project.asgi.application"
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('redis', os.environ.get('REDIS_PORT'))],
        },
    },
}

asgi.pyファイルを作成し、asgiアプリケーションの定義をします。

import os

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application

import chat.routing

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings')

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    # Just HTTP for now. (We can add other protocols later.)
    "websocket": AuthMiddlewareStack(
        URLRouter(
            chat.routing.websocket_urlpatterns
        )
    ),
})

urls.pyにchatアプリのurlを追加します。

from django.contrib import admin
from django.urls import path
from django.conf.urls import include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('chat/', include('chat.urls')),
]

chat/urls.pyにcharアプリの部屋名を指定できるようなルーティングを追加します。

from django.urls import path

from . import views

urlpatterns = [
    path('<str:room_name>/', views.room, name='room'),
]

chat/routing.pyにwebsocket用のルーティングを追加します。

from django.urls import re_path

from . import consumers

websocket_urlpatterns = [
    re_path(r'ws/chat/(?P<room_name>\w+)/

#039;, consumers.ChatConsumer.as_asgi()), ]

chat/consumers.pyにchatのConsumerを追記します。

import json
from asgiref.sync import async_to_sync
from channels.generic.websocket import AsyncWebsocketConsumer

class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room_name = self.scope['url_route']['kwargs']['room_name']
        self.room_group_name = 'chat_%s' % self.room_name

        # Join room group
        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )

        await self.accept()

    async def disconnect(self, close_code):
        # Leave room group
        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )

    # Receive message from WebSocket
    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']

        # Send message to room group
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message
            }
        )

    # Receive message from room group
    async def chat_message(self, event):
        message = event['message']

        # Send message to WebSocket
        await self.send(text_data=json.dumps({
            'message': message
        }))

chat/views.pyにchatルームのView関数を設定します。

def room(request, room_name):
    return render(request, 'chat/room.html', {
        'room_name': room_name
    })

最後にchatルームのtemplateを作成します。

<!-- chat/templates/chat/room.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Chat Room</title>
</head>
<body>
    <textarea id="chat-log" cols="100" rows="20"></textarea><br>
    <input id="chat-message-input" type="text" size="100"><br>
    <input id="chat-message-submit" type="button" value="Send">
    {{ room_name|json_script:"room-name" }}
    <script>
        const roomName = JSON.parse(document.getElementById('room-name').textContent);

        const chatSocket = new WebSocket(
            'ws://'
            + window.location.host
            + '/ws/chat/'
            + roomName
            + '/'
        );

        chatSocket.onmessage = function(e) {
            const data = JSON.parse(e.data);
            document.querySelector('#chat-log').value += (data.message + '\n');
        };

        chatSocket.onclose = function(e) {
            console.error('Chat socket closed unexpectedly');
        };

        document.querySelector('#chat-message-input').focus();
        document.querySelector('#chat-message-input').onkeyup = function(e) {
            if (e.keyCode === 13) {  // enter, return
                document.querySelector('#chat-message-submit').click();
            }
        };

        document.querySelector('#chat-message-submit').onclick = function(e) {
            const messageInputDom = document.querySelector('#chat-message-input');
            const message = messageInputDom.value;
            chatSocket.send(JSON.stringify({
                'message': message
            }));
            messageInputDom.value = '';
        };
    </script>
</body>
</html>

参考リポジトリ

https://github.com/daikidomon/nginx-django-channels/tree/supervisord

よかったらシェアしてね!
目次
閉じる