Как обновить ядро в системе без перезапуска сервисов (пошаговая инструкция)

в 6:11, , рубрики: checkpoint, containers, CRIU, docker, kernel, kexec, linux, open source, openvz, systemd, travis, travis-ci, Ubuntu, virtuozzo, vz, Блог компании Virtuozzo, Разработка под Linux, системное программирование

Как вы думаете на сколько реально зайти на машину по ssh, обновить систему, загрузить новое ядро и при этом оставаться в той же ssh сессии. Сейчас есть модное движения по обновлению ядра на лету (ksplice, KernelCare, ReadyKernel, etc), но у этого способа есть много ограничений. Во-первых, он не позволяет применять изменения, которые меняют структуру данных. Во-вторых, объекты в памяти могут уже содержать неверные данные, которые могут вызвать проблемы в дальнейшем. Здесь будет описан более «честный» способ обновить ядро. На самом деле, сам способ уже давно известен [1], а ценность этой статьи в том, что мы разберем все в деталях на реальном примере, поймем на сколько это просто или сложно, и чего стоит ждать от подобных экспериментов.

Travis CI — одна из популярных систем непрерывной интеграции, которая хорошо работает с Github. Сервис быстро развивается и если несколько лет назад он предоставлял только контейнеры с не очень свежими дистрибутивами, то сегодня там есть выбор между контейнерами и вмками, есть поддержка не только Linux систем и многое другое.

Мы начали использовать Travis-CI в нашем проекте CRIU (checkpoint/restrore in userspace) несколько лет назад и всегда брали от сервиса максимум. Начинали с проверки компиляции на x86_64, а сегодня Travis-CI запускает наши тесты, проверяет компиляцию на всех архитектурах, с разными компиляторами и даже тестирует совместимость с новыми ядрами, в том числе и самой нестабильной и передовой веткой Linux-Next.

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

А теперь к делу, господа…

Но сегодня я хочу рассказать совсем не о том, как мы тестируем CRIU, а об одном интересном варианте его использования. Представьте, что на входе у нас есть виртуальная машина, в которой через ssh запущен процесс. Как нам загрузить свое ядро так, чтобы процесс этого не заметил? Это ровно та ситуация, которую мы имеем в Travis-CI.

Внешнего доступа к виртуалке мы не имеем, и если процесс Travis по какой-то причине умирает (завершается), то сервис завершает задачу и удаляет ВМ. Согласитесь, задачка, прямо скажем, непростая. Мы даже сделали внизу голосование – просигнальте, пришло ли вам сразу в голову решение или нет.

Но мы поступили следующим образом: берем CRIU, дампим ssh-сессию Travis, загружаем новое ядро, восстанавливаем процессы и бежим дальше. Примерно так я думал, когда решил немного развлечься после обеда и показать, как все это взлетит.

