Любое программное обеспечение необходимо обновлять — ПО для систем хранения данных (СХД) не исключение. Логика апгрейда в СХД нетривиальна: в системе есть несколько контроллеров хранения, обновлять которые нужно последовательно, учитывая все возможные риски — потерю отказоустойчивости, конфликт версий и другие. При этом даунтайм сервиса или миграция данных на другую систему — варианты, которые не устроят ни одну компанию.

Я Владимир Приходько, руководитель отдела разработки подсистем управления в компании YADRO. Вместе с командой мы развиваем пользовательский функционал СХД. В тексте расскажу о специфике бесшовного обновления ПО в системах хранения данных и дам рекомендации, как выстроить этот процесс с учетом лучших практик. Все описанные подходы мы с командой успешно используем в обновлении СХД TATLIN.UNIFIED.

Почему обновлять программное обеспечение в СХД трудно

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

На фото — контрольное шасси СХД TATLIN.UNIFIED (вид сверху). Зеленым выделен один из контроллеров.
На фото — контрольное шасси СХД TATLIN.UNIFIED (вид сверху). Зеленым выделен один из контроллеров.

Сложность устройства — лишь один из факторов, усложняющих работу. Перечислю еще несколько:

  • В кластере могут быть контроллеры с разными версиями ПО и микросервисы, которые общаются между собой на разных контроллерах. Значит, нам нужно поддерживать обратную совместимость протоколов общения.

  • Перед обновлением часто необходимо подготовить систему — например, поправить кластерные конфиги. Так следующая версия ПО будет работать корректнее.

  • Пользователь СХД не должен терять доступ к созданным ресурсам даже во время сервисных действий.

  • Разработчикам важно избежать проблемы совместимости компонентов, когда контроллеры неминуемо окажутся на разных версиях ПО.

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

Разделы системного диска на контроллере, которые участвуют в обновлении

Давайте рассмотрим разделы системного диска на контроллере хранения, которые понадобятся для обновления системы.

Это упрощенная схема. Структура системного диска необязательно выглядит так.
Это упрощенная схема. Структура системного диска необязательно выглядит так.

Разделы part0 и part1 обычно используются для обновления. При старте контроллера система считает из GRUB информацию о том, откуда ей стоит загружаться, и выберет один из них. Например, если система запущена на part0, в процессе обновления мы подготовим раздел part1 — с него загрузимся после апгрейда контроллера. Каждый из разделов отвечает за свою версию ПО и связанные с ней микросервисы. part0 и part1 также позволяют выполнить процедуру отката, если обновление пойдет не по плану.

В раздел resque загружается и распаковывается архив с обновлением. Этот раздел доступен с part0 и part1 только в момент апгрейда — после он очищается и отмонтируется.

Вот что входит в раздел resque.
Вот что входит в раздел resque.

Структура архива с обновлением

Образ новой версии ПО — это архив, в котором хранится все необходимое для обновления:

  • Большую его часть занимает SquashFS — сжатая файловая система, которая в процессе обновления развернется на part0 или part1. В ней все файлы новой версии (файлы операционной системы, компоненты и т. д.).

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

  • Для бесшовного обновления нужны тщательные приготовления и проверки в ходе процесса. С этой целью добавляем в образ архив с prechecks- и postchecks-скриптами. Проверки для разных версий ПО могут отличаться, поэтому скрипты динамические, меняются от версии к версии и поставляются с образом. О них я еще расскажу подробнее.

Поскольку вся специфика находится в образе (в сжатой файловой системе и архиве со скриптами), мы можем обновляться на новую версию с разных версий ПО. Также вместе с этим образом мы можем добавить код, который меняет поведение системы. На практике зачастую это очень нужно.

История про баг

Мы всегда дожидались корректной остановки ресурсов на контроллере, который запланирован к обновлению. Но в одной из версий допустили баг, из-за него остановка ресурсов на контроллере не происходила, а обновление стопорилось. Поэтому в следующей версии мы добавили дополнительную проверку, которая меняла обычный ребут на «вышибание табуретки» (принудительное выключение контроллера, о котором можно прочитать тут) из-под контроллера через SysRq. Так мы успешно завершали обновления с версии, которая содержала баг-помеху.

Фазы обновления ПО в системе хранения данных

Алгоритм обновления, к которому мы в YADRO пришли на практике, выглядит так (все шаги выполняются на каждом контроллере):

  1. Распаковка образа на resque-партицию.

  2. Запуск prechecks-скриптов.

  3. Прогон actions-скриптов.

  4. Обновление контроллера.

  5. Проверка корректности обновления.

  6. Запуск postchecks-скриптов.

  7. Очистка и отмонтирование resque.

  8. Завершение стейт-машины и запись информации об успешном завершении процесса обновления на всех нодах в базу.

