Почему важна идемпотентность и как писать идемпотентные bash-скрипты

Почему важна идемпотентность и как писать идемпотентные bash-скрипты главное изображение

Идемпотентность помогает проектировать более надёжные системы. Это математическая концепция, которую должен понимать каждый разработчик. Операция считается идемпотентной, если её многократное выполнение приводит к тому же результату, что и однократное выполнение. Например, умножение на 1 — идемпотентная операция.

x * 1 == x * 1 * 1

Умножение на ноль — тоже идемпотентная операция.

x * 0 == x * 0 * 0

Ключевая концепция, которую нужно запомнить: однократное выполнение операции может иметь побочные эффекты, но повторное выполнение этой же операции не приведёт ни к чему другому, кроме того, что было сделано при первом выполнении. То есть присваивание — идемпотентная операция.

x := 4

Вы можете присваивать x значение 4 сколько угодно раз, но x всё равно будет иметь значение 4. При этом присваивание x значения 4 один раз отличается от нуля.

Идемпотентность HTTP-методов

HTTP-методы могут быть идемпотентными или нет.

DELETE — идемпотентный метод. Вы можете сколько угодно раз использовать DELETE, но результат будет всегда таким же, как после первого выполнения операции. Например, DELETE /users/4/contacts/3 удаляет контакт с ID 3. Если вы выполните эту же операцию ещё раз, ничего не произойдёт, так как контакт уже удалён.

GET — тоже идемпотентный метод. Это даже не просто идемпотентный, но ещё и безопасный метод. А безопасные методы можно сравнить с умножением на единицу. Умножать на 1 можно сколько угодно раз, результат всегда будет одинаковым. GET просто получает ресурс. Например, никогда не стоит использовать нормальные ссылки для удаления ресурсов.

POST не относится к идемпотентным методам. При каждом выполнении POST можно ждать побочных эффектов. Например, когда вы используете POST для отправки данных из контактной формы, отправляется письмо.

Потребители и провайдеры используют эту концепцию, когда речь идёт об API. Дизайн с учётом этой концепции позволяет соблюдать правило наименьшего удивления.

NB! Правило наименьшего удивления или principle of least astonishment гласит, что результат выполнения операции должен быть очевидным и предсказуемым по названию операции. Полезно ознакомиться со статьями «Именование в программировании» и «Ошибки именования в программировании».

Очереди сообщений

Представьте, что разрабатываете веб-приложение для управления мероприятиями. В нём есть функция добавления пользователей в список приглашённых на то или иное мероприятие. То есть на одно мероприятие можно пригласить сколько угодно людей. Для ускорения процесса вы решаете отправлять приглашения через воркер. Когда пользователь оформляет мероприятие, сообщения отправляются в очередь. Воркер получает эти сообщения и отправляет приглашения на мероприятия.

Это очень распространённый паттерн, и если вы его ещё не используете, стоит подумать о том, чтобы начать.

В какой-то момент вы замечаете проблемы с SMTP и понимаете, что ваши письма в какой-то момент перестали отправляться. Логи не позволяют определить этот момент или понять, какие письма не отправлены. Вы решаете вызвать функцию, которая снова отправит письма. Но понимаете, что не знаете, для каких мероприятий нужно вызывать эту функцию.

Здесь в игру вступает идемпотентность.

Убедитесь в идемпотентности задач, которые выполняете через воркер

Вернёмся к примеру с электронными письмами. Вы можете сохранять в базе данных дату отправки письма приглашённому человеку в соответствующем ряду таблицы. Если письмо уже отправлялось, то есть значение в базе данных отличается от NULL, повторно отправлять письмо не нужно. Очень простое решение. Также можно проверять, завершено ли оформление мероприятия. Если оформление не завершено, письмо отправлять не нужно.

Если обобщить:

  • Убедитесь, что задача готова к исполнению. Если задача не готова к исполнению, ничего делать не надо.
  • Убедитесь, что задача ещё не исполнена. Если она исполнена, ничего не нужно делать.
  • Выполните задачу, занесите дату выполнения или какие-то другие данные в базу данные. Запишите в лог, что задача выполнена. Например, укажите, что приглашение на такое-то мероприятие отправлено такому-то пользователю.
  • Убедитесь, что задачи гранулированные. Нужно отправить пять писем? Запланируйте задачу, которая выполнит пять других задач. Пример кода ниже.
send_event_emails(event_id)
send_event_email_to_invitee(event_id, invitee_id)

Увидели, что что-то идёт не так? Можно снова вызвать функцию, которая отправит все приглашения на все мероприятия. Снова проблемы, когда половина писем отправлена? Исправьте проблему и вызовите функцию снова. Достаточно просто выяснить, какие письма не были отправлены. В качестве бонуса: вы можете понять, сколько писем в день рассылается, когда шлётся больше всего писем и так далее.

