Сказ о том, как разблокировка LUKS по SSH сломала Docker

Я, как и многие из читающих, имею домашний сервер, уже довольно долго как. Привык к нему, обустроился, знаете ли. Раньше он был на Arch Linux, но потом я решил, что недостаточно стабильно и перекатился на Федору. Смейтесь, но за два с чем-то года ни разу никакое обновление ничего не сломало.

Правда, с обновлениями есть одна маленькая проблема — ядро тоже обновляется, а после таких вещей принято перезагружаться. Но у меня headless-сервер где-то в дальнем углу и острая паранойя, поэтому / наглухо зашифрован. И я каждый раз откладывал апдейты до последнего, потом приходил, перезагружался, вслепую вводил пароль на подключённой клавиатуре в надежде, что я всё правильно сделал и сейчас всё забутится. Долгое время считал, что иного варианта и нет, разве если какой-нибудь PiKVM подключить, который тоже эмулирует клавиатуру. Но в ночь описываемых событий я был в романтическом настроении и решил погуглить. А вдруг.

Что-то действительно было. Оказалось, что можно сразу после загрузки ядра вызвать dropbear (который один из вариантов имплементации SSH) как хук initramfs и ввести пароль через него. Просто, логично, понятно даже. Только проблема в том, что я на Федоре и тут никакого mkinitcpio нет. Тут прогрессивный и загадочный dracut.

Но на него тоже что-то есть! Встречайте, dracut-crypt-ssh. Это, по сути, те же яйца, но для дракута, которые подключаются как модули на него. За подробностями читайте сорцы, т.к. я сам, к сожалению, ещё не особо разбираюсь в тонкостях работы дракута, но по сути всю тяжёлую работу выполняет модуль 60crypt-ssh, который читает конфиги и запускает dropbear в определённом этапе инициализации, разрешает две-три команды, а потом вырубает его, как разблокировка LUKS станет успешной. Казалось, что может пойти не так?

Так Readme выглядит

Я старательно прочитал хороший, годный Readme и принялся за работу — сделал апдейт (а чего зря перезагружаться), нагенерил ключей, создал отдельный ключ ssh только под разблокировку LUKS, прописал authorized_keys, прописал в /etc/default/grub параметры ядра ip=dhcp rd.neednet=1, чтобы у меня сеть запустилась сразу же, а потом перебилдил initramfs через dracut --force. Ещё раз всё проверив, решил перезагрузиться. Работает! С первого раза! Вводишь console_auth, скармливаешь пароль и бут идёт дальше. Никакой беготни, никаких клавиатур по USB.

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

Спустя ещё 15 минут до меня дошло, что дело — табак. Docker-контейнеры не были доступны извне, а т.к. на этой машине у меня также крутится DNS-сервер — у меня тоже сломалась сеть. Когда-нибудь я всё это сетевое вынесу в отдельный роутер, но пока руки не добрались. Ну ладно, не впервой, зарядив свой компьютер другим DNS-сервером, я пошёл разбираться.

Сперва я на DNS и грешил, т.к. демон докера ругался на то, что в /etc/resolv.conf нет никаких не-локальных резолверов, поэтому будем резолвиться через 8.8.8.8. Да откуда ж им взяться, /etc/resolv.conf же давно является stub-симлинком на systemd-resolved, который специально для программ, привыкших искать резолвер там, запускает локальный DNS-сервер. Странно, конечно, можно заоверрайдить и попробовать. Чтобы было понятно, сейчас положение вещей примерно такое (в порядке понижения авторитативности, так сказать):

Рутовые сервера → авторитативный DNS-сервер на Docker → systemd-resolved → stub-resolver → собственный DNS докера

Docker за меня решает, что гугловские серверы резолва дороже DNS на локалхосте

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

Зараза, я ж апдейт делал! Неужели что-то сломалось? С поникшим сердцем я открываю поисковик, ищу docker update port bug и вариации на это за последний месяц. Ни-че-го. В багтрекере на гитхабе тоже ничего релевантного. Это может быть апдейт, а может и не быть апдейт. Но если б это был апдейт, то об этом бы наверное говорили, так? Реверснуть всё назад никак, я бэкапов не сделал, да и это не решение проблемы в долгосрочной перспективе. Думаем дальше.

Захожу в один из работающих контейнеров и начинаю пробовать curl. Таймаут. nslookup — тоже таймаут. Пинги? Не, пинги тоже не проходят. Это не DNS, тут вообще сети нет.

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

Хмммм… а ведь сети Docker работают через виртуальные интерфейсы. Может дело в этом? Быстрый поиск вывел меня на Arch Wiki, где в траблшуте есть вот такой абзац:

When systemd-networkd tries to manage the network interfaces created by Docker, e.g. when you configured Name=* in the Match section, this can lead to connectivity issues. Try disabling management of those interfaces. I.e. networkctl list should report unmanaged in the SETUP column for all networks created by Docker. 

Ага, значит systemd-networkd не должен лезть в работу интерфейсов Docker. А лезет ли? networkctl status говорит, что да, лезет.

На всякий случай решил свериться с основным хостом, поднял докер, проверил аналогично — нет, на нём всё OK, unmanaged. Так что же проихсодит? Время глядеть логи systemd-networkd:

Логи systemd-networkd

Так, а это ещё кто? Кто-то конфижит мои интерфейсы мимо меня? Причём из /run/? Это не может быть правильно… Открываем файл и лицезреем такую картину:

Конфиг systemd-network-generator по умолчанию

Опять Поттеринг подкрался. Какой ещё network-generator? А вот такой. Согласно ману, это сервис, который читает параметры ядра при запуске и, если там есть сетевые параметры, авто-генерирует сетевые конфиги в /run/, а из-за того, что я сам явно не конфигурирую br, docker и прочие интерфейсы помимо моего основного сетевого адаптера, эта штука начинает конфигурировать их сама по вайлдкард-маске. Но почему Name=*? А потому, что я не указал интерфейс, когда прописывал параметр! Всё это время это было из-за параметра ядра!

К счастью, есть фикс. Меняем абстрактный ip=dhcp на конкретный ip=enp1s0:dhcp и всё должно быть в ажуре. Регенерирую грубовский конфиг, ребутаюсь, всё должно работать. Но нет. Не работает ничего. Поттеринг не мог обойтись без последнего ножа в псину.

Получается, что systemd-network-generator достаточно умный, чтобы увидеть dhcp в поле ip и запустить DHCP, но (пока что) недостаточно умный, чтобы спарсить интерфейс и не конфижить вообще всё неправильно. Из ситуации есть два выхода:

  1. Создать отдельные .network файлы под нужные докеру интерфейсы и прописать там явно Unmanaged=true как сделал этот товарищ. Из плюсов: точечное решение. Из минусов: не знаешь, какие ещё завтра будут нужны виртуальные интерфейсы и будешь ли ты тогда помнить про существование этого бага.
  2. Вырубить systemd-network-generator. Из плюсов: точно не забудется. Из минусов: в перспективе network-generator позволит избавиться от .network файлов вообще, учитывая простоту моей сетевой конфигурации. Но, зная как долго матерел systemd-networkd, дела в сетевой сфере так быстро не делаются…

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