В YADRO мы написали отдельный микросервис, который отвечает за запуск и проведение обновления, позволяет мониторить состояние этого процесса.

В качестве языка выбрали Go, потому что давно с ним работаем, и собрали ряд внутренних библиотек. Для управления запуском обновления и мониторинга используем тандем Pacemaker и systemd. Для хранения записей о процессах обновления и служебной информации — кластерную key-value-базу etcd.

Сам микросервис делится на две части:

  • Сервер, обрабатывающий запросы пользователя. Проверяет корректность загружаемого образа и то, что его версия больше текущей версии кластера. Поддерживать даунгрейд сложно, к тому же пользовательских сценариев для него мы не нашли. Также сервер копирует образ на все контроллеры в системе и проверяет контрольную сумму образа. Так он убеждается, что процесс завершился корректно.

  • Асинхронная часть. Запускает стейт-машину, которая последовательно проводит каждый контроллер по всем фазам обновления и сохраняет соответствующие стейты в базе.

Теперь рассмотрим некоторые фазы подробнее.

Обновление контроллера

Как я упоминал выше, здесь происходит развертывание SquashFS на партицию part0 или part1. Прописывается следующий раздел в настройках загрузки. Контроллер перезагружается.

Проверка корректности обновления

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

Скрипты проверок — prechecks и postchecks

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

Для этого нужно использовать скрипты проверок — они помогут поймать проблемы в процессе обновления системы. Полезно разделять скрипты на «нодные» и «кластерные». Первые запускает каждый контроллер, а вторые — лишь один из них (любой).

Для бесшовного обновления СХД достаточно двух видов скриптов:

  • prechecks — набор проверок до того, как любой из контроллеров совершит обновление. Помогает определить, готов ли контроллер к апгрейду.

  • postchecks — набор проверок, который оценивает успешность обновления контроллера. Этот же скрипт может выполнять изменения в системе, например добавлять функционал, требующий полного апгрейда контроллеров.

Рекомендации для prechecks-скриптов

Запуск prechecks обычно занимает около 10–15 минут и входит в отведенный на обновление временной слот. Если в ходе проверки произойдет ошибка, инженеру потребуется время на решение проблемы. Иногда поиск причин может затянуться, а решение потребует отдельного патча от разработчиков СХД.

Здесь хорошей практикой будет прогон prechecks-скриптов за пару дней до запланированного апгрейда. Так вы будете уверены, что с системой почти наверняка все в порядке. Конечно, между прохождением проверок и обновлением пройдет время, за которое может случиться разное. Но какие-то критические проблемы вы точно исключите, а значит, с большей вероятностью справитесь с ними в короткий слот под апгрейд.

Далее в тексте я расскажу, как автоматизировать функционал проверки готовности комплекса к обновлению.

Разделение prechecks-скриптов на две фазы

К разделению подготовительных скриптов на два шага мы в команде пришли не сразу. Но это решение существенно повлияло на качество выполняемого апгрейда, поэтому я рекомендую его как хорошую практику.

Толчком для разделения стали две проблемы:

  • нельзя отменить обновление, поскольку мы уже могли внести в систему критические изменения,

  • нельзя проверить, готова ли система к обновлению, до апгрейда.

Из одного этапа precheck-скриптов лучше сделать два. Выделить checks — скрипты проверки, что кластер или нода готовы к обновлению. И actions — скрипты, ответственные за подготовку контроллера к самому обновлению. Это разделение позволяет реализовать несколько важных фич, о которых я расскажу далее в тексте.

Само разделение реализовать несложно. Для этого нужно:

  1. Поддержать его в скриптах, разбив их на две фазы, выполняемые разными командами.

Выглядеть это может так
case $actions_type in
"precheck-checks")
    case $kind_type in
    "node")
      checks=(
        "md_check.sc main"
        "wait_ready.sc wait_ready"
        "fc_links_check.sc check_fc_links"
      );;
    "cluster")
      checks=(
        "check_zero_media.sc check_zero_media"
        "config_checks.sc config_resolved"
        "config_validate.sc validate_config"
        "pools_check.sc check_thin_pools"
      );;
    esac;;
"precheck-actions")
  case $kind_type in
  "node")
    actions=(
      "update_config.act main"
      "move_node_number.act main"
      "sync.act main"
    );;
  "cluster")
    actions=(
      "migrate_snmp.act main"
      "migrate_syslog.act main"
      "cluster-ipc-limit.act main"
      "set_local_bypass_flag.act main"
      "data_role_to_interfaces.act main"
    );;
  esac;;
