Сборка оптимизированных контейнеров для Kubernetes

Образы контейнеров являются основным форматом упаковки приложений в Kubernetes. Образы, используемые в качестве основы для подов и других объектов, играют важную роль в использовании функций Kubernetes и влияют на эффективность запуска приложений. Хорошо продуманные образы надежны и высокоэффективны. Они могут реагировать на конфигурации или инструкции Kubernetes, а также использовать конечные точки, с помощью которых система оркестровки определяет состояние внутреннего приложения.

Данный мануал ознакомит вас с некоторыми стратегиями создания высококачественных образов. Также мы обсудим общие цели, которые помогут вам сориентироваться при контейнеризации приложения. В мануале мы сосредоточимся на сборке образов, которые предназначены для запуска в Kubernetes, но многие но многие из предложенных здесь стратегий применимы к другим платформам оркестровки или в других контекстах.

Характеристики эффективных образов

Прежде чем мы перейдем к конкретным действиям, давайте поговорим о том, что делает образ контейнера хорошим и производительным. Какие цели нужно ставить себе при разработке новых образов? Какие характеристики и какое поведение наиболее важны?

Вот краткий список качеств, к которым нужно стремиться:

Единая, четко определенная цель.

  1. Образы должны иметь четкий фокус. Об образах контейнеров не стоит думать как о виртуальных машинах, где объединение связанных функций может иметь смысл. Воспринимайте образы как, например, утилиты Unix – лучше пусть каждый образ выполняет одну небольшую функцию. Приложения можно координировать за пределами контейнера для поддержки сложных функций.
  2. Универсальная структура с возможностью ввода конфигурации во время выполнения. Образы по возможности нужно проектировать так, чтобы их можно было повторно использовать. Например, часто на основных этапах (таких как тестирование образа перед развертыванием в производство) бывает необходима возможность изменить конфигурации во время выполнения. Малые, универсальные образы можно комбинировать в разных конфигурациях для изменения поведения, не создавая при этом новых образов.
  3. Небольшой размер. Маленькие образы имеют ряд преимуществ в кластерной среде Kubernetes. Они быстро загружаются и чаще всего имеют меньший набор установленных пакетов, что хорошо для безопасности. Такие образы упрощают устранение неполадок, сводя к минимуму количество задействованного программного обеспечения.
  4. Внешне управляемое состояние. Жизненный цикл контейнеров в кластерных средах очень изменчивый, он включает в себя запланированные и незапланированные отключения из-за нехватки ресурсов, масштабирования или сбоев нод. Чтобы поддерживать согласованность и доступность сервисов, а также во избежание потери данных, важно сохранить состояние приложения в стабильном расположении за пределами контейнера.
  5. Простота. Очень важно постараться сделать образ как можно более простым. При устранении неполадок в простой конфигурации образа можно быстро найти ошибки и проблемы. Воспринимайте образы контейнеров как формат упаковки вашего приложения, а не как конфигурацию машины – это может помочь вам найти баланс.
  6. Применение оптимальных методов.
  7. Образы должны разрабатываться для работы внутри модели контейнера, а не для автономной работы. Избегайте применения обычных методов администрирования системы.
  8. Использование функций Kubernetes. Помимо соответствия модели контейнера важно учитывать среду и инструменты, которые предоставляет Kubernetes, и согласовывать образ с ними. Например, предоставление конечных точек для проверки состояния и готовности или корректировка операций, основанная на изменениях конфигурации или среды, может помочь вашим приложениям использовать динамическую среду развертывания Kubernetes в своих интересах.

Теперь, когда мы обсудили некоторые качества, которые определяют эффективность образов, мы можем рассмотреть стратегии, которые помогут вам достичь этих целей.

Повторное использование минимальных базовых уровней

Можно начать с изучения ресурсов, из которых создаются образы контейнеров – с базовых образов. Каждый образ строится либо из родительского образа (используемого в качестве начальной точки), либо из абстрактного уровня scratch, пустого уровня образа без файловой системы. Базовый образ представляет собой образ контейнера, который служит основой для будущих образов, определяя базовую операционную систему и основные функции. Образы состоят из одного или нескольких уровней.

Никакие стандартные утилиты или файловая система не доступны при работе с нуля, на уровне scratch, а это значит, что у вас есть доступ к чрезвычайно ограниченной функциональности. Хотя образы, созданные с нуля, могут быть очень упорядоченными, их основная цель заключается в определении базовых образов. Как правило, образы создаются на основе родительского образа, который определяет базовую среду, в которой работают приложения (так что создавать полную систему для каждого образа не нужно).

Существует много базовых образов для различных дистрибутивов Linux, но лучше всего хорошо обдумать, какие системы вы будете использовать. Каждая новая машина должна будет загрузить родительский образ и все добавленные вами слои. Загрузка больших образов может занять много пропускной способности и вначале заметно увеличить время запуска контейнеров. В процессе сборки контейнера невозможно уменьшить образ, который используется как родительский, поэтому начать с минимального родительского образа – хорошая идея.

Многофункциональные системы, такие как Ubuntu, позволяют приложению работать в знакомой среде, но нужно учитывать некоторые нюансы. Образы Ubuntu (и аналогичные обычные образы дистрибутива) чаще всего бывают относительно большими (более 100 МБ) – значит, все образы контейнеров, построенные из них, наследуют этот размер.

Alpine Linux является популярной альтернативой базовым образам, поскольку он успешно упаковывает множество функций в очень небольшой базовый образ (~ 5 МБ). Он включает в себя диспетчер пакетов с репозиториями и имеет большинство стандартных утилит минимальной среды Linux.

При разработке приложений рекомендуется повторно использовать один родительский образ для каждого нового образа. Когда у образов один родитель, машины, запускающие контейнеры, будут загружать родительский уровень только один раз. После этого им нужно будет только загрузить уровни образов, которые отличаются друг от друга. Если вы хотели бы встроить в каждый образ какие-то общие функции, объедините эти образы общим родительским образом, от которого они могут наследовать эти функции. Образы с общим происхождением помогают свести к минимуму количество дополнительных данных, которые необходимо загрузить на свежие серверы.

Управление уровнем контейнеров

После того, как вы выбрали родительский образ, вы можете определить образ своего контейнера, добавив дополнительное программное обеспечение, скопировав файлы, открыв порты и выбрав процессы для запуска. Некоторые инструкции в файле конфигурации образа (Dockerfile в Docker, например) добавят в образ дополнительные уровни.

При добавлении уровней важно помнить о том, что каждый новый уровень влияет на размер образа, наследование функций и сложность выполнения. Чтобы не создавать большие, громоздкие образы, важно хорошо понимать, как взаимодействуют уровни контейнера, как механизм сборки кэширует их и как небольшие различия могут повлиять на образ в целом.

Уровни образа и кэш сборки

Docker создает новый уровень образа каждый раз, когда он выполняет инструкцию RUN, COPY или ADD. Если вы снова создадите образ, механизм сборки проверит каждую команду, чтобы увидеть, имеется ли в кэше уровень для этой операции. Если он находит совпадение в кэше, он использует существующий уровень образа, а не выполняет команду снова.

Этот процесс может значительно сократить время сборки, но сейчас важно понять механизм, используемый для предотвращения потенциальных проблем. Для выполнения команд копирования файлов, таких как COPY и ADD, Docker сравнивает контрольные суммы файлов, чтобы увидеть, нужно ли снова выполнять операцию. Для выполнения RUN Docker проверяет, имеет ли он кэшированный уровень образа для этой конкретной команды.

Это неочевидно, но такое поведение может привести к неожиданным результатам, если вы не будете осторожны. Общим примером является обновление локального индекса пакетов и установка пакетов как два разных действия. Рассмотрим эту ситуацию на примере Ubuntu (но это работает и для других дистрибутивов):

FROM ubuntu:18.04

RUN apt -y update

RUN apt -y install nginx

. . .

Здесь локальный индекс обновляется в одной инструкции RUN (apt -y update), а Nginx устанавливается в другой операции. При первом запуске это работает без проблем. Однако если позже Dockerfile обновляется, чтобы установить дополнительный пакет, могут возникнуть проблемы:

FROM ubuntu:18.04

