Пишем онлайн-тренажёр для Python, C++ и Go: опыт Практикума
Привет! Меня зовут Павел Свиридов, я руководитель группы разработки в Яндекс Практикуме. Сегодня я вместе со своим коллегой, разработчиком Владимиром Лукьяновым, хочу рассказать о том, как наша команда развивала онлайн-тренажёры в вебе — это такие маленькие среды разработки, встроенные в курсы Практикума. Основное внимание уделю тренажёрам Python и С++, а о других языках скажу пару слов в самом конце и покажу на примере, как создать тренажёр для языка Go.
Но сначала обсудим, для чего вообще нам понаобился тренажёр внутри образовательной платформы? Ведь студенты могут установить, например, Python себе на компьютер и выполнять задания локально. Могут, но такой подход не решит несколько важных задач и создаст несколько серьёзных проблем.
Первая наша задача — максимально снизить порог входа для студентов. Курсы Практикума проходят даже те, кто не имел никакого отношения к разработке. Сразу погружать новичков в работу с полноценными IDE — идея не самая гуманная.
Вторая задача — обеспечить доступность курсов на разных устройствах. Так студенты могут читать и решать задания в любой удобный момент. Поэтому всё окружение должно быть развернуто у нас, чтобы учащиеся использовали свои «тонкие» клиенты.
Третья задача отчасти связана с первыми двумя — нам необходима одинаковая среда для запуска заданий. Даже если студент, который видит код второй-третий раз в жизни, установит себе IDE, не факт, что у него сразу получится настроить окружение как надо. Скорее всего, у него будут не те зависимости (своя ОС или версия интерпретатора) икакое-то своё окружение, что может привести к возникновению кастомных ошибок.
Опыт показал, что у кураторов и наставников не хватает времени на то, чтобы массово бороться с проблемами, возникающими в локальных окружениях. Облачный тренажёр позволяет не тратить время сотрудников Практикума и студентов на борьбу с такими ошибками, и в целом облегчает жизнь всем участникам образовательного процесса.
И, наконец, четвёртая не самая очевидная задача, которую мы решаем, создавая свой онлайн-тренажёр — дать студентам возможности, недоступные локально. К примеру, для обучения нейросети в курсе для специалистов по Data Science мы предоставляем машину с GPU, так что процесс, который мог бы занимать сутки и более, сокращается до нескольких часов.
Первый подход к снаряду: пробуем опенсорсное решение
Сначала мы решили не изобретать велосипед, а взяли в качестве тренажёра по Python опенсорсную библиотеку, которую написали Stepic. Она называется Epic Box. Запускать её предстояло на своих серверах, поэтому мы завернули библиотеку в сервис и раскатили у себя. Успешно пользовались ей полтора года — на старте проекта Epic Box нам очень помогла. Библиотека стабильно работала на небольшом количестве запросов, но не подходила для решения всех наших задач, например, для языка C++, где многие задания были интерактивными.
Также обнаружилась ещё одна проблема — задержки при запуске. В Epic Box код студента изолирован в докер-контейнере, и когда требуется его запустить, система поднимает контейнер, распаковывает, выполняет код и возвращает результат обратно. Таким образом, подготовка контейнера для выполнения задания студента начинается только после того, как студент нажимает кнопочку «выполнить» или «проверить».
Практика показала, что накладные расходы на выполнение всех этих действий достаточно высоки. Это минимум плюс 1–2 секунды, а в некоторых случаях и 3–4. Казалось бы, задержки небольшие, но они очень раздражали студентов, потому что случались каждый раз после правки одной буквы или строки. Особенно мешали задержки не в тех сценариях, где учащиеся решают объёмные задания в отдельном окне, а в сниппетах: у нас есть уроки, где после куска теории идут небольшие окошки, в которых можно быстро посмотреть, как работает та или иная функция на маленьком кусочке кода.
Как это выглядит в интерфейсе урока
Поэтому параллельно внутри формировалось собственное решение. Его функциональность отличалась от того, что было в Epic Box, где студент отправляет код в тренажер, код выполняется, а затем обратно передается результат. Как я уже упоминал, в курсе по C++ нужно было реализовать более сложную схему с интерактивными задачами: после отправки кода на выполнение студенты должны иметь возможность взаимодействовать с контейнером, т.е. контейнер не включается после выполнения задания, а остаётся запущенным в ожидании команд или инпута от студента, отвечает на ввод и выключается только после бездействия или перехода к другому заданию.
Переносим опыт с самописным тренажёром C++ на Python
Архитектура второй версии тренажёра подразумевала постоянное соединение по веб-сокету между клиентом и его докер-контейнером. То есть в тот момент, когда студент заходит на страницу и начинает читать теорию или текст задания, мы уже поднимаем ему контейнер, с которым устанавливается соединение. И как только студент что-то пишет и нажимает кнопку «отправить», докер уже поднят, окружение готово, и результат возвращается очень быстро.
Тренажёр C++ на такой архитектуре работал отлично, всем всё нравилось. Но этому языку учится в разы меньше студентов, чем Python. Дело в том, что Python используется не в одной профессии, а во многих: его учат дата-аналитики, специалисты по Data Science, дата-инженеры и Python-разработчики. Из-за этого нагрузка на систему кратно возрастает. Когда на платформе одновременно работает 1000 студентов, у каждого открыто своё задание, для каждого нужно поднять докер-контейнер и держать его наготове. При этом 90–99% времени контейнер простаивает, и мы только впустую греем воздух в дата-центрах и платим за это.
Здесь нужно отметить, что все запуски студенческого кода происходят вне контура Яндекса из соображений безопасности. Это изолированная среда в облаке, у которой нет доступа к пользовательским данным. В этом смысле мы работаем с Yandex Cloud как любой другой внешний заказчик. Расходы при использовании самописного решения в Python-тренажёре получались большими, хотелось их сократить.
Кроме того, из-за огромного количества виртуальных машин, которые постоянно запускались и гасли, оркестрация потихоньку сходила с ума. Swarm не справлялся и переставал работать адекватно. Студентам вылетали ошибки при запуске, реанимировать такие кластеры было невозможно, оставалась только жёсткая перезагрузка. При этом Docker Swarm считается уже устаревшей технологией, ему на смену пришёл Kubernetes. Мы проконсультировались с разными внешними экспертами по Swarm, и все отвечали нам одинаково — лучше переезжайте.
Переходим на serverless
Мы стали думать, что делать с нашей системой и обратили внимание, что в Yandex Cloud появился сервис Serverless Containers. Он умеет делать примерно то же самое, что и Epic Box, но с тем отличием, что Yandex Cloud полностью забирает на себя оркестрацию. Так появилась идея третьей версии тренажёра. Вместо поддержки собственного кластера мы делегировали работу Облаку, которое автоматически масштабировало ресурсы под загрузку.
Облако держит 1–2 контейнера заранее поднятыми (горячими), а при возрастании нагрузки поднимает дополнительные контейнеры. Задания, идущие в «горячий» контейнер, выполняются быстро, идущие в «холодный» — с задержкой, но «разогрев» (поднятие дополнительного контейнера) происходит постепенно и 75–80% заданий попадают на «горячие» контейнеры. При уменьшении нагрузки Облако останавливает лишние контейнеры.
Благодаря такому подходу при стабильной загрузке пользователи получают ответ за 300 миллисекунд, если речь идёт о каких-то нетяжёлых операциях. Это несколько медленнее, чем было в самописном решении, но всё ещё достаточно быстро, чтобы не создавать сложностей студентам.
Протестировав решение коллег из Yandex Cloud, мы собрали образ для Serverless Containers, запустили отдельное облако и переписали клиентский код так, чтобы он отправлял задания по новому адресу. Проблемы с падениями удалось решить полностью — мы получили очень стабильный и быстрый тренажёр, а стоимость содержания инфраструктуры сократилась в несколько раз.
Результаты и перспективы
Когда мы реализовали эту историю с Python, к нам пришли другие факультеты, которые тоже захотели перейти на serverless. Для таких случаев был разработан пайплайн: мы предоставляем коллегам репозиторий, они сами пишут для него docker-файл, затем мы подключаем CI, из которого собирается докер-образ и сразу пушится в Облако. Так можно получить из коробки тренажёр на любом языке, с какими угодно зависимостями и библиотеками. Всё очень просто! Единственное ограничение — отсутствие пользовательского ввода, но даже факультет C++ хочет использовать serverless-подход в сниппетах, где input/output не требуется. В качестве примера давайте сделаем свой простой Golang-тренажёр на технологии Serverless Containers.
Создадим новый Dockerfile:
FROM python:3.9-slim
# Создаём отдельного пользователя для запуска кода
RUN useradd -rm -d /home/student -s /bin/bash -u 1001 student
# Установим Golang, ведь нам предстоит компилировать и запускать код на Go!
RUN apt-get update && \
apt-get install -y -q --no-install-recommends golang && \
rm -rf /var/lib/apt/lists/
RUN pip install --no-cache-dir --upgrade pip
WORKDIR /agent
COPY launch.py /agent/launch.py
Теперь нужно создать простой лаунчер — так мы называем веб-сервер, который принимает запрос по HTTP на выполнение пользовательского кода и возвращает результат. Он будет иметь всего один endpoint /run/
.
import asyncio
import logging
import os
import subprocess
from types import SimpleNamespace
from typing import List
from aiohttp import web
from serverhub_agent.utils.filesystem import TempFileManager
AGENT_PORT = os.getenv("PORT")
TIMEOUT = int(os.getenv("TIMEOUT"))
TESTS_PATH = "/home/student/tests/"
async def run(request: web.Request) -> web.Response:
body = await request.json()
files = [
SimpleNamespace(name=f["name"], content=f["content"]) for f in body["files"]
]
timeout = False
stdout = b""
stderr = b""
return_code = 1
oom_killed = False
async with run_lock:
with TempFileManager(directory=TESTS_PATH, files=files) as manager:
try:
proc = subprocess.run(
(
f"chown -R student {manager.directory} "
f"&& chown -R student /tmp/ "
f"&& chown -R student /home/student/ "
f"&& su - student -c \"cd {manager.directory} && {body['command']}\" "
),
capture_output=True,
timeout=TIMEOUT,
shell=True,
)
stdout = proc.stdout
stderr = proc.stderr
return_code = proc.returncode
except subprocess.TimeoutExpired:
timeout = True
result = {
"exit_code": return_code,
"stdout": stdout.decode(),
"stderr": stderr.decode(),
"oom_killed": oom_killed,
"timeout": timeout,
"duration": 0,
}
return web.json_response(result)
def setup_routes(app: web.Application) -> None:
app.router.add_post("/run/", run)
app = web.Application()
run_lock = asyncio.Lock()
setup_routes(app)
logging.basicConfig(level=logging.DEBUG)
web.run_app(
app,
host="0.0.0.0",
port=AGENT_PORT,
)
Структура запроса /run/
будет такой:
{
"command": "my command",
"files": [
{"name": "blah", "content": "balh"},
{"name": "blah2", "content": "balh2"},
...
]
}
Теперь можно собрать и запустить локально докер-контейнер.
docker build -f Dockerfile -t local/serverless-golang:latest ./
docker run -p 3000:3000 --rm local/serverless-golang:latest python3 /agent/launch.py
Проверим, что наш лаунчер работает.
curl --location --request POST 'http://localhost:9999/run/' \
--header 'Content-Type: application/json' \
--data-raw '{
"command": "go run main.go",
"files": [
{
"name": "main.go",
"content": "package main\nimport \"fmt\"\nfunc main() {fmt.Println(\"Hello 世界!!!\")}"
}
]
}'
Получаем в ответ результат.
{
"exit_code": 0,
"stdout": "Hello 世界!!!\n",
"stderr": "",
"oom_killed": false,
"timeout": false,
"duration": 0
}
Отлично, лаунчер работает. Теперь соберём докер-образ и загрузим его в Container Registry в Yandex Cloud.
docker build cr.yandex/crp1gs30bam49ookc3cc/serverless-golang-testing:latest .
docker push cr.yandex/crp1gs30bam49ookc3cc/serverless-golang-testing:latest
В интерфейсе Облака создадим Serverless Container, как показано на скриншотах ниже.
После создания контейнера Облако вернёт нам ссылку, по которой можно к нему обратиться. Например, https://bbaboup6o35p650rctf1.containers.yandexcloud.net/
.
Попробуем повторить тестовый запрос, но добавим авторизацию.
curl --location --request POST 'https://bbaboup6o35p650rctf1.containers.yandexcloud.net/run/' \
--header 'Content-Type: application/json' \
--header 'Authorization: Api-Key ...' \
--data-raw '{
"command": "go run main.go",
"files": [
{
"name": "main.go",
"content": "package main\nimport \"fmt\"\nfunc main() {fmt.Println(\"Hello 世界!!!\")}"
}
]
}'
Получаем тот же результат, что и при локальном запуске.
{
"exit_code": 0,
"stdout": "Hello 世界!!!\n",
"stderr": "",
"oom_killed": false,
"timeout": false,
"duration": 0
}
У бэкенда практикума уже написан код интеграции с Serverless Container, поэтому всё, что нам осталось сделать — это создать новый конфиг для урока и привязать его к конкретному заданию.
In [10]: task.meta["coderun_config"] = {'cpu': 1,
...: 'memory': 1024,
...: 'command': 'go run main.go',
...: 'timeout': 10,
...: 'serverless': True,
...: 'docker_image': {'tags': ['latest'],
...: 'full_name': 'https://bbaboup6o35p650rctf1.containers.yandexcloud.net/'}}
In [11]: task.save()
Готово! Можем запускать код на Go в интерфейсе тренажера Практикума.
Вот и всё. Надеюсь, вам было интересно, а также удалось почерпнуть из статьи что-то новое и полезное. Делитесь мыслями в комментариях — мы обязательно ответим.