Предлагаю читателям «Хабрахабра» перевод публикации «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 <fcntl.h> #include <limits.h> #include <stdio.h> #include <sys/fanotify.h> int main(int argc, char** argv) { int fan; char buf[4096]; char fdpath[32]; char path[PATH_MAX + 1]; ssize_t buflen, linklen; struct fanotify_event_metadata *metadata; // Init fanotify structure fan = fanotify_init(FAN_CLASS_NOTIF, O_RDONLY); // Watch open/access events on root mountpoint fanotify_mark( fan, FAN_MARK_ADD | FAN_MARK_MOUNT, FAN_ACCESS | FAN_OPEN, -1, "/" ); while(1) { buflen = read(fan, buf, sizeof(buf)); metadata = (struct fanotify_event_metadata*)&buf; while(FAN_EVENT_OK(metadata, buflen)) { if (metadata->mask & FAN_Q_OVERFLOW) { printf("Queue overflow!\n"); continue; } // Resolve path, using automatically opened fd sprintf(fdpath, "/proc/self/fd/%d", metadata->fd); linklen = readlink(fdpath, path, sizeof(path) - 1); path[linklen] = '\0'; printf("%s\n", path); close(metadata->fd); metadata = FAN_EVENT_NEXT(metadata, buflen); } } }
Собираем:
gcc main.c --static -o fanotify-profiler
Грубо говоря, теперь мы имеем инструмент для мониторинга любого доступа к файлу на активной «/» точке монтирование в реальном времени. Отлично.
Что дальше? Давайте создадим Ubuntu контейнер, стартуем наш мониторинг, и выполним /bin/ls. Fanotify’ю необходима CAP_SYS_ADMIN возможность. В основном это «catch-all» root возможность. В любом случае это лучше, чем выполнять в —privileged моде.
# 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: 209MB
ubuntu_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, который я упомянул в начале этой статьи, но сравнение не является справедливым.
ссылка на оригинал статьи http://habrahabr.ru/post/259021/
Добавить комментарий