RUN apt -y update

RUN apt -y install nginx php-fpm

. . .

Мы добавили второй пакет в команду установки, выполняемую второй инструкцией. Если с момента создания предыдущего образа прошло значительное количество времени, новая сборка может завершиться ошибкой. Это связано с тем, что команда обновления индекса пакетов (RUN apt -y update) не изменилась, поэтому Docker повторно использует уровень образа, связанный с этой инструкцией. Поскольку мы используем старый индекс, версия php-fpm в локальных записях больше не может находиться в репозиториях, что приводит к ошибке при выполнении второй команды.

Чтобы избежать этой ошибки, обязательно соедините все взаимозависимые шаги в единую инструкцию RUN, чтобы Docker повторно выполнил все необходимые команды при изменении:

FROM ubuntu:18.04

RUN apt -y update && apt -y install nginx php-fpm

. . .

Теперь инструкция обновит локальный индекс пакетов перед установкой.

Уменьшение размера уровня образа путем настройки команд RUN

Предыдущий пример демонстрирует, как поведение кэширования Docker может привести к неожиданным результатам. Но есть и другие вещи, которые следует учитывать, когда инструкции RUN взаимодействуют с системой уровней Docker. Как упоминалось ранее, в конце каждой инструкции RUN Docker фиксирует изменения как дополнительный уровень образа. Чтобы обеспечить контроль над объемом созданных уровней, вы можете очистить ненужные файлы в конечной среде.

