Проблема любого онлайн-движка исполнения кода в том, что он должен принять чужой код и выполнить его как есть — а этот код может оказаться вредоносным. Поэтому нужно спроектировать всё так, чтобы при запуске кода мы могли свести к минимуму негативные последствия, которые «враждебный» код может вызвать. Например, если кто-то попытается вызвать команду вроде rm -rf /, то это не удалит нам всю систему.

Привет, меня зовут Макс Мартынов, я ведущий разработчик в Атвинте. Расскажу, как мы реализовали такую песочницу для образовательной платформы, чтобы ученики писали код прямо в браузере, запускали его и видели свои результаты.
Содержание
Задача от бизнеса
Пару лет назад онлайн-школа по подготовке школьников к ЕГЭ обратилась к нам с запросом добавить на платформу новую дисциплину — информатику.
Помимо уже существующих механик обучения требовалось добавить новый тип домашних заданий — написание кода на Python. Нужна была не просто подсветка синтаксиса, а возможность выполнить написанный код и сразу же увидеть результат. Ученики тренируются в том же формате, в котором будут сдавать экзамен: ЕГЭ по информатике с 2021 года проходит на компьютере, а часть задач там решается написанием и запуском программы — чаще всего как раз на Python. Ещё нужна была возможность создавать задания, в которых код получает данные на вход и/или читает их из прикреплённого файла, как в экзаменационных задачах.
Также требовалось придумать механизм, который облегчит наставникам проверку домашних заданий. Для этого нужно было предусмотреть возможность создания тест-кейсов, в которых код ученика получает данные, отличающиеся от тех, что были в задании. При этом вывод сравнивается с ожидаемым.
Попытка изобрести велосипед
Поначалу выбор казался очевидным: использовать Docker. С точки зрения безопасности Docker подходит, потому что содержимое Docker-контейнера перестаёт существовать после его остановки и удаления. Это означает, что даже если кто-то выполнит rm -rf, то это произойдёт только внутри, а снаружи ничего не изменится, будто ничего и не произошло. Кроме того, с Docker мы можем задавать лимиты ресурсов, чтобы отдельный контейнер не мог потреблять слишком много.
Но есть нюансы:
Во-первых, если для обычного использования Docker — то есть развёртывания сервисов на серверах — его скорость более чем достаточна, то в нашем сценарии создание контейнера добавляло секунды к выполнению.
Во-вторых, демон Docker работает синхронно, то есть, если отправить несколько запросов на запуск контейнеров, они будут выполняться один за другим. В итоге чем больше пользователей одновременно пытаются выполнить свой код, тем дольше будет ждать каждый следующий из них.
А так как все сервисы нашей платформы мы размещаем и оркестрируем в Docker-контейнерах, мы поэкспериментировали с подходом Docker-In-Docker. Задумка была в том, чтобы иметь один Docker-контейнер, внутри которого наш сервис запускает отдельные Docker-контейнеры для выполнения кода. Но, во-первых, это никак не решало проблему времени старта контейнера. А во-вторых, вынуждало выдать дополнительные привилегии основному контейнеру.
Готовое решение
В один из дней нам попалось видео «How I Built The Internet's Best Performing Code Execution Engine» от сообщества Engineer Man. Оказалось, что его авторы тоже ходили по граблям запуска задач в Docker-контейнерах и использования Docker-In-Docker — и в итоге отказались и от первого, и от второго в пользу Linux containers (LXC). В первую очередь им была важна безопасность, поэтому они с самого начала буквально призывали всех сломать их движок любыми доступными способами: исчерпать память, занять всё дисковое пространство, устроить fork bomb — в общем, делать что угодно. Так как это поощрялось, пользователи снова и снова всё ломали, а авторы, благодаря этому, вскрывали слабые места, устраняли их и со временем добились состояния, когда сломать что-либо стало почти невозможным.
В итоге получился действительно крутой движок исполнения кода под названием Piston. Исходный код полностью доступен публично, а развернуть его у себя можно из готового Docker-образа. В видео автор разобрал, как система была устроена на тот момент:
сам по себе LXC, даже без какого-либо дополнительного усиления защиты, уже даёт высокий уровень безопасности просто потому, что это полностью отдельная среда по отношению к системе, на которой он запущен;
все операции выполняются от имени непривилегированных пользователей. Их в системе 150 — runner1, runner2, runner3 и так далее вплоть до runner150. На каждую новую задачу используется следующий пользователь, а после runner150 очередь возвращается к runner1;
у каждого runner-пользователя свои лимиты ресурсов — каждое выполнение кода может получить собственный набор ограничений;
максимальное число процессов — 64. Это защищает от таких вещей, как fork bomb, которые без такого лимита почти гарантированно привели бы к падению системы;
максимальное число открытых файлов — 2048. Если процесс попытается открыть десятки тысяч файловых дескрипторов и тем самым исчерпать все ресурсы системы, у него это не получится;
для runner-пользователя все ресурсы файловой системы смонтированы в режиме «только для чтения», за исключением файлов в директории /tmp, которые этот пользователь создал сам. Вредительские команды вроде rm -rf --no-preserve-root / тут не сработают;
исходящее сетевое взаимодействие отключено. В строгом смысле это защита не столько от взлома, сколько от нежелательного использования — чтобы сервис не стал частью ботнета;
можно задать лимиты на время компиляции и время исполнения;
стандартный вывод ограничен 65 536 символами. Это гарантирует, что процесс не сможет израсходовать все ресурсы, если будет бесконечно печатать данные;
после завершения задачи выполняются намеренно избыточные меры очистки. Такие сервисы часто ломают именно мусором: забивают дисковое пространство и память остатками прошлых запусков. Во-первых, всем процессам, работающим от имени runner-пользователя, посылается SIGKILL. Никакого SIGTERM или попыток мягкой остановки. Так как о речь идёт о процессах, которые не модифицируют файлы — риска повреждения данных нет. Вызов команды kill -9 немедленно снимет их из таблицы процессов. Во-вторых, удаляются все файлы, принадлежащие этому пользователю.
С тех пор движок заметно эволюционировал: осенью 2024 года авторы перевели изоляцию с LXC на isolate — песочницу на основе Linux namespaces, chroot и cgroups, выросшую из олимпиадного программирования. Поменялись и дефолтные лимиты: например, стандартный вывод теперь обрезается уже на 1024 символах. Мы разворачивали одну из первых версий, а лимиты корректировали по ходу эксплуатации.
С февраля 2026 года публичный API Piston закрыт для свободного использования — ключи выдают в основном некоммерческим и образовательным проектам. Так что развернуть свой инстанс — уже единственный надёжный вариант для продукта.
Кастомизация и снижение зависимости от исходника
В эпоху санкций, блокировок и прочих непростых отношений между российским и зарубежным сегментами интернета сборку образа и его выполнение нужно было максимально изолировать, чтобы всё необходимое для работы движка было на нашей стороне и не зависело от решений его авторов. Благо в случае с Piston это осуществимо без вмешательства в исходный код.
Чтобы не зависеть от исходного образа, мы собираем и храним его у себя, то есть все его слои хранятся в нашем реестре контейнеров.
Также нам было необходимо ограничить функционал только языком Python. Для этого в образе через переменную среды PISTON_REPO_URL изменён путь до хранилища пакетов, языков программирования и сред выполнения. Нужные пакеты хранятся в нашем хранилище. Это сделало невозможной установку через API других пакетов и сред, отличных от тех, что нам нужны, а нужен нам только Python.
Вообще Piston очень гибок: многие настройки и лимиты задаются через переменные среды. Но нам потребовалось увеличить лимит на размер тела запроса, так как файлы, относящиеся к заданию, мы тоже передаём в запросе. А вот для этого переменную авторы не предусмотрели: body-parser создаётся с дефолтными значениями. Поэтому мы внесли правки в index.js и теперь устанавливаем лимит для парсера json-тела запроса равным переменной MAX_FILE_SIZE.
Так как наша версия Piston использует LXC, мы вынуждены выполнять его в Docker-контейнере с повышенными привилегиями. Поэтому на окружениях мы не стали помещать его в общую Docker-сеть с другими элементами платформы, а разместили на отдельном хосте (VPS), доступном только внутри локальной сети. Другие элементы системы отправляют запросы на порт этого хоста. Docker-контейнер запускается с нужными лимитами и healthcheck’ом, чтобы в случае сбоя оркестратор мог остановить и перезапустить его.
На этом с серверной частью закончили, перейдём к самой платформе.
Логика внутри платформы
Для интеграции на бэкенде был написан простой Bridge-класс, в который инкапсулирована логика взаимодействия с API Piston. При инициализации экземпляра класса мы через DI складываем в него конфиг — хост, порт, лимиты, каналы логирования и тому подобное. Тут всё как у всех (у всех же так, да??). Но есть парочка хитростей.
Во-первых, чтобы слои в реестре контейнеров весили меньше, собранный образ не содержит в себе пакеты и среды выполнения. Это значит, что свежезапущенный контейнер не сможет выполнить Python-код, так как среды Python в нём нет. В Piston среды устанавливаются через API. Поэтому перед отправкой кода на выполнение мы должны убедиться GET-запросом на эндпойнт runtimes, что нужная среда существует, и если её нет, то создать её POST-запросом на эндпойнт packages. И только после этого можем отправить код пользователя на исполнение.
Во-вторых, заказчик просил ограничить импорты, которые ученики могут использовать в коде. Для этого мы написали небольшой Python-скрипт, который переопределяет функцию builtins.__import__ и бросает ошибку при попытке импорта модуля целиком или его функции, которых нет в списке разрешённых. Отправляем этот файл в каждом запросе на исполнение, а в код ученика первой строкой добавляем импорт нашего файла-модуля, и благодаря этому все дальнейшие импорты уже выполняются через нашу функцию. Это учебное ограничение, а не защита. Такая фильтрация нужна, чтобы ученик решал задачу сам и без вызова готовой библиотеки.
Что касается бизнес-логики самих заданий — тут всё проще и понятнее. В сервисном слое основного бэкенда добавили класс-сервис, который получает код, подтягивает из задания входные данные и прикреплённые файлы (если они есть), отдаёт это экземпляру Bridge-класса и возвращает DTO с результатами.

