RE:NODE

Приложения13 мин чтения

Хостинг бота на discord.py: установка, коги, intents и деплой

Запуск бота discord.py на сервере: фиксация зависимостей, intents, коги и setup_hook, синхронизация дерева команд и заблокированный цикл событий, который убивает ботов.

0 прочтений

Бот на discord.py - это процесс Python, который держит один открытый WebSocket к Discord. Хостить его несложно, но ломается он считанным числом конкретных способов: под тем же именем импорта установлена не та библиотека, привилегированный intent так и не включили в Developer Portal, расширение не загрузилось и утянуло за собой весь бот, и - с большим отрывом самая частая причина - синхронный вызов заблокировал цикл событий, пока Discord не отчаялся дождаться heartbeat.

В этом руководстве - какую версию библиотеки ставить, что должно лежать в requirements.txt, как сочетаются setup_hook и коги, как синхронизировать дерево команд приложения, не упёршись в лимиты, и как должна выглядеть команда запуска на сервере. Общие принципы того, как держать бота постоянно запущенным - только исходящее соединение, никакого входящего порта, перезапуск при выходе, - те же, что и для бота на Node, и изложены в статье как хостить Discord-бота 24/7. Про память и процессор - в статье сколько RAM и CPU нужно Discord-боту.

Выбор библиотеки и версии Python#

Три библиотеки делят одно имя импорта. import discord может означать discord.py, py-cord или устаревший заглушечный пакет discord на PyPI, и pip охотно установит больше одной из них в одно и то же окружение, после чего файлы перезаписывают друг друга, а ошибки перестают иметь смысл.

ПакетИмпортПримечания
discord.pydiscordОригинал. В версии 2.x есть app commands и коги
py-corddiscordФорк со своими декораторами для слэш-команд
nextcordnextcordФорк, сменивший имя, поэтому может жить рядом
discorddiscordЗаглушка, подтягивающая discord.py. Не зависьте от неё

Если импорты повели себя странно, лечится это удалением всех этих пакетов и установкой ровно одного:

bash
$ pip uninstall -y discord.py py-cord discord$ pip install -U discord.py

Эти два API не взаимозаменяемы. py-cord использует @bot.slash_command и discord.Option; discord.py - @bot.tree.command, @app_commands.command и discord.app_commands.describe. Код, скопированный из руководства, написанного для одной библиотеки, не запустится на другой, а получившийся AttributeError выглядит как сломанная установка, а не как неверная библиотека. Решите, какую используете, зафиксируйте версию и проверяйте, что любой вставляемый фрагмент ей соответствует.

Теперь о версии Python: для discord.py 2.x нужен Python 3.8 или новее. Берите 3.11 или 3.12, если нет причин поступить иначе. В Python 3.13 удалили модуль стандартной библиотеки audioop, на который опиралась поддержка голоса, поэтому старые релизы discord.py на 3.13 не импортируются вообще, а новые подтягивают вместо него пакет-бэкпорт. Если ваш бот воспроизводит звук, фиксируйте версию интерпретатора осознанно и читайте список изменений библиотеки, прежде чем переходить на новую.

requirements.txt и установка на сервере#

При установке на сервере нет интерактивной оболочки и нет возможности ответить на вопрос. Всё необходимое лежит в одном файле.

requirements.txt
# example pins - use the versions you actually tested againstdiscord.py==2.5.2python-dotenv==1.1.1aiosqlite==0.21.0

Фиксируйте точные версии через ==. Альтернатива - без фиксации или с >= - означает, что сервер поставит то, что вышло сегодня утром, и бот, не менявшийся шесть недель, ломается при перезапуске. Фиксация ещё и делает сбой воспроизводимым: вы можете поставить тот же набор локально и увидеть ту же ошибку. Инструменты фиксации подробно разобраны в статье requirements для Python и виртуальные окружения; pip freeze - грубая отправная точка, потому что записывает всё транзитивное дерево, включая то, что вы не просили.

Команда запуска на сервере приложений обычно сначала устанавливает, а потом запускает:

bash
pip install --no-cache-dir -r requirements.txt && python -u bot.py

Два флага оправдывают своё место. --no-cache-dir не даёт pip хранить кэш wheel-пакетов, который бесполезен в контейнере, всё равно переустанавливающем всё с нуля, а на тарифе с 5 GB этот кэш занимает заметную долю диска. python -u делает stdout небуферизованным, и разница - между строками журнала, которые вы видите в консоли по мере появления, и тишиной на несколько минут, пока не заполнится буфер. Переменная окружения PYTHONUNBUFFERED=1 делает то же самое.