В общем, связывая команды в цепочки, в единую инструкцию RUN, вы обеспечиваете контроль над уровнем. Вы можете настроить состояние уровня (apt -y update), выполнить основную команду (apt install -y nginx php-fpm) и удалить ненужные артефакты для очистки среды до ее фиксации. Например, многие Dockerfiles помещают rm -rf /var/lib/apt/lists/*  в конце apt-команд, удаляя загруженные индексы, чтобы уменьшить размер последнего уровня:

FROM ubuntu:18.04

RUN apt -y update && apt -y install nginx php-fpm && rm -rf /var/lib/apt/lists/*

. . .

Чтобы еще сильнее уменьшить размер создаваемых вами уровней образа, вы можете ограничить другие непредвиденные побочные эффекты запущенных команд. Например, кроме явно объявленных пакетов, apt также устанавливает «рекомендуемые» по умолчанию пакеты. Вы можете включить —no-install-recommends для команд apt, чтобы заблокировать это поведение. Возможно, вам придется поэкспериментировать, чтобы узнать, использует ли приложение какие-либо функции, предоставляемые этими дополнительными пакетами.

В качестве примера в этом разделе мы использовали команды управления пакетами, но эти же принципы применимы и к другим сценариям. Общая идея состоит в том, что в одной команде RUN нужно обеспечить требуемые условия, выполнить команду, а затем очистить все ненужные артефакты, чтобы уменьшить уровень.

Многоэтапные сборки

Многоэтапные сборки были введены в Docker 17.05, что позволяет разработчикам более точно контролировать конечные образы, которые они производят. Многоэтапные сборки позволяют разделить файл Docker на несколько разделов, представляющих разные этапы, каждый из которых имеет инструкцию FROM, где нужно указать отдельные родительские образы.

Предшествующие разделы определяют образы, которые можно использовать для создания приложения и подготовки активов. Они часто содержат инструменты сборки и файлы разработки, необходимые для создания приложения, но не запускают их. Каждый последующий этап, определенный в файле, будет иметь доступ к артефактам, созданным на предыдущих этапах.

Последний оператор FROM определяет образ, который будет использоваться для запуска приложения. Как правило, это уменьшенный образы, который устанавливает только зависимости для выполнения, а затем копирует артефакты приложения, созданные на предыдущих этапах.

Эта система позволяет меньше беспокоиться об оптимизации инструкций RUN на этапах сборки, поскольку эти уровни контейнеров не будут присутствовать в окончательном образе. Вам все равно нужно обратить внимание на то, как инструкции взаимодействуют с кэшированием уровней на этапах сборки, но теперь ваши усилия могут быть направлены на минимизацию времени сборки, а не на конечный размер образа. На заключительном этапе по-прежнему важно внимание к инструкциям (чтобы уменьшить размер образа), но, разделяя этапы сборки контейнера, можно получить упрощенные образы на простых Dockerfile.

Функции на уровне контейнера и пода

Конечно, выбор, который вы делаете в отношении инструкций по сборке контейнеров, очень важен, но контейнеризация сервисов и связанные с ней решения часто оказывают более сильное влияние на успех приложения. В этом разделе мы поговорим о том, как перевести приложения из привычной среды на платформу контейнеров.

Контейнеризация по функции

Как правило, хорошей практикой является упаковка каждой части независимых функций в отдельный образ.

Этот метод отличается от общих стратегий, используемых в средах виртуальных машин, где приложения часто группируются вместе в одном образе, чтобы уменьшить размер и минимизировать ресурсы, необходимые для запуска ВМ. Поскольку контейнеры представляют собой легкие абстракции, которые не виртуализируют весь стек операционной системы, этот метод в Kubernetes не очень хорошо работает. То есть, виртуальная машина может связывать веб-сервер Nginx с сервером приложений Gunicorn для обслуживания приложения Django, но в Kubernetes они могут быть разделены на отдельные контейнеры.

Проектирование таких индивидуальных контейнеров для функций имеет ряд преимуществ. Каждый контейнер может разрабатываться независимо, если между сервисами установлены стандартные интерфейсы. Например, контейнер Nginx потенциально можно использовать как прокси-сервер для нескольких различных бэкендов или как балансировщик нагрузки.

После развертывания каждый образ контейнера можно масштабировать в индивидуальном порядке (для обращения к различным ресурсам и ограничениям нагрузки). Разбирая приложения на несколько образов, вы получаете гибкую разработку, организацию и развертывание.

Объединение образов контейнеров в поды

В Kubernetes поды представляют собой наименьшую единицу, которая может управляться непосредственно плоскостью контроля. Поды состоят из одного или нескольких контейнеров вместе с дополнительными данными конфигурации, которые сообщают платформе, как эти компоненты должны запускаться. Контейнеры внутри пода всегда планируются на одной рабочей ноде кластера, и система автоматически перезапускает нерабочие контейнеры. Абстракция пода очень полезна, но она требует новых решений о том, как объединить компоненты приложений.

Подобно образам контейнеров, поды становятся менее гибкими, если в единый объект объединяется слишком много функций. Сами по себе поды можно масштабировать, используя другие уровни абстракции, но контейнерами внутри подов нельзя управлять или масштабировать независимо. Поэтому, чтобы продолжить предыдущий пример, отдельные контейнеры Nginx и Gunicorn, вероятно, не стоит объединять в один контейнер, чтобы их можно было контролировать и развертывать отдельно друг от друга.

Однако существуют сценарии, в которых имеет смысл объединить функционально разные контейнеры в единый контейнер. В целом, их можно классифицировать как ситуации, когда дополнительный контейнер поддерживает или улучшает главные функции основного контейнера или помогает адаптировать его к среде развертывания. Вот некоторые общие случаи:

  • Sidecar: в этом шаблоне вторичный контейнер расширяет и улучшает основные функции основного контейнера. Этот шаблон включает выполнение нестандартных или служебных функций в отдельном контейнере. Например, контейнер, который пересылает журналы или следит за обновлением значений конфигурации, может увеличить функциональность модуля без существенного изменения его основного фокуса.
  • Ambassador: этот шаблон использует дополнительный контейнер для абстракции удаленных ресурсов основного контейнера. Основной контейнер подключается непосредственно к контейнеру ambassador, который, в свою очередь, соединяет и абстрагирует пул потенциально сложных внешних ресурсов, таких как распределенный кластер Redis. Первичный контейнер не должен знать или заботиться о реальной среде развертывания для подключения к внешним службам.
  • Adaptor: этот шаблон используется для перевода данных, протоколов или интерфейсов основного контейнера в соответствие со стандартами других сторон. Контейнеры-адаптеры обеспечивают единый доступ к централизованным сервисам, даже если приложения, которые они обслуживают, поддерживают несовместимые интерфейсы. Основной контейнер может работать с собственными форматами, а контейнер adaptor преобразует данные для связи с внешней средой.

Как вы могли заметить, каждый из этих шаблонов поддерживает стратегию построения стандартных общих образов, которые затем можно развернуть в различных контекстах и конфигурациях. Вторичные контейнеры помогают преодолеть разрыв между основным контейнером и конкретной средой развертывания. Некоторые sidecar контейнеры также можно повторно использовать для адаптации нескольких основных контейнеров. Преимущество шаблонов заключается в использовании общей файловой системы и пространства имен сетей, предоставляемых абстракцией пода. При этом они обеспечивают независимую разработку и гибкое развертывание стандартизированных контейнеров.

Проектирование для поддержки конфигурации runtime

Существует некоторая напряженность между желанием создавать стандартизированные, повторно используемые компоненты, и требованиями, связанными с адаптацией приложений к их среде выполнения. Конфигурация времени выполнения является одним из лучших способов преодоления разрыва между этими двумя аспектами. Компоненты могут быть универсальными и гибкими, а требуемое поведение определяется дополнительной информацией о конфигурации ПО. Этот стандартный подход работает для контейнеров так же, как для приложений.

Сборка с учетом такой конфигурации требует продумывания наперед на этапах разработки приложений и в процессе контейнеризации. Приложения должны уметь читать значения параметров командной строки, файлов конфигурации или переменных среды при их запуске или перезапуске. Эта логика анализа и инжекционная логика должны быть реализованы в коде до контейнеризации.

При написании Dockerfile контейнер также должен быть спроектирован с учетом конфигурации времени выполнения. Контейнеры имеют ряд механизмов для предоставления данных во время выполнения. Пользователи могут монтировать файлы или каталоги с хоста в виде томов в контейнере, чтобы включить конфигурацию из файлов. Аналогично, переменные окружения можно передать во внутреннюю среду контейнера при запуске. Инструкции CMD и ENTRYPOINT в Dockerfile также могут передавать информацию о конфигурации во время выполнения.

Поскольку Kubernetes вместо контейнеров управляет объектами более высокого уровня, такими как поды, существуют механизмы, позволяющие определить конфигурацию и внедрить ее в среду контейнера во время выполнения. Kubernetes ConfigMaps и Secrets позволяют вам отдельно определять данные конфигурации, а затем проектировать значения в среду контейнера в качестве переменных среды или файлов во время выполнения. ConfigMaps – это механизм, используемый для хранения данных, которые могут быть открыты для контейнеров и других объектов в runtime. Данные, хранящиеся в ConfigMaps, могут быть представлены как переменные среды или смонтированы в виде файлов в контейнере. Secrets – это аналогичный тип объекта Kubernetes, используемый для безопасного хранения конфиденциальных данных и выборочного доступа к портам и другим компонентам.

Понимая и правильно используя параметры конфигурации времени выполнения, доступные на каждом уровне абстракции, вы можете создавать гибкие компоненты, которые принимают свои значения из среды. Это позволяет повторно использовать одни и те же образы в самых разных сценариях, что снижает затраты на разработку и улучшает гибкость приложений.

Управление процессами с помощью контейнеров

При переходе на контейнерные среды пользователи часто переносят в новую систему существующие рабочие нагрузки с небольшими изменениями или без изменений. Они упаковывают приложения в контейнеры и внедряют инструменты, которые они уже используют в новой абстракции. Хотя обычные шаблоны иногда подходят для запуска, контейнеризация предыдущих реализаций приложения без учета особенностей среды часто не очень эффективна.

Контейнеры как приложения

Проблемы часто возникают в том случае, когда разработчики реализуют внутри контейнеров много функций управления сервисами. Например, запуск системных сервисов в контейнере или демонизация веб-серверов – типичная практика в обычной вычислительной среде, но она часто противоречит механизмам и принципам работы модели контейнера.

Хосты управляют событиями жизненного цикла контейнера, отправляя сигналы в PID 1 (идентификатор процесса) внутри контейнера. PID 1 – это первый запущенный процесс, аналог системы инициализации в традиционных вычислительных средах. Однако, поскольку хост может управлять только PID 1, использование обычной системы инициализации для управления процессами в контейнере иногда приводит к тому, что контроль над основным приложением теряется.  Хост может запускать, останавливать или убивать внутреннюю систему инициализации, но не может напрямую управлять основным приложением. Иногда сигналы сообщают запущенному приложению, как себя вести, но это сложнее и не всегда необходимо.

В большинстве случаев рабочую среду внутри контейнера лучше упростить, чтобы PID 1 запускал основное приложение на переднем плане. Если необходимо запустить несколько процессов, PID 1 будет отвечать за управление жизненным циклом последующих процессов. Некоторые приложения, такие как Apache, обрабатывают это путем порождения и управления рабочими процессами, которые обслуживают соединения.

Для других приложений в некоторых случаях можно использовать сценарий оболочки или очень простую систему инициализации, такую как dumb-init или tini. Независимо от выбранной вами системы процесс, работающий в контейнере как PID 1, должен соответствующим образом реагировать на сигналы TERM, отправленные Kubernetes.

Управление состоянием контейнеров в Kubernetes

Развертывания и сервисы Kubernetes обеспечивают управление жизненным циклом длительных процессов и надежный постоянный доступ к приложениям даже при перезапуске контейнеров или изменении реализации. Снимая с контейнера ответственность за мониторинг и поддержание здоровья сервиса, вы можете использовать инструменты платформы для управления рабочими нагрузками.

Чтобы Kubernetes правильно управлял контейнерами, он должен понимать, работают ли приложения в контейнерах правильно и способны ли они выполнять работу. Для этого в контейнеры можно внедрить проверку живучести: конечные точки сети или команды, которые могут использоваться отслеживания работоспособности приложения. Kubernetes будет периодически проверять определенные показатели живучести, чтобы определить, правильно ли работает контейнер. Если контейнер не отвечает соответствующим образом, Kubernetes перезапустит его в попытке восстановить функциональность.

Kubernetes также предоставляет аналогичную конструкцию – проверку готовности. Вместо проверки здоровья контейнера датчики готовности определяют, готово ли приложение к приему трафика. Это может быть полезно, если контейнерное приложение проходит процедуру инициализации перед приемом соединений. Kubernetes использует датчики готовности, чтобы определить, следует ли добавить под или удалить его из сервиса.

Определение конечных точек этих двух типов датчиков поможет Kubernetes эффективно управлять контейнерами, может предотвратить проблемы жизненного цикла контейнера или повлиять на доступность сервисов. Механизмы реагирования на работоспособность должны быть встроены в само приложение и отображаться в конфигурации образа Docker.

Заключение

В этой статье вы узнали некоторые базовые подходы к оптимизации контейнеризованных приложений в Kubernetes. Вот основные моменты:

  • Используйте маленькие родительские образы для создания новых образов, чтобы не перегружать кластер и сократить время запуска.
  • Используйте многоэтапные сборки для разделения среды сборки и среды выполнения контейнера.
  • Объединяйте инструкции Dockerfile, чтобы создать более простые и чистые уровни образа, и избегайте ошибок кэширования образов.
  • При контейнеризации изолируйте дискретные функции, чтобы обеспечить гибкое масштабирование и управление.
  • Старайтесь сфокусировать под на одной узконаправленной цели.
  • Используйте вспомогательные контейнеры для расширения функциональности основного контейнера или для адаптации к среде развертывания.
  • Приложения и контейнеры должны отвечать на конфигурацию runtime, чтобы обеспечить большую гибкость при развертывании.
  • Запускайте приложения в контейнерах в качестве основных процессов, чтобы Kubernetes мог управлять событиями жизненного цикла.
  • Разработайте в приложении или контейнере конечные точки готовности и живучести, чтобы Kubernetes мог контролировать состояние контейнера.

На протяжении всего процесса разработки и внедрения вам необходимо будет принимать решения, которые могут повлиять на надежность и эффективность вашего сервиса. Зная, чем контейнеризованные приложения отличаются от обычных и как они работают в среде кластера, вы можете избежать некоторых общих ошибок и воспользоваться всеми преимуществами Kubernetes.

Tags: