Разработчики многие годы используют утилиту make. При запуске утилита читает файл с описанием проекта (Makefile) и, интерпретируя его содержимое, предпринимает необходимые действия. Файл с описанием проекта представляет собой текстовый конфигурационный файл, где описаны зависимости и команды, которые необходимо выполнить. Он похож на Dockerfile или другой файл конфигурации контейнера: там тоже указаны команды, на основе которых формируются образы для развёртывания контейнеров.
В этой статье я расскажу о том, как управлять контейнерами, используя Makefile. Контейнерный конфигурационный файл описывает образ контейнера, а Makefile описывает процесс сборки проекта, тестирование и развёртывание, а также другие полезные команды.
Цели и структура Makefile
Утилита make по умолчанию установлена в большинстве современных Linux-дистрибутивов, поэтому проблем с её использованием обычно не возникает. И чтобы начать её использовать, нужно создать файл с именем Makefile.
Makefile состоит из набора целей (target), зависимостей (dependency) и команд (commands), необходимых для их выполнения:
target: [dependency [dependency [dependency ... [dependency]]]]
commands
commands
# commands
Цель — это некий желаемый результат, способ достижения которого описан в Makefile. Под целью подразумевают выполнение некого действия либо получение новой версии файла. А dependency — это некие «исходные данные», условия необходимые для достижения указанной цели. Зависимость может быть результатом выполнения другой цели, либо обычным файлом.
Команды вводят с использованием символа табуляции (пробелы не подойдут). Цель может не содержать команд, но при этом содержать зависимости.
Сначала проверяются и выполняются все зависимости по порядку, в случае завершения какой-либо команды из зависимости с ненулевой ошибкой, выполнение команды прерывается. Далее выполняются все команды перечисленные в самой цели, в случае завершения какой-либо команды с ненулевой ошибкой, выполнение цели прерывается.
Все команды выполняются в контексте текущей директории. Можно закомментировать команду поставив перед ней знак решётки (#).
Чтобы отправить цель на выполнение, нужно указать её название при вызове утилиты make:
# Выполнение цели "build_image"
$ make build_image
В этом вся прелесть Makefile. Вы можете создать набор целей для каждой задачи. В контексте управления контейнерами, это создание образа и его отправка в реестр, тестирование и развёртывание контейнера, а также обновление его сервиса.
Я проиллюстрирую использование Makefile на примере своего личного веб-сайта и покажу как просто можно автоматизировать выполнение перечисленных задач.
Сборка, тестирование и развёртывание
Я создал простой веб-сайт с помощью Hugo, генератора статических сайтов. Он позволяет получить статический HTML из файлов YAML. В качестве веб-сервера я использовал Caddy.
Теперь посмотрим, как Makefile упростит сборку и развёртывание этого проекта на проде.
Первой целью в Makefile будет image_build:
image_build:
podman build --format docker -f Containerfile -t $(IMAGE_REF):$(HASH) .
Эта цель вызывает Podman для создания контейнера из его конфигурационного файла (Containerfile), включенного в проект. В приведённой выше команде есть несколько переменных. Переменные в Makefile можно использовать примерно так же, как в простых скриптовых языках программирования. В данном случае мне это нужно для создания «ссылки» на образ, который будет отправлен ??в удалённый реестр:
# Image values
REGISTRY := "us.gcr.io"
PROJECT := "my-project-name"
IMAGE := "some-image-name"
IMAGE_REF := $(REGISTRY)/$(PROJECT)/$(IMAGE)
# Git commit hash
HASH := $(shell git rev-parse --short HEAD)
Используя эти переменные, цель image_build формирует идентификатор вида us.gcr.io/my-project-name/my-image-name:abc1234.
В конце, в качестве тега образа добавлен хэш соответствующей Git-ревизии.
В нашем случае образ будет помечен тегом :latest. Этот тег нам пригодится для очистки контейнера:
image_tag:
podman tag $(IMAGE_REF):$(HASH) $(IMAGE_REF):latest
Итак, теперь контейнер создан, и его необходимо проверить, чтобы убедиться, что он соответствует некоторым минимальным требованиям. Для моего личного веб-сайта важно ответить на два вопроса:
- «Запускается ли веб-сервер?»
- «Что он возвращает?»
Это можно было сделать, выполняя shell-команды внутри Makefile. Но мне было проще написать на Python скрипт, который запускает контейнер с помощью Podman, отправляет HTTP-запрос в контейнер, проверяет, получает ли он ответ, а затем очищает его. Механизм обработки исключений в Python (try, except, finally) идеально подходит для этой задачи. И реализовать эту логику на Python значительно проще, чем на shell:
#!/usr/bin/env python3
import time
import argparse
from subprocess import check_call, CalledProcessError
from urllib.request import urlopen, Request
parser = argparse.ArgumentParser()
parser.add_argument('-i', '--image', action='store', required=True, help='image name')
args = parser.parse_args()
print(args.image)
try:
check_call("podman rm smk".split())
except CalledProcessError as err:
pass
check_call(
"podman run --rm --name=smk -p 8080:8080 -d {}".format(args.image).split()
)
time.sleep(5)
r = Request("http://localhost:8080", headers={'Host': 'chris.collins.is'})
try:
print(str(urlopen(r).read()))
finally:
check_call("podman kill smk".split())
Проверку можно усложнить. Например, во время процесса сборки можно потребовать, чтобы в ответе содержался хэш Git-ревизии. И тогда мы сможем проверить, содержится ли ответ заданный хэш.
Если с тестами всё хорошо, образ готов к развёртыванию. Я использую для размещения своего веб-сайта сервис Google Cloud Run: скармливаю ему образ, и он мгновенно выдаёт мне URL. Как и многие подобные сервисы, с ним можно общаться через интерфейс командной строки (CLI). С Cloud Run развёртывание сводится к отправке образов (созданных локально) в удалённый реестр контейнеров и к запуску процесса самого развёртывания с помощью инструмента командной строки gcloud.
Теперь создадим цель push. Я используюPodman (но можно вместо него использовать Skopeo или тот же Docker).
push:
podman push --remove-signatures $(IMAGE_REF):$(HASH)
podman push --remove-signatures $(IMAGE_REF):latest
После того, как образ будет отправлен, используйте команду gcloud run deploy, чтобы развернуть новейшую версию образа в проекте и «оживить» его.
Давайте снова создадим цель в Makefile. В этом файле я могу создать переменные для аргументов --platform и --region, чтобы мне не нужно было каждый раз запоминать их. Иначе мне пришлось бы вводить их из головы каждый раз, когда я развёртываю новый образ. А я так редко пишу для своего личного блога, что нет никаких шансов, что я запомню эти переменные.
rollout:
gcloud run deploy $(PROJECT) --image $(IMAGE_REF):$(HASH) --platform $(PLATFORM) --region $(REGION)
Дополнительные цели
Локальный запуск
При тестировании CSS или других изменений кода мне хочется проверять работу проекта локально, без развёртывания на удалённом сервере. Для этого в мой Makefile я добавлю цель run_local. Она выбирает контейнер в соответствии с моим текущим коммитом и открывает в браузере URL-адрес страницы с локального веб-сервера.
В традиционных реализациях, у программы make нет надежного способа узнать, чем именно является цель. Ведь она может быть как именем действия, так и именем файла. Утилита make просто ищет на диске файл с именем, которое указано в качестве цели. Если такой файл существует, то цель считается именем файла.
Поэтому приходится явно объявлять цели абстрактными (то есть действиями). Для этого достаточно добавить ключевое слово .PHONY.
Создадим абстрактную цель run_local:
.PHONY: run_local
run_local:
podman stop mansmk ; podman rm mansmk ; podman run --name=mansmk --rm -p $(HOST_ADDR):$(HOST_PORT):$(TARGET_PORT) -d $(IMAGE_REF):$(HASH) && $(BROWSER) $(HOST_URL):$(HOST_PORT)
Я также использую переменную для имени браузера, поэтому при желании могу тестировать на разных браузерах. Когда я запускаю make run_local, по умолчанию сайт открывается в Firefox. Чтобы протестировать то же самое в Google Chrome, я должен модифицировать вызов make run_local, добавив BROWSER = 'google-chrome'.
Очистка старых контейнеров и образов
При работе с контейнерами очистка старых образов — неприятная рутинная работа, особенно при частых итерациях. Поэтому добавим в Makefile цели для выполнения этих задач. Если контейнер не существует, Podman или Docker вернутся из процесса очистки с кодом 125. Но к сожалению, make ожидает, что каждая команда вернёт 0 (если всё ОК) или прекратит работу (если что-то не так). Поэтому придётся написать на bash вот такую обработку:
#!/usr/bin/env bash
ID="${@}"
podman stop ${ID} 2>/dev/null
if [[ $? == 125 ]]
then
# No such container
exit 0
elif [[ $? == 0 ]]
then
podman rm ${ID} 2>/dev/null
else
exit $?
fi
Для очистки образов требуется реализовать более сложную логику, но всё это можно сделать в Makefile. Чтобы сделать это легко, я добавлю метку (через Containerfile) к образу (на этапе его создания). Это позволяет легко найти все образы с заданными метками. Самые последние из них можно определить по тегу :latest. И теперь все образы, кроме самых последних (с тегом: latest), могут быть удалены:
clean_images:
$(eval LATEST_IMAGES := $(shell podman images --filter "label=my-project.purpose=app-image" --no-trunc | awk '/latest/ {print $$3}'))
podman images --filter "label=my-project.purpose=app-image" --no-trunc --quiet | grep -v $(LATEST_IMAGES) | xargs --no-run-if-empty --max-lines=1 podman image rm
Связанные одной целью
На данный момент мой Makefile включает команды для создания и маркировки образов, тестирования, отправки образов, развёртывания новых версий, очистки образов и запуска локальной версии. Выполнять каждую из них с помощью make image_build && make image_tag && make test и так далее значительно проще, чем выполнение каждой из команд, находящихся внутри этих вызовов. Но всё можно упростить ещё больше.
Я хочу, чтобы make в моём проекте по умолчанию делала всё — от создания образа до тестирования, развёртывания и очистки:
.PHONY: all
all: build test deploy clean
.PHONY: build image_build image_tag
build: image_build image_tag
.PHONY: deploy push rollout
deploy: push rollout
.PHONY: clean clean_containers clean_images
clean: clean_containers clean_images
Makefile может запускать сразу несколько целей. Сгруппируем цели image_build и image_tag внутри одной цели build. Теперь, чтобы запустить их, нужно просто вызвать make build. Более того, цели build, test, deploy и clean ядополнительно сгруппирую в цель all, что позволит мне запускать их все (в указанной последовательности) за один вызов:
$ make all
или ещё проще:
$ make
Не только контейнеры
C помощью Makefile можно объединить все команды, необходимые для сборки, тестирования и развёртывания проекта. Так можно упростить и автоматизировать огромное количество задач.
Но Makefile можно использовать и для задач, связанных с разработкой: запуск модульных тестов, компиляция двоичных файлов и формирование контрольных сумм.
Жаль, Makefile не может писать код за нас (подмигивает).
Купить VDS-хостинг с быстрыми NVMе-дисками и посуточной оплатой у хостинга Маклауд.
OnYourLips
ИМХО киллер-фича мейкфайла — это то, что он давно перестал развиваться, и поэтому кто-то не сможет добавить в него то, что не будет работать у других.
Если писать вместо него скрипты сборки на альтернативных технологиях (например rake или Paver), то придется ограничить себе каким-то старым подмножеством языка, и периодически следить за тем, какие вещи становятся deprecated. Но на мой взгляд это того стоит: make очень обманчив в своей простоте и его поддержка обходится слишком дорого.