Локальные файлы при переносе приложения в Kubernetes
При построении процесса CI/CD с использованием Kubernetes порой возникает проблема несовместимости требований новой инфраструктуры и переносимого в неё приложения. В частности, на этапе сборки приложения важно получить один образ, который будет использоваться во всех окружениях и кластерах проекта. Такой принцип лежит в основе правильного по мнению Google управления контейнерами (не раз об этом говорил и наш техдир).
Однако никого не увидишь ситуациями, когда в коде сайта используется готовый фреймворк, использование которого накладывает ограничения на процесс сборки. И если в «обычной среде» с этим легко справиться, в Kubernetes подобное поведение может стать проблемой, особенно когда вы сталкиваетесь с этим впервые. Хотя изобретательный ум и способен предложить инфраструктурные решения, кажущиеся очевидными и даже неплохими на первый взгляд… важно помнить, что большинство ситуаций могут и должны решаться архитектурно.
Разберем популярные workaround-решения для хранения файлов, которые могут привести к неприятным последствиям при эксплуатации кластера, а также укажем на более правильный путь.
Хранение статики
Для иллюстрации рассмотрим веб-приложение, которое использует некий генератор статики для получения набора картинок, стилей и прочего. Например, в PHP-фреймворке Yii есть встроенный менеджер ассетов, который генерирует уникальные названия директорий. Соответственно, на выходе получается набор заведомо не пересекающихся между собой путей для статики сайта (сделано это по нескольким причинам — например, для исключения дубликатов при использовании одного и того же ресурса множеством компонентов). Так, из коробки, при первом обращении к модулю веб-ресурса происходит формирование и раскладывание статики (на самом деле — зачастую симлинков, но об этом позже) с уникальным для данного деплоя общим корневым каталогом:
-
webroot/assets/2072c2df/css/…
-
webroot/assets/2072c2df/images/…
-
webroot/assets/2072c2df/js/…
Чем это чревато в разрезе кластера?
Простейший пример
Возьмем довольно распространенный кейс, когда перед PHP стоит nginx для раздачи статики и обработки простых запросов. Самый простой способ — Deployment с двумя контейнерами:
apiVersion: extensions/v1beta1
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: extensions/v1beta1
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: extensions/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.
Читайте также в нашем блоге: