Однажды я был маленьким, и задавался вопросом — вот если Unix way это (упрощенно) небольшие, довольно простые утилиты и библиотеки, которые делают одну вещь, но делают её хорошо (Peter H. Salus: «…that do one thing and do it well»), то… Где тогда утилита, которая занимается шаблонизацией и не хватает звёзд с неба? Вот есть у тебя некоторый шаблон, и есть некоторые данные, которые ты имеешь желание в этот шаблон подставить. Брать для этого Jinja2? Писать что-то своё используя sed
+ awk
? Или тащить %tool_name% на несколько мегабайт ради столь тривиальной задачи?
Спустя некоторое время, вновь столкнувшись с подобной задачей, и поняв что попытка найти что-то подходящее вновь претерпела фиаско, было принято волевое решение — да-да, написать свой прекрасный проект велосипед шаблонизатор для использования в CLI. Ограничения были выбраны следующие:
- Статическая линковка — один бинарный файл без каких-либо зависимостей (он мне понадобится в docker scratch)
- Итоговый размер должен быть минимально возможным (постараться уместиться в 100Кб без upx)
На чем писать, если хочется боли компактного результата и быстрого выполнения — естественно, берём C. Какой шаблонизатор использовать, если хочется минимализма? Под такую задачу хорошо подойдет mustache. И вот, спустя некоторое время появляется утилита под кодовым именем mustpl (must — mustache, tpl — template).
Как её использовать?
Предельно просто — дай на вход путь до файла с шаблоном, файла с данными для этого шаблона (или передай их в виде JSON-строки используя флаг -d
), и опционально передай нужные переменные окружения. Для примера давай представим, что у нас есть следующий шаблон для Nginx (nginx.tpl
):
server { listen 8080; server_name{{#names}} {{ . }}{{/names}}; location / { root /var/www/data; index index.html index.htm; } }
И мы имеем желание сгенерировать из него настоящий конфиг, подставив в качестве server_name
значения example.com
и google.com
. Для этого достаточно выполнить:
$ export SERVER_NAME_1=example.com $ mustpl -d '{"names": ["${SERVER_NAME_1:-fallback.com}", "google.com"]}' ./nginx.tpl server { listen 8080; server_name example.com google.com; location / { root /var/www/data; index index.html index.htm; } }
Или другой пример, с циклом, но тем же конфигом для Nginx. Берём данные (data.json
):
{ "servers": [ { "listen": 8080, "names": [ "example.com" ], "is_default": true, "home": "/www/example.com" }, { "listen": 1088, "names": [ "127-0-0-1.nip.io", "127-0-0-2.nip.io" ], "home": "/www/local" } ] }
Берём шаблон (nginx.tpl
):
{{#servers}} server { listen {{ listen }}; server_name{{#names}} {{ . }}{{/names}}{{#is_default}} default_server{{/is_default}}; location / { root {{ home }}; index index.html index.htm; } } {{/servers}}
И рендерим:
$ mustpl -f ./data.json ./nginx.tpl server { listen 8080; server_name example.com default_server; location / { root /www/example.com; index index.html index.htm; } } server { listen 1088; server_name 127-0-0-1.nip.io 127-0-0-2.nip.io; location / { root /www/local; index index.html index.htm; } }
Естественно, что конфигом Nginx вы не ограничены, да и вообще — рендерить можно любые тектовые данные. Единственное, наверняка будут сложности с python и yaml (там, где отступы имеют значение), но если что — создавайте issue, подумаем что можно придумать.
Красота — она в простоте. Кроме всего прочего, шаблонизатором поддерживаются и условия (это уже не совсем Logic-less получается, ну да ладно), и подключение других файлов-шаблонов, и escaping значений — все детали и нужные ссылки сможешь найти в readme файле репозитория с приложением.
А как установить?
На данный момент есть 3 пути по установке — это скачивание уже готового бинарного файла под необходимую архитектуру со страницы релизов, собственная сборка из исходников и использование готового docker-образа с приложением.
Для сборки потребуется только gcc
(и musl-dev
, если собираешь, скажем, в alpine linux), а docker-образ уже собран под наиболее популярные платформы, так что всё, что потребуется тебе сделать в твоём Dockerfile, это лишь:
COPY --from=ghcr.io/tarampampam/mustpl:latest /bin/mustpl /bin/mustpl
Крайне рекомендую не использовать тег
latest
из-за того, что при мажорных изменениях есть риск получить обратно-несовместимые изменения. Лучше всего использовать версионирование в форматеX.Y.Z
в связке с настроенным (dependa|renovate)bot.
Поддержки windows на данный момент нет так как «а зачем?». Если будет такой запрос — создайте issue, подумаем что можно сделать.
Но почему ты просто не взял %tool_name%?
Кроме того, что целью был минимальный размер итогового бинарного файла, отсутствие зависимостей и скорость работы, есть ещё как минимум одна очень важная причина — это комфортное использование в docker, а именно — навык парсить переменные окружения (примерно как envsubst
) и возможность использования в качестве точки входа (entrypoint).
Скорее всего ты знаешь, что основной процесс, запускаемый в контейнере — должен иметь PID равный 1 (в неймспейсе контейнера). Нужно это для того, чтоб демон докера мог корректно общаться (отправляя сигналы) с приложением, что у тебя в этом самом контейнере крутится.
Именно необходимость сохранить PID 1 является причиной тому что, как правило, в entrypoint-скриптах используются конструкции вида:
#!/bin/sh set -e if [ -n "$MY_OPTION" ]; then # если переменная окружения имеется sed -i "s~foo~bar ${MY_OPTION}~" /etc/app.cfg # то подставляем её в конфиг fi; exec "$@" # <-- а вот это самое интересное
Которая в паре со следующими entrypoint
/cmd
в dockerfile:
ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["/bin/app", "--another", "flags"]
Работает следующим образом:
- Запускается процесс
sh
c PID 1, который выполняет скрипт/docker-entrypoint.sh
- Скрипт выполняет все необходимые модификации конфига некоторого приложения (если это необходимо), и вызывает
exec
(который заменяет текущий процесс новым, не изменяя при этом свой PID, детали вman exec
) - Запускается процесс
app
с аргументами--another flags
и его PID становится 1
И мне очень хотелось иметь возможность отказаться от этих самых entrypoint скриптов, так как они тянут массу зависимостей (а distroless же наше всё), да и писать их утомляет очень быстро. И было принято решение научить mustpl выполнять этот самый exec
самостоятельно. Т.е. чтоб алгоритм запуска был следующий:
- Запускается mustpl с PID 1, который читая файл шаблона и данные для него генерирует необходимый конфиг для некоторого приложения
- Выполняет
exec
, запуская нужное приложение, не меняя PID (т.е. оставляя его равным 1)
Как это выглядит? Тоже очень просто, давай создадим файлы с шаблоном (template.ini
):
[config] value = {{ my_option }}
Данными для него (data.json
):
{ "my_option": "${MY_OPTION:-default value}" }
И следующий Dockerfile
:
FROM alpine:latest COPY --from=ghcr.io/tarampampam/mustpl /bin/mustpl /bin/mustpl COPY ./data.json /data.json COPY ./template.ini /template.ini ENTRYPOINT ["mustpl", "-f", "/data.json", "-o", "/rendered.txt", "/template.ini", "--"] CMD ["sleep", "infinity"]
Теперь давай соберем образ и запустим его:
$ docker build --tag test:local . $ docker run --rm --name mustpl_example -e "MY_OPTION=foobar" test:local
В этот момент происходит следующее:
- Запускается
mustpl
(т.к. он указан вentrypoint
), который читает файлы/data.json
и/template.ini
- В данных шаблона значение для
my_option
заменяется наfoobar
, так как переменная окруженияMY_OPTION
установлена (мы же указали-e "MY_OPTION=foobar"
; в противном случае там бы оказалось значениеdefault value
) - Шаблон рендерится, и сохраняется в
/rendered.txt
mustpl
сохраняет все аргументы, что были указаны после пути до файла с шаблоном (это единственный обязательный параметр), трактуя их как имя и параметры запускаемого приложения (в нашем случае этоsleep
с аргументомinfinity
), двойное тире--
необходимо чтоб любые последующие флаги не парсилисьmustpl
а читались «как есть»- Запускается процесс
sleep
и PID равный 1 сохраняется уже за ним, а mustpl просто завершает свою работу (фактически происходит замена образа, но это сейчас не так важно)
Давай проверим, так ли это на самом деле (выполним в отдельном терминале):
$ docker exec mustpl_example ps aux PID USER TIME COMMAND 1 root 0:00 sleep infinity # <-- PID как видим на самом деле == 1 7 root 0:00 ps aux $ docker exec mustpl_example cat /rendered.txt [config] value = foobar # <-- а вот и наше значение! $ docker kill mustpl_example
В общем и целом, если тебе понадобиться запустить приложение в контейнере, которое для своей конфигурации требует именно файл (а не флаги запуска или переменные окружения), и у тебя есть желание не хардкодить значения кофигурации, а сделать возможность их менять с помощью переменных окружения — то однозначно присмотрись к этой тулзовине.
Вместо заключения
Область применения этой утилиты, естественно, ограничена. Да, она не умеет Jinja-like модификаторов, кастомных функций (хотя, они описаны в спецификации mustache), да много ещё чего. Но она умеет просто шаблонизировать, и если сделает кому-то жизнь чуточку проще — я буду счастлив. Инструкции по установке, готовые бинарники, документация — всё это найдете в репозитории этой тулы.
Отдельное спасибо jetexe и AlexndrNovikov за ревью и режим «желтой уточки».
p.s. Если вы видите этот тест, то это означает что в данный момент его автор рассматривает предложения по работе, вместо CV — профиль на GitHub, а писать можно в хабра-личку или телеграм. Довольно много разрабатываю на Go, играю (и нередко выигрываю) в DevOps, проектирую системы различной сложности.
p.p.s. Об очепятках, пожалуйста, пишите в хабра-личку. Заранее благодарен!
ссылка на оригинал статьи https://habr.com/ru/post/684898/
Добавить комментарий