Помните, некоторые очереди не гарантируют отправку одного письма один раз. К ним относится Amazon SQS. Поэтому ваши воркеры должны выполнять только идемпотентные задачи.

SQL-миграции

Когда вы выполняете SQL-миграции, позаботьтесь об их идемпотентности.

Например, вам нужно разделить таблицу пользователя на две. Одна таблица будет для пользователей (users), а вторая для деталей, которые не всегда важны (profiles). Вы помещаете внешний ключ user_id в в таблицу profiles. Получаете миграцию, которая берёт каждый ряд в users (SELECT * FROM users) и вставляет ряд с пользовательскими данными в profiles. Запускаете миграцию, но она крашится через час. Это происходит из-за значений NULL, которые вы не учли. Вы исправляете ошибку, снова запускаете миграцию, но вдруг понимаете, что для некоторых пользователей созданы по два профиля.

Этого можно избежать с помощью идемпотентного решения. Вместо SELECT * FROM users можно выбрать пользователей, у которых ещё нет ряда в profiles. С таким решением миграцию можно запускать сколько угодно раз. В ходе каждого запуска будут обработаны данные тех пользователей, у которых ещё нет профиля. Огромное преимущество этого подхода — вам не нужно останавливать приложение при выполнении миграции. Как только вы готовы развернуть новую версию, в которой используется profiles, можно вызвать функцию, которая обеспечит миграцию новых пользователей. Это не самый лучший пример, так как пользователь во время миграции может изменить какие-то данные в таблице users. Учитывайте этот момент.

Денормализованные данные

У вас есть приложение, в котором у каждого пользователя есть много документов. Пользователи могут искать документы по тегам. Теги приходят из разных источников: названия документов, директорий, имена авторов, собственно теги и так далее. Вы решаете держать все теги для всех документов в таблице tags. Это выглядит так:

  • ID (хэш)
  • Tag (текст тега)
  • document _id (внешний ключ к документу)

Когда вы добавляете автора в документ, в таблицу tags попадают данные. Когда вы удаляете автора, соответствующий тег удаляется из таблицы.

Однажды вы замечаете в логах, что время от времени случались ошибки из-за проблемы кодировки символов. Вы фиксите проблему и разворачиваете приложение с обновлённым кодом. Однако видите, что много тегов отсутствует, и их нужно восстанавливать вручную.

Вместо функции add_tag_for_new_author вам нужно изначально сделать функцию update_tags_for_document. Эта функция не просто добавляет тег автора. Она проверяет документ, перестраивает список тегов и убеждается, что в базу данных попала корректная информация. При таком подходе таблица tags обрабатывается корректным способом: с помощью кэша. Вы можете удалить все ряды из таблицы и запустить update_tags_for_document. Требуется 2 секунды, чтобы обновить теги? Пусть этим занимается воркер, добавьте сообщение в очередь.

Как писать идемпотентные bash-скрипты

Иногда случается такое: вы пишете bash-скрипт, запускаете его, но через какое-то время он завершается из-за ошибки. Вы фиксите ошибку в системе и снова запускаете скрипт. Но часть шагов вашего скрипта падает с ошибкой, так как эти шаги уже были выполнены при первом запуске. Чтобы создавать отказоустойчивые системы, нужно писать идемпотентные программы.

Bash-идиомы

Ниже вы найдёте несколько советов и bash-идиом, которые помогут писать идемпотентные скрипты. Вы наверняка используете некоторые из них, не задумываясь о побочных эффектах.

Создание пустого файла

Это простая задача. Команда touch по умолчанию идемпотентная. Вы можете вызывать её много раз без проблем. Повторный вызов не повлияет на контент файла. Но он изменит время модификации файла. Если вы его используете, будьте осторожны.

touch example.txt

Создание директории

Никогда не используйте команду mkdir как есть. Применяйте её с флагом -p. Этот флаг гарантирует отсутствие ошибки при запуске mkdir, если директория уже существует.

mkdir -p mydir

Создание символической ссылки

Для создания символьных ссылок используется такая команда:

ln -s source target

Но эта команда генерирует ошибку, если вы повторно вызовите её с существующей целью. Чтобы сделать команду идемпотентной, добавьте флаг -f.

ln -sf source target

Флаг -f удаляет целевой путь перед созданием символической ссылки, поэтому команда всегда будет успешной. Если вы ссылаетесь на директорию, нужно добавить флаг -n. В противном случае повторный вызов создаст символическую ссылку внутри директории.

mkdir a
ln -sf a b
ln -sf a b
ls a
a

