×

Создаем приложение: Docker, VueJs и Python-Sanic. Часть 3

Сегодня завершающая часть трилогии (предыдущие: Часть 1, Часть 2) о создании мультисервисного web-приложения на базе технологии Docker. В этой статье мы окончательно «сложим пазлы» в единую картину работающего приложения.

Уточним, что нам осталось сделать согласно постановки задачи:

  1. Реализовать WebSocket сервер на http://localhost/ws; (используем Python-Sanic).
  2. Написать простейший чат http://localhost/ (используя VueJs) который будет авторизироваться через http://localhost/api, реализованный в Части 2. Получим некий token, при помощи которого можно будет подключиться к чату на базе WebSocket-сервера из п. 1.

Этап 1. WebSocket server на Sanic

Небольшое отступление. Асинхронный фреймворк Sanic позволяет реализовать WS-сервер на базе созданного еще во 2-й части API. Я решил создать отдельный процесс, чтобы, с одной стороны, не смешивать код из разных частей статьи, с другой, чтобы наглядно продемонстрировать простоту микросервисной архитектуры.

Итак:

# Из корня проекта выполняем:
  mkdir ws

Добавляем файл ws/Dockerfile

FROM python:3.6.7-slim-stretch
WORKDIR /app
RUN apt-get update
RUN apt-get -y install gcc
COPY requirements.txt /tmp
RUN pip install -r /tmp/requirements.txt
VOLUME [ "/app" ]
EXPOSE 8000
CMD ["python", "run.py"]

Здесь мы «просим» docker создать нам контейнер, взяв за основу образ с python:3.6.7. Нужно доустановить в него некоторые системные библиотеки и необходимые для работы приложения python-пакеты из ws/requirements.txt:

# содержимое ws/requirements.txt
sanic
asyncio_redis

Кроме того, мы указали, что запуск приложения будет осуществляться на 8000 порту внутри самого контейнера, а команда реализация сервера находиться внутри run.py:

import os
from time import time
from sanic import Sanic
import ujson
import asyncio_redis
from websockets.exceptions import ConnectionClosed

app = Sanic('websocket')

conn = {}
CONN_CACHE_TIME = 10 # sec

@app.listener('before_server_start')
async def start(app, loop):
    app.redis = await asyncio_redis.Pool.create(host='redis', poolsize=10)


@app.listener('after_server_stop')
async def stop(app, loop):    
    app.redis.close()


async def check_token(request):
    token = request.args['token']   
    

async def checkTokenAlive(ws, token):
    if time() - conn.get(ws, 0) > CONN_CACHE_TIME:
        token_exists = await app.redis.exists(token)
        if token_exists:
            conn[ws]=time()            
        else:
            return False
    return True
    

@app.websocket('/')
async def feed(request, ws):                      
    token = request.args['token'].pop()
    if token:
        isAlive = await checkTokenAlive(ws, token)
        while isAlive:
            try:
                data = await ws.recv()            
                if data:                
                    if data=="/out":
                        await ws.close()
                    await ws.send(f'I\'ve received: {data}')                            
            except  ConnectionClosed:
                pass
            isAlive = await checkTokenAlive(ws, token)
    await ws.close()


if __name__ == "__main__":                
    debug_mode =  os.getenv('API_MODE', '') == 'dev'   

    app.run(
        host='0.0.0.0',
        port=8000,
        debug=debug_mode, 
        access_log=debug_mode
    )

В сервере предусмотрена периодическая (10 секунд) проверка token на «наличие» в Redis. Напомню, данный токен — это то, что получит наше SPA в случае успешной авторизации на localhost/api/v1.0/user/auth (реализация в Часть 2). Дополнительно реализована инструкция «/out» для самого чата, которая при отправке с клиента, закрывает текущее WebSocket-соединение.

Дополняем docker-compose.yml новым сервисом:

services:    
  ws: 
    container_name: test_ws
    build: 
      context: ./ws
    tty: true
    restart: always
    volumes: 
      - "./ws:/app"    
    links:      
      - "redis"     
    networks:      
      - internal
    env_file:
      - .env

Корректируем конфигурацию nginx сервера таким образом, чтобы все запросы, которые приходят на localhost/ws, проксировались к контейнеру «ws»:

services:    
       location /ws {            
        rewrite /ws$     /    break;  
        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-Proto    $scheme;
        proxy_set_header Host $http_host;
        proxy_pass http://ws:8000;
    }

Так как мы пишем SPA-приложение, работающее в браузере, нам необходимо получить самый что ни есть «классический» JavaScript, который гарантированно будет выполняться на подавляющем большинстве браузеров, установленных у пользователей. В силу очевидных причин, развитие JavaScript как языка программирования (ES5, ES6 и т. д.) сильно ушло вперед по сравнению с тем, что могут предложить существующие браузеры. По этой причине для того, чтобы воспользоваться всей мощью языка на клиенте, нам необходимо «преобразовать» наш «современный» синтаксис в («старый») понятный для браузера код.

Для этой цели как нельзя лучше подходит пакетный статический анализатор кода Webpack, к которому в качестве плагинов можно подключить конвертеры: Browserify, TypeScript, Sass, Less, css-minify, js-minify и многие другие, облегчающие web-разработку клиента. В общем случае — Webpack представляет собой демон (работающий процесс), который в зависимости от конфигурации отслеживает изменение кода и «налету» преобразовывает «удобный и современный» js-код в аналогичный по функционалу, но подходящий для работы в большинстве браузеров. В общем случае текст кода, который мы пишем, последовательно преобразовывается подключаемыми к демону плагинами и сохраняется в виде одного/двух файлов в директории /dist.

Настройка Webpack (установка и конфигурирование плагинов) — весьма муторное занятие, но существуют «утилиты-помощники», позволяющие выполнить эту затратную по времени операцию. Так как мы пишем фронтенд на VueJs, то в качестве такого «помощника» воспользуемся утилитой vue-cli, которая, кроме разворачивания Webpack, создаст базовое тестовое приложение на Vue, которое мы изменим под свои цели.

Этап 2. Создаем «SPA demo» при помощи vue-cli

Итак, нам нужно:

  • запустить контейнер с Node (версии LTS 10.5);
  • сгенерировать при помощи vue-cli demo-приложение;
  • запустить его в контейнере;
  • настроить маршрутизацию запросов сервера nginx.

Выполним из корня нашего проекта команды:

    # Переменной GID присваиваем идентификатор группы хост-машины
    echo "GID=$(id -g)" >> .env
    # Переменной GID присваиваем идентификатор текущего пользователя
    echo "UID=$(id -u)" >> .env

Cоздаем файл app/Dockerfile:

# За основу выбераем последний стабильный (LTS) образ версии Node
FROM node:10.15.0-alpine
WORKDIR /app
VOLUME ["/app"]
# инсталируем vue-cli согласно https://cli.vuejs.org/guide/installation.html
RUN npm install -g @vue/cli

В docker-compose.yml добавляем:

services:
  app:
    build: ./app
    tty: true
    user: "${UID}:${GID}"
    container_name: test_app
    volumes:
      - "./app:/app"
    networks:
    - internal    
    env_file:
      - .env

Хочу обратить внимание на 5-ю строчку в сервисе app. Предварительно мы сохранили в файл .env ID пользователя и группы хостмашины в силу особенности устройства ядра Linux. Даём указание docker запустить контейнер от лица этого юзера и группы. Это сделано для того, чтобы файлы, которые мы будем сейчас создавать внутри контейнера, можно было редактировать/создавать извне, то есть из хостмашины, не меняя владельца (chown) всех файлов.

Далее выполняем:

# из корня нашего проекта, перестраиваем и перезапускаем контейнеры
make upb
# или, если нравится традиционный способ, то:
docker-compose up -d --force-recreate --build

Мы запустили контейнер с Node версии 10.15.0, c предварительно установленным vue-cli.

Теперь:

  # подключаемся к sh-консоли работающего контейнера c Node  (в docker-compose его имя test_app)
docker exec -it test_app /bin/sh
# в консоли контейнера переходим в корневой каталог
cd /
# создаем demo приложение при помощи уже установленной во время создания контейнера vue-cli
vue create app
# Мастер, сообщаем что директория не пуста.. Выбираем "Merge"
# Затем в  меню "Manually select features" выбираем пакеты которые бы мы хотели установить для нашего приложения
# Выбираем нужные пакеты, я оставил:
# ◉ Babel
# ◉ Router
# ◉ CSS Pre-processors
# ◉ Linter / Formatter
# Далеее, на все вопросы установщика, можем соглашаться по умолчанию.
# В конце установки пакетов, мастер выдает сообщение
# $ cd app
# $ npm run serve

Выполнив последние 2 команды, мы увидим, что webpack по умолчанию запустился на 8080 порту.

Отключаемся от контейнера (Ctrl+С) и видим, что на хост-машине (ls -la app/ ) в папке фронтенда контейнер сгенерил «кучу» файлов, которые благодаря механизму Volumes, теперь являются общими для хост-машины и для контейнера. Самое интересное здесь то, что благодаря GID и UID сгенеренные из контейнера файлы принадлежат текущему юзеру host-машины, хотя внутри контейнера юзер имеет другое имя. Более исчерпывающую информацию о пользователях/группах в контейнерах можно почерпнуть здесь.

Так как у нас уже есть готовый рабочий код, все, что нам осталось сделать, — это подправить наш app/Dockerfile таким образом, чтобы контейнер при запуске выполнял команду запуска демона, то есть npm run serve.

# окончательный вид app/Dockerfile
FROM node:10.15.0-alpine
RUN apk add --no-cache bash
RUN npm install -g @vue/cli
WORKDIR /app
VOLUME ["/app"]
RUN npm install 
EXPOSE 8080
CMD ["npm", "run", "serve"]

Этап 2.1. Настраиваем nginx для vue-приложения

В конец конфигурационного файла nginx/server.conf вставим:

location / {                    
        rewrite /(.*) /$1  break;          
        proxy_redirect     off;
        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;
        proxy_set_header   X-Forwarded-For      $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto    $scheme;
        proxy_set_header Host $http_host;
        proxy_pass http://app:8080;
    }

Теперь перезапускаем все созданные контейнеры:

make upb

и заходим на http://localhost. Должны увидеть demo-страницу vue.

Важно! Demo-приложение, которое генерит vue-cli, по умолчанию для mode dev подключает интегрированный плагин vue-hot-reload-api, который через WebSocket-соединение «узнает» от демона webpack об изменениях на сервере и автоматически перезагружает страницу приложения в браузере, подтягивая новые данные. По этой причине, в конфигурации nginx, заложена поддержка проксирования WebSocket-заголовков.

  # Если нужно видеть вывод консоли работающего WebPack, 
  # цепляемся к контейнеру командой
docker attach test_app

Этап 2.2. Пишем клиентское приложение

