Салют, Хабр!

Auto-scaling, или автоматическое масштабирование, — это механизм, позволяющий гибко адаптироваться к изменяющимся условиям нагрузки, автоматически расширяя или сокращая ресурсы. Эта технология очень актуальна в нашем мире.

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

Auto-scaling позволит не только поддерживать стабильную работу бота при всплесках активности, но и существенно снизит затраты на поддержание избыточной инфраструктуры в периоды низкой активности.

База Auto-Scaling:

Основная цель auto-scaling - поддержание стабильной производительности при минимизации затрат.

Auto-scaling позволяет системе динамически адаптироваться к изменяющемуся объему работы, автоматически наращивая или уменьшая количество серверов или экземпляров приложений. Это помогает избежать перерасхода ресурсов в периоды низкой нагрузки и недостатка ресурсов в пиковые периоды.

Auto-scaling основывается на мониторинге ключевых показателей производительности, таких как CPU, память, сетевая активность или даже специфичные для приложения метрики, к примеру в случае с телеграм ботами - количество одновременных пользовательских сессий.

Определяются пороговые значения для этих метрик, при достижении которых система автоматически инициирует процесс масштабирования.

Горизонтальное масштабирование заключается в добавлении или удалении экземпляров серверов или контейнеров.

Вертикальное масштабирование включает увеличение или уменьшение ресурсов для существующих экземпляров (например, увеличение ОЗУ или CPU).

Все процессы автоскелинга автоматизированы и могут выполняться без человеческого вмешательства. После масштабирования система должна продолжать функционировать так же, как и до масштабирования, без потери данных или функциональности.

Определяются правила или политики, определяющие, когда и как следует проводить масштабирование. Эти правила могут быть основаны на расписании (например, разные параметры масштабирования для часов пик и внепиковых часов) или на адаптивных алгоритмах, реагирующих на изменения в реальном времени.

Когда и почему вашему боту может потребоваться Auto-Scaling.

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

Типы нагрузки:

Пиковая нагрузка: Временные всплески активности, часто вызванные специфическими событиями или маркетинговыми акциями. Постоянная нагрузка: Регулярное количество запросов, которое поддерживается в течение длительного времени. Переменная нагрузка: Нерегулярные изменения в активности, часто связанные с временем суток или днями недели.

Метрики для анализа нагрузки:

Число активных пользователей (DAU/MAU): Ежедневно и ежемесячно активные пользователи. Количество сообщений в секунду/минуту (MPS/MPM): Среднее количество сообщений, обрабатываемых за секунду или минуту. Время отклика: Время, необходимое боту для ответа на запрос пользователя. Частота ошибок: Процент запросов, завершающихся неудачей.

Если метрики нагрузки превышают заранее определенные пороговые значения (например, время отклика или MPS), это является звоночком к масштабированию.

Как реализовать

Безсостоянийная архитектура (stateless architecture)

Основная фича состоит в том, что каждый запрос обрабатывается независимо, без сохранения какого-либо пользовательского состояния на сервере между запросами. Это позволяет любому экземпляру обрабатывать любой запрос.

Бот не хранит информацию о состоянии пользователя между последовательными запросами. Вся необходимая информация передается в каждом запросе. Все данные о состоянии пользователя и сессии хранятся во внешнем хранилище, к примеру БД. Каждый запрос должен быть идемпотентным, то есть его повторное выполнение должно приводить к одним и тем же результатам.

К примеру это может выглядеть так:

from telegram.ext import Updater, CommandHandler, MessageHandler, Filters
import os
import psycopg2

# Подключение к базе данных PostgreSQL
DATABASE_URL = os.getenv('DATABASE_URL')
conn = psycopg2.connect(DATABASE_URL, sslmode='require')

# Функция обработки команды /start
def start(update, context):
    user_id = update.effective_user.id
    with conn.cursor() as cur:
        cur.execute("INSERT INTO users (user_id) VALUES (%s) ON CONFLICT DO NOTHING", (user_id,))
        conn.commit()
    update.message.reply_text("Привет! Я безсостоянийный телеграм-бот.")

# Функция для обработки обычных сообщений
def handle_message(update, context):
    user_id = update.effective_user.id
    # Получение данных пользователя из базы данных
    with conn.cursor() as cur:
        cur.execute("SELECT * FROM users WHERE user_id = %s", (user_id,))
        user_data = cur.fetchone()
        # Здесь можно обработать данные пользователя
    update.message.reply_text("Ваше сообщение обработано!")

# основа примера
def main():
    TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
    updater = Updater(TOKEN, use_context=True)

    dp = updater.dispatcher
    dp.add_handler(CommandHandler("start", start))
    dp.add_handler(MessageHandler(Filters.text & ~Filters.command, handle_message))

    updater.start_polling()
    updater.idle()

if __name__ == '__main__':
    main()

Используется PostgreSQL для хранения данных о пользователях. Подключение осуществляется через psycopg2. start и handle_message являются обработчиками команд и сообщений соответственно. Они взаимодействуют с базой данных для чтения или записи данных о пользователе.

Вся информацияо пользователе хранится в базе данных. Каждый запрос обрабатывается независимо, и состояние не сохраняется на сервере бота.