Для безопасности всегда используйте такую команду:

ln -sfn source target

Удаление файла

Следующую ниже команду нежелательно использовать для удаления файлов:

rm example.txt

Чтобы команда игнорировала несуществующие файлы, надо использовать флаг -f.

rm -f example.txt

Изменение файла

Иногда необходимо добавить новую строку в файл, например, /etc/fstab. Нужно убедиться, что повторный запуск скрипта не приводит к добавлению строки ещё раз. Представьте, что используете такой скрипт:

echo "/dev/sda1 /mnt/dev ext4 defaults 0 0" | sudo tee -a /etc/fstab

Если вы запустите скрипт повторно, получите дублирующуюся запись в /etc/fstab. Один из способов сделать скрипт идемпотентным — проверить существование конкретных плейсхолдеров с помощью grep.

if ! grep -qF "/mnt/dev" /etc/fstab; then
  echo "/dev/sda1 /mnt/dev ext4 defaults 0 0" | sudo tee -a /etc/fstab
fi

В данном случае -q обозначает тихий режим, а -F — режим фиксированной строки. Grep в фоновом режиме завершится с ошибкой, если /mnt/dev не существует, поэтому echo не вызовется.

Проверка существования переменной, файла или директории

Вы часто записываете в директорию, читаете из файла или выполняете простые строковые манипуляции с переменной. Например, у вас может быть инструмент, который создаёт новый файл, основываясь на определённых входящих данных.

echo "complex set of rules" > /etc/conf/foo.txt

Вычисление текста может быть дорогой операцией, поэтому вы не захотите писать его каждый раз, когда вызываете скрипт. Для идемпотентности вы проверяете существование файла с помощью флага -f встроенного свойства test командной оболочки.

if [ ! -f "/etc/conf/foo.txt" ]; then
 echo "complex set of rules" > /etc/conf/foo.txt
fi

В данном случае -f — только пример. Есть много других флагов, в том числе:

  • -d: директория;
  • -z: строка с нулевой длиной;
  • -p: пайп;
  • -x: файл с разрешением на исполнение.

Например, если вы хотите установить бинарный файл, но только если его ещё не существует, можно использовать флаг -x таким способом:

# install 1password CLI
if ! [ -x "$(command -v op)" ]; then
  export OP_VERSION="v0.5.6-003"
  curl -sS -o 1password.zip https://cache.agilebits.com/dist/1P/op/pkg/${OP_VERSION}/op_linux_amd64_${OP_VERSION}.zip
  unzip 1password.zip op -d /usr/local/bin
  rm -f 1password.zip
fi

Это устанавливает файл op в /usr/local/bin. Повторный запуск скрипта не приведёт к повторной установке бинарного файла. В данном случае есть ещё одно преимущество. Вы можете легко обновить бинарный файл. Для этого достаточно удалить его из системы, обновить OP_VERSION env и повторно запустить скрипт. Список флагов и операторов можно получить с помощью man test.

Форматирование устройства

Для форматирования можно использовать такую команду:

mkfs.ext4 "$VOLUME_NAME"

Эта команда печатает атрибуты для заданного блока устройства. Соответственно, предварительное добавление значит продолжение форматирования только при ошибке blkid, которая сообщает, что соответствующий том ещё не отформатирован.

Монтирование устройства

Попытка монтировать том в существующий каталог может выполняться с помощью такой команды:

mount -o discard,defaults,noatime "$VOLUME_NAME" "$DATA_DIR"

Но если он уже установлен, возникнет ошибка. Можно проверять вывод команды mount. Но есть лучший вариант. Это использование команды mountpoint.

if ! mountpoint -q "$DATA_DIR"; then
  mount -o discard,defaults,noatime "$VOLUME_NAME" "$DATA_DIR"
fi

Эта команда проверяет, является ли файл или каталог точкой монтирования. Флаг -g гарантирует, что она ничего не выводит и завершается в фоновом режиме. Если точка монтирования не существует, команда монтирует устройство.

Завершение

Вы узнали о важности идемпотентности, а также познакомились со способами написания идемпотентных скриптов. Многие из предложенных советов и трюков давно известны, но разработчики часто пренебрегают этими возможностями. Некоторые из представленных идиом очень специфичные. К таким относятся монтирование и форматирование. Тем не менее создание идемпотентных программ — выигрышная стратегия в долгосрочной перспективе. Поэтому полезно знать даже специфичные идиомы.

Адаптированный перевод статей The Importance of Idempotence by Antoine Leclair и How to write idempotent Bash scripts by Fatih Arslan.

Специально для сайта ITWORLD.UZ. Новость взята с сайта Блог Хекслета