Тест-кейсы для облегчения проверки наставником сделаны сущностями, принадлежащими заданию. Для каждого можно указать свои входные данные, прикрепить файлы и задать ожидаемый результат. Метод выполнения тест-кейса в классе-сервисе производит:
подмену входных данных и файлов задания на указанные в тест-кейсе;
вызов исполнения кода экземпляром Bridge-класса;
удаление пробелов и переносов строк в начале и в конце вывода и ожидаемого результата;
сравнение вывода с ожидаемым результатом;
возврат DTO с флагом успешности, фактическим и ожидаемым выводами.

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

economist75
07.09.2026 04:38В схожей ситуации просто подняли JupyterHub на сервере Linux. Плюсы:
десятки языков программирования (Python, R, SQL, bash итд), единый uv для всего змеиного, питоновские либы (а их в среднем 300 на юзера) - это просто хардлинки на единственную копию файла из /var. Пользователей около 100, но есть чужие примеры по десятку тысяч (скажем, целый вуз), с неск. сотнями одновременно активных
авторизации LDAP, github из коробки, в хомяке у каждого своя папка, но есть и Shared
shared readonly-venv, простая возможность запуска чужой среды с чужим блокнотом (нет больше нигде) через использование RO-kernel
одновременная правка (мультикурсоры) групповых блокнотов, как в GoogleDocs, но для выполнимого кода (нет больше нигде)
мониторинг, изоляция и авто-закрытие дохлых вычислений (kernel)
удобная web-IDE с автопереводом выделенного текста (расширеие браузера) и плюс VSCode как промстандарт. Но .ipynb в .gitignore, потому что блокноты авто-зеркалятся расширением jupytext в простые .py файлы (их как раз и ест Git).
Xak-Tak
Хороший разбор, особенно понравился момент с переходом от Docker к LXC и дальше к isolate. На таких задачах хорошо видно, что запустить пользовательский код — это далеко не то же самое, что безопасно его запустить. Лимиты на процессы, память, файловые дескрипторы, вывод и сеть здесь выглядят вполне логично. Отдельно полезно, что автор показал не только архитектуру, но и реальные компромиссы при внедрении — это делает статью гораздо практичнее.