[Перевод] Как разработать онлайновый компилятор кода при помощи Java и Docker
![image](https://habrastorage.org/r/w780q1/webt/e6/dk/2c/e6dk2c_0au6rmsrq7wtejn9uxce.jpeg)
Вы когда-нибудь задумывались, как устроены такие платформы как Codeforces и LeetCode? Как именно они компилируют и выполняют код, поступающий от множества пользователей и проверяют его в тестовых кейсах? Как определяют эффективность алгоритмов?
В этой статье мы подробно разберём, как выстроить высокоэффективную платформу для решения задач.
Исходный код к этой статье выложен на Github в этом репозитории
Спецификация
Функциональные аспекты
На нашей платформе должны гарантироваться следующие возможности:
- Поддержка множества языков программирования.
- Выполнение пользовательского кода применительно ко множеству тестовых кейсов
- Возможность возвращать верный вердикт после выполнения кода. Список вердиктов (Принято, Неверный ответ, Израсходован лимит времени, Израсходован лимит памяти, Ошибка выполнения, Ошибка компиляции).
- Возможность возвращать пользователю подробное описание ошибки в случае одного из следующих вердиктов: return a detailed error to the user if the verdict is one of these (Израсходован лимит времени, Ошибка компиляции, Ошибка выполнения, Израсходован лимит памяти).
- Возможность сообщить, сколько времени длилась компиляция.
- Возможность сообщить, сколько времени длился каждый тестовый кейс.
Нефункциональные аспекты
На нашей платформе должны гарантироваться следующие возможности:
- Возможность конкурентно выполнять сразу множество запросов
- Предоставление отдельных окружений для выполнения (вредоносный пользовательский код не должен получать доступа к хост-машине)
- Выполнение кода не допускается, если будет израсходован лимит времени.
- Для каждого запроса пользовательский код должен выполняться ровно один раз и выполняться множество раз, для каждого тестового кейса.
- Пользователь не должен иметь доступа к файловой системе хоста.
Интерфейс
Пример ввода:
{
"testCases": {
"test1": {
"input": "",
"expectedOutput": ""
},
"test2": {
"input": "",
"expectedOutput": ""
},
...
},
"sourceCode": "",
"language": "JAVA",
"timeLimit": 15,
"memoryLimit": 500
}
Примеры вывода:
{
"verdict": "Accepted",
"statusCode": 100,
"error": "",
"testCasesResult": {
"test1": {
"verdict": "Accepted",
"verdictStatusCode": 100,
"output": "0 1 2 3 4 5 6 7 8 9",
"error": "",
"expectedOutput": "0 1 2 3 4 5 6 7 8 9",
"executionDuration": 175
},
"test2": {
"verdict": "Accepted",
"verdictStatusCode": 100,
"output": "9 8 7 1",
"error": "" ,
"expectedOutput": "9 8 7 1",
"executionDuration": 273
},
...
},
"compilationDuration": 328,
"averageExecutionDuration": 183,
"timeLimit": 1500,
"memoryLimit": 500,
"language": "JAVA",
"dateTime": "2022-01-28T23:32:02.843465"
}
{
"verdict": "Runtime Error",
"statusCode": 600,
"error": "panic: runtime error: integer divide by zero\n\ngoroutine 1 [running]:\nmain.main()\n\t/app/main.go:11 +0x9b\n",
"testCasesResult": {
"test1": {
"verdict": "Accepted",
"verdictStatusCode": 100,
"output": "0 1 2 3 4 5 6 7 8 9",
"error": "",
"expectedOutput": "0 1 2 3 4 5 6 7 8 9",
"executionDuration": 175
},
"test2": {
"verdict": "Runtime Error",
"verdictStatusCode": 600,
"output": "",
"error": "panic: runtime error: integer divide by zero\n\ngoroutine 1 [running]:\nmain.main()\n\t/app/main.go:11 +0x9b\n" ,
"expectedOutput": "9 8 7 1",
"executionDuration": 0
}
},
"compilationDuration": 328,
"averageExecutionDuration": 175,
"timeLimit": 1500,
"memoryLimit": 500,
"language": "GO",
"dateTime": "2022-01-28T23:32:02.843465"
}
Реализация
Отдельные окружения для выполнения кода
Если нужно предусмотреть отдельные окружения для выполнения кода, то для этой цели можно использовать контейнеры. Концепция выглядит так: берём предоставленный пользователем исходный код и на его основе создаём образ Docker, в котором содержится информация о выполнении (лимит времени, лимит памяти, исходный код, тестовые кейсы), после чего применяем его с множеством тестовых кейсов. По коду выхода, получаемому из контейнера, можно определить результат выполнения (Принято, Неверный ответ, Израсходован лимит времени, Израсходован лимит памяти, Ошибка выполнения, Ошибка компиляции).
Некоторые достоинства работы с контейнерами.
- Изоляция: удобно изолировать приложения друг от друга, помещая их в контейнеры; при этом они также отграничиваются от хост-системы. Так можно предотвращать конфликты и повышать безопасность.
- Портируемость: в контейнер упаковываются все зависимости, необходимые для выполнения приложения. Поэтому приложение становится легко переносить между различными окружениями.
- Согласованность: поскольку в контейнер упаковываются все зависимости, необходимые для выполнения приложения, легко обеспечить согласованную работу приложения в разных окружениях.
- Масштабируемость: контейнеры легко масштабируются, как в сторону наращивания, так и в сторону свёртывания, в зависимости от изменения потребности. Поэтому при работе с контейнерами легко управлять ресурсами и гарантировать, что приложение всегда будет работать на оптимальном уровне производительности.
- Эффективное использование ресурсов: при использовании контейнеров можно снизить затраты на эксплуатацию и поддержку приложений, поскольку они легковесны и требуют не так много ресурсов, как традиционные виртуальные машины.
- Гибкость: контейнеры легко развёртываются и на предприятии, и в облаке, и в гибридных окружениях.
![image](https://habrastorage.org/r/w1560/webt/m0/7y/ew/m07yewcgqiryquqysukyaarrwoq.png)
Как показано на иллюстрации выше, нам потребуются контейнеры двух типов: контейнеры для компиляции и контейнеры для выполнения. При каждом запросе будет создаваться контейнер одного из этих типов. Затем будет создаваться один экземпляр контейнера для компиляции и по одному экземпляру контейнеров для выполнения на каждый интересующий нас тестовый кейс.
Контейнеры для компиляции
Контейнеры такого типа используются для компиляции исходного кода в двоичный. Эти контейнеры очень специфические, поскольку они используют том совместно с главным сервисом.
Пример:
FROM openjdk:11.0.6-jdk-slim
WORKDIR /app
ENTRYPOINT ["/bin/sh", "-c", "javac -d $EXECUTION_PATH $EXECUTION_PATH/$SOURCE_CODE_FILE_NAME && rm $EXECUTION_PATH/$SOURCE_CODE_FILE_NAME"]
Контейнеры для выполнения
В контейнерах такого типа содержится вся информация о выполнении, причём, такой контейнер выполняется отдельно для каждого тестового кейса. Кроме того, этот контейнер изолирован (не разделяет том ни с одним другим контейнером или приложением).
Пример:
FROM openjdk:11.0.6-jre-slim
WORKDIR /app
USER root
RUN groupadd -r user -g 111 && \
useradd -u 111 -r -g user -s /sbin/nologin -c "Docker image user" user
ADD . .
RUN chmod a+x ./entrypoint-*.sh
USER user
ENTRYPOINT ["/bin/sh", "-c", "./entrypoint-$TEST_CASE_ID.sh"]
Как упоминалось в файле Dockerfile, файл входной точки (entrypoint) в каждый контейнер снабжён префиксом TEST_CASE_ID, он генерируется приложением по шаблону для каждого тестового кейса.
#!/usr/bin/env bash
ulimit -s [(${compiler.memoryLimit})]
timeout -s SIGTERM [(${compiler.timeLimit})] [(${compiler.executionCommand})]
exit $?
В шаблоне указывается лимит времени и лимит памяти, допустимый для каждого тестового кейса.
Политика безопасности
По соображениям безопасности и для того, чтобы закрыть пользователю доступ к файловой системе, можно применять политики безопасности.
В Java действуют политики безопасности, применяемые для контроля над системными ресурсами, в частности, над файлами и сетевыми ресурсами, используемыми в приложениях. Диспетчер безопасности Java обеспечивает соблюдение этих политик. Этот диспетчер можно сконфигурировать так, чтобы он предоставлял или отменял права доступа к конкретному коду в зависимости от его происхождения. Ориентироваться можно, например, на расположение кода в файловой системе или на его цифровую подпись.
grant {
permission java.io.FilePermission "/tmp/test.txt", "read,write";
permission java.net.SocketPermission "www.example.com:80", "connect";
};
Вышеприведённую политику можно задать как аргумент командной строки, приступая к работе с JVM, вот так:
java -Djava.security.policy=mypolicy.policy MyApp
Пользовательский запрос
Пользовательский ввод будет выглядеть так:
@Getter
@NoArgsConstructor
@EqualsAndHashCode
@AllArgsConstructor
public class Request {
/**
* The Source code.
*/
@ApiModelProperty(notes = "The sourcecode")
@NonNull
@JsonProperty("sourcecode")
protected String sourcecode;
/**
* The Language.
*/
@ApiModelProperty(notes = "The programming language")
@NonNull
@JsonProperty("language")
protected Language language;
/**
* The Time limit.
*/
@ApiModelProperty(notes = "The time limit in sec")
@NonNull
@JsonProperty("timeLimit")
protected int timeLimit;
/**
* The Memory limit.
*/
@ApiModelProperty(notes = "The memory limit")
@NonNull
@JsonProperty("memoryLimit")
protected int memoryLimit;
/**
* The Test cases.
*/
@ApiModelProperty(notes = "The test cases")
@NonNull
@JsonProperty("testCases")
protected LinkedHashMap testCases; // Note: test cases should be given in order
}
По каждому тестовому кейсу нам известен ввод и ожидаемый вывод:
@Getter
@AllArgsConstructor
@EqualsAndHashCode
public class TestCase {
@ApiModelProperty(notes = "The input, can be null")
@JsonProperty("input")
private String input;
@ApiModelProperty(notes = "The expected output, can not be null")
@NonNull
@JsonProperty("expectedOutput")
private String expectedOutput;
}
Стратегия компиляции
Ниже приведён пример кода, демонстрирующий, как обычно выполняется компиляция. Можно воспользоваться паттерном «стратегия», чтобы выбрать, какой алгоритм будет использоваться с компилируемыми языками, а какой — с интерпретируемыми.
@Override
public CompilationResponse compile(Execution execution) {
// repository name must be lowercase
String compilationImageName = IMAGE_PREFIX_NAME + execution.getLanguage().toString().toLowerCase();
// If the app is running inside a container, we should share the same volume with the compilation container.
final String volume = compilationContainerVolume.isEmpty()
? System.getProperty("user.dir")
: compilationContainerVolume;
String sourceCodeFileName = execution.getSourceCodeFile().getOriginalFilename();
String containerName = COMPILATION_CONTAINER_NAME_PREFIX + execution.getImageName();
var processOutput = new AtomicReference();
compilationTimer.record(() -> {
processOutput.set(
compile(volume, compilationImageName, containerName, execution.getPath(), sourceCodeFileName)
);
});
ProcessOutput compilationOutput = processOutput.get();
int compilationDuration = compilationOutput.getExecutionDuration();
ContainerInfo containerInfo = containerService.inspect(containerName);
ContainerHelper.logContainerInfo(containerName, containerInfo);
Verdict verdict = getVerdict(compilationOutput);
compilationDuration = ContainerHelper.getExecutionDuration(
containerInfo == null ? null : containerInfo.getStartTime(),
containerInfo == null ? null : containerInfo.getEndTime(),
compilationDuration);
ContainerHelper.deleteContainer(containerName, containerService, threadPool);
return CompilationResponse
.builder()
.verdict(verdict)
.error(compilationOutput.getStdErr())
.compilationDuration(compilationDuration)
.build();
}
Стратегия выполнения
Вот фрагмент кода, демонстрирующий, как построено выполнение кода в контейнере.
public ExecutionResponse run(Execution execution, boolean deleteImageAfterExecution) {
buildContainerImage(execution);
var testCasesResult = new LinkedHashMap();
Verdict verdict = null;
String err = "";
for (ConvertedTestCase testCase : execution.getTestCases()) {
TestCaseResult testCaseResult = executeTestCase(execution, testCase);
testCasesResult.put(testCase.getTestCaseId(), testCaseResult);
verdict = testCaseResult.getVerdict();
log.info("Status response for the test case {} is {}", testCase.getTestCaseId(), verdict.getStatusResponse());
// Update metrics
verdictsCounters.get(verdict.getStatusResponse()).increment();
if (verdict != Verdict.ACCEPTED) {
// Don't continue if the current test case failed
log.info("Test case id: {} failed, abort executions", testCase.getTestCaseId());
err = testCaseResult.getError();
break;
}
}
// Delete container image asynchronously
if (deleteImageAfterExecution) {
ContainerHelper.deleteImage(execution.getImageName(), containerService, threadPool);
}
return ExecutionResponse
.builder()
.verdict(verdict)
.testCasesResult(testCasesResult)
.error(err)
.build();
}
private TestCaseResult executeTestCase(Execution execution,
ConvertedTestCase testCase) {
log.info("Start running test case id = {}", testCase.getTestCaseId());
String expectedOutput = testCase.getExpectedOutput();
// Free memory space
testCase.freeMemorySpace();
var result = new AtomicReference();
executionTimer.record(() -> {
// Run the execution container
result.set(runContainer(execution, testCase.getTestCaseId(), expectedOutput));
});
TestCaseResult testCaseResult = result.get();
return testCaseResult;
}
В каждом языке программирования предусмотрены собственные параметры выполнения и конкретная конфигурация. Чтобы это абстрагировать, можно воспользоваться принципом инверсии зависимостей и создавать классы Execution при помощи паттерна абстрактная фабрика.
![image](https://habrastorage.org/r/w780q1/webt/u-/6t/q4/u-6tq4dxdvyor0hyq_gco_dff1i.jpeg)
Абстрактная фабрика
Абстрактная фабрика — это паттерн проектирования, позволяющий создавать семейства родственных или зависимых объектов, не указывая конкретные классы. С его помощью создаются объекты, относящиеся к одному семейству, но не предназначенные для совместного использования.
@FunctionalInterface
public interface AbstractExecutionFactory {
/**
* Create execution.
*
* @param sourceCode the source code
* @param testCases the test cases
* @param timeLimit the time limit
* @param memoryLimit the memory limit
* @return the execution
*/
Execution createExecution(MultipartFile sourceCode,
List testCases,
int timeLimit,
int memoryLimit);
}
public abstract class ExecutionFactory {
private static Map registeredExecutionTypes = new EnumMap<>(Language.class);
private static Map registeredFactories = new EnumMap<>(Language.class);
private ExecutionFactory() {}
/**
* Register.
*
* @param language the language
* @param factory the factory
*/
public static void registerExecution(Language language, AbstractExecutionFactory factory) {
registeredFactories.putIfAbsent(language, factory);
}
/**
* Register execution type.
*
* @param language the language
* @param executionType the execution type
*/
public static void registerExecutionType(Language language, ExecutionType executionType) {
registeredExecutionTypes.putIfAbsent(language, executionType);
}
/**
* Gets execution type.
*
* @param language the language
* @return the execution type
*/
public static ExecutionType getExecutionType(Language language) {
return registeredExecutionTypes.get(language);
}
/**
* Gets registered factories.
*
* @return the registered factories
*/
public static Set getRegisteredFactories() {
return registeredFactories
.keySet()
.stream()
.collect(Collectors.toSet());
}
/**
* Gets execution.
*
* @param sourceCode the source code
* @param testCases the test cases
* @param timeLimit the time limit
* @param memoryLimit the memory limit
* @param language the language
* @return the execution
*/
public static Execution createExecution(MultipartFile sourceCode,
List testCases,
int timeLimit,
int memoryLimit,
Language language) {
AbstractExecutionFactory factory = registeredFactories.get(language);
if (factory == null) {
throw new FactoryNotFoundException("No ExecutionFactory registered for the language " + language);
}
return factory.createExecution(
sourceCode,
testCases,
timeLimit,
memoryLimit);
}
}
All languages can be registered in a config class.
private void configureLanguages() {
// Register factories
register(Language.JAVA,
(sourceCode, testCases, timeLimit, memoryLimit) -> new JavaExecution(
sourceCode,
testCases,
timeLimit,
memoryLimit,
ExecutionFactory.getExecutionType(Language.JAVA)));
register(Language.PYTHON,
(sourceCode, testCases, timeLimit, memoryLimit) -> new PythonExecution(
sourceCode,
testCases,
timeLimit,
memoryLimit,
ExecutionFactory.getExecutionType(Language.PYTHON)));
...
Подробнее о классе Execution и о том, как он создаёт среду выполнения, рассказано в разделе Execution class
Как рассчитать длительность компиляции и выполнения
В данном случае можно воспользоваться командой inspect из docker, чтобы получить подробную информацию о контейнере (дата создния, жата начала выполнения, дата окончания выполнения, статус выхода…).
Docker Inspect
Можно воспользоваться командой docker inspect, указав в качестве аргумента ID образа или контейнера, либо имя образа.
Например, чтобы проверить контейнер под именем «my_container», нужно выполнить следующую команду:
docker inspect my_container
Также можно воспользоваться опцией --format, чтобы отображать только конкретные иоля или чтобы отформатировать вывод конкретным образом.
docker inspect --format='{{json .Config}}' my_container
Подробнее см. в полном исходном коде приложения.
Какие ещё вещи есть в базе кода
- Helm-диаграммы для развёртывания сервиса в Kubernetes здесь
- Предоставление инфраструктуры в Azure при помощи ARM-шаблона
- Локальное выполнение при помощи docker-compose, в частности, коммуникация между RabbitMq и ApacheKafka здесь
Заключение
Создать платформу для решения задач бывает очень непросто. Но, если пользоваться контейнерами, то этот процесс становится гораздо более управляемым. Учитывая, сколько достоинств у контейнеров (изоляция, переносимость, согласованность, масштабируемость, экономичность), не составляет труда увидеть, почему они отлично подходят для выстраивания мощной платформы, на которой пользователи решают задачи. Итак, кто бы вы ни были — программист-энтузиаст, желающий отточить свои навыки, либо представитель бизнеса, изыскивающий возможность улучшить процессы, не сомневайтесь, попробуйте контейнеры. Вам понравится! Помните, что «работа с контейнерами — всё равно, что сборка кода из лего». Поэтому удачи вам в программировании платформы для решения задач. Здесь открываются просто безграничные возможности.
P.S Обращаем ваше внимание на то, что у нас на сайте проходит распродажа.
Habrahabr.ru прочитано 10059 раз