Предлагаю читателям «Хабрахабра» перевод публикации «How I shrunk a Docker image by 98.8% – featuring fanotify».
Несколько недель назад я делал внутренний доклад о Docker. Во время презентации один из админов спросил простой на первый взгляд вопрос: «Есть ли что-то вроде „программы похудения для Docker образов“»?
Для решения этой проблемы вы можете найти несколько вполне адекватных подходов в интернете, вроде удаления директорий кэша, временных файлов, уменьшение разных избыточных пакетов, если не всего образа. Но если подумать, действительно ли нам необходима полностью рабочая Linux система? Какие файлы нам действительно необходимы в отдельно взятом образе? Для Go binary я нашел радикальный и довольно эффективный подход. Он был собран статически, почти без внешних зависимостей. Конечный образ — 6.12 МB.
Да, уж! Но существует ли шанс сделать что-либо подобное с любым другим приложением?
Оказывается, такой подход может существовать. Идея проста: мы можем так или иначе профилировать образ во время исполнения, чтобы определить, какие файлы подвергались обращению/открытию/…, и удалить все оставшиеся файлы, которые за таким замечены не были. Эмм, звучит многообещающе, давайте же напишем PoC для этой идеи.
/bin/ls это хороший пример: довольно простой для проверки идеи, без подводных камней, но все же не тривиальный, ведь он использует динамическое связывание.
Теперь, когда у нас есть цель, давайте определимся с инструментом. Основная идея — это мониторинг события доступа к файлу. Будь то stat или open. Существует пара хороших кандидатов для этого. Мы могли бы использовать inotify, но его необходимо настраивать и каждый watch должен быть назначен отдельному файлу, что в итоге приведет к целой куче этих самых watch’ей. Мы могли бы использовать LD_PRELOAD, но, во-первых — использование его радости лично мне не доставляет, а во-вторых — он не будет перехватывать системные вызовы напрямую, ну и в-третьих — он не будет работать для статически собранных приложений (кто сказал golang’ов?). Решением, которое бы работало даже для статически собранного приложения, было бы использование ptrace для трассировки системных вызовов в реальном времени. Да, у него тоже существуют тонкости в настройке, но все же это было бы надежное и гибкое решение. Менее известный системный вызов — fanotify и, как уже стало ясно из названия статьи, использоваться будет именно он.
fanotify был изначально создан как «достойный» механизм для анти-вирусных вендоров для перехвата событий файловой системы, потенциально на всей точке монтирования за раз. Звучит знакомо? В то время как он может использоваться для отказа в доступе, или же просто осуществлять не блокирующий мониторинг доступа к файлу, потенциально отбрасывая события, если очередь ядра переполняется. В последнем случае специальное сообщение будет сгенерировано для уведомления user-space слушателя о потере сообщения. Это именно то, что нам нужно. Ненавязчивый, вся точка монтирование за раз, прост в настройке (ну, исходя из того что вы найдете документацию конечно…). Это может показаться смешным, но это действительно важно, как я узнал позже.
В итоге мы напечатаем полное имя каждого файла, к которому был осуществлен доступ, и добавим обнаружение переполнение очереди. Для наших целей этого должно вполне хватить (комментарии и проверки ошибок опущены ради иллюстрации решения).
Собираем:
Грубо говоря, теперь мы имеем инструмент для мониторинга любого доступа к файлу на активной «/» точке монтирование в реальном времени. Отлично.
Что дальше? Давайте создадим Ubuntu контейнер, стартуем наш мониторинг, и выполним /bin/ls. Fanotify’ю необходима CAP_SYS_ADMIN возможность. В основном это «catch-all» root возможность. В любом случае это лучше, чем выполнять в —privileged моде.
Результат выполнения:
Прекрасно! Сработало. Теперь мы знаем наверняка, что в конечном счете необходимо для выполнения /bin/ls. Так что теперь мы просто скопируем все это в «FROM scratch» Docker образ — и готово.
Но не тут-то было… Однако давайте не забегать наперед, все по порядку.
Запустим наш образ:
В итоге получаем:
Да уж. Но что пошло не так? Помните, я упомянул что этот системный вызов изначально создавался для работы с антивирусом? Антивирус в реальном времени должен обнаруживать доступ к файлу, проводить проверки и по результату принимать решения. Что здесь имеет значение, так это содержимое файла. В частности, состояния гонки в файловой системе должны обходиться всеми силами. Это причина, по которой fanotify выдает файловые дескрипторы вместо путей, к которым осуществлялся доступ. Вычисление физического пути файла выполняется пробированием /proc/self/fd/[fd]. К тому же, он не в состоянии сказать, какая символьная ссылка подверглась доступу, только файл на который она указывает.
Для того, чтобы заставить это заработать, нам нужно найти все ссылки на найденные fanotify’ем файлы, и установить их в отфильтрованном образе таким же образом. Команда find нам в этом поможет.
Это может быть легко автоматизировано циклом:
Что в итоге дает нам список недостающих семантических ссылок. Это все библиотеки:
Теперь давайте скопируем их из исходного образа и пересоздадим результирующий образ.
Важное замечание: данный метод ограничен. К примеру, он не вернет ссылки на ссылки, так же как и абсолютные ссылки. Последнее требует по крайней мере chroot. Или выполняться должно из исходного образа, при условии что find или его альтернативна в нем присутствует.
Запустим результирующий образ:
Теперь все работает:
ubuntu: 209MB
ubuntu_lean: 2.5MB
В результате мы получили образ, в 83.5 раз меньше. Это сжатие на 98.8%.
Как и все методы, основанные на профилировании, он в состоянии сказать, что в действительности сделано/использовалось в данном сценарии. К примеру, попробуйте выполнить /bin/ls -l в конечном образе и увидите всё сами.
Техника профилирования не без изъяна. Она не позволяет понять, как именно файл был открыт, только что это за файл. Это проблема для символьные ссылок, в частности cross-filesytems (читай cross-volumes). При помощи fanotify, мы потеряем оригинальную символическую ссылку и сломаем приложение.
Если бы мне пришлось построить такой «сжиматель», готовый для использования в продакшене, я скорее всего использовал бы ptrace.
Несколько недель назад я делал внутренний доклад о 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, который я упомянул в начале этой статьи, но сравнение не является справедливым.