esac

  1. Разделить фазы checks и actions в микросервисе, добавив дополнительные состояния в стейт-машину. Шаг checks выполняется на всех нодах, после чего мы приступаем к шагу actions.

  2. Важно поддержать в скриптах совместимость старой версии микросервиса с новым образом: прошлая версия ПО ничего не знает о разделении этапа и новых шагах.

Такой подход легко ложится в базовый алгоритм выполнения обновления и позволяет проверить готовность системы к обновлению.

Проверка готовности к обновлению

Благодаря этой функции вы сможете заранее прогнать все checks до часа X и обнаружить возможные проблемы до начала обновления. 

Чтобы реализовать возможность, достаточно добавить новое состояние (например, PRECHECKS_FINISHED) в стейт-машину и закончить процесс на этой стадии.

Остается решить, какие prechecks мы запускаем (то есть откуда мы их достаем). Есть несколько вариантов:

  • Занести на систему весь образ — он займет много места, зато процесс не будет отличаться от стандартного.

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

  • Залить отдельный небольшой образ, который будет содержать только проверки. Чтобы уметь создавать такой образ в дополнение к основному, потребуются изменения в CI.

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

Экстренные фазы

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

Откат процесса обновления

После обновления одного из контроллеров (4-й шаг обновления) мы проверяем корректность версии, доступность сервисов и ресурсов. Если что-то пошло не так, пытаемся решить проблему автоматически. Но иногда это не помогает. Возникает риск, что разные ноды будут жить на разных версиях ПО долгое время.

В таком случае стоит признать процесс обновления неудачным и автоматически выполнить откат системы до прошлой версии. Все ноды, которые уже успели обновиться, переведутся на старую партицию. Кластер приходит в состояние «до обновления». Так пользователь может продолжить использовать СХД без просадки производительности. А инженеры начинают разбираться, по какой причине апгрейд не удался.

Отмена процесса обновления

Представим ситуацию: на этапе загрузки образа или выполнения prechecks что-то пошло не так — например, попался битый файл или упала одна из проверок. Пока решается проблема, отведенное для обновления время может подойти к концу. А следующее окно под апгрейд может настать не скоро. Чтобы все это время у пользователя в CLI не висел ошибочный статус, можно добавить небольшую, но приятную опцию отмены обновления.

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

Если вам понравился текст, вам могут быть интересны другие статьи блога:

Тестирование блочных стораджей: нюансы и особенности практики

→ Пишем свой драйвер Molecule без костылей и боли

Из FPGA-дизайнера в ASIC: четыре личных истории

Три решения для оптимизации апгрейда ПО

В этой части текста я расскажу, какие дополнительные «обвязки» для системы обновления помогут сделать процесс более надежным.

Исключаем параллельное исполнение шагов — кластерная блокировка

Ноды выполняют каждую фазу по очереди. Хотя на некоторых шагах теоретически возможно параллельное выполнение, лучше реализовать поступательный — простой и надежный — алгоритм обновления. Чтобы запретить параллельное исполнение шагов, нужна кластерная блокировка. Расскажу, как она устроена.

Когда нода на основе данных о статусе обновления решает, что ей необходимо приступить к следующему шагу, она пытается взять Lock (лок) в базе данных etcd. Мы сохраняем uuid лока и идентификатор ноды, которая его взяла. Нода, согласно состоянию из базы, определяет, что ей необходимо сделать в этой фазе.

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

Схематичное представление работы локов при обновлении.
Схематичное представление работы локов при обновлении.

Если у ноды не получилось взять лок из-за того, что он уже во владении соседнего контроллера, она начинает процесс «слежки» за локом. Если лок не освобождается по прошествии определенного времени (для каждой фазы — свой таймаут), процесс обновления признается «упавшим» (fall).

Нода, которая заметила «падение», перехватывает лок, одновременно с этим записывая ошибочный стейт в базу. Далее уже сервисный инженер, проводящий обновление, должен понять, что случилось, оживить упавшую ноду и продолжить апгрейд.

Запрет на изменение системы в процессе обновления

Любые пользовательские изменения во время перезагрузки нод могут вызвать проблемы кластерного взаимодействия. Чтобы сделать апгрейд стабильнее, мы можем запретить пользователю вносить изменения в систему в это время.

Сделать это можно отдельным шагом, с помощью скриптов prepare-actions. Выполним их после всех checks-скриптов, но до запуска actions. Запрет легко сделать через конфигурацию Nginx для публичных микросервисов:

location /<location-name>/ {
    ....
    if ($upgrade_status = is_upgrading-method_not_allowed) {
        error_page 403  /403-forbidden-due-to-upgrade.json;
        return 403;
    }
    ....
}

В пару к скрипту нужен шаг, который отменит запрет на изменения после обновления.

"prepare-actions")
    case $kind_type in
    "node")
      actions=(
        "nginx_set_is_upgrading.act main"
      );;
    "cluster")
      actions=();;
    esac;;
