Я вынуждена признаться: последние 8 лет я избегаю Docker. И дело тут не в непонимании пользы контейнеров. Мой первый опыт использования Docker оказался очень плохим. Настолько плохим, что убедил меня в том, что я не смогу освоить эту сложную технологию до приличного уровня. Позже я узнала, что из-за характера поставленной задачи (и поскольку я была и остаюсь пользователем Mac), я, на свое несчастье, столкнулась с большинством неприятных проблем ранних версий Docker. К моменту осознания этого, я уже полностью переключилась на устаревшие технологии, где большинство моих проблем было связано с попыткой развернуть контроль версий или базовое тестирование…

Чуть позже я занялась менеджментом и казалось, что мне больше никогда не придется столкнуться со своими страхами в лице Docker! Ура!

А потом появился Fault.

Fault — это проект по разработке языка программирования. На определенном этапе его развития стало очевидно, что для выполнения спецификаций Fault потребуется SMT-решатель. Возник вопрос о том, как правильно управлять зависимостями, не связанными с основным ЯП проекта — Go. Я пробовала применять разные подходы для решения, параллельно ощущая маячащий вдалеке Docker.

В этот раз я точно понимала, как заставить Docker сделать то, что я хочу. Этот факт не мог не радовать. Единственная проблема заключалась в том, что Fault как язык программирования должен работать в командной строке. В то же время использование Docker предполагает создание веб-сервиса, который будет обмениваться информацией с остальными объектами через открытые порты. Но приложения командной строки не взаимодействуют таким образом. Я хотела сохранить интерфейс командной строки, при этом не отказываясь от преимуществ контейнеризации.

Шаг первый: многоэтапная сборка (multi-stage docker)


Основная причина использования Docker в проекте — это критическая зависимость Fault от Z3. Если на устройстве, на котором создаются спецификации Fault, не установлен и не настроен Z3, то Fault может скомпилировать их в SMTLib2 (язык SMT-решателя), но не оценить их. Это может показаться особенностью конкретно моего проекта, но, на самом деле, многие языки программирования зависят от чего-то наподобие LLVM или JVM. Типичный подход заключается в создании инсталлера, устанавливающего все зависимости (и в надежде на отсутствие конфликтов версий), либо в перекладывании всех ошибок на плечи пользователей, подталкивая их к циклу google->stackoverflow->apt-get.

В нашем случае Docker является особенно хорошим решением. Всё дело в том, что люди, которым нравится решать технические проблемы с помощью SMT-решателя чем-то похожи на фанатов драг-рейсинга. Я подразумеваю, что любители драг-рейсинга — это такое небольшое сообщество людей, уверенных, что они могут менять детали в двигателе по своему усмотрению и «выжимать» его до предела. Z3 — один из самых передовых и стабильных SMT-решателей, но некоторые пользователи неизбежно захотят создать сборки Fault с другими SMT-решателями. 

Сделать многоэтапную сборку Docker очень легко. На первом этапе официальный образ Go используется в качестве базового и с его помощью создается бинарный файл компилятора Fault. На следующем этапе в качестве базового используется образ Z3, копируется бинарный файл Fault и настраивается большая часть оставшейся конфигурации. Какой именно SMT-решатель используется в сборке Fault определяется переменной среды в контейнере. При работе со спецификацией Fault ищет эту переменную и загружает в шаблон соответствующий SMT-решатель. 

Таким образом, добавить поддержку нового SMT-решателя так же просто, как и написать для него шаблон и добавить Dockerfile.

Шаг 2: Makefile установщик


Хитрость заключается в том, что результат каждого из этапов многоэтапной сборки сохраняется в репозитории как Dockerfile, после чего установщик объединяет их в новый Dockerfile. Именно его использует команда docker build. Это всё, что делает установщик: собирает финальный Dockerfile и запускает docker build. Я решила использовать Makefile, потому что поддержка нового SMT-решателя — это просто новое правило в том же файле. Я запускаю команду make fault-z3 для билда Fault с Z3, а позже смогу запустить Fault с Yices с помощью make fault-yices.

Шаг 3: Размещение CLI в PATH хоста


С помощью команд ENTRYPOINT и CMD в Dockerfile можно “докеризировать” инструменты командной строки. ENTRYPOINT передаёт в Docker команду, которую необходимо инициировать при запуске контейнера CMD, а также передает значения по умолчанию для любых необходимых аргументов или флагов. 