Если вам нужен голос, установите discord.py[voice], что добавляет PyNaCl, и убедитесь, что в контейнере есть бинарник ffmpeg: библиотека вызывает его отдельным процессом и падает в момент воспроизведения, а не при импорте, так что эту ошибку вы обнаружите на глазах у публики.

Бот, его intents и setup_hook#

Intents - это подписка, которую вы объявляете при подключении. discord.Intents.default() даёт всё, кроме трёх привилегированных, которые нужно включить и на вкладке Bot в Developer Portal, и в вашем коде.

bot.py
import osimport discordfrom discord.ext import commandsintents = discord.Intents.default()intents.message_content = True   # privileged: needed for prefix commandsintents.members = False          # privileged: the expensive oneclass Bot(commands.Bot):    def __init__(self):        super().__init__(command_prefix="!", intents=intents)    async def setup_hook(self):        for extension in ("cogs.moderation", "cogs.levels"):            await self.load_extension(extension)bot = Bot()bot.run(os.environ["DISCORD_TOKEN"], log_handler=None)

setup_hook выполняется один раз, после входа, но до готовности бота, и именно там место асинхронной работе при запуске: загрузке расширений, открытию пула соединений с базой, регистрации постоянных представлений. Он заменяет привычку выполнять работу внутри on_ready, что неправильно способом, который легко упустить: on_ready может сработать больше одного раза, потому что срабатывает снова после переподключения, которое не удалось возобновить. Всё, что вы делаете там, произойдёт дважды.

Две детали intents порождают большую часть путаницы. Если message_content выключен, префиксные команды молча ничего не делают, потому что бот получает событие сообщения с пустым полем content; discord.py при запуске пишет в журнал предупреждение об отсутствующем привилегированном intent, и это предупреждение - весь ответ. Если включён members, библиотека при подключении загружает список участников каждой гильдии частями, что медленно и требует много памяти у бота на больших серверах, - передайте chunk_guilds_at_startup=False, если вам на самом деле не нужен этот кэш.

Коги: как разделить бота, ничего не сломав#

Ког - это класс, группирующий команды, слушатели и состояние. Расширение - это модуль, который его загружает. В discord.py 2.x и функция setup модуля, и load_extension асинхронны, и это самое большое отличие от руководств для версии 1.x.

cogs/moderation.py
import discordfrom discord.ext import commandsfrom discord import app_commandsclass Moderation(commands.Cog):    def __init__(self, bot: commands.Bot):        self.bot = bot    @app_commands.command(description="Remove recent messages")    @app_commands.describe(count="How many messages, 1-100")    async def purge(self, interaction: discord.Interaction, count: int):        await interaction.response.defer(ephemeral=True)        deleted = await interaction.channel.purge(limit=count)        await interaction.followup.send(f"Deleted {len(deleted)}.")async def setup(bot: commands.Bot):    await bot.add_cog(Moderation(bot))

Практические правила для когов на сервере:

  • Упавшее расширение останавливает бота. Исключение, возникшее в setup, выходит из load_extension, и если этот вызов стоит в setup_hook без всякой обёртки, процесс завершается. Если бот должен пережить один сломанный ког, оберните каждую загрузку в try и громко пишите о сбое в журнал, а не позволяйте одному модулю утянуть остальные.
  • Перезагрузка - для разработки. await bot.reload_extension("cogs.levels") заново импортирует модуль, что удобно локально и опасно в продакшене: объекты, созданные старой версией, остаются жить в слушателях и задачах, которые вы не очистили. На сервере вместо этого перезапускайте.
  • Наводите порядок в `cog_unload`. Запущенный когом tasks.loop продолжает работать после исчезновения кога, если вы не отмените его там.
  • Фоновым циклам нужен `before_loop`. Цикл, обращающийся к Discord, должен сначала дождаться готовности бота, иначе его первая итерация выполнится на клиенте без кэша.
python
from discord.ext import tasks@tasks.loop(minutes=15)async def sweep(self):    ...@sweep.before_loopasync def before_sweep(self):    await self.bot.wait_until_ready()

Слэш-команды и синхронизация дерева#

Команды приложения живут на стороне Discord. bot.tree хранит ваши локальные определения, а sync загружает их. Пока вы не выполните синхронизацию, ничего не происходит, и синхронизация - действие деплоя, а не запуска.

python
# Development: instant, one guildGUILD = discord.Object(id=123456789012345678)bot.tree.copy_global_to(guild=GUILD)await bot.tree.sync(guild=GUILD)# Production: global, propagates within about an hourawait bot.tree.sync()

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