"post-upgrade-actions")
    case $kind_type in
    "node")
      actions=(
        "nginx_unset_is_upgrading.act main"
      );;
    "cluster")
      actions=();;
    esac;;

В коде видно, что на шаге prepare-actions мы вызвали перевод Nginx в upgrade-режим. Для выхода Nginx из этого режима мы добавили шаг post-upgrade-actions.

Отказ от повторного прохождения скриптов

Напомню, что мы разделили предваряющие обновление скрипты на checks и actions — последние вносят необходимые изменения в систему.

Повторный прогон checks может быть оправдан желанием удостовериться, что все готово к обновлению. Его можно оставить. А вот для actions повторный прогон избыточен — их исполнение может быть затратным как по времени, так и по ресурсам. Например, нам нужно поменять кластерную конфигурацию ресурсов, а их в системе могут быть сотни и даже тысячи.

Значит, делаем так, чтобы все actions начинались именно со скрипта, который завершился ошибкой. Для этого налаживаем общение между микросервисом и скриптами. Учим микросервис получать сообщения об ошибке в таком формате:

{  
  "msg": "really bad error",  
  "exit_code": "119",  
  "check_id": "precheck-actions/node-wrong_action.act main"  
}

Теперь необходимо сохранить эту структуру в базу, а при следующем вызове передать ее check_id в executor (внутренняя утилита, которая исполняет prechecks- и action-скрипты из образа), выполняющий скрипты. Дальше дело техники: поддержать запуск скриптов при нахождении переданного check_id в списке.

Опция пропуска скрипта

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

Чтобы обойти остановку обновления и пропустить недавно «упавший» скрипт при повторном прогоне на ноде, можно добавить опцию --skip. Реализация довольно проста. Мы уже храним check_id «упавшего» скрипта (смотрите предыдущий пункт). К команде возобновления обновления добавляем опцию --skip. В микросервисе запоминаем все «скипнутые» скрипты. А в executor, который их запускает, добавляем логику пропуска скриптов, которые решили не выполнять.

Какие еще практики можно внедрить

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

Отдельный репозиторий для скриптов

Когда мы проектировали систему скриптов обновления, мы делали все возможное, чтобы их могли писать все сотрудники, работающие над развитием СХД TATLIN.UNIFIED. Во-первых, не хотели сосредотачивать знания и компетенции в одной команде. Во-вторых, человек, который пишет скрипт обновления, должен не только знать, как написать скрипт, но и быть погруженным в предметную область. А их в СХД очень много.

В итоге мы положили исходники скриптов в репозиторий образа, и любой сотрудник, работающий с TATLIN.UNIFIED, мог его пополнять. C ростом продукта и количества человек, которые пишут скрипты обновления, мы столкнулись с двумя проблемами:

  • начал разъезжаться общий стиль скриптов — например, по-разному формировались ошибки, которые показываем пользователю.

  • проскакивали очевидные ошибки; их можно было отловить автотестированием СХД, но в контексте апгрейда и ошибок пречеков это очень трудозатратно.

В результате мы вынесли все скрипты в отдельный репозиторий. У него есть отдельный ревьюер — специалист, который отлавливает ошибки заранее и следит за консистентностью скриптов.

Сам образ при этом не поменялся — изменения затронули лишь процесс его создания. Теперь в репозитории образа указывается лишь версия скриптов, которую туда нужно поставить. А при создании образа берется RPM со скриптами, который создает наш CI при коммите в мастер-ветку репозитория со скриптами. RPM устанавливается и запаковывается нужным образом рядом со SquashFS. Новая методика не влияет на ход обновления, так что нам не надо беспокоиться об обратной совместимости старых версий с образами.

Добавление утилиты в структуру образа

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

Кратко напомню, как это устроено.
Кратко напомню, как это устроено.

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

Инкрементальное обновление

Иногда важно добавить быстрое изменение системы без запуска всего цикла обновления.

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

Заключение

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

Сталкивались ли вы с задачей обновления ПО для системы хранения данных? Если да, то с какими проблемами сталкивались и как их решали? Пишите в комментариях!

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


  1. mpa4b
    10.10.2023 15:03

    В btrfs можно иметь на одном физическом разделе как и 'rescue' subvolume, так и две разных рабочиx rootfs в виде опять же subvolumes, и выбирать в грубе, какую rootfs подмонтировать для загрузки. Клонирование (как в ro, так и в rw subvol) выполняется почти мгновенно и без затраты дискового пространства на копии (механизм CoW).


    1. yadro_team
      10.10.2023 15:03

      Действительно, метод разделения на партиции можно выбрать другой — через тот же btrfs. Но существенных преимуществ именно в этом способе не видим.