[Из песочницы] Как я уменьшил Docker образ на 98.8% при помощи fanotify
Предлагаю читателям «Хабрахабра» перевод публикации «How I shrunk a Docker image by 98.8% — featuring fanotify».Несколько недель назад я делал внутренний доклад о Docker. Во время презентации один из админов спросил простой на первый взгляд вопрос: «Есть ли что-то вроде «программы похудения для Docker образов»?
Для решения этой проблемы вы можете найти несколько вполне адекватных подходов в интернете, вроде удаления директорий кэша, временных файлов, уменьшение разных избыточных пакетов, если не всего образа. Но если подумать, действительно ли нам необходима полностью рабочая Linux система? Какие файлы нам действительно необходимы в отдельно взятом образе? Для Go binary я нашел радикальный и довольно эффективный подход. Он был собран статически, почти без внешних зависимостей. Конечный образ — 6.12 МB.Да, уж! Но существует ли шанс сделать что-либо подобное с любым другим приложением?
Оказывается, такой подход может существовать. Идея проста: мы можем так или иначе профилировать образ во время исполнения, чтобы определить, какие файлы подвергались обращению/открытию/…, и удалить все оставшиеся файлы, которые за таким замечены не были. Эмм, звучит многообещающе, давайте же напишем PoC для этой идеи.
Исходные данныеОбраз: Ubuntu (~200MB) Приложение, которое должно быть запущено: /bin/ls Цель: Создать образ с наименьшим возможным размером /bin/ls это хороший пример: довольно простой для проверки идеи, без подводных камней, но все же не тривиальный, ведь он использует динамическое связывание.Теперь, когда у нас есть цель, давайте определимся с инструментом. Основная идея — это мониторинг события доступа к файлу. Будь то stat или open. Существует пара хороших кандидатов для этого. Мы могли бы использовать inotify, но его необходимо настраивать и каждый watch должен быть назначен отдельному файлу, что в итоге приведет к целой куче этих самых watch«ей. Мы могли бы использовать LD_PRELOAD, но, во-первых — использование его радости лично мне не доставляет, а во-вторых — он не будет перехватывать системные вызовы напрямую, ну и в-третьих — он не будет работать для статически собранных приложений (кто сказал golang«ов?). Решением, которое бы работало даже для статически собранного приложения, было бы использование ptrace для трассировки системных вызовов в реальном времени. Да, у него тоже существуют тонкости в настройке, но все же это было бы надежное и гибкое решение. Менее известный системный вызов — fanotify и, как уже стало ясно из названия статьи, использоваться будет именно он.
fanotify был изначально создан как «достойный» механизм для анти-вирусных вендоров для перехвата событий файловой системы, потенциально на всей точке монтирования за раз. Звучит знакомо? В то время как он может использоваться для отказа в доступе, или же просто осуществлять не блокирующий мониторинг доступа к файлу, потенциально отбрасывая события, если очередь ядра переполняется. В последнем случае специальное сообщение будет сгенерировано для уведомления user-space слушателя о потере сообщения. Это именно то, что нам нужно. Ненавязчивый, вся точка монтирование за раз, прост в настройке (ну, исходя из того что вы найдете документацию конечно…). Это может показаться смешным, но это действительно важно, как я узнал позже.
В использовании он очень прост
Инициализируем fanotify в FAN_CLASS_NOTIFICATION моде используя системный вызов fanotify_init:
// Open ``fan`` fd for fanotify notifications. Messages will embed a
// filedescriptor on accessed file. Expect it to be read-only
fan = fanotify_init (FAN_CLASS_NOTIF, O_RDONLY);
Подписываемся на FAN_ACCESS и FAN_OPEN события в »/» FAN_MARK_MOUNTPOINT используя системный вызов fanotify_mark:
// Watch open/access events on root mountpoint
fanotify_mark (
fan,
FAN_MARK_ADD | FAN_MARK_MOUNT, // Add mountpoint mark to fan
FAN_ACCESS | FAN_OPEN, // Report open and access events, non blocking
-1,»/» // Watch root mountpoint (-1 is ignored for FAN_MARK_MOUNT type calls)
);
Считываем сообщения из файлового дескриптора, который мы получили от fanotify_init и проходим по ним итератором используя FAN_EVENT_NEXT:
// Read pending events from ``fan`` into ``buf``
buflen = read (fan, buf, sizeof (buf));
// Position cursor on first message
metadata = (struct fanotify_event_metadata*)&buf;
// Loop until we reached the last event
while (FAN_EVENT_OK (metadata, buflen)) {
// Do something interesting with the notification
// ``metadata→fd`` will contain a valid, RO fd to accessed file.
// Close opened fd, otherwise we’ll quickly exhaust the fd pool.
close (metadata→fd);
// Move to next event in buffer
metadata = FAN_EVENT_NEXT (metadata, buflen);
}
В итоге мы напечатаем полное имя каждого файла, к которому был осуществлен доступ, и добавим обнаружение переполнение очереди. Для наших целей этого должно вполне хватить (комментарии и проверки ошибок опущены ради иллюстрации решения).
#include
# Run image docker run --name profiler_ls \ --volume $PWD:/src \ --cap-add SYS_ADMIN \ -it ubuntu /src/fanotify-profiler # Run the command to profile, from another shell docker exec -it profiler_ls ls # Interrupt Running image using docker kill profiler_ls # You know, the «dynamite» Результат выполнения: /etc/passwd /etc/group /etc/passwd /etc/group /bin/ls /bin/ls /bin/ls /lib/x86_64-linux-gnu/ld-2.19.so /lib/x86_64-linux-gnu/ld-2.19.so /etc/ld.so.cache /lib/x86_64-linux-gnu/libselinux.so.1 /lib/x86_64-linux-gnu/libacl.so.1.1.0 /lib/x86_64-linux-gnu/libc-2.19.so /lib/x86_64-linux-gnu/libc-2.19.so /lib/x86_64-linux-gnu/libpcre.so.3.13.1 /lib/x86_64-linux-gnu/libdl-2.19.so /lib/x86_64-linux-gnu/libdl-2.19.so /lib/x86_64-linux-gnu/libattr.so.1.1.0 Прекрасно! Сработало. Теперь мы знаем наверняка, что в конечном счете необходимо для выполнения /bin/ls. Так что теперь мы просто скопируем все это в «FROM scratch» Docker образ — и готово.Но не тут-то было… Однако давайте не забегать наперед, все по порядку.
# Export base docker image mkdir ubuntu_base docker export profiler_ls | sudo tar -x -C ubuntu_base # Create new image mkdir ubuntu_lean # Get the linker (trust me) sudo mkdir -p ubuntu_lean/lib64 sudo cp -a ubuntu_base/lib64/ld-linux-x86–64.so.2 ubuntu_lean/lib64/ # Copy the files sudo mkdir -p ubuntu_lean/etc sudo mkdir -p ubuntu_lean/bin sudo mkdir -p ubuntu_lean/lib/x86_64-linux-gnu/ sudo cp -a ubuntu_base/bin/ls ubuntu_lean/bin/ls sudo cp -a ubuntu_base/etc/group ubuntu_lean/etc/group sudo cp -a ubuntu_base/etc/passwd ubuntu_lean/etc/passwd sudo cp -a ubuntu_base/etc/ld.so.cache ubuntu_lean/etc/ld.so.cache sudo cp -a ubuntu_base/lib/x86_64-linux-gnu/ld-2.19.so ubuntu_lean/lib/x86_64-linux-gnu/ld-2.19.so sudo cp -a ubuntu_base/lib/x86_64-linux-gnu/ld-2.19.so ubuntu_lean/lib/x86_64-linux-gnu/ld-2.19.so sudo cp -a ubuntu_base/lib/x86_64-linux-gnu/libselinux.so.1 ubuntu_lean/lib/x86_64-linux-gnu/libselinux.so.1 sudo cp -a ubuntu_base/lib/x86_64-linux-gnu/libacl.so.1.1.0 ubuntu_lean/lib/x86_64-linux-gnu/libacl.so.1.1.0 sudo cp -a ubuntu_base/lib/x86_64-linux-gnu/libc-2.19.so ubuntu_lean/lib/x86_64-linux-gnu/libc-2.19.so sudo cp -a ubuntu_base/lib/x86_64-linux-gnu/libpcre.so.3.13.1 ubuntu_lean/lib/x86_64-linux-gnu/libpcre.so.3.13.1 sudo cp -a ubuntu_base/lib/x86_64-linux-gnu/libdl-2.19.so ubuntu_lean/lib/x86_64-linux-gnu/libdl-2.19.so sudo cp -a ubuntu_base/lib/x86_64-linux-gnu/libattr.so.1.1.0 ubuntu_lean/lib/x86_64-linux-gnu/libattr.so.1.1.0 # Import it back to Docker cd ubuntu_lean sudo tar -c. | docker import — ubuntu_lean Запустим наш образ: docker run --rm -it ubuntu_lean /bin/ls В итоге получаем: # If you did not trust me with the linker (as it was already loaded when the profiler started, it does not show in the ouput) no such file or directoryFATA[0000] Error response from daemon: Cannot start container f318adb174a9e381500431370a245275196a2948828919205524edc107626d78: no such file or directory # Otherwise /bin/ls: error while loading shared libraries: libacl.so.1: cannot open Да уж. Но что пошло не так? Помните, я упомянул что этот системный вызов изначально создавался для работы с антивирусом? Антивирус в реальном времени должен обнаруживать доступ к файлу, проводить проверки и по результату принимать решения. Что здесь имеет значение, так это содержимое файла. В частности, состояния гонки в файловой системе должны обходиться всеми силами. Это причина, по которой fanotify выдает файловые дескрипторы вместо путей, к которым осуществлялся доступ. Вычисление физического пути файла выполняется пробированием /proc/self/fd/[fd]. К тому же, он не в состоянии сказать, какая символьная ссылка подверглась доступу, только файл на который она указывает.Для того, чтобы заставить это заработать, нам нужно найти все ссылки на найденные fanotify«ем файлы, и установить их в отфильтрованном образе таким же образом. Команда find нам в этом поможет.
# Find all files refering to a given one find -L -samefile »./lib/x86_64-linux-gnu/libacl.so.1.1.0» 2>/dev/null # If you want to exclude the target itself from the results find -L -samefile »./lib/x86_64-linux-gnu/libacl.so.1.1.0» -a! -path »./ Это может быть легко автоматизированно циклом: for f in $(cd ubuntu_lean; find) do ( cd ubuntu_base find -L -samefile »$f» -a! -path »$f» ) 2>/dev/null done Что в итоге дает нам список недостающих семантических ссылок. Это все библиотеки: ./lib/x86_64-linux-gnu/libc.so.6 ./lib/x86_64-linux-gnu/ld-linux-x86–64.so.2 ./lib/x86_64-linux-gnu/libattr.so.1 ./lib/x86_64-linux-gnu/libdl.so.2 ./lib/x86_64-linux-gnu/libpcre.so.3 ./lib/x86_64-linux-gnu/libacl.so.1 Теперь давайте скопируем их из исходного образа и пересоздадим результирующий образ. # Copy the links sudo cp -a ubuntu_base/lib/x86_64-linux-gnu/libc.so.6 ubuntu_lean/lib/x86_64-linux-gnu/libc.so.6 sudo cp -a ubuntu_base/lib/x86_64-linux-gnu/ld-linux-x86–64.so.2 ubuntu_lean/lib/x86_64-linux-gnu/ld-linux-x86–64.so.2 sudo cp -a ubuntu_base/lib/x86_64-linux-gnu/libdl.so.2 ubuntu_lean/lib/x86_64-linux-gnu/libdl.so.2 sudo cp -a ubuntu_base/lib/x86_64-linux-gnu/libpcre.so.3 ubuntu_lean/lib/x86_64-linux-gnu/libpcre.so.3 sudo cp -a ubuntu_base/lib/x86_64-linux-gnu/libacl.so.1 ubuntu_lean/lib/x86_64-linux-gnu/libacl.so.1 sudo cp -a ubuntu_base/lib/x86_64-linux-gnu/libattr.so.1 ubuntu_lean/lib/x86_64-linux-gnu/libattr.so.1 # Import it back to Docker cd ubuntu_lean docker rmi -f ubuntu_lean; sudo tar -c. | docker import — ubuntu_lean Важное замечание: данный метод ограничен. К примеру, он не вернет ссылки на ссылки, так же как и абсолютные ссылки. Последнее требует по крайней мере chroot. Или выполняться должно из исходного образа, при условии что find или его альтернативна в нем присутствует.Запустим результирующий образ:
docker run --rm -it ubuntu_lean /bin/ls Теперь все работает: bin dev etc lib lib64 proc sys Итог ubuntu: 209MBubuntu_lean: 2.5MBВ результате мы получили образ, в 83.5 раз меньше. Это сжатие на 98.8%.
Послесловие Как и все методы, основанные на профилировании, он в состоянии сказать, что в действительности сделано/использовалось в данном сценарии. К примеру, попробуйте выполнить /bin/ls -l в конечном образе и увидите всё сами.спойлер для ленивых Оно не работает. Ну то есть работает, но не так, как ожидалось.
Техника профилирования не без изъяна. Она не позволяет понять, как именно файл был открыт, только что это за файл. Это проблема для символьные ссылок, в частности cross-filesytems (читай cross-volumes). При помощи fanotify, мы потеряем оригинальную символическую ссылку и сломаем приложение.Если бы мне пришлось построить такой «сжиматель», готовый для использования в продакшене, я скорее всего использовал бы ptrace.
Примечания Признаюсь, в действительности мне было интересно поэкспериментировать с системными вызовами. Образы Docker скорее хороший предлог; Вообще-то вполне можно было бы использовать FAN_UNLIMITED_QUEUE, вызывая fanotify_init для обхода этого ограничения, при условии, что вызывающий процесс по крайней мере CAP_SYS_ADMIN; Он так же в 2.4 раза меньше, чем образ на 6.13MB, который я упомянул в начале этой статьи, но сравнение не является справедливым.