Бинарный файл Fault на вход требует три аргумента:

  • расположение входного файла;
  • input или тип входного файла. Компилятор будет анализировать спецификации написанные на Fault (.fspec), а также написанный вами код SMT и отправлять их SMT-решателю;
  • mode — пользователь может остановить компилятор на ранней стадии и узнать текущее состояние входных данных. 

Вот как это выглядит в Dockerfile. mode и input имеют значение по умолчанию, а требуемый аргумент по умолчанию является пустой строкой. Пустая строка вызывает обработанную ошибку и передает её хосту.

ENTRYPOINT [ "./fcompiler"]
CMD [ "-mode=check", "-input=fspec", ""]

Теперь мы можем запустить компилятор Fault на машине-хосте следующим образом:

docker run fault-lang/fault-z3 -filepath=test.fspec

Docker запускает контейнер, выполняет компиляцию и закрывает контейнер, когда компилятор возвращает результат. Отлично… За исключением того, строка получилась слишком длинной и неказистой. Я не хочу, чтобы пользователи использовали команду docker run для работы с Fault. Я хочу видеть что-то наподобие fault -filepath=...

Для решения этого вопроса подойдёт простой скрипт bash, который можно добавить в PATH хост-компьютера:

#!/bin/bash
while getopts f:m:i: flag
do
    case "${flag}" in
        f) file=${OPTARG};;
        m) mode=${OPTARG};;
        i) input=${OPTARG};;
    esac
done
if [ -z "$file" ]
then
    echo "You must specify a spec file."
else
    docker run fault-lang/fault-z3 -mode=$mode -filepath=$file
    -input=$input
fi

Я предпочла выбрать bash из-за удобства, но на самом деле это можно сделать на любом скриптовом языке.

Все, что осталось сделать — добавить строку в Makefile, скопировав скрипт из репозитория Fault в /usr/local/bin с названием fault без расширения.

Шаг 4: Файловая система хоста


Место, где Fault будет искать файл спецификации, находится внутри контейнера, а именно в файловой системе хост-компьютера. Для того чтобы это работало, Docker должен смонтировать всю или часть файловой системы хоста. К счастью, подобная манипуляция — это довольно простое изменение скрипта на bash:

docker run -v ${HOME}:/host:ro fault-lang/fault-z3 -mode=$mode -filepath=$filepath -input=$input

-v ${HOME}:/host монтирует домашний каталог в каталог в контейнере с названием host. У меня были мысли о том, чтобы установщик настраивал определенный каталог и монтировал его вместо этого, примерно так же, как в старых версиях Go требовалось, чтобы все файлы go находились в директории GOPATH. Но большинство разработчиков предпочитают сохранять файлы спецификаций с исходных кодом, который эти спецификации и определяет. Конечно, при монтировании такой большой части файловой системы хоста возникают некоторые вопросы касательно безопасности, однако Fault только читает файлы спецификации, поэтому я ограничилась установкой режима “только чтение” (:ro) между хостом и контейнером и двинулась дальше.

Одна из вещей, которую я избегала — запись зависимости от Docker в компилятор. Помимо того, что вы должны иметь возможность запустить Fault в Docker, вы также должны иметь возможность запустить его вне контейнера, при условии наличия на компьютере установленных LLVM и Z3.

Монтирование $HOME каталога в /host подразумевает, что заключительный этап этого шага — это небольшой хакинг. Компилятору необходимо определить, находиться он в контейнере или нет, а затем соответствующем образом изменить пути. Я решила эту проблему установив с помощью переменной среды, определяющей путь к хосту. Если Fault работает в контейнере, то необходимо изменить путь к файлам, прежде чем пытаться их открыть. В противоположном случае компилятор оставляет всё в покое.

Шаг 5: VERSION и HELP


Для более эффективного использования ресурсов, некоторые аргументы, такие как получение справочного меню или проверка версии, желательно не пропускать через Fault и контейнер, запуск которого занимает некоторое время. Поэтому я решила изменить скрипт bash, чтобы обрабатывать эти ситуации, при этом никак не взаимодействуя с Fault. Справка — это простая функция, которая выводит отформатированное справочное руководство для пользователей:

