Утилита make позволяет просто управлять контейнерами, объединив команды для сборки, тестирования и развёртывания в одном конфигурационном файле.


Разработчики многие годы используют утилиту 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

Итак, теперь контейнер создан, и его необходимо проверить, чтобы убедиться, что он соответствует некоторым минимальным требованиям. Для моего личного веб-сайта важно ответить на два вопроса:

  1. «Запускается ли веб-сервер?»
  2. «Что он возвращает?»

Это можно было сделать, выполняя 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е-дисками и посуточной оплатой у хостинга Маклауд.