При построении процесса CI/CD с использованием Kubernetes порой возникает проблема несовместимости требований новой инфраструктуры и переносимого в неё приложения. В частности, на этапе сборки приложения важно получить один образ, который будет использоваться во всех окружениях и кластерах проекта. Такой принцип лежит в основе правильного по мнению Google управления контейнерами (не раз об этом говорил и наш техдир).
Однако никого не увидишь ситуациями, когда в коде сайта используется готовый фреймворк, использование которого накладывает ограничения на его дальнейшую эксплуатацию. И если в «обычной среде» с этим легко справиться, в Kubernetes подобное поведение может стать проблемой, особенно когда вы сталкиваетесь с этим впервые. Хотя изобретательный ум и способен предложить инфраструктурные решения, кажущиеся очевидными и даже неплохими на первый взгляд… важно помнить, что большинство ситуаций могут и должны решаться архитектурно.
Разберем популярные workaround-решения для хранения файлов, которые могут привести к неприятным последствиям при эксплуатации кластера, а также укажем на более правильный путь.
Хранение статики
Для иллюстрации рассмотрим веб-приложение, которое использует некий генератор статики для получения набора картинок, стилей и прочего. Например, в PHP-фреймворке Yii есть встроенный менеджер ассетов, который генерирует уникальные названия директорий. Соответственно, на выходе получается набор заведомо не пересекающихся между собой путей для статики сайта (сделано это по нескольким причинам — например, для исключения дубликатов при использовании одного и того же ресурса множеством компонентов). Так, из коробки, при первом обращении к модулю веб-ресурса происходит формирование и раскладывание статики (на самом деле — зачастую симлинков, но об этом позже) с уникальным для данного деплоя общим корневым каталогом:
-
webroot/assets/2072c2df/css/…
-
webroot/assets/2072c2df/images/…
-
webroot/assets/2072c2df/js/…
Чем это чревато в разрезе кластера?
Простейший пример
Возьмем довольно распространенный кейс, когда перед PHP стоит nginx для раздачи статики и обработки простых запросов. Самый простой способ — Deployment с двумя контейнерами:
apiVersion: apps/v1
kind: Deployment
metadata:
name: site
spec:
selector:
matchLabels:
component: backend
template:
metadata:
labels:
component: backend
spec:
volumes:
- name: nginx-config
configMap:
name: nginx-configmap
containers:
- name: php
image: own-image-with-php-backend:v1.0
command: ["/usr/local/sbin/php-fpm","-F"]
workingDir: /var/www
- name: nginx
image: nginx:1.16.0
command: ["/usr/sbin/nginx", "-g", "daemon off;"]
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: nginx.conf
В упрощенном виде конфиг nginx сводится к следующему:
apiVersion: v1
kind: ConfigMap
metadata:
name: "nginx-configmap"
data:
nginx.conf: |
server {
listen 80;
server_name _;
charset utf-8;
root /var/www;
access_log /dev/stdout;
error_log /dev/stderr;
location / {
index index.php;
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
include fastcgi_params;
}
}
При первом обращении к сайту в контейнере с PHP появляются ассеты. Но в случае с двумя контейнерами в рамках одного pod’а — nginx ничего не знает об этих файлах статики, которые (согласно конфигурации) должны отдаваться именно им. В результате, на все запросы к CSS- и JS-файлам клиент увидит ошибку 404. Самым простым решением тут будет организовать общую директорию к контейнерам. Примитивный вариант — общий
emptyDir
:apiVersion: apps/v1
kind: Deployment
metadata:
name: site
spec:
selector:
matchLabels:
component: backend
template:
metadata:
labels:
component: backend
spec:
volumes:
- name: assets
emptyDir: {}
- name: nginx-config
configMap:
name: nginx-configmap
containers:
- name: php
image: own-image-with-php-backend:v1.0
command: ["/usr/local/sbin/php-fpm","-F"]
workingDir: /var/www
volumeMounts:
- name: assets
mountPath: /var/www/assets
- name: nginx
image: nginx:1.16.0
command: ["/usr/sbin/nginx", "-g", "daemon off;"]
volumeMounts:
- name: assets
mountPath: /var/www/assets
- name: nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: nginx.conf
Теперь генерируемые в контейнере файлы статики отдаются nginx’ом корректно. Но напомню, что это примитивное решение, а значит — оно далеко от идеала и имеет свои нюансы и недоработки, о которых ниже.
Более продвинутое хранилище
Теперь представим ситуацию, когда пользователь зашёл на сайт, подгрузил страницу с имеющимися в контейнере стилями, а пока он читал эту страницу, мы повторно задеплоили контейнер. В каталоге ассетов стало пусто и требуется запрос к PHP, чтобы запустить генерацию новых. Однако даже после этого ссылки на старую статику будут неактуальными, что приведет к ошибкам отображения статики.
Кроме того, у нас скорее всего более-менее нагруженный проект, а значит — одной копии приложения не будет достаточно:
- Отмасштабируем Deployment до двух реплик.
- При первом обращении к сайту в одной реплике создались ассеты.
- В какой-то момент ingress решил (в целях балансировки нагрузки) отправить запрос на вторую реплику, и там этих ассетов еще нет. А может быть, их там уже нет, потому что мы используем
RollingUpdate
и в данный момент делаем деплой.
В общем, итог — снова ошибки.
Чтобы не терять старые ассеты, можно изменить
emptyDir
на hostPath
, складывая статику физически на узел кластера. Данный подход плох тем, что мы фактически должны привязаться к конкретному узлу кластера своим приложением, потому что — в случае переезда на другие узлы — директория не будет содержать необходимых файлов. Либо же требуется некая фоновая синхронизация директории между узлами. Какие есть пути решения?
- Если железо и ресурсы позволяют, можно воспользоваться cephfs для организации равнодоступной директории под нужды статики. Официальная документация рекомендует SSD-диски, как минимум трёхкратную репликацию и устойчивое «толстое» подключение между узлами кластера.
- Менее требовательным вариантом будет организация NFS-сервера. Однако тогда нужно учитывать возможное повышение времени отклика на обработку запросов веб-сервером, да и отказоустойчивость оставит желать лучшего. Последствия же отказа катастрофичны: потеря mount’а обрекает кластер на гибель под натиском нагрузки LA, устремляющейся в небо.
Помимо всего прочего, для всех вариантов создания постоянного хранилища потребуется фоновая очистка устаревших наборов файлов, накопленных за некий промежуток времени. Перед контейнерами с PHP можно поставить DaemonSet из кэширующих nginx, которые будут хранить копии ассетов ограниченное время. Это поведение легко настраивается с помощью
proxy_cache
с глубиной хранения в днях или гигабайтах дискового пространства.Объединение этого метода с упомянутыми выше распределенными файловыми системами даёт огромное поле для фантазий, ограничение лишь в бюджете и техническом потенциале тех, кто это будет реализовать и поддерживать. По опыту же скажем, что чем проще система, тем стабильнее она работает. При добавлении подобных слоёв поддерживать инфраструктуру становится гораздо сложнее, а вместе с этим увеличивается и время, затрачиваемое на диагностику и восстановление при любых отказах.
Рекомендация
Если реализация предлагаемых вариантов хранилищ вам тоже кажется неоправданной (сложной, дорогой…), то стоит посмотреть на ситуацию с другой стороны. А именно — копнуть в архитектуру проекта и искоренить проблему в коде, привязавшись к какой-то статической структуре данных в образе, однозначное определение содержимого или процедуры «прогрева» и/или прекомпиляции ассетов на этапе сборки образа. Так мы получаем абсолютно предсказуемое поведение и одинаковый набор файлов для всех окружений и реплик запущенного приложения.
Если вернуться к конкретному примеру с фреймворком Yii и не углубляться в его устройство (что не является целью статьи), достаточно указать на два популярных подхода:
- Изменить процесс сборки образа с тем, чтобы размещать ассеты в предсказуемом месте. Так предлагают/реализуют в расширениях вроде yii2-static-assets.
- Определять конкретные хэши для каталогов ассетов, как рассказывается, например, в этой презентации (начиная со слайда №35). Кстати, автор доклада в конечном счёте (и не без оснований!) советует после сборки ассетов на build-сервере загружать их в центральное хранилище (вроде S3), перед которым поставить CDN.
Загружаемые файлы
Другой кейс, который обязательно выстрелит при переносе приложения в кластер Kubernetes, — хранение пользовательских файлов в файловой системе. Например, у нас снова приложение на PHP, которое принимает файлы через форму загрузки, что-то делает с ними в процессе работы и отдаёт обратно.
Место, куда эти файлы должны помещаться, в реалиях Kubernetes должно быть общим для всех реплик приложения. В зависимости от сложности приложения и необходимости организации персистивности этих файлов, таким местом могут быть упомянутые выше варианты shared-устройств, но, как мы видим, у них есть свои минусы.
Рекомендация
Одним из вариантов решения является использование S3-совместимого хранилища (пусть даже какую-то разновидность категории self-hosted вроде minio). Переход на работу с S3 потребует изменений на уровне кода, а как будет происходить отдача контента на фронтенде, мы уже писали.
Пользовательские сессии
Отдельно стоит отметить организацию хранения пользовательских сессий. Нередко это тоже файлы на диске, что в разрезе Kubernetes приведёт к постоянным запросам авторизации у пользователя, если его запрос попадёт в другой контейнер.
Отчасти проблема решается включением
stickySessions
на ingress (фича поддерживается во всех популярных контроллерах ingress — подробнее см. в нашем обзоре), чтобы привязать пользователя к конкретному pod’у с приложением:apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
name: nginx-test
annotations:
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/session-cookie-name: "route"
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
spec:
rules:
- host: stickyingress.example.com
http:
paths:
- backend:
serviceName: http-svc
servicePort: 80
path: /
Но это не избавит от проблем при повторных деплоях.
Рекомендация
Более правильным способом будет перевод приложения на хранение сессий в memcached, Redis и подобных решениях — в общем, полностью отказаться от файловых вариантов.
Заключение
Рассматриваемые в тексте инфраструктурные решения достойны применения только в формате временных «костылей» (что более красиво звучит на английском как workaround). Они могут быть актуальны на первых этапах миграции приложения в Kubernetes, но не должны «пустить корни».
Общий же рекомендуемый путь сводится к тому, чтобы избавиться от них в пользу архитектурной доработки приложения в соответствии с уже хорошо многим известным 12-Factor App. Однако это — приведение приложения к stateless-виду — неизбежно означает, что потребуются изменения в коде, и тут важно найти баланс между возможностями/требованиями бизнеса и перспективами реализации и обслуживания выбранного пути.
P.S.
Читайте также в нашем блоге:
Комментарии (11)
softadmin
16.10.2019 18:17+1Складывается ощущение, что persistent storage это головная боль для k8s. При приличной нагрузке по iops решения вида Ceph, GlusterFS проигрывают «старомодным» решениям. Все-таки k8s в нынешнем виде очень тяжело готовить для statefull-приложений требовательных к дисковой системе.
Hixon10
16.10.2019 21:35+1Да вроде бы, понятно, что делать с k8s + требованием по большому iops. Нужно использовать локальные диски серваков, и прибивать pod к ним.
Как раз об этом Дмитрий рассказывал —
VolCh
17.10.2019 08:15Как-то заметил в последнее время, что именно в k8s при формальных девизах 12f они нарушаются даже в примерах. Вот даже в посте: nginx конфигурируются через примонтированный конфиг, а не включением его в образ.
P. S. В Symfony специально работают над воспроизводимостью билдов.:)
RouR
18.10.2019 11:25а не включением его в образ.
Нет. Включение конфига внутрь докер-образа будет прямым нарушением 3го фатора — про конфигурацию.
В кубере есть kind: ConfigMap — конфиг хранится в etcd, и при старте пода он мапится в файл внутри пода. Это позволяет использовать один и тот же докер-образ с разными конфигами просто перезапуском пода
VolCh
17.10.2019 08:18А как вариант с монтированием S3 в контейнеры через fuse? Буквально вчера столкнулся с идеей. А вообще был уверен, что для S3 в кубике если не из коробки есть драйвер, для storage class, то достаточно популярный. Увы :(
MasMaX
Kubernetes ради kubernetes?
Зачем изобретать велосипеды, если для решения этих задач можно использовать другие интрументы?
snd3r
Очевидно ради унификации инфраструктуры.
Если команда поддерживает более чем один продукт, то лучше подложить костылей в одном месте и иметь одинаковое и комфортное окружение, чем для отдельного продукта делать свою обособленную конфигурацию прода, о которую в итоге, какой-нибудь, малоопытный инженер обязательно запнется.
VolCh
Даже в рамках одного продукта, но построенного на базе SOA/MSA унификация полезна. Когда с десяток сервисов деплоятся и конфигурируются единообразно, то это очень упрощает жизнь, как в плане регулярных задач, так и плане человеческой ошибки.
shurup
Во введении не написано, что хранение файлов в Kubernetes является самоцелью. По сути статья описывает часть большей задачи — по контейнеризации и миграции приложения в Kubernetes. Если такой задачи нет, то начинать лучше с понимания, зачем оно (Kubernetes) вообще нужно. Может быть, действительно не нужно?.. Без понимания/согласия на этом верхнем уровне нет смысла говорить про велосипеды и сравнивать с другими инструментами (что бы вы под ними ни подразумевали).
VolCh
Для многих задач k8s выглядит оверхедом, но альтернатива ему полумёртвый Docker Swarm — технически для многих задач подходит почти идеально, но выглядит как путь в никуда: рано или поздно потребуются какие-то фичи k8s.