Help()
{
# Display Help
echo "###########################################################"
echo "Fault: a language and model checker for dynamic systems"
echo "###########################################################"
echo
echo "-f [filepath]    spec file."
echo "-h               print this help guide."
echo "-m [mode]        stop compiler at certain milestones: ast,"
echo "                 ir, smt, or check (default: check)"
echo
echo "-i [input]       format of the input file (default: fspec)"
echo "-V               print software version and exit."
echo
}
####################################################################
# Main program                                                                 #
####################################################################
while getopts f:m:i:h flag
do
    case "${flag}" in
        f) file=${OPTARG};;
        m) mode=${OPTARG};;
        i) input=${OPTARG};;
        h) Help
           exit;;
    esac
done
if [ -z "$file" ]
then
    echo "You must specify a spec file."
else
    docker run fault-lang/fault-z3 -mode=$mode -filepath=$file
    -input=$input
fi

Вопрос с версией ПО был немного интереснее. Обычной практикой является хранение информации о версии в тегах git, но в финальном Docker хранится бинарник Fault, а не исходный код. Поскольку мы создаем образ контейнера, необходимо добавить информацию о версии в LABEL и, дабы быть любезными перед будущими разработчиками, я хочу следовать стандарту OCI (Open Container Iniciative) для этого типа метаданных:

LABEL org.opencontainers.image.vendor="Fault-lang"
LABEL org.opencontainers.image.authors="Marianne Bellotti"
LABEL org.opencontainers.image.created=${BUILD_DATE}
LABEL org.opencontainers.image.version=${BUILD_VERSION}
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.description="Fault using Z3Solver as its engine"

Значения BUILD_DATE и BUILD_VERSION передаются с помощью флага --build-arg, поэтому Makefile ожидает небольшая корректировка:

VERSION := $$(git describe --tags --abbrev=0)
fault-z3:
     $(shell touch "fault.Dockerfile")
     cat Dockerfile ./solvers/z3.Dockerfile > fault.Dockerfile
     @docker build -t ${NAME}-z3:${TAG} --no-cache --build-arg
         BUILD_VERSION=${VERSION} --build-arg BUILD_DATE=$(date) -f
         fault.Dockerfile .
     @docker tag ${NAME}-z3:${TAG} ${NAME}-z3:latest
     @rm fault.Dockerfile
     @cp fault-lang.sh /usr/local/bin/fault

Теперь в скрипте bash я создаю функцию Version():

Version()
{
   # Lookup the current version of Fault
   docker inspect fault-lang/fault-z3 -f='{{ index .Config.Labels
   "org.opencontainers.image.version" }}'
}

Вуаля! Теперь, когда пользователь инициирует команду make fault-z3 создается двоичный файл Fault, который поступает в контейнер с SMT-решателем. В PATH добавляется bash скрипт, который предоставляет пользователю чистый интерфейс и избавляет от гадания, работает Docker или нет. 

Однако Fault ещё не готов полностью. Я проделала эту работу в стремлении подготовить Fault к работе для других людей. Предстоит пройти ещё большой путь, но Fault становится всё ближе к тому, чтобы пользователи могли его запускать на своих компьютерах каждый день, и Docker очень помог нам на этом пути. 

Комментарии (5)


  1. ivankudryavtsev
    19.01.2022 10:21
    +7

    В этот раз я точно понимала, как заставить Docker сделать то, что я хочу. Этот факт не мог не радовать. Единственная проблема заключалась в том, что Fault как язык программирования должен работать в командной строке. В то же время использование Docker предполагает создание веб-сервиса, который будет обмениваться информацией с остальными объектами через открытые порты. Но приложения командной строки не взаимодействуют таким образом. Я хотел сохранить интерфейс командной строки, при этом не отказываясь от преимуществ контейнеризации.

    Так понималА или хотеЛ. Вполне docker run -it работает без портов... И вполне себе много утилит уже в docker завернуты. Например, те же kafka console producer/consumer.


  1. duke_alba
    21.01.2022 13:41

    Честно говоря, я не понял, как связана реализация ЯП с докером. Возможно, автор имел в виду установку и последующий запуск программ, на нём написанныз, на различных платформах?


    1. uyrij
      21.01.2022 20:05

      Автор желала , чтоб реализованный как пакет питонa решатель сложных логических уравнений Z3 был бы в докере.


      1. duke_alba
        21.01.2022 23:27

        Понял, спасибо.