Привет, это снова Павел Тыквин, разработчик Яндекс.Контеста. Контест больше всего известен как площадка для соревнований по программированию: прямо сейчас идёт квалификационный этап чемпионата Yandex Cup. Я уже писал на Хабре о том, как мы решаем одну из стоящих перед нами проблем: выравниваем время исполнения кода. Ну а в этой статье я приоткрою детали процесса проверки, расскажу, через какие этапы проходит код участников и какими методами мы оптимизируем этот процесс, а также — как мы добавили возможность решать задачи на том языке, с которым участник уже знаком (вне зависимости от способов тестирования внутри платформы).
Возьмём для примера простейшую задачу: вам заданы два целых числа a и b, выведите a+b.
Допустим, решение будет таким:
Как можно проверить это решение вручную без участия дополнительных сервисов и библиотек? Например, так:
Компиляция
Тестирование
Даже в ручном режиме необходимо скомпилировать решение:
И протестировать его:
![](https://habrastorage.org/r/w780q1/webt/ny/y8/d9/nyy8d9nkvpia1kwo1ta4lddzluc.jpeg)
Обратите внимание, что в фазе тестирования необходимо запустить и решение участника, и чекер. Взглянем на вариант с задачей посложнее.
Напишите программу, которая угадывает задуманное целое число от 1 до 1000. Тестирующая система загадывает число и не сообщает программе. Угадать число нужно не более чем за 10 попыток. На каждую попытку пользователь отвечает, что загаданное число больше названного (вводит символ «>»), меньше названного («<») или угадано правильно («=»).
Решение:
В этом примере есть некоторое взаимодействие между решением участника и тестирующей программой (интерактором). Такие задачи называются интерактивными — во время тестирования решения нужно будет запускать и решение участника, и интерактор.
Визуализация полного процесса проверки:
![](https://habrastorage.org/r/w780q1/webt/fh/ml/qx/fhmlqxrcvv8acvh_-poht2lforw.jpeg)
Все этапы выполняются на специально настроенных виртуалках. Виртуалка должна быть подготовлена к их выполнению. Задача команды инфраструктуры — настроить механизм подготовки виртуальной машины, чтобы она в нужный момент исполнила решение.
Что нужно учитывать? Где-то есть участник, который только что отправил свою посылку на проверку. И нам нужно уменьшить оверхед на подготовку окружения, в идеале до нуля. При этом окружения под некоторые задачи могут занимать более 10 Гб. А значит всё, что нужно для выполнения посылки, уже должно быть на виртуалке. В противном случае к времени ожидания участника добавится ещё несколько минут, а в плохих случаях — и десятков минут. Когда речь идёт о соревновании с ограниченным временем (а это большинство наших соревнований), такие задержки становятся критичными. Нам важно обеспечить участникам равные и разумные условия.
Когда-то мы решали эту задачу достаточно наивно: давайте просто поставим на виртуалку все нужные компиляторы с нужными библиотеками нужных версий и будем это поддерживать. Долго сервис шёл именно по такому пути — в то время у нас было около 40 решающих машин, а участникам было доступно всего лишь несколько самых популярных языков программирования. У этого способа есть неоспоримый плюс — время на подготовку окружения равно нулю, ведь все нужные пакеты уже и так стоят на решающей машине.
Однако у такого подхода есть неизбежная проблема роста. На платформе растёт количество соревнований, например, за 2020 год создано и проведено около 5000. Это почти вдвое больше, чем в 2019-м. Кроме того, мы хотим дать возможность новым участникам решать задачи на тех языках, к которым они привыкли. Нам важно, чтобы в соревнованиях могли участвовать все разработчики.
Немного цифр: сейчас у нас почти 600 виртуалок, которые занимаются проверкой решений, мы поддерживаем 22 «языка» в 124 версиях и наборах библиотек.
Добавление нового компилятора или обновление существующего было одной из наших регулярных задач. Проще говоря, у нас был зоопарк окружений, которые постоянно добавлялись и обновлялись, и нам нужно было поддерживать его на 600 машинах так, чтобы они не конфликтовали друг с другом. Конечно же, в определённый момент мы отказались от этого подхода и пошли в сторону контейнеризации — в сторону Docker.
Идея проста: давайте собирать наши окружения в docker-образы и выкачивать на решающую машинку нужные во время проверки. По одному образу на каждый поддерживаемый язык программирования. Так мы получим удобный инструмент для добавления новых компиляторов и обновления старых из коробки, интерфейс для загрузки окружений и переиспользование уже скачанных слоёв в нескольких образах.
Но давайте вспомним, что во время проверки решения нам на самом деле нужно запустить несколько программ. Решение участника, чекер и в некоторых случаях интерактор. И всё это может быть написано на разных языках с использованием разных компиляторов и библиотек.
Значит, нам нужно уметь комбинировать несколько докерных образов в один и получать окружение для запуска любой комбинации компиляторов. Например: участник написал решения на Java, а чекер написан на Python 3.9. Соответственно, в окружении должна быть и JDK, и Python 3.9 с нужными библиотеками. И это просто пример. Комбинации могут быть любыми. Получается, нам нужно как-то объединить содержимое нескольких докерных образов.
Как известно, докер-образ состоит из множества слоёв (каждый из которых представляет собой дельту изменений файловой системы) и манифеста, который описывает порядок этих слоёв. Для работы с докером есть хороший API. С его помощью можно получить и манифест, и слои образа.
Отсюда нам нужно получить две важные вещи: хэши и порядок слоёв образа из fsLayers, а ещё — переменные окружения из Env. И на самом деле это всё, что нам нужно от докера для подготовки окружений. Мы сами выкачиваем из docker-registry слои образов и метаинформацию об их последовательности. Слои — по сути обычные тарники.
Мы распаковываем слои и раскладываем их в нужные директории на решающей машинке. Затем мёрджим две последовательности слоёв. Чаще всего у двух компиляторов так или иначе есть общий корень, который мы переиспользуем.
Этапы подготовки окружения:
Монтаж множества директорий в одну мы делаем примерно так же, как это делает докер — с помощью OverlayFS.
OverlayFS позволяет накладывать одно дерево каталогов (обычно доступное в режиме «чтение-запись») на другое, но с доступом только для чтения. Все изменения переходят на верхний слой с возможностью записи. © ArchWiki
Звучит круто! Давайте отвлечёмся от докера и попробуем вручную смонтировать OverlayFS.
Подготовим каталоги:
содержимое lower и upper будем объединять в merged. Workdir — служебный каталог, в котором каталоги объединятся перед атомарным перемещением в merged.
Смонтируем upper и lower в merged:
mount -t overlay -o lowerdir=./lower,upperdir=./upper,workdir=./workdir overlay ./merged
И получим в каталоге merged вот в таком виде:
файл file.a был взят из каталога upper.
Во время подготовки контейнера докер делает примерно то же самое. Это даже можно проверить с помощью команды docker inspect.
Запустим какой-нибудь докер-контейнер и выполним docker inspect {container_id}
В секции GraphDriver увидим уже знакомые нам термины:
Допустим, у нас есть задача, в которой для запуска чекера используется Python 3.9, а для запуска решения участника — Java 11.
Слои докерных образов для Java 11 и Python 3.9:
Обратите внимание — эти образы достаточно близки, и мы можем переиспользовать бóльшую часть их слоёв.
Все популярные слои и компиляторы скачаны заранее на решающие машины, то есть у нас нет оверхеда на их загрузку. Кроме того, при необходимости мы можем прогреть виртуалки нужными компиляторами заранее. Мы можем поддерживать неограниченное количество языков программирования и их версий, на которых участники могут решать задачи.
В этом случае добавление нового компилятора — достаточно простая задача, которая сводится к сборке нового образа. Мы собираем новые образы так, чтобы по максимуму переиспользовать слои существующих — так мы экономим время на их загрузку, а разработчики могут писать на новых и новых языках, если им это нужно. Комбинирование образов позволяет собирать окружения любой сложности под любую задачу.
Универсальные возможности платформы позволяют проводить проверку очень разноплановых заданий. Вы можете ознакомиться и порешать примеры:
Надеюсь, статья показалась вам интересной. Любопытно, применяет ли кто-то подобные подходы для решения своих задач, да и просто хотелось бы узнать ваше мнение о материале — пишите в комментариях.
Как происходит проверка решения
Возьмём для примера простейшую задачу: вам заданы два целых числа a и b, выведите a+b.
Допустим, решение будет таким:
source.cpp:
#include <bits/stdc++.h>
using namespace std;
int main() {
int a, b;
cin >> a >> b;
cout << a + b << endl;
}
Как можно проверить это решение вручную без участия дополнительных сервисов и библиотек? Например, так:
Компиляция
g++ -O3 -fno-stack-limit -std=c++20 -x c++ source.cpp -o solution
Тестирование
test `echo 1 2 | ./solution` -eq 3 && echo “OK” || echo “WA”</li>
test `echo 2 6 | ./solution` -eq 8 && echo “OK” || echo “WA”</li>
test `echo -20 10 | ./solution` -eq -10 && echo “OK” || echo “WA”
Даже в ручном режиме необходимо скомпилировать решение:
- нужен g++ и stdlib с++20;
- должен быть прописан PATH для вызова g++;
- нужна сама строка компиляции, должны быть указаны нужные флаги;
- ну и естественно нам нужен файл с решением.
И протестировать его:
- нужен скомпилированный код;
- окружение — для запуска всё ещё нужна stdlib нужной версии;
- тесты — нужно знать, что будем подавать на вход решению и с чем будем сравнивать вывод — %%echo 1 2%%;
- чекер — нужно знать способ, которым мы будем сравнивать output решения с ожидаемым ответом. В нашем примере это -eq.
![](https://habrastorage.org/webt/ny/y8/d9/nyy8d9nkvpia1kwo1ta4lddzluc.jpeg)
Обратите внимание, что в фазе тестирования необходимо запустить и решение участника, и чекер. Взглянем на вариант с задачей посложнее.
Напишите программу, которая угадывает задуманное целое число от 1 до 1000. Тестирующая система загадывает число и не сообщает программе. Угадать число нужно не более чем за 10 попыток. На каждую попытку пользователь отвечает, что загаданное число больше названного (вводит символ «>»), меньше названного («<») или угадано правильно («=»).
Решение:
# минимальное значение, которое может иметь загаданное число
lower_bound = 1
# максимальное значение, которое может иметь загаданное число
upper_bound = 1000
middle = (upper_bound + lower_bound) // 2
print(middle)
answer = input()
while answer != "=":
if answer == '<':
upper_bound = middle - 1
elif answer == '>':
lower_bound = middle + 1
middle = (upper_bound + lower_bound) // 2
print(middle)
answer = input()
В этом примере есть некоторое взаимодействие между решением участника и тестирующей программой (интерактором). Такие задачи называются интерактивными — во время тестирования решения нужно будет запускать и решение участника, и интерактор.
Визуализация полного процесса проверки:
![](https://habrastorage.org/webt/fh/ml/qx/fhmlqxrcvv8acvh_-poht2lforw.jpeg)
Тонкости подготовки окружения
Все этапы выполняются на специально настроенных виртуалках. Виртуалка должна быть подготовлена к их выполнению. Задача команды инфраструктуры — настроить механизм подготовки виртуальной машины, чтобы она в нужный момент исполнила решение.
Что нужно учитывать? Где-то есть участник, который только что отправил свою посылку на проверку. И нам нужно уменьшить оверхед на подготовку окружения, в идеале до нуля. При этом окружения под некоторые задачи могут занимать более 10 Гб. А значит всё, что нужно для выполнения посылки, уже должно быть на виртуалке. В противном случае к времени ожидания участника добавится ещё несколько минут, а в плохих случаях — и десятков минут. Когда речь идёт о соревновании с ограниченным временем (а это большинство наших соревнований), такие задержки становятся критичными. Нам важно обеспечить участникам равные и разумные условия.
Когда-то мы решали эту задачу достаточно наивно: давайте просто поставим на виртуалку все нужные компиляторы с нужными библиотеками нужных версий и будем это поддерживать. Долго сервис шёл именно по такому пути — в то время у нас было около 40 решающих машин, а участникам было доступно всего лишь несколько самых популярных языков программирования. У этого способа есть неоспоримый плюс — время на подготовку окружения равно нулю, ведь все нужные пакеты уже и так стоят на решающей машине.
Однако у такого подхода есть неизбежная проблема роста. На платформе растёт количество соревнований, например, за 2020 год создано и проведено около 5000. Это почти вдвое больше, чем в 2019-м. Кроме того, мы хотим дать возможность новым участникам решать задачи на тех языках, к которым они привыкли. Нам важно, чтобы в соревнованиях могли участвовать все разработчики.
Немного цифр: сейчас у нас почти 600 виртуалок, которые занимаются проверкой решений, мы поддерживаем 22 «языка» в 124 версиях и наборах библиотек.
Вот какие языки поддерживаются
Free Basic
C
C#
C++
D
Delphi
Go
Haskell
Java
Kotlin
JS
Kumir
Swift
Pascal
Perl
PHP
Python
R
Ruby
Rust
Scala
GNU bash
SQLite
C
C#
C++
D
Delphi
Go
Haskell
Java
Kotlin
JS
Kumir
Swift
Pascal
Perl
PHP
Python
R
Ruby
Rust
Scala
GNU bash
SQLite
Добавление нового компилятора или обновление существующего было одной из наших регулярных задач. Проще говоря, у нас был зоопарк окружений, которые постоянно добавлялись и обновлялись, и нам нужно было поддерживать его на 600 машинах так, чтобы они не конфликтовали друг с другом. Конечно же, в определённый момент мы отказались от этого подхода и пошли в сторону контейнеризации — в сторону Docker.
Доставка окружений с помощью Docker
Идея проста: давайте собирать наши окружения в docker-образы и выкачивать на решающую машинку нужные во время проверки. По одному образу на каждый поддерживаемый язык программирования. Так мы получим удобный инструмент для добавления новых компиляторов и обновления старых из коробки, интерфейс для загрузки окружений и переиспользование уже скачанных слоёв в нескольких образах.
Но давайте вспомним, что во время проверки решения нам на самом деле нужно запустить несколько программ. Решение участника, чекер и в некоторых случаях интерактор. И всё это может быть написано на разных языках с использованием разных компиляторов и библиотек.
Значит, нам нужно уметь комбинировать несколько докерных образов в один и получать окружение для запуска любой комбинации компиляторов. Например: участник написал решения на Java, а чекер написан на Python 3.9. Соответственно, в окружении должна быть и JDK, и Python 3.9 с нужными библиотеками. И это просто пример. Комбинации могут быть любыми. Получается, нам нужно как-то объединить содержимое нескольких докерных образов.
Чуть глубже погружаемся в докер
Как известно, докер-образ состоит из множества слоёв (каждый из которых представляет собой дельту изменений файловой системы) и манифеста, который описывает порядок этих слоёв. Для работы с докером есть хороший API. С его помощью можно получить и манифест, и слои образа.
Посмотрим на примере одного из наших компиляторов
{
"schemaVersion": 1,
"name": "contest/compilers/idao2021",
"tag": "base-track-2_v2",
"architecture": "amd64",
"fsLayers": [
{
"blobSum": "sha256:51ae359e96f0e6d324e9078a3ece6ef363b8f89cc2f66a8456c8c04a940d6e53"
},
{
"blobSum": "sha256:207cc68f4ffd56411712e878da214391f799fcb76af871f48b2252eb434d54b6"
},
{
"blobSum": "sha256:c0eef81609058076ca5e812463fd86d9942edea3e887260d39909f99c7fe558e"
},
{
"blobSum": "sha256:036fd4ca4f5c5deee5021cd655f9d711ba2b70c24e270aa3c01cae937267ac1a"
},
{
"blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
},
{
"blobSum": "sha256:3563871736b2edc6a77b60b10a7f1a7d762fedcd13886373aa290a491114cd8d"
},
{
"blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
},
{
"blobSum": "sha256:3217f7c42f9440a65c159326bb06c6fe83f9cfa745a25668a6a3dc06732d63c0"
},
{
"blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
},
{
"blobSum": "sha256:27e29f63878d809559ceba531389e227c2b5cbe9b86bc9ac5b6dbafc26ef3a7d"
},
{
"blobSum": "sha256:e0a5040427feb008958cf06bc0fd95593e1ae63058219d77d8fbbf50cb3af7b8"
},
{
"blobSum": "sha256:91b66f28e0f5355971d67074d108f09194e5ab8653ff93f623d353b8f851559f"
}
],
"history": [
{
"v1Compatibility": "{\"architecture\":\"amd64\",\"config\":{\"Hostname\":\"\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/conda/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",\"CONDA_AUTO_UPDATE_CONDA=false\"],\"Cmd\":null,\"Image\":\"sha256:d6aca2309536874024406e17bd860154771f9edab2673a93ffd754fb2e043e64\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"container_config\":{\"Hostname\":\"\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/conda/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",\"CONDA_AUTO_UPDATE_CONDA=false\"],\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) COPY dir:90a04825e2a5956b663d0da222d640d2c85bb4887a9bfd055b632207985bf0f4 in /opt/. \"],\"Image\":\"sha256:d6aca2309536874024406e17bd860154771f9edab2673a93ffd754fb2e043e64\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"created\":\"2021-02-26T20:14:09.653968151Z\",\"docker_version\":\"19.03.13\",\"id\":\"883cbf03c5215f16fe4e9af245933954d2dbb3e0795d06ec975c86faed5a73f0\",\"os\":\"linux\",\"parent\":\"3b0b60f28177489b7b3559dddaa4be6bef1399644f6e57004076c5fe25c7ff94\"}"
},
{
"v1Compatibility": "{\"id\":\"3b0b60f28177489b7b3559dddaa4be6bef1399644f6e57004076c5fe25c7ff94\",\"parent\":\"98745b97e709dc2f0a6dbccb78c6fda0e26af101e167cb4681daac174e7d82a0\",\"created\":\"2021-02-25T15:30:09.48470141Z\",\"container_config\":{\"Cmd\":[\"|1 DEBIAN_FRONTEND=noninteractive /bin/sh -c pip install -r requirements.txt \\u0026\\u0026 rm -rf /root/.cache\"]}}"
},
{
"v1Compatibility": "{\"id\":\"98745b97e709dc2f0a6dbccb78c6fda0e26af101e167cb4681daac174e7d82a0\",\"parent\":\"54dca58a3b2e48ad6a5b51db76f82e9c4ac14baf7501a715d27a40228c1cf42f\",\"created\":\"2021-02-25T14:36:59.180850517Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) COPY file:cf69df63b2664ddb31f84a0b28e685e8301aac8b5ca2c80696412310300b6405 in requirements.txt \"]}}"
},
{
"v1Compatibility": "{\"id\":\"54dca58a3b2e48ad6a5b51db76f82e9c4ac14baf7501a715d27a40228c1cf42f\",\"parent\":\"38f02d2a280c8df8dc97822b1d06afa5abd9434ba9816a724b9899b37d8d1857\",\"created\":\"2021-02-25T14:36:54.214431183Z\",\"container_config\":{\"Cmd\":[\"|1 DEBIAN_FRONTEND=noninteractive /bin/sh -c conda install -y -c pytorch torchvision numpy scipy pandas scikit-learn joblib tqdm ipython pip cython numba \\u0026\\u0026 pip install statsmodels pqdict xlearn ml_metrics tsfresh mlxtend h5py tempita \\u0026\\u0026 pip install xgboost lightgbm catboost \\u0026\\u0026 pip install tensorflow \\u0026\\u0026 pip install keras \\u0026\\u0026 conda clean -ya\"]}}"
},
{
"v1Compatibility": "{\"id\":\"38f02d2a280c8df8dc97822b1d06afa5abd9434ba9816a724b9899b37d8d1857\",\"parent\":\"f322970e485ded2c99e9f5807000e4123f17167ebe65df31f11a1dacef763af2\",\"created\":\"2021-02-25T14:24:42.6007957Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ENV PATH=/usr/conda/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"]},\"throwaway\":true}"
},
{
"v1Compatibility": "{\"id\":\"f322970e485ded2c99e9f5807000e4123f17167ebe65df31f11a1dacef763af2\",\"parent\":\"4e236223ccfcfcb88a01bc8038927fb003a094021158edb274f888b47f681e37\",\"created\":\"2021-02-25T14:24:41.840774695Z\",\"container_config\":{\"Cmd\":[\"|1 DEBIAN_FRONTEND=noninteractive /bin/sh -c curl -sLo ~/miniconda.sh -O https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh \\u0026\\u0026 chmod +x ~/miniconda.sh \\u0026\\u0026 ~/miniconda.sh -b -p /usr/conda \\u0026\\u0026 rm ~/miniconda.sh\"]}}"
},
{
"v1Compatibility": "{\"id\":\"4e236223ccfcfcb88a01bc8038927fb003a094021158edb274f888b47f681e37\",\"parent\":\"391a1b10fed9a0500c4d673784a4a0454a43e56440f91061d70b31baa9413584\",\"created\":\"2021-02-25T14:24:00.946714723Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ENV CONDA_AUTO_UPDATE_CONDA=false\"]},\"throwaway\":true}"
},
{
"v1Compatibility": "{\"id\":\"391a1b10fed9a0500c4d673784a4a0454a43e56440f91061d70b31baa9413584\",\"parent\":\"6eef95503618cf18089bd3533f88070409464dba8f6d816dd85704a422cc8b22\",\"created\":\"2021-02-25T14:24:00.07189637Z\",\"container_config\":{\"Cmd\":[\"|1 DEBIAN_FRONTEND=noninteractive /bin/sh -c apt-get update \\u0026\\u0026 apt-get install -y libx11-6 gdebi-core libapparmor1 libcurl4-openssl-dev build-essential gnupg2 cmake \\u0026\\u0026 rm -rf /var/lib/apt/lists/*\"]}}"
},
{
"v1Compatibility": "{\"id\":\"6eef95503618cf18089bd3533f88070409464dba8f6d816dd85704a422cc8b22\",\"parent\":\"fa34e0102c013df7746ced279e658968126c0da2488a5f5e30e34a8b67ba14ff\",\"created\":\"2021-02-25T14:22:20.04321914Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ARG DEBIAN_FRONTEND=noninteractive\"]},\"throwaway\":true}"
},
{
"v1Compatibility": "{\"id\":\"fa34e0102c013df7746ced279e658968126c0da2488a5f5e30e34a8b67ba14ff\",\"parent\":\"e19d114a4069123d8ea31652d2f9bf19f13c69dadaa6e3e6f5d49f471dc44fae\",\"created\":\"2021-02-09T12:26:22.362344323Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c locale-gen en_US.UTF-8 \\u0026\\u0026 update-locale\"]}}"
},
{
"v1Compatibility": "{\"id\":\"e19d114a4069123d8ea31652d2f9bf19f13c69dadaa6e3e6f5d49f471dc44fae\",\"parent\":\"a6ff22232898c55b65243fa3334afe797dc8c95689170917ed8d43a0f844d5b8\",\"created\":\"2021-02-09T12:26:20.126790354Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c apt-get update -qq \\u0026\\u0026 apt-get install -y unzip locales \\u0026\\u0026 apt-get clean \\u0026\\u0026 rm -rf /var/lib/apt/lists/*\"]}}"
},
{
"v1Compatibility": "{\"id\":\"a6ff22232898c55b65243fa3334afe797dc8c95689170917ed8d43a0f844d5b8\",\"comment\":\"Imported from Arcadia\",\"created\":\"2021-01-29T22:39:09.637789Z\",\"container_config\":{\"Cmd\":[\"\"]}}"
}
],
"signatures": [
{
"header": {
"jwk": {
"crv": "P-256",
"kid": "4LWK:R4UB:VAUJ:DB3Q:MYYJ:7ADA:F2JX:AYNT:WWBE:5E7R:44ML:CLNG",
"kty": "EC",
"x": "i_RQJaARlq1Ja3wTcAAnWG0sH2g6J6KCpFZQwV_ESq4",
"y": "V6pnZYrx249hGS29Ute3h-OvaoNy3nx6oR_n_l0S0FQ"
},
"alg": "ES256"
},
"signature": "zcTdTjzMH8hpdo7hLsQFysow7Cu9Hbf4CDO7q1g9LNxA72qn8UhIqLqQ2n-sFbUxHT4dt6GQBbX3tXJyDOTREA",
"protected": "eyJmb3JtYXRMZW5ndGgiOjc3NzEsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAyMS0wOS0yNlQyMDoxNjowNVoifQ"
}
]
}
Отсюда нам нужно получить две важные вещи: хэши и порядок слоёв образа из fsLayers, а ещё — переменные окружения из Env. И на самом деле это всё, что нам нужно от докера для подготовки окружений. Мы сами выкачиваем из docker-registry слои образов и метаинформацию об их последовательности. Слои — по сути обычные тарники.
Мы распаковываем слои и раскладываем их в нужные директории на решающей машинке. Затем мёрджим две последовательности слоёв. Чаще всего у двух компиляторов так или иначе есть общий корень, который мы переиспользуем.
Этапы подготовки окружения:
- получаем список нужных образов (допустим, нам нужно окружение Java + Python 3.9);
- получаем манифесты (список слоёв) каждого образа;
- выкачиваем недостающие слои;
- монтируем слои нескольких образов в единую директорию. Эта директория и станет корнем файловой системы контейнера исполнения решения.
Монтаж множества директорий в одну мы делаем примерно так же, как это делает докер — с помощью OverlayFS.
Объединённые файловые системы на примере OverlayFS
OverlayFS позволяет накладывать одно дерево каталогов (обычно доступное в режиме «чтение-запись») на другое, но с доступом только для чтения. Все изменения переходят на верхний слой с возможностью записи. © ArchWiki
Звучит круто! Давайте отвлечёмся от докера и попробуем вручную смонтировать OverlayFS.
Подготовим каталоги:
.
├── lower
│ ├── file.a
│ └── file.b
├── merged
├── upper
│ ├── file.a
│ └── file.c
└── workdir
содержимое lower и upper будем объединять в merged. Workdir — служебный каталог, в котором каталоги объединятся перед атомарным перемещением в merged.
Смонтируем upper и lower в merged:
mount -t overlay -o lowerdir=./lower,upperdir=./upper,workdir=./workdir overlay ./merged
И получим в каталоге merged вот в таком виде:
├── merged
│ ├── file.a
│ ├── file.b
└─└── file.c
файл file.a был взят из каталога upper.
Во время подготовки контейнера докер делает примерно то же самое. Это даже можно проверить с помощью команды docker inspect.
Запустим какой-нибудь докер-контейнер и выполним docker inspect {container_id}
$ docker run ubuntu
В секции GraphDriver увидим уже знакомые нам термины:
"GraphDriver": {
"Data": {
"LowerDir": "/var/lib/docker/overlay2/8f76fee.../diff",
"MergedDir": "/var/lib/docker/overlay2/8f76fee.../merged",
"UpperDir": "/var/lib/docker/overlay2/8f76fee.../diff",
"WorkDir": "/var/lib/docker/overlay2/8f76fee.../work"
},
"Name": "overlay2"
}
OverlayFS на живом примере
Допустим, у нас есть задача, в которой для запуска чекера используется Python 3.9, а для запуска решения участника — Java 11.
Слои докерных образов для Java 11 и Python 3.9:
Java 11 | Python 3.9 |
91b66f28e0f5355971d67074d108f09194e5ab8653ff93f623d353b8f851559f | 91b66f28e0f5355971d67074d108f09194e5ab8653ff93f623d353b8f851559f |
e0a5040427feb008958cf06bc0fd95593e1ae63058219d77d8fbbf50cb3af7b8 | e0a5040427feb008958cf06bc0fd95593e1ae63058219d77d8fbbf50cb3af7b8 |
27e29f63878d809559ceba531389e227c2b5cbe9b86bc9ac5b6dbafc26ef3a7d | 27e29f63878d809559ceba531389e227c2b5cbe9b86bc9ac5b6dbafc26ef3a7d |
ce0fb3a26a16672f5bd3d1c59bfb43ca20ab0f810e5688c38d0c3d404410724a | df3d10133e37e06c7973b724110da65f30af8c1cfa9699924b8539a82427534f |
3a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4 |
Merged |
91b66f28e0f5355971d67074d108f09194e5ab8653ff93f623d353b8f851559f |
e0a5040427feb008958cf06bc0fd95593e1ae63058219d77d8fbbf50cb3af7b8 |
27e29f63878d809559ceba531389e227c2b5cbe9b86bc9ac5b6dbafc26ef3a7d |
ce0fb3a26a16672f5bd3d1c59bfb43ca20ab0f810e5688c38d0c3d404410724a |
a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4 |
df3d10133e37e06c7973b724110da65f30af8c1cfa9699924b8539a82427534f |
Какие задачи мы решили таким образом
Все популярные слои и компиляторы скачаны заранее на решающие машины, то есть у нас нет оверхеда на их загрузку. Кроме того, при необходимости мы можем прогреть виртуалки нужными компиляторами заранее. Мы можем поддерживать неограниченное количество языков программирования и их версий, на которых участники могут решать задачи.
В этом случае добавление нового компилятора — достаточно простая задача, которая сводится к сборке нового образа. Мы собираем новые образы так, чтобы по максимуму переиспользовать слои существующих — так мы экономим время на их загрузку, а разработчики могут писать на новых и новых языках, если им это нужно. Комбинирование образов позволяет собирать окружения любой сложности под любую задачу.
Несколько примеров необычных задач
Универсальные возможности платформы позволяют проводить проверку очень разноплановых заданий. Вы можете ознакомиться и порешать примеры:
- Проверка сервиса, написанного участником (Python, C++, Java): contest.yandex.ru/contest/29674/problems/A
- Сравнение отрендеренных HTML и CSS со скриншотами: contest.yandex.ru/contest/29674/problems/B
- Инференс обученной модели: contest.yandex.ru/contest/29254/problems (для доступа к задаче нужна регистрация на Yandex Cup)
- Запрос к базе данных на SQL: contest.yandex.ru/contest/29674/problems/C
- Пример взаимодействия клиентского кода с игровым модулем (Python, C++, Java, Swift, Kotlin): contest.yandex.ru/contest/29674/problems/D
Надеюсь, статья показалась вам интересной. Любопытно, применяет ли кто-то подобные подходы для решения своих задач, да и просто хотелось бы узнать ваше мнение о материале — пишите в комментариях.