Как вы думаете на сколько реально зайти на машину по 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