Не вызывайте sync() в on_ready или setup_hook. Discord ограничивает число команд, которые можно создать за сутки, и бот, синхронизирующийся при каждом запуске и застрявший в цикле перезапусков, способен заблокировать себя до конца дня. Обычный приём - префиксная команда только для владельца, которая синхронизирует по требованию, так что деплой без изменений в командах ничего не стоит:

python
@bot.command()@commands.is_owner()async def sync(ctx):    synced = await bot.tree.sync()    await ctx.send(f"Synced {len(synced)} commands.")

Если синхронизация падает с ошибкой валидации, читайте путь к полю: имена должны быть в нижнем регистре, 1-32 символа, без пробелов, а описания - 1-100 символов. Гибридные команды - @commands.hybrid_command() - регистрируются и как префиксная, и как слэш-команда из одной функции, и это наименее болезненный способ поддержать оба варианта, не переписывая всё дважды.

Никогда не блокируйте цикл событий#

Это раздел, который важнее всего в продакшене, и именно его нет в большинстве руководств.

discord.py работает на asyncio. Один поток, один цикл, чередующий корутины. Каждый await - это точка, где цикл может заняться чем-то другим, в том числе отправить heartbeat, поддерживающий соединение с шлюзом. У синхронного вызова такой точки нет, поэтому пока он выполняется, не работает ничто другое - ни остальные команды, ни heartbeat.

Симптом - строка журнала от библиотеки:

code
WARNING discord.gateway Heartbeat blocked for more than 10 seconds.

За ней, если это тянется достаточно долго, следует закрытие сокета со стороны Discord и переподключение бота. Пользователи видят бота, который «случайно уходит в офлайн», на сервере, где график CPU выглядит нормально.

Обычные виновники и чем их заменять:

БлокирующееЧто использовать вместо этого
requests.get(...)aiohttp, он уже входит в зависимости
time.sleep(5)await asyncio.sleep(5)
open(...).read() для большого файлаawait asyncio.to_thread(...)
Синхронный драйвер базы данныхasyncpg, aiosqlite, motor
Обработка изображений, архивация, разборawait asyncio.to_thread(...)

asyncio.to_thread (Python 3.9 и новее) выполняет функцию в рабочем потоке и ждёт результат; это решение в одну строку для лёгкой по CPU, но медленной работы. По-настоящему тяжёлым вычислениям на боте не место вообще; отправьте их в очередь и отдельному воркеру, как в статье фоновые задачи на небольшом сервере.

Та же дисциплина относится и к лимитам запросов. discord.py сам обрабатывает ответы 429, дожидаясь retry_after, но цикл, редактирующий сообщение каждую секунду, или команда, отправляющая пятьдесят сообщений подряд, проведут большую часть времени в ожидании. Объединяйте запросы в пакеты где можно и никогда не ставьте вызов API внутрь плотного цикла по списку участников.

Запуск на хосте#

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

Переменные окружения размещаются на вкладке Startup, а не в репозитории: токен, URL базы данных, ID гильдии, в которую вы синхронизируете. Читайте их так, чтобы отсутствие приводило к жёсткой ошибке, потому что discord.LoginFailure через три секунды после запуска - куда худшее сообщение, чем «DISCORD_TOKEN is not set». Локально python-dotenv читает те же имена из файла .env, который прописан в .gitignore. Более подробно об этом - в статье где хранить секреты на сервере приложений.

На RE:NODE интеграция с Git работает только с GitHub, через GitHub App, выдающее короткоживущие токены, так что приватные репозитории работают без учётных данных, оставленных на сервере. Управляют ею два переключателя: забирать ветку при каждом запуске и деплоить при push, что перезапускает сервер, когда GitHub сообщает о push в эту ветку, - и только если он уже был запущен. Каждый деплой - одна запись, так что вы отличите выкатившийся деплой от упавшего. Тот же процесс для Node пройден в статье деплой приложения Node.js с GitHub и для Python работает точно так же.

Для журналирования вызовите discord.utils.setup_logging() или настройте logging сами, а если настраиваете сами, передайте log_handler=None в bot.run, иначе библиотека установит второй обработчик и каждая строка появится дважды. Пишите журнал в stdout, чтобы консоль показывала его вживую; файл журнала внутри контейнера - это файл, за которым придётся идти по SFTP, а если его никто не ротирует, он заполнит диск за несколько месяцев. О том, что стоит писать, рассказано в статье журналы, которые стоит хранить.

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

Шардирование и когда оно нужно#

Discord требует шардировать бота, у которого больше 2 500 гильдий. Ниже этого порога шардирование - решение проблемы, которой у вас нет.

Когда вы дойдёте до этого, discord.py делает всё просто: commands.AutoShardedBot заменяет commands.Bot и запускает все шарды в одном цикле событий в одном процессе. У шлюза спрашивают, сколько шардов ему нужно, кэши общие, а память растёт вместе с объёмом данных, а не с числом шардов. Это существенно другая ситуация, чем в discord.js, который запускает по процессу на шард и умножает на это число базовую память.

python
bot = commands.AutoShardedBot(command_prefix="!", intents=intents)

Что действительно меняется - одна заблокированная корутина теперь влияет на каждый шард, поэтому дисциплина цикла событий из предыдущего раздела перестаёт быть советом и становится требованием. Крупные боты к тому же получают от шлюза больший max_concurrency, который позволяет нескольким шардам идентифицироваться одновременно и делает полный перезапуск заметно быстрее. Что вам положено, проверьте авторизованным запросом GET /gateway/bot, а не гадайте.

Диагностика#

`ModuleNotFoundError` после добавления библиотеки. Она установлена локально и отсутствует в requirements.txt. Сервер ставит только то, что указано в файле.

`PrivilegedIntentsRequired` при запуске. Привилегированный intent есть в коде, но не включён в Developer Portal.

`LoginFailure: Improper token has been passed`. Пустой или неверный DISCORD_TOKEN, сброшенный токен, который так и не выкатили заново, либо вместо токена бота вставлен client secret.

Префиксные команды ничего не делают, слэш-команды работают. Дело в intent message_content. Бот получает событие с пустым содержимым.

`CommandNotFound` для существующей слэш-команды. Её так и не синхронизировали, либо синхронизировали в другую гильдию, либо глобальная синхронизация ещё не распространилась.

`AttributeError: 'Bot' object has no attribute 'slash_command'`. Код py-cord запущен на discord.py. Выберите одну библиотеку.

Бот перестаёт отвечать на минуту. Заблокированный цикл событий. Найдите в журнале предупреждение о heartbeat и посмотрите, что выполнялось непосредственно перед ним.

`RuntimeError: Event loop is closed` при завершении в Windows. Досадная особенность локальной разработки, связанная с proactor-циклом событий; в Linux-контейнере, где бот хостится, этого не бывает.

FAQ#

Что выбрать: discord.py или py-cord?

discord.py снова активно поддерживается, и именно на него ссылается большая часть актуальной документации и ответов, так что это более безопасный вариант по умолчанию. py-cord - разумный выбор, если вам нравится его синтаксис слэш-команд. Выбором не является смешивание: они устанавливаются под одним именем импорта и ломают друг друга.

Нужно ли виртуальное окружение на стороне хостинга?

На управляемом сервере приложений каждый контейнер и так изолирует ваши зависимости, поэтому виртуальное окружение внутри него добавляет каталог и почти ничего больше. Используйте его локально, где несколько проектов делят один интерпретатор, и фиксируйте requirements.txt, чтобы оба окружения ставили одно и то же.

Где хранить токен бота?

В переменной окружения на вкладке Startup, читая её через os.environ["DISCORD_TOKEN"]. Не в коде, не в закоммиченном .env, не в файле конфигурации в репозитории. Если он уже попал в push, сбросьте его в Developer Portal: единственное решение - смена токена, потому что история git хранит старое значение.

Почему бот замедляется, когда им пользуется больше людей?

Обычно из-за одного блокирующего вызова в популярной команде. Каждый пользователь, ожидающий этот вызов, ждёт один и тот же цикл событий. Найдите синхронный вызов библиотеки, перенесите его в asyncio.to_thread или асинхронный клиент, и проблема исчезнет без добавления CPU.

Можно ли запускать бота и небольшую веб-панель на одном сервере?

Да, если держать их в одном процессе с асинхронным веб-фреймворком или смириться с тем, что два процесса делят один лимит памяти и одну долю CPU. Отдельный небольшой тариф сохраняет графики читаемыми и не даёт упавшей панели утянуть бота. По веб-части подробности есть в статье деплой FastAPI или Flask.

Как понять, что бот действительно работает?

У бота на шлюзе нет адреса, который можно опрашивать, поэтому читайте консоль: она показывает вывод вживую без фильтров и графики памяти и CPU относительно лимитов. Пишите одну строку при готовности, с числом гильдий, и одну строку при ошибке команды. Как отличить шум при запуске от настоящей неисправности, рассказано в статье как читать консоль сервера, не гадая.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000