Системы управления очередями

Очереди позволяют приложениям асинхронно обрабатывать задачи. Производитель отправляет сообщения в очередь, а потребитель извлекает их и обрабатывает, когда готов. Помогают равномерно распределить нагрузку между различными рабочими узлами или процессами.

Они также увеличивают устойчивость системы к сбоям, поскольку сообщения могут быть сохранены и повторно обработаны в случае ошибок.

Позволяет легко добавлять дополнительные рабочие узлы для обработки сообщений.

Для примера сделаем интеграцию очереди RabbitMQ с ботом. В этом сценарии бот будет отправлять сообщения в очередь RabbitMQ, которая затем будет обрабатываться отдельными рабочими процессами:

Производитель: Телеграм-бот

import os
from telegram.ext import Updater, CommandHandler, MessageHandler, Filters
import pika

# Подключение к RabbitMQ
connection = pika.BlockingConnection(pika.ConnectionParameters(host='localhost'))
channel = connection.channel()
channel.queue_declare(queue='telegram_queue')

# Функция обработки сообщений
def handle_message(update, context):
    message = update.message.text
    channel.basic_publish(exchange='',
                          routing_key='telegram_queue',
                          body=message)
    update.message.reply_text('Сообщение отправлено в очередь!')

def main():
    TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
    updater = Updater(TOKEN, use_context=True)

    dp = updater.dispatcher
    dp.add_handler(MessageHandler(Filters.text & ~Filters.command, handle_message))

    updater.start_polling()
    updater.idle()

if __name__ == '__main__':
    main()

Потребитель: Рабочий процесс обработки сообщений

import pika

def callback(ch, method, properties, body):
    print(f"Получено сообщение: {body.decode()}")

connection = pika.BlockingConnection(pika.ConnectionParameters(host='localhost'))
channel = connection.channel()
channel.queue_declare(queue='telegram_queue')

channel.basic_consume(queue='telegram_queue', on_message_callback=callback, auto_ack=True)

print('Ожидание сообщений. Для выхода нажмите CTRL+C')
channel.start_consuming()

RabbitMQ юзается как система управления очередями. Сообщения от телеграм-бота помещаются в очередь telegram_queue. Бот отправляет полученные сообщения в очередь RabbitMQ. Каждое сообщение от пользователя становится отдельным сообщением в очереди.

Отдельный процесс (или несколько процессов) подписан на очередь и обрабатывает сообщения по мере их поступления.

Микросервисная архитектура

Думаю, микросервисы не нуждаются в представление.

Допустим, у нас есть бот, который предоставляет две основные функции: обработку текстовых команд и обработку изображений. В микросервисной архитектуре мы разделим эти функции на два отдельных сервиса:

  1. TextCommandService: Обрабатывает текстовые команды от пользователей.

  2. ImageProcessingService: Обрабатывает запросы на обработку изображений.

Оба сервиса могут быть развернуты независимо и масштабироваться в зависимости от нагрузки.

Напишем простой код, где оба микросервиса взаимодействуют через HTTP-запросы.

TextCommandService

# text_command_service.py
from flask import Flask, request
app = Flask(__name__)

@app.route('/process_text', methods=['POST'])
def process_text():
    text = request.json.get('text')
    # Обработка текстовой команды
    response = f"Обработан текст: {text}"
    return response

if __name__ == '__main__':
    app.run(port=5000)

ImageProcessingService

# image_processing_service.py
from flask import Flask, request
app = Flask(__name__)

@app.route('/process_image', methods=['POST'])
def process_image():
    image_data = request.json.get('image_data')
    # Обработка изображения
    response = f"Обработано изображение: {image_data[:10]}..."
    return response

if __name__ == '__main__':
    app.run(port=5001)

Телеграм-бот

# telegram_bot.py
import requests
from telegram.ext import Updater, CommandHandler, MessageHandler, Filters

def handle_text(update, context):
    text = update.message.text
    response = requests.post("http://localhost:5000/process_text", json={"text": text})
    update.message.reply_text(response.text)

def handle_photo(update, context):
    photo_file = update.message.photo[-1].get_file()
    image_data = photo_file.download_as_bytearray()
    response = requests.post("http://localhost:5001/process_image", json={"image_data": image_data})
    update.message.reply_text(response.text)

def main():
    TOKEN = 'YOUR_TELEGRAM_BOT_TOKEN'
    updater = Updater(TOKEN, use_context=True)

    dp = updater.dispatcher
    dp.add_handler(MessageHandler(Filters.text & ~Filters.command, handle_text))
    dp.add_handler(MessageHandler(Filters.photo, handle_photo))

    updater.start_polling()
    updater.idle()

if __name__ == '__main__':
    main()

TextCommandService и ImageProcessingService запущены на разных портах и обрабатывают разные типы запросов. бот определяет тип входящего сообщения (текст или изображение) и перенаправляет запрос к соответствующему микросервису.

Каждый из микросервисов может быть масштабирован независимо. Например, если большинство запросов связано с обработкой изображений, ImageProcessingService можно масштабировать, добавив больше экземпляров.

Контейнеризация и оркестрация

Контейнеризация — это процесс упаковки приложения вместе со всеми его зависимостями и конфигурациями в изолированный контейнер.

Пример Dockerfile для телеграм-бота:

FROM python:3.12

# Устанавливаем рабочую директорию в контейнере
WORKDIR /bot

# Копируем файлы проекта в контейнер
COPY . /bot

# Устанавливаем зависимости
RUN pip install -r requirements.txt

CMD ["python", "./my_telegram_bot.py"]

Создаем образ Docker для телеграм-бота, начиная с базового образа Python, устанавливая необходимые зависимости и определяя команду для запуска бота.

Оркестрация контейнеров — это процесс управления жизненным циклом контейнеров.

Для развертывания телеграм-бота в Kubernetes можно создать файл конфигурации deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: telegram-bot-deployment
spec:
  replicas: 2 # Начинаем с двух экземпляров
  selector:
    matchLabels:
      app: telegram-bot
  template:
    metadata:
      labels:
        app: telegram-bot
    spec:
      containers:
      - name: telegram-bot
        image: my_telegram_bot:latest
        ports:
        - containerPort: 80

Для включения автоскейлинга в Kubernetes, можно использовать Horizontal Pod Autoscaler (HPA), который автоматически масштабирует количество подов в ответ на наблюдаемую нагрузку:

apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
  name: telegram-bot-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: telegram-bot-deployment
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 80

Этот HPA будет масштабировать количество подов для telegram-bot-deployment от 2 до 10, основываясь на использовании CPU.

CI/CD

Непрерывная интеграция (CI) подразумевает автоматическое тестирование кода при каждом коммите в систему контроля версий.

Рассмотрим пример, где мы используем GitHub Actions для автоматического тестирования телеграм-бота при каждом коммите.

Создадим файл .github/workflows/python-app.yml в репозитории на GitHub:

name: Python application

on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout
    - name: Set up Python 3.12
      uses: actions/setup-python
      with:
        python-version: 3.12
    - name: Install dependencies
      run: |
        pip install -r requirements.txt
    - name: Run tests
      run: |
        pytest

on: Определяет, когда должны выполняться действия (на push и pull request в ветку master).

jobs: Определяет набор задач, которые должны быть выполнены (здесь только build).

steps: Последовательность шагов, включая проверку кода из репозитория, установку питона, установку зависимостей и запуск тестов с помощью pytest.

Непрерывная доставка (CD) автоматизирует развертывание приложения в тестовую или рабочую среду после успешного прохождения тестов.

Допустим, мы хотим автоматически развернуть нашего телеграм-бота на Kubernetes после успешного прохождения тестов. Для этого нам потребуется Docker контейнер и Kubernetes конфигурация:

За пример возьмем докерфайл, который рассматривали выше. Настроим GitHub Actions для сборки и публикации Docker образа:

- name: Build and Push Docker image
  if: github.event_name == 'push'
  run: |
    echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
    docker build -t my_telegram_bot .
    docker tag my_telegram_bot ${{ secrets.DOCKER_USERNAME }}/my_telegram_bot:latest
    docker push ${{ secrets.DOCKER_USERNAME }}/my_telegram_bot:latest

Автоматическое развертывание в Kubernetes:

- name: Deploy to Kubernetes
  if: github.event_name == 'push'
  run: |
    kubectl apply -f k8s-deployment.yaml

k8s-deployment.yaml — это файл конфигурации Kubernetes, который описывает, как должен быть развернут и масштабирован телеграм-бот.


Грамотный автоскейлинг позволяет справляться с изменяющимися нагрузками и значительно повышает устойчивость и адаптивность бота.

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

Комментарии (2)


  1. ovsds
    19.12.2023 22:23

    К сожалению горизонтального скалирования для телеграм бота нормального нет. Если вы запустите два инстанса, то увидете спам из следующего в трейсах:
    telegram.error.Conflict: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running. Это вызывается особенностями того как устроен механизм получения обновлений ботом через long polling.

    В остальном - да, если вынести всю обработку, а на инстанс с телеграмм ботом оставить только хэндлинг, то можно очень не плохо выиграть, но к сожалению не в синхронном коде, поскольку в вашем примере обращение в сервисы обработки всё ещё блокирующие и по сути лишь негативно скажутся на времени обработки запросов (хоть и незначительно в случае если сервисы будут на одной ноде расположены).

    Итого, если подытожить: что б выжать максимум из неинстансируемого приложения телеграмма, действительно нужно вынести всё что только можно в другие приложения, но само приложение-обработчик всё равно придётся скалировать только вертикально, да и плюс ко всему для увеличения РПС скорее всего придётся уйти в асинхронность, поскольку, по крайней мере выбранный в статье фреймворк не супер хорошо справляется в мультитрединг.

    А так да, грамотный скейлинг - это важно. Форточку сам открою :)


  1. bevial
    19.12.2023 22:23

    Какую нейроночку использовали для написания статьи? )))

    Судя по иллюстрации от далишки, и некоторым паттернам, таким как чрезмерное разжевывание, это был chatgpt 4. Я прав? )))