Сразу скажу, что задача – отнюдь не абстрактная. У нее есть несколько реальных применений. Одно из них — это желание некоторых пользователей загрузить Ubuntu 16.04 (https://github.com/travis-ci/travis-ci/issues/5821). Разработчики Travis решать эту задачу пока не собираются, а мы можем попробовать сделать это без их помощи. Идея тут та же, берем начальную систему 14.04, обновляем ее и перезагружаемся в новое окружение.

Решение

Обновление системы — меньшая из бед, решается парой команд:

sed -i -e "s/trusty/xenial/g" /etc/apt/sources.list
apt-get update && apt-get dist-upgrade -y

Но дальше становится намного веселее. Во-первых, возникает опрос: откуда начинать дампить? Во-вторых, как будем восстанавливать? Если что-то пойдет не так, как мы узнаем, что именно? От замороженного Travis помощи ждать не приходится.

Так что начнем разбираться своими силами. Смотрим на дерево процессов и понимаем, что дампить надо начинать с процесса SSHD, который обрабатывает нашу SSH-сессию.

Дерево процессов:

12253 ?        Ss     0:03 /usr/sbin/sshd -D
32443 ?        Ss     0:00  _ sshd: root@pts/0
32539 pts/0    Ss     0:00  |   _ -bash

Идем по всем родителям, начиная с себя, и берем второй процесс sshd от init-а:

ppid=""
pid=$$
while :; do
   p=$(awk '/^PPid:/ { print $2 }' /proc/$pid/status)
   test “$p” -eq 1 && break
   ppid=$pid
   pid=$p
done
echo $pid

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

setsid bash -c "setsid ./scripts/travis/kexec-dump.sh $ppid < /dev/null &> /travis.log &"

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

./criu/criu dump -D /imgs -o dump.log -t $pid --tcp-established --ext-unix-sk -v4 --file-locks —link-remap

Если перевести ее на русский язык, это команда звучит примерно так: “CRIU, сделай нам дамп поддерева начиная с процесса $pid, все данные сложи в директорию /imgs, логи сохрани в файле dump.log, рассказывай подробно обо всем что делаешь, а также разрешаем тебе сохранить tcp-сокеты, unix-сокеты связанные с внешним миром, файловые локи и дескрипторы на удаленные файлы”.

Кажется, тут все понятно кроме удаленных файлов — откуда они возьмутся? Но достаточно вспомнить, что мы установили мажорный Update на систему, а это значит, что обновилось почти все, в том числе библиотеки и запускаемые файлы. При этом наш процесс не перезапускался и по прежнему использует старые версии этих файлов. Именно для них мы и указываем опцию --link-remap.

Тут же возникает и еще одна проблема. Между сохранением и восстановлением процессов сетевой трафик должен быть заблокирован, иначе нет никакой гарантии, что TCP соединения переживут эту операцию. CRIU добавляет для этого пару правил Iptables, и наша задача — эти правила восстановить после загрузки нового ядра, но до того как произойдет настройка сети. Здесь мне пришлось немного погуглить, но в целом также задача решилась не слишком сложно.

cat > /etc/network/if-pre-up.d/iptablesload << EOF
#!/bin/sh
iptables-restore < /etc/iptables.rules
unlink /etc/network/if-pre-up.d/iptablesload
unlink /etc/iptables.rules
exit 0
EOF

chmod +x /etc/network/if-pre-up.d/iptablesload
iptables-save -c > /etc/iptables.rules

Восстановление

Итак, процессы сохранены, и пришло время время подготовить того, кто будет их восстанавливать. Тут нам придется написать свой небольшой сервис.

cat > /lib/systemd/system/crtr.service << EOF
[Unit]
Description=Restore a Travis process

[Service]
Type=idle
ExecStart=/root/criu/scripts/travis/kexec-restore.sh $d $f

[Install]
WantedBy=multi-user.target
EOF

Кажется все готово и можно взлетать. Ключ на старт.

kernel=$(ls /boot/vmlinuz* | tail -n 1 | sed 's/.*vmlinuz-(.*)/1/')
echo $kernel
kexec -l /boot/vmlinuz-$kernel --initrd=/boot/initrd.img-$kernel --reuse-cmdline

Полетели!

kexec -e

Так мы взлетели, но, как и SpaceX, с первого раза сесть не смогли. А не смогли мы, потому что посадочная платформа была кем-то уже занята. А если серьезно, то проблема в том, что CRIU позволяет восстанавливать процессы только с теми же идентификаторами, что у них были на момент дампа. Мы же перезагрузились в новую систему, где systemd (!!!) и процессов стало немного больше. Эта проблема уже давно изучена наукой и тут нам помогут контейнеры, точнее говоря, только их маленькая часть, называемая пространством имен процессов (pid namespace).

unshare -pfm --mount-proc --propagation=private ./criu/criu restore 
-D /imgs -o restore.log -j --tcp-established --ext-unix-sk 
-v4 -l --link-remap &

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

#!/usr/bin/env python2
import dropbox, sys, os
access_token = os.getenv("DROPBOX_TOKEN")
client = dropbox.client.DropboxClient(access_token)
f = open(sys.argv[1])
fname = os.path.basename(sys.argv[1])
response = client.put_file(fname, f)
print 'uploaded: ', response
print "====================="
print client.share(fname)['url']
print "====================="

Под прицелом камер мы теряем очередной корабль и понимаем, что шутки кончились. На этот раз нам жалуются на DBus сокет, т е это уже такая связь, состояние которой нам недоступно, ведь ей владеет только DBus-демон. С другой стороны, зачем sshd нужен этот сокет? Наверняка он хочет отслеживать состояние сети и прочую ерунду. Мы ничего такого делать не собираемся (точнее мы уже все сделали), так что давайте просто восстановим этот сокет как-нибудь и поедем дальше.

diff --git a/criu/sk-unix.c b/criu/sk-unix.c
index 5cbe07a..f856552 100644
--- a/criu/sk-unix.c
+++ b/criu/sk-unix.c
@@ -708,5 +708,4 @@ static int dump_external_sockets(struct unix_sk_desc *peer)
                               if (peer->type != SOCK_DGRAM) {
                                       show_one_unix("Ext stream not supported", peer);
                                       pr_err("Can't dump half of stream unix connection.n");
-                                       return -1;
                               }

Фактически мы сделали свой собственный патч для CRIU. Это можно было решить более элегантно с помощью плагинов, но так было быстрее. Снова заливаем наши изменения и ждем очередного падения. На этот раз возникает проблема с псевдотерминалами: нужные нам номера уже кем-то используются. Мы могли бы монтировать devpts с newinstance, но эта опция с недавнего времени не работает.

   - The newinstance mount option continues to be accepted but is now
      Ignored. // Eric W. Biederman

Похоже пришло время залезть в образы процессов и немного подправить их напильником. Давайте поменяет в них номера псевдотерминалов и добавим префикс 1. Был терминал с номером 1, станет с номером 11. Для этого в CRIU есть возможность переформатировать образа в Json формат и обратно. Выглядит это примерно так:

./crit/crit show /imgs/tty-info.img  | 
   sed 's/"index": ([0-9]*)/"index": 11/' | 
   ./crit/crit encode > /imgs/tty-info.img.new
./crit/crit show /imgs/reg-files.img  | 
   sed 's|/dev/pts/([0-9]*)|/dev/pts/11|' | 
   ./crit/crit encode > /imgs/reg-files.img.new

Опять запускаем и ждем. Время уже давно послеобеденное, и вся эта затея явно сильно затянулась. Привычно получаем ошибку — на этот раз о том, что какие-то fifo файлы из /run/systemd/sessions не могут быть восстановлены. Разбираться, что это за файлы нет никакого желания, поэтому перед восстановлением просто создадим их и побежим дальше.

f=$(lsof -p $1 | grep /run/systemd/sessions | awk '{ print $9 }')
...
criu dump
kexec
mkfifo $f
criu restore

Опять падаем, и на этот раз похоже налетаем на баг в CRIU. Видим, что sys_prctl(PR_SET_MM, PR_SET_MM_MAP, …) возвращает EACCES, лезем в ядро и находим, что виной тому восстановление ссылки на запускаемый файл. Ядро видит, что мы передаем ссылку на файл, у которого нет соответствующего бита. Вы же помните, что мы обновили систему целиком, и теперь эта ссылка из процесса указывает на удаленный файл. Оказывается, что перед тем как удалить файл, dpkg снял с него права на запуск.

# strace -e chmod,link,unlink -f apt-get install --reinstall sudo
...
3331  link("/usr/bin/sudo", "/usr/bin/sudo.dpkg-tmp") = 0
3331  chmod("/usr/bin/sudo.dpkg-tmp", 0600) = 0
3331  unlink("/usr/bin/sudo.dpkg-tmp")  = 0
...

Кажется, достаточно сделать еще один патч к CRIU, и золотой ключик будет у нас в кармане.

diff --git a/criu/cr-restore.c b/criu/cr-restore.c
index 12f13ae..39277cf 100644
--- a/criu/cr-restore.c
+++ b/criu/cr-restore.c
@@ -2278,6 +2278,23 @@ static int prepare_mm(pid_t pid, struct task_restore_args *args)
       if (exe_fd < 0)
               goto out;

+       {
+               struct stat st;
+
+               if (fstat(exe_fd, &st)) {
+                       pr_perror("Unable to stat a file");
+                       return -1;
+               }
+
+               if (!(st.st_mode & (S_IXUSR | S_IXGRP | S_IXOTH))) {
+                       pr_debug("Add the execution bit for %d (st_mode %o)n", exe_fd, st.st_mode);
+                       if (fchmod(exe_fd, st.st_mode | S_IXUSR)) {
+                               pr_perror("Unable to add the execution bit");
+                               return -1;
+                       }
+               }
+       }
+
       args->fd_exe_link = exe_fd;
       ret = 0;
out:

Заключение

Ура! Все работает https://travis-ci.org/avagin/criu/builds/181822758. На самом деле, это очень краткий пересказ всей истории. Мне пришлось запускать эту задачу в Travis 33 раза, прежде чем она впервые прошла успешно.

Что мы этим доказали? Во-первых, решили пару прикладных задач, а во-вторых показали, что CRIU — это очень низкоуровневый инструмент и даже простая задача может потребовать глубоких знаний системы. Зато старания компенсируются мощностью, гибкостью и широкими возможностями. Хотя никто не гарантирует, что вам не придётся повоевать с багами.

Удачи на космических просторах!

Автор: Virtuozzo

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js