Немного поговорим о том, как будет работать наш SPA-клиент:

  1. При заходе на localhost проверяем, есть ли в localStorage значение для ключа «token». Если есть, пробуем установить WebSocket-соединение с ws://localhost/ws?token=xxx с сервером. Если сервер закрыл соединение (token отсутствует в redis), сбрасываем приложение на страницу /login.
  2. На странице /login находится форма авторизации, которая отправляет данные на api (http://localhost/api/v1.0/user/auth), и в случае успеха сохраняет token в localStorage с последующим редиректом на главную.
  3. В случае неудачной авторизации, отрисовываем повторно форму авторизации с отображением ошибки валидации.

К сожалению, объем кода SPA-приложения довольно большой, что неминуемо приведет к тому, что объем статьи будет огромный. Все изменения кода в этой статье, я выполнил в отдельной ветке на GitHub. Их можно посмотреть в виде pull request, которые я выполнил по сравнению с состоянием кода по окончании 2-й части.

Итоговый результат

Если вы дочитали до этого места, то, пожалуй, именно сейчас как раз тот момент, когда вы можете полностью увидеть реализованный вариант рабочего приложения, состоящего из 7 контейнеров.

  cd ~
git clone [email protected]:v-kolesov/vue-sanic-spa.git
cd vue-sanic-spa
docker-compose up  -d

После запуска всех контейнеров в браузере вбейте http://localhost. Вы должны увидеть, нечто похожее. В моем случае, в правом нижнем углу — 2 терминала, которые подключены непосредственно к контейнерам test_app, test_ws (нужно в целях отладки и дебагинга).

Все про українське ІТ в телеграмі — підписуйтеся на канал DOU

👍ПодобаєтьсяСподобалось0
До обраногоВ обраному2
LinkedIn

Схожі статті




11 коментарів

Підписатись на коментаріВідписатись від коментарів Коментарі можуть залишати тільки користувачі з підтвердженими акаунтами.

Доходчивая и цельная статья. Спасибо за Ваше потраченное время !
Не останавливайтесь.

Важно: хак для линукса с юзером не будет работать на mac os. Можно оставить без указания user в docker-compose

Полезно, спасибо, не знал.

т.е. правильно ли я понял что в контейнере спа-приложения есть статический сервер на ноде который проксируется нжинксом?
Надеялся увидеть как нжинкс будет отдавать спа без лишних прослоек, с гзипом и кеш-заголовками.

Во время разработки, да, для SPA запущен демон WEBPACKa, который преобразовывает код динамически, подгружая новые изменения через встроенный WebSocket. В случае продакшена, вам 1 раз достаточно из контейнера test_app (директория /app) запустить npm run build, и затем тем же nginx раздать статику из папки dist. Только эту папку нужно заранее подсунуть в виде VOLUMES для nginx. Как бы в статье, я больше рассказывал именно про то, как пользовать докеры для разработки, при деплое в продакшен, структура контейнеров незначительно меняется, делаеть это удобно в Ansible.

в таком случае было бы интересно увидеть 4 часть про Ansible

Ansible — уже сильно уходит больше в сторону DevOps. Мне сложно таргетировать аудиторию подобной статьи. Разработчикам врядли оно будет интересно. Самый простой случай для фулстек одиночки это отдельно 2 файла держать docker-compose.dev.yml и docker-compose.prod.yaml, и деплоить ручками прямо с сервера соотвествующее окружение в зависимости от того где находимся. Но я всё же обдумаю ваше предложение.

Спасибо за цикл статей!
(мечтательно), Еще бы 4-ю, дополнительную, про то как это все запихнуть в кубернетес!
Сейчас решаю такую задачу — запихнуть в докер уже реализованную бизнеслогику программы стат. моделирования в R, приделать к ней веб-интерфейс, и все это скалировать кубером.
Скалирование — самое примитивное — чтоб с ростом числа пользователей достаточно было бы тупо добавить железа в кластер или «железа» в клауд.
Первую задачу решил, вторую — в начале процесса.

Честно, говоря, в силу «мелкости» моих проектов, не доводилось добавлять аркистратор над этим всем. В моем случае стандартный флоу «деплой контейнеров на сервер при помощи ansible». Но да, актуальность ваших пожеланий очевидно, возможно кто-то сподвигниться, и поделиться опытом.

возможно кто-то сподвигниться, и поделиться опытом

Вполне возмоно, что этот кто-то буду я сам :)

Хороше подання, от ще б на базі .NET Core такі докладні приклади знайти..

Підписатись на коментарі