Django GraphQL подписки с использованием вебсокетов в докеризованном API работают нормально локально, но не работают в продакшене
У меня есть Django GraphQL API и Angular 12 UI, который использует Apollo для взаимодействия с GraphQL.
Приложение Django докеризовано и использует NGINX. Вот мои файлы:-
settings.py (только соответствующие разделы вставлены ниже)
INSTALLED_APPS = [
'channels',
'corsheaders',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites', # This is for altering the domain name in the migration
'app',
'graphene_django',
'graphql_jwt.refresh_token.apps.RefreshTokenConfig',
'graphql_auth',
'rest_framework',
'django_filters',
]
GRAPHENE = {
'SCHEMA': 'project.schema.schema',
'MIDDLEWARE': [
'graphql_jwt.middleware.JSONWebTokenMiddleware',
],
"SUBSCRIPTION_PATH": "/ws/graphql"
}
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'common.utils.UpdateLastActivityMiddleware'
]
AUTHENTICATION_BACKENDS = [
'graphql_auth.backends.GraphQLAuthBackend',
'django.contrib.auth.backends.ModelBackend',
]
GRAPHQL_AUTH = {
"ALLOW_LOGIN_NOT_VERIFIED": False
}
GRAPHQL_JWT = {
"JWT_ALLOW_ANY_CLASSES": [
"graphql_auth.mutations.Register",
"graphql_auth.mutations.VerifyAccount",
"graphql_auth.mutations.ResendActivationEmail",
"graphql_auth.mutations.SendPasswordResetEmail",
"graphql_auth.mutations.PasswordReset",
"graphql_auth.mutations.ObtainJSONWebToken",
"graphql_auth.mutations.VerifyToken",
"graphql_auth.mutations.RefreshToken",
"graphql_auth.mutations.RevokeToken",
],
'JWT_PAYLOAD_HANDLER': 'common.utils.jwt_payload',
"JWT_VERIFY_EXPIRATION": True,
"JWT_LONG_RUNNING_REFRESH_TOKEN": True,
'JWT_REUSE_REFRESH_TOKENS': True, # Eliminates creation of new db records every time refreshtoken is requested.
'JWT_EXPIRATION_DELTA': timedelta(minutes=60), # Expiry time of token
'JWT_REFRESH_EXPIRATION_DELTA': timedelta(days=7), # Expiry time of refreshToken
}
ROOT_URLCONF = 'project.urls'
WSGI_APPLICATION = 'project.wsgi.application'
ASGI_APPLICATION = 'project.router.application'
REDIS_URL = env('REDIS_URL')
hosts = [REDIS_URL]
if DEBUG:
hosts = [('redis', 6379)]
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": hosts,
},
},
}
router.py
@database_sync_to_async
def get_user(token_key):
try:
decodedPayload = jwt.decode(
token_key, key=SECRET_KEY, algorithms=['HS256'])
user_id = decodedPayload.get('sub')
User = get_user_model()
user = User.objects.get(pk=user_id)
return user
except Exception as e:
return AnonymousUser()
# This is to enable authentication via websockets
# Source - https://stackoverflow.com/a/65437244/7981162
class TokenAuthMiddleware(BaseMiddleware):
# We get the auth token from the websocket call where token is passed as a URL param
def __init__(self, inner):
self.inner = inner
async def __call__(self, scope, receive, send):
query = dict((x.split("=")
for x in scope["query_string"].decode().split("&")))
token_key = query.get("token")
scope["user"] = await get_user(token_key)
scope["session"] = scope["user"] if scope["user"] else None
return await super().__call__(scope, receive, send)
application = ProtocolTypeRouter(
{
"http": get_asgi_application(),
"websocket": AllowedHostsOriginValidator(TokenAuthMiddleware(
URLRouter(
[path("ws/graphql/", MyGraphqlWsConsumer.as_asgi())]
)
)),
}
)
docker-compose.yml
version: "3.9"
services:
nginx:
build: ./nginx
ports:
- ${PORT}:80
volumes:
- static-data:/vol/static
depends_on:
- web
restart: "on-failure"
redis:
image: redis:latest
ports:
- 6379:6379
volumes:
- ./config/redis.conf:/redis.conf
command: ["redis-server", "/redis.conf"]
restart: "on-failure"
db:
image: postgres:13
volumes:
- ./data/db:/var/lib/postgresql/data
env_file:
- database.env
restart: always
pg_admin:
image: dpage/pgadmin4:latest
container_name: app_io_pgadmin4
ports:
- "5000:80"
logging:
driver: none
environment:
- GUNICORN_THREADS=1
- PGADMIN_DEFAULT_EMAIL=admin@email.com
- PGADMIN_DEFAULT_PASSWORD=admin
depends_on:
- db
restart: "on-failure"
web:
build: .
command: bash -c "python manage.py makemigrations && python manage.py migrate && python manage.py runserver 0.0.0.0:8000"
container_name: app_io_api
volumes:
- .:/project
ports:
- 8000:8000
expose:
- 8000
depends_on:
- db
- redis
restart: "on-failure"
volumes:
database-data: # named volumes can be managed easier using docker-compose
static-data:
Dockerfile
FROM python:3.8.3
LABEL maintainer="https://github.com/ryarasi"
# ENV MICRO_SERVICE=/app
# RUN addgroup -S $APP_USER && adduser -S $APP_USER -G $APP_USER
# set work directory
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
COPY ./requirements.txt /requirements.txt
# create root directory for our project in the container
RUN mkdir /project
# COPY ./scripts /scripts
WORKDIR /project
# Copy the current directory contents into the container at /project
ADD . /project/
# Install any needed packages specified in requirements.txt
# This is to create the collectstatic folder for whitenoise
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r /requirements.txt && \
mkdir -p /vol/web/static && \
mkdir -p /vol/web/media
CMD python manage.py wait_for_db && python manage.py collectstatic --noinput && python manage.py migrate && gunicorn project.wsgi:application --bind 0.0.0.0:$PORT
nginx.conf (переменная $PORT здесь назначается Heroku и доступна как переменная env. Heroku - это место, где я развертываю все это.
upstream app {
server web:$PORT;
}
server {
listen 80;
location /static {
alias /vol/static;
}
location / {
proxy_pass http://app;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host web;
proxy_redirect off;
client_max_body_size 10M;
}
location /ws/ {
proxy_set_header Host $http_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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Url-Scheme $scheme;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_pass http://app;
}
}
Schema.py, который обрабатывает промежуточное ПО подписки
schema = graphene.Schema(query=Query, mutation=Mutation,
subscription=Subscription)
def subscription_middleware(next_middleware, root, info, *args, **kwds):
if(info.operation.name is not None and info.operation.name.value != "IntrospectionQuery"):
print("Subscription Middleware report")
print(" user :", info.context.user)
print(" operation :", info.operation.operation)
print(" resource :", info.operation.name.value)
return next_middleware(root, info, *args, **kwds)
class MyGraphqlWsConsumer(channels_graphql_ws.GraphqlWsConsumer):
async def on_connect(self, payload):
self.scope["user"] = self.scope["session"]
self.user = self.scope["user"]
schema = schema
middleware = [subscription_middleware]
Все работает на локальном сервере. В продакшене все обычные функции http работают правильно. Но вебсокеты не работают.
Здесь нужно отметить, что в локальной версии я могу использовать соединения ws://, но в Heroku я могу использовать только wss://, т.е. безопасные соединения websocket. Поэтому я модифицирую запрос из пользовательского интерфейса для этого.
Это ошибка, которую я постоянно вижу:-
WebSocket connection to 'wss://<link>.herokuapp.com/ws/graphql/?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InRlc3RhZG1pbiIsInN1YiI6IjIiLCJleHAiOjE2NDA1MTA0OTMsIm9yaWdJYXQiOjE2NDA1MDY4OTN9.tGrHphndoCBtE73qHwe2un49fk8qJHej-6QfpYtztrs' failed:
SubscriptionClient.connect @ client.js:446
(anonymous) @ client.js:414
timer @ zone.js:2561
invokeTask @ zone.js:406
TaskTrackingZoneSpec.onInvokeTask @ task-tracking.js:73
invokeTask @ zone.js:405
onInvokeTask @ core.js:28645
invokeTask @ zone.js:405
runTask @ zone.js:178
invokeTask @ zone.js:487
ZoneTask.invoke @ zone.js:476
data.args.<computed> @ zone.js:2541
Из журналов API я вижу следующее:-
2021-12-26T09:14:30.059249+00:00 heroku[router]: at=info method=GET path="/ws/graphql/?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImxlYXJuZXIiLCJzdWIiOiIzIiwiZXhwIjoxNjQwNTExMzUyLCJvcmlnSWF0IjoxNjQwNTA3NzUyfQ.DF16VMpJwIrlhRrnvkua6_-Mo-4CAJrgCPG7wjVSwfo" host=<link>.herokuapp.com request_id=07e05b16-f96a-4655-bf15-3e6f3a70667a fwd="157.51.140.85" dyno=web.1 connect=0ms service=2ms status=404 bytes=465 protocol=https
Я сделал все это, следуя документации и различным блогам в Интернете, и я признаю, что не знаю всех тонкостей того, что я сделал. Я просто следовал инструкциям из различных источников, в первую очередь из документации используемых мной пакетов - django-channels и django-channels-graphql-ws.
Я устал от попыток выяснить, где существуют проблемы. Я уже несколько месяцев думаю, что с этим делать.