Архив метки: Devops

SOPS + Age: Шифруем секреты

Это краткая напоминалка для шифрования YAML-файлов с секретами, которые публикуются в Git. Удобно для дальнейшей интеграции с Flux2ArgoCD (через Helm Secrets плагин)




Вместо тысячи слов: Age is a simple, modern and secure file encryption tool, format, and Go library. Рекомендуется пользоваться age вместо GPG – по крайней мере, так рекомендуют сами разработчики sops.




Инструкция ниже была выполнена на Ubuntu 20.04 в WSL2




Getting started




  • Необходимо сформировать приватный и публичный ключи, на основе которых будет производиться шифрование:




# создать каталог, где age по умолчанию будет искать ключи

mkdir -p $HOME/.config/sops/age/

 

# сгенерировать ключи

age-keygen -o $HOME/.config/sops/age/keys.txt

 

# добавить публичный ключ в переменную

PUB_KEY=$(cat $HOME/.config/sops/age/keys.txt | grep "public" | awk '{print $4}')




  • В данном примере будут шифроваться yaml файлы с чувствительными данными. Поэтому для необходимых полей yaml необходимо создать маску. В encrypted_regex добавить необходимые значения полей, а в age – публичный ключ, сформированный на предыдущем шаге в файле $HOME/.config/sops/age/key.txt:




cat > $HOME/.sops.yaml << EOF

creation_rules:

  - encrypted_regex: '^(data|key|password)$'

    age: $(echo $PUB_KEY)

EOF




К файлу .sops.yaml и keys.txt sops будет обращаться при каждом выполнении.




Так как $HOME/.config/sops/age/keys.txt – путь по умолчанию, sops не требуется явно передавать путь к файлу ключей. Но при необходимости его также можно задавать через переменную окружения – export SOPS_AGE_KEY_FILE=$HOME/.config/sops/age/keys.tx




  • Теперь можно создать файл-пример:




cat > $HOME/secret.yaml << EOF

apiVersion: v1

kind: Secret

metadata:

  name: mysecret

type: Opaque

data:

  USER_NAME: bob

  PASSWORD: strongpass

EOF




  • И при наличии всех необходимых данных выполнить шифрование:




sops -e -i secret.yaml




  • На выходе будет файл с тем же именем, но с шифрованным содержимым в поле data:




cat secret.yaml

apiVersion: v1

kind: Secret

metadata:

    name: mysecret

type: Opaque

data:

    USER_NAME: ENC[AES256_GCM,data:BQ6O,iv:XyjcRI5/TzDykCQo6a9FoDotHjJlYNGH+4Yq30F+5k4=,tag:YNyd8LnwMnmHe0TUm5wKhg==,type:str]

    PASSWORD: ENC[AES256_GCM,data:dD3prw2Cs9VOxw==,iv:2adiZTUmXrIHnpwrgAvJvsMafvrG+DDGXhUKD/C9nio=,tag:Xbl9NCuVdFRwED1AuXFtQA==,type:str]

sops:

    kms: []

    gcp_kms: []

    azure_kv: []

    hc_vault: []

    age:

        - recipient: age10teuyr66yq0glkkdp8w8adpvuty7xl4vhp6ergna8k95r68g9p7q5gvt94

          enc: |

            -----BEGIN AGE ENCRYPTED FILE-----

            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGdVRaTU1OT2g2SkhhK2tm

            ckpCZ0pWanBTYXJBdloybXVvYzNKeUFLM3lnCnUvd2FtbDAzZFlIY2ZHTEwyYmFs

            MWl0VmNldnk0VzBzSUdCZmJzZ0VZaU0KLS0tIFJ6ZzJyZmxKYUE5cjRVdVViVzV6

            ZFVLN3V0N3dUQ1lPSjUxZE0xMXh0SjgKFNwNL2bDPhuTZU7qb46ZPQ+qWm0dcSUf

            mt2xZismI0/g9oZH1K4CzMkrgYHDketCDPFLDZ9lRPha7jZzY9lKNA==

            -----END AGE ENCRYPTED FILE-----

    lastmodified: "2022-12-29T10:46:46Z"

    mac: ENC[AES256_GCM,data:ZEKJyGWWvKlP2/nrxosy7tLbiNx0p6Eu0+wYLz69NHjwa1bzIWAzVNXKQgZy1fWLT/AGlgwqTfx5zg8bg0iv43dHqL+NhIhTrqMLPlmYO/IiCn9PBfSY0UVdUwoz2E9QLCDHFaBvOqK5Q9EVkAApjsTwC39KlofPkwB5f+5FKqY=,iv:6hfqwbsUWHxkEWILMa5SrRnpPM81W2lX4gNntbxvoW0=,tag:lWl47uddfa4N/lQ0J+0fBA==,type:str]

    pgp: []

    encrypted_regex: ^(data|key|password)$

    version: 3.7.3




  • Для расшифровки:




sops -d -i secret.yaml

 

apiVersion: v1

kind: Secret

metadata:

    name: mysecret

type: Opaque

data:

    USER_NAME: bob

    PASSWORD: strongpass




  • При необходимости можно задать расширение (например, enc), указав, что файл зашифрован:




sops -e secret.yaml > secret.yaml.enc




  • И дешифровать




sops -d --input-type yaml --output-type yaml secret.yaml.enc




Важно указать явно input и output формат – yaml. Этого нет в документации, см issue




VSCode extensions




Для удобства работы с шифрованными секретами можно установить плагин для VSC, который позволяет налету производить расшифровку. Плагин не требует настройки, если путь для хранения публичныхприватных ключей в linux лежит по пути $HOME/.config/sops/age/ – можно сразу открывать шифрованные файлы после установки и работать с ними.




Источник: https://it-lux.ru/sops-age/



2023-02-23T02:40:42
DevOps

CI/CD для фронтенда: обзор инструментов и практик для автоматизации разработки

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




Мне кажется, это хорошая идея — разобраться, как ваше приложение будет автоматически собираться и деплоиться. Тем более сейчас (на самом деле всегда) тренд на T-shaped people — спецов в своей области, которые немного разбираются в смежных.




Что такое CI/CD







Для начала небольшой ликбез. CI/CD расшифровывается как Continuous Integration и Continuous Delivery aka Deployment — то есть непрерывная интеграция и непрерывная доставка. Зачем это нужно?




Чаще всего конечная цель разработки — приложение. Чтобы им пользоваться, люди должны получить к нему доступ: либо скачать из стора и установит. Если это сайт — вбить в адресную строку URL и открыть страничку. Чтобы мобильное приложение попало в стор, его нужно туда загрузить. В случае с сайтами нужно загрузить наши HTML/JS/CSS-файлы.




Вроде бы все просто. Но загружать файлы вручную как минимум неудобно:




  • Нужно находиться за компьютером, на котором эти файлы есть.



  • Когда файлы загружает человек, он может забыть что-то выгрузить или выгрузить что-то не то.




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




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




Что входит в CI




  • линтеры;



  • тесты;



  • подготовка продакшен-билда.




Линтеры




Зачем




Линтеры — это статические анализаторы кода, которые его проверяют, не запуская. Они позволяют сократить время на код-ревью и избавить разработчиков от рутинных задач: проверки стилистики кода (пробелы, точки с запятыми и длина строки); поиска проблем и потенциальных багов: неиспользованные фрагменты кода, заведомо опасные или переусложненные конструкции.




Как







  • ESLlint — де-факто стандартный линтер для JavaScript.



  • TSLint — был основным линтером для TypeScript, однако разработчики отказываются от его поддержки в пользу ESLint.



  • Prettier — не совсем линтер, скорее, форматтер, который следит за единой стилистикой кода; без проблем интегрируется с ESLint и TSLint.



  • stylelint — линтер для CSS и самых популярных его диалектов (SASS, LESS), для которых у него есть плагины.




Тесты




Зачем




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




Как







  • JestMochaJasmine — фреймворки для организации и запуска тестов; в последнее время наиболее популярен Jest, так как он идет из коробки с Create React App.



  • Testing LibraryEnzyme — утилиты, в первую очередь нацеленные на тестирование веб-приложений (рендеринг, симуляция кликов и т. п.).



  • selenium-webdriverCypress — инструменты для тестирования end-to-end, то есть когда будет действительно запускаться браузер и туда будут отправляться команды, эмулирующие действия пользователя (клики, нажатия клавиш и т. п.).




Подготовка продакшен-сборки




Что и зачем




Сборка — это преобразование исходных файлов так, чтобы их можно было раздавать сервером как веб-сайт (то есть как набор HTML-/JS-/CSS-файлов, которые понимает браузер), публиковать в менеджере пакетов (если вы пишете библиотеку, фреймворк или утилиту), использовать как расширения для браузера, приложение на Electron и др.




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




Условно продакшен-сборка состоит из таких процессов:




  • Разрешение импортов. Браузеры начали понимать модульность только недавно, и то до сих пор не все. Необходимо разобраться, в каком порядке запускать скрипты и как передавать результаты их исполнения другим скриптам.



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



  • Вшивание переменных окружения. Одно и то же приложение может работать в разных средах. Простейший пример — на тестовом сервере и на продакшене: в этом случае необходимо сбилдить приложения два раза, один раз — когда в окружении задан адрес апи тестового сервера, второй раз — продакшен-сервера.




Как







  • webpackParcelRollup, SystemJS, gulpGrunt — основные сборщики приложений, которые решают большинство упомянутых задач.



  • Dotenvdotenv-cli — npm-пакеты, которые упрощают работу с переменными окружения, особенно при разработке.




Дополнительно




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




Храните этот файл таким образом, чтобы он был легко доступен рядом с веб-приложением. Например, по адресу: https://your-site.com/version.json.




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




Итог. Npm Scripts




Все эти процессы — это integration из CI, но пока что не очень continuous. Чтобы их автоматизировать, необходимо потратить время (один раз) и сконфигурировать их так, чтобы они запускались одной командой в командной строке.




Для этого отлично подходят npm-скрипты. В итоге все 3 предыдущих процесса можно свести к запуску трех команд, который будет похож на что-то вроде:




npm run lint
npm run test
npm run build




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




Бонус. Git Hooks




Зачем




Чтобы не забывать запускать линтеры и тесты. Их запуск можно автоматизировать с помощью Git Hooks, то есть линтеры и тесты будут запускаться, например, перед каждым коммитом.




Как




  • Husky — позволяет привязывать npm-скрипты к Git Hooks внутри package.json.



  • lint-staged — позволяет запускать линтеры только для тех файлов, которые подготовлены для коммита.




Что входит в CD




  • версионирование и релиз;



  • деплоймент.




Версионирование и релиз




Зачем




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




  1. Маркеры стабильных ревизий. Упрощают поиск последней стабильной ревизии при необходимости откатить версию приложения (если, например, критический баг попал в продакшен).



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




Как







  • Semantic Versioning — методология для формирования номера версии. Одна из многих, но именно эта используется для версионирования npm-пакетов (ее удобно совмещать с версией в package.json).



  • Npm versionyarn version — команды, которые увеличивают версию вашего приложения. Они автоматически меняют версию в package.json, делают коммит с соответствующим сообщением и ставят тег, в котором будет имя новой версии.




Деплоймент




Деплоймент — это доставка и выгрузка файлов в место, откуда они будут раздаваться. То, как происходит деплой в значительной мере зависит от того, как именно хостится ваше приложение. Это может быть один из многих вариантов, например: AWS S3 Bucket / AWS CloudFront / другой сервис AWS, коих множество, Heroku/Dokku, VPS/VPH.




Зачем




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




Как







Деплоймент — это просто выгрузка файлов на другой сервер. Разница лишь в протоколе, по которому она будет происходить:




  • SSH — с некоторыми оговорками можно представить как пуш в некий удаленный (в смысле находящийся далеко) репозиторий.



  • HTTP — простой и знакомый фронтендерам способ, когда каждый файл отправляется в теле соответствующего HTTP-запроса.



  • FTP — самый старый из перечисленных протоколов, для которого можно найти клиент на Node.js, но, возможно, придется попотеть, настраивая его.




Операция выгрузки файлов может быть свернута до единого npm script, который будет запускать файл Node.js. Большинство API работают на Node.js (к примеру AWS).




Итог




По аналогии с CI мы получим несколько простых npm-скриптов, которые позволят запускать более сложные и ответственные процессы.




Пайплайны




Если переводить слово pipeline с английского в контексте computer science, одним из переводов будет «конвейер». И это слово хорошо описывает ситуацию.




Зачем




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




Существуют взаимозависимости и порядок процессов. Например, нет смысла деплоить приложение, если тесты упали. Упрощенно конвейер для нашего приложения выглядит так: линтинг и тесты — версионирование — билд — деплой.




Именно здесь вступают в дело пайплайны — как инструмент, который описывает и запускает конвейер для процессов CI/CD.




Как










Почти все, что я привел в списке, — это хостинги репозиториев, кроме Jenkins’а (который я добавил для полноты картины, чтобы было ясно, что такие инструменты не обязательно являются частью хостинга репозиториев).




Далее я приведу несколько примеров, как это выглядит в GitLab Pipelines. Для примеров я взял именно GitLab по нескольким причинам. У меня есть опыт плотной работы с этим сервисом. Бесплатный аккаунт на GitLab предоставляет хороший пакет, связанный с пайплайнами, которого с головой хватит, чтобы потренироваться на пет-проекте. То же относится к standalone GitLab-серверу. Также он дает общее понимание, как настраиваются пайплайны. Лично мне было нетрудно по аналогии с GitLab разобраться с тем, что предлагали Bitbucket Pipelines.




GitLab CI/CD




Как это выглядит. Для каждого запушенного коммита запускается пайплайн. Ниже можно увидеть список пайплайнов, которые запускались для разных коммитов.







Рис. 1. Успешно завершенные пайплайны




Пайплайн состоит из шагов (steps). Степы, в свою очередь, состоят из задач (jobs). Ниже можно увидеть развернутую структуру пайплайна. Колонки Setup, Code_quality и далее — это steps. Каждый блок с зеленой иконкой — это отдельная job.







Рис. 2. Декомпозиция пайплайна




Если одна из джоб падает, пайплайн останавливается. В этот момент ясно видна выгода от связки хостинг-репозитория и пайплайна: если для последнего коммита в мерж-реквесте упал пайплайн, смержить такой реквест не удастся. Это не допустит попадания в стабильные ветки кода, который, например, не проходит проверку линтеров или тестов.







Рис. 3. Пайплайн, завершившийся неудачей, так как линтеры упали




.gitlab-ci.yml




Как это настраивать. Пайплайн описывается в файле .gitlab-ci.yml, который должен лежать в корневой папке репозитория.




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




image: node:8

variables:
 REACT_APP_ENV_NAME: $CI_ENVIRONMENT_NAME

stages:
 - setup
 - code_quality
 - testing
 - semver
 - deployment




Строки 1-11 .gitlab-ci.yaml




image — указывает, в каком докер-контейнере должен запускаться пайплайн. Если очень коротко, докер — это технология, позволяющая получить предсказуемую среду выполнения. В данном случае мы хотим запускаться в условном Linux, на котором установлена 8-я версия Node.js.




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




stages — описывает очередность выполнения задач. Ставим зависимости, линтим скрипты и стили, потом тестируем, после чего уже можем деплоить. Выглядит это как массив строчных значений, которые используются для маркировки задач. Эти же стадии изображены на рис. 2.




Jobs & Scripts




dependencies:installation:
 stage: setup
 cache:
   paths:
     - node_modules/
 script:
   - yarn --prefer-offline --no-progress --non-interactive --frozen-lockfile
 tags:
   - web-ci

lint:scripts:
 stage: code_quality
 cache:
   paths:
     - node_modules/
 script:
   - yarn run lint:scripts:check --max-warnings 0
 only:
   changes:
     - src/**/*.{ts,tsx}
 tags:
   - web-ci

lint:styles:
 stage: code_quality
 cache:
   paths:
     - node_modules/
 script:
   - yarn run lint:styles:check
 only:
   changes:
     - src/**/*.{css,scss}
 tags:
   - web-ci


unit:testing:
 stage: testing
 cache:
   paths:
     - node_modules/
 only:
   changes:
     - src/**/*.{ts,tsx}
 script:
   - yarn test
 tags:
   - web-ci




Строки 13-60 .gitlab-ci.yaml




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




script — набор команд, которые будут выполнены в процессе работы джобы. Для dependencies installation мы видим, что это всего одна команда — yarn — c аргументами, которые говорят не качать лишнего, если оно есть в кеше.




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




only и exlude позволяют определить, когда джоба должна работать, а когда нет. Например, мы видим, что линтинг скриптов происходит только при изменениях в рамках .ts- и .tsx-файлов, CSS- и SCSS-стилей.




Таким же образом можно сделать джобу деплоя доступной только для мастер-ветки.




Версионирование




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




.semver_script: &semver_script
 stage: semver
 when: manual
 only:
   - master
 except:
   refs:
     - /^vd+.d+.d+$/
 tags:
   - web-ci
 script:
   - mkdir -p ~/.ssh && chmod 700 ~/.ssh
   - ssh-keyscan $CI_SERVER_HOST >> ~/.ssh/known_hosts && chmod 644 ~/.ssh/known_hosts
   - eval $(ssh-agent -s)
   - ssh-add <(echo "$SSH_PRIVATE_KEY")
   - git remote set-url --push origin git@$CI_SERVER_HOST:$CI_PROJECT_PATH.git
   - git config --local --replace-all user.email "
 noreply@yourmail.com"
   - git config --local --replace-all user.name "Gitlab CI"
   - git checkout $CI_COMMIT_REF_NAME
   - git reset --hard origin/$CI_COMMIT_REF_NAME
   - npm version $SEMVER_LEVEL
   - git push -u origin $CI_COMMIT_REF_NAME --tags

semver:minor:
 <<: *semver_script
 variables:
   SEMVER_LEVEL: minor

semver:patch:
 <<: *semver_script
 variables:
   SEMVER_LEVEL: patch




Строки 62-93 .gitlab-ci.yaml




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




  • Добавление приватного SSH-ключа, который хранится в переменных окружения и который имеет доступ для пуша в репозиторий.



  • Добавление хоста репозитория в список известных хостов.



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




Чтобы не копировать этот фрагмент для минорной и патч-версий, здесь используется фича YAML-файлов, которая называется YAML anchor. Благодаря подобным фичам YAML-файлы становятся лучшим форматом для описания конфигураций.




Деплоймент и переменные окружения







Рис. 4. Веб-интерфейс гитлаба для управления окружениями




На рис. 4 показан веб-интерфейс гитлаба для создания и редактирования деплоймент-окружений. После того как они созданы здесь, их можно использовать в .gitlab-ci.yaml.




Ниже приведен фрагмент конфигурации деплоймента на примере выгрузки результатов билда в AWS S3 Bucket. Здесь также использован YAML anchor для исключения дублирования кода.




.deploy_script: &deploy_script
  cache:
    paths:
      - node_modules/
  stage: deployment
  script:
    - yarn run build
    - yarn run deploy
  tags:
    - web-ci

deploy:dev:
  <<: *deploy_script
  variables:
    AWS_S3_HOST_BUCKET_NAME: $AWS_S3_HOST_BUCKET_NAME__DEV
    REACT_APP_API_BASE: $REACT_APP_API_BASE__DEV
  environment:
    name: dev
    url: http://$AWS_S3_HOST_BUCKET_NAME.s3-website.us-east-1.amazonaws.com/
  only:
    - develop


deploy:qa:
  <<: *deploy_script
  when: manual
  variables:
    AWS_S3_HOST_BUCKET_NAME: $AWS_S3_HOST_BUCKET_NAME__QA
    REACT_APP_API_BASE: $REACT_APP_API_BASE__QA
  environment:
    name: qa
    url: http://$AWS_S3_HOST_BUCKET_NAME.s3-website.us-east-1.amazonaws.com/
  only:
    refs:
      - /^vd+.d+.d+$/
    changes:
      - package.json




Строки 95-131 .gitlab-ci.yaml




Обратите внимание, как используются переменные окружения. Команды yarn run build и yarn run deploy используют имена переменных без постфиксов, которые определяются на уровне конкретной джобы из значений, находящихся в переменных с постфиксами.







Рис. 5. Веб-интерфейс гитлаба для управления переменными окружения




На рис. 5 показан веб-интерфейс, в котором можно описать переменные окружения. Они будут доступны внутри пайплайна, когда он запустится. Тут можно определить адреса апи бэкенда, ключи апи для сервисов, которые вы используете: например, Google API key, SSH-ключи для версионирования и другие данные, коммитить которые небезопасно.




Заключение




Даже при рассмотрении CI/CD в рамках специфики фронтенда обнаруживается много деталей и нюансов. Файл конфигурации пайплайнов из моего примера — рабочий, вы можете использовать его для своих проектов, подставив соответствующие npm- или yarn-скрипты. Надеюсь, эта статья станет отправной точкой для дискуссий и погружения в тему.




Источник: http://gs-studio.com/news-about-it/34189-cicd



2023-02-07T14:11:41
DevOps

Kubernetes: Ingress, ошибка 502, readinessProbe и livenessProbe

Имеется приложение на Go, API-бекенд.




Периодически начинает возвращать 502 ошибку, хотя сам под работает и в статусе Running.




Что бы рассмотреть, как и почему Ingress и Service могут возвращать 502, и как работают readinessProbe и livenessProbe в Kubernetes Deployment – напишем простой веб-сервер на Go, в котором опишем два ендпоинта – один будет возвращать нормальный ответ, а во втором – выполнение программы будет прерываться.




Затем задеплоим его в AWS Elastic Kubernetes, создадим Kubernetes Ingress, который создаст AWS Application Load balancer, и потрестируем работу приложения.




Golang HTTP server




Пишем приложение на Go, которое потом упакуем в Docker-контейнер, и запустим в Kubernetes:




package main



import (

    "fmt"

    "log"

    "net/http"

)

  

func main() {

    

    http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request){

        fmt.Fprintf(w, "pong")

    })

      

    http.HandleFunc("/err", func(w http.ResponseWriter, r *http.Request){

        panic("Error")

    })

      

    fmt.Printf("Starting server at port 8080n")

    if err := http.ListenAndServe(":8080", nil); err != nil {

        log.Fatal(err)

    }   

}




Тут мы запускаем http.ListenAndServe() на порту 8080, и определяем два роута:




  • /ping – при обращении сюда всегда возвращаем 200



  • /err – при обращении сюда прерываем выполнение функции с panic, что сэмулировать некорретный ответ приложения




Проверяем локально.




Запускаем:




go run http.go

Starting server at port 8080




Проверяем роут /ping:




curl -I localhost:8080/ping

HTTP/1.1 200 OK




И URI /err, который вызовет panic:




curl -I localhost:8080/err

curl: (52) Empty reply from server




Лог приложения:




go run http.go

Starting server at port 8080

2020/11/11 14:34:53 http: panic serving [::1]:43008: Error

goroutine 6 [running]:

...




Docker образ




Пишем Dockefile:




FROM golang:latest

WORKDIR /app

COPY . .

RUN go build -o main .

EXPOSE 8080

CMD ["./main"]




Собираем образ и пушим в Docker Hub:




docker build -t setevoy/go-http .

docker push setevoy/go-http




Kubernetes




Deployment




Описываем запуск пода с этим образом – создаём 1 под, Service для него, и Ingress, который создаст AWS Application Load Balancer.




Начнём с Deployment:




apiVersion: apps/v1

kind: Deployment

metadata:

  name: go-http

  labels:

    app: go-http

spec:

  replicas: 1

  strategy:

    type: Recreate

  selector: 

    matchLabels:

      app: go-http

  template:

    metadata:

      labels:

        app: go-http

    spec:

      containers:

      - name: go-http

        image: setevoy/go-http

        ports:

        - containerPort: 8080

        imagePullPolicy: Always

        livenessProbe:

          httpGet:

            path: /ping

            port: 8080

          initialDelaySeconds: 1

          periodSeconds: 1

        readinessProbe:

          httpGet:

            path: /ping

            port: 8080

          initialDelaySeconds: 1

          periodSeconds: 1

      restartPolicy: Always




Тут создаём один под, который слушает порт 8080.




В strategy деплоймента указываем Recreate, что бы при тестах не оставались старые поды.




Для него описываем проверки – livenessProbe и readinessProbe, обе проверки ходят на URI /ping, где получают ответ 200.




Позже мы поменяем путь в проверках, и посмотрим, к чему это приведёт.




Service




Создаём Kubernetes Service, который откроет на WorkerNode порт для ALB, и будет роутить трафик к нашему поду на порт 8080 (см. Kubernetes: Service, балансировка нагрузки, kube-proxy и iptables):




---

apiVersion: v1

kind: Service

metadata:

  name: go-http-svc

spec:

  type: NodePort

  ports:

  - port: 80

    targetPort: 8080

    protocol: TCP

  selector:

    app: go-http




Ingress




Добавляем Ingress, который создаст AWS Application Load Balancer, который будет направлять трафик к go-http-svc Service:




--- 

apiVersion: extensions/v1beta1

kind: Ingress

metadata:

  name: go-http-ingress

  annotations:    

    kubernetes.io/ingress.class: alb

    alb.ingress.kubernetes.io/scheme: internet-facing

    alb.ingress.kubernetes.io/healthcheck-path: /ping

    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}]'

  labels:   

    app: go-http

spec:         

  rules:

  - http: 

      paths:

      - path: /*

        backend: 

          serviceName: go-http-svc

          servicePort: 80




Создаём ресурсы:




kubectl apply -f go-http.yaml

deployment.apps/go-http created

service/go-http-svc created

ingress.extensions/go-http-ingress created




Проверяем под:




kubectl get pod -l app=go-http

NAME                      READY   STATUS    RESTARTS   AGE

go-http-8dc5b4779-7q4kw   1/1     Running   0          8s




И его Ingress:




kubectl get ingress -l app=go-http

NAME              HOSTS   ADDRESS                                                                 PORTS   AGE

go-http-ingress   *       e172ad3e-default-gohttping-ec00-691779486.us-east-2.elb.amazonaws.com   80      31s




Ждём, пока наш DNS увидит новый URL e172ad3e-default-gohttping-ec00-691779486.us-east-2.elb.amazonaws.com, и проверяем работу приложения – выполняем запрос к /ping:




curl -I e172ad3e-default-gohttping-ec00-691779486.us-east-2.elb.amazonaws.com/ping

HTTP/1.1 200 OK




Kubernetes Ingress 502




А теперь – обращаемся к ендпоинту /err, который в Go-приложении вызовет panic, и ловим 502 ошибку:




curl -vI e172ad3e-default-gohttping-ec00-691779486.us-east-2.elb.amazonaws.com/err

...

< HTTP/1.1 502 Bad Gateway

HTTP/1.1 502 Bad Gateway

< Server: awselb/2.0

Server: awselb/2.0




Логи пода:




kubectl logs go-http-8dc5b4779-7q4kw

Starting server at port 8080

2020/11/11 12:57:10 http: panic serving 10.3.39.145:8926: Error

goroutine 5169 [running]:

net/http.(*conn).serve.func1(0xc000260e60)

/usr/local/go/src/net/http/server.go:1801 +0x147

panic(0x654840, 0x6f0ba0)

/usr/local/go/src/runtime/panic.go:975 +0x47a

main.main.func2(0x6fa0a0, 0xc00012c540, 0xc000127700)

/app/http.go:16 +0x39

net/http.HandlerFunc.ServeHTTP(0x6bab98, 0x6fa0a0, 0xc00012c540, 0xc000127700)

/usr/local/go/src/net/http/server.go:2042 +0x44

net/http.(*ServeMux).ServeHTTP(0x8615e0, 0x6fa0a0, 0xc00012c540, 0xc000127700)

/usr/local/go/src/net/http/server.go:2417 +0x1ad

net/http.serverHandler.ServeHTTP(0xc0000ea000, 0x6fa0a0, 0xc00012c540, 0xc000127700)

/usr/local/go/src/net/http/server.go:2843 +0xa3

net/http.(*conn).serve(0xc000260e60, 0x6fa4e0, 0xc00011b8c0)

/usr/local/go/src/net/http/server.go:1925 +0x8ad

created by net/http.(*Server).Serve

/usr/local/go/src/net/http/server.go:2969 +0x36c




Тут всё логично – load-balancer отправил наш запрос к поду, под не ответил (вспомним curl: (52) Empty reply from server в наших первых тестах), и мы получили ответ 502 от балансировщика aka Ingress.




readinessProbe и livenessProbe




Теперь посмотрим, как изменение в readinessProbe и livenessProbe повлияют на ответы Ingress и работу самого пода.




readinessProbe




Документация – тут>>>.




readinessProbe используется для проверки того, готов ли под принимать трафик.




Сейчас наш под в статусе Ready:




kubectl get pod -l app=go-http

NAME                      READY   STATUS    RESTARTS   AGE

go-http-8dc5b4779-7q4kw   1/1     Running   0          28m




Или так:




kubectl get pod -l app=go-http -o json | jq '.items[].status.containerStatuses[].ready'

true




И запрос к /ping возвращает нам ответ 200:




curl -I e172ad3e-default-gohttping-ec00-691779486.us-east-2.elb.amazonaws.com/ping

HTTP/1.1 200 OK




Меняем readinessProbe – задаём path=/err, что бы проверка постоянно фейлилась:




...

        readinessProbe:

          httpGet:

            path: /err

...




Передеплоиваем:




kubectl apply -f go-http.yaml

deployment.apps/go-http configured




И проверяем:




kubectl get pod -l app=go-http

NAME                      READY   STATUS    RESTARTS   AGE

go-http-5bd557544-2djcw   0/1     Running   0          4s




Если мы теперь отправим запрос даже на ендпоинт /ping – всё-равно получим 502, т.к. бекенд Сервиса, т.е. под, не принимает трафик, потому что не прошёл readinessProbe:




kubectl get pod -l app=go-http -o json | jq '.items[].status.containerStatuses[].ready'

false




Пробуем:




curl -I e172ad3e-default-gohttping-ec00-691779486.us-east-2.elb.amazonaws.com/ping

HTTP/1.1 502 Bad Gateway




livenessProbe




Документация – тут>>>.




Вернём readinessProbe в /ping, что бы трафик на под пошёл, но изменим livenessProbe – зададим path в /err, а initialDelaySeconds и periodSeconds установим в 15 секунд, плюс добавим failureThreshold равным одной попытке:




...

        livenessProbe:

          httpGet:

            path: /err

            port: 8080

          initialDelaySeconds: 15

          periodSeconds: 15

          failureThreshold: 1

        readinessProbe:

          httpGet:

            path: /ping

...




Теперь после запуска пода Kubernetes выждет 15 секунд, затем выполнит livenessProbe и будет повторять её каждые следующие 15 секунд.




Передеплоиваем, и проверяем:




kubectl get pod -l app=go-http

NAME                       READY   STATUS    RESTARTS   AGE

go-http-78f6c66c8b-q6fkf   1/1     Running   0          6s




Проверяем запрос к ендпоинту /ping:




curl -I e172ad3e-default-gohttping-ec00-691779486.us-east-2.elb.amazonaws.com/ping

HTTP/1.1 200 OK




Всё хорошо.




И /err нам ожидаемо вернёт 502:




curl -I e172ad3e-default-gohttping-ec00-691779486.us-east-2.elb.amazonaws.com/err

HTTP/1.1 502 Bad Gateway




Через 15 секунд, после выполнения первой live проверки – под будет перезапущен:




kubectl get pod -l app=go-http

NAME                       READY   STATUS    RESTARTS   AGE

go-http-668c674dcb-4db9x   0/1     Running   1          19s




Events этого пода:




kubectl describe pod -l app=go-http

...

Normal   Created    6s (x4 over 81s)  kubelet, ip-10-3-55-229.us-east-2.compute.internal  Created container go-http                                                                                                                         

 Warning  Unhealthy  6s (x3 over 66s)  kubelet, ip-10-3-55-229.us-east-2.compute.internal  Liveness probe failed: Get http://10.3.53.103:8080/err: EOF                                                                                       

 Normal   Killing    6s (x3 over 66s)  kubelet, ip-10-3-55-229.us-east-2.compute.internal  Container go-http failed liveness probe, will be restarted                                                                                        

Normal   Started    5s (x4 over 81s)  kubelet, ip-10-3-55-229.us-east-2.compute.internal  Started container go-http




Контейнер в поде не прошёл проверку livenessProbe, и Kubernetes перезапускает под в попытке “починить” его.




Если получится попасть на сам момент перезапуска контейнера – увидим стаус CrashLoopBackOff, а запрос к /ping снова вернёт нам 502:




kubectl get pod -l app=go-http

NAME                       READY   STATUS             RESTARTS   AGE

go-http-668c674dcb-4db9x   0/1     CrashLoopBackOff   4          2m21s




Выводы




Используем readinessProbe для проверки того, что приложение запустилось, в данном случае – Go-бинарник начал прослушивать порт 8080, и на него можно направлять трафик, и используем livenessProbe во время работы пода для проверки того, что приложение в нём все ещё живо.




Если приложение начинает отдавать 502 на определённые запросы – то следует поискать проблему именно в запросах, т.к. если бы была проблема в настройках Ingress/Service – получали бы 502 постоянно.




Самое важное – понимать принципиальную разницу между readinessProbe и livenessProbe:




  • если фейлится readinessProbe – процесс aka контейнер в поде останется в том же состоянии, в котором был на момент сфейленой ready-проверки, но под будет отключен от трафика к нему



  • если фейлится livenessProbe – трафик на под продолжает идти, но контейнер будет перезапущен




Итак, имеем ввиду, что:




  • Если readinessProbe не задана вообще – kubelet будет считать, что под готов к работе, и направит к нему трафик сразу после старта пода. При этом если на  запуск пода уходит минута – то клиенты, которые к нему были направлены после его запуска, будут ждать эту минуту, пока он ответит.



  • Если приложение ловит ошибку, которую не может обработать – оно должно завершить свой процесс, и Kubernetes сам перезапустит контейнер.



  • Используем livenessProbe для проверки состояний, которые нельзя обработать в самом приложении, например – deadlock или бесконечный цикл, при которых контейнер не может ответить на ready-проверку. В таком случае если нет livenessProbe, которая может перезапустить процесс, то под будет отключен от Service – но останется в статусе Running, продолжая потреблять реурсы WorkerNode.




Источник: https://rtfm.co.ua/ru/kubernetes-ingress-oshibka-502-readinessprobe-i-livenessprobe/



2023-01-03T02:03:12
DevOps

Kubernetes: NGINX/PHP-FPM graceful shutdown – избавляемся от 502 ошибок

Имеется PHP-приложение, работает в Kubernetes в подах с двумя контейнерами – NGINX и PHP-FPM.




Проблема: во время скейлинга приложения начинают проскакивать 502 ошибки. Т.е. при остановке подов – некорректно отрабатывает завершение подключений.




Рассмотрим процесс остановки подов вообще, и особенности NGINX и PHP-FPM в частности.




Тестировать будем приложение в AWS Elastic Kubernetes Service с помощью Yandex.Tank.




Ingress создаёт AWS Application Load Balancer с помощью AWS ALB Ingress Controller.




Для управления контейнерами на Kubernetes WorkerNodes испольузется Docker.




Pod Lifecycle – Termination of Pods




Посмотрим, как вообще происходит процесс остановки и удаления подов.




Итак, под – это процесс(ы), запущенные на WorkerNode, для остановки которых используются стандартные сигналы IPC (Inter Process Communication).




Что бы дать возможность поду (процессу/ам) закончить все его текущие операции – система управления контейнерами (container runtime) пытается мягко завершить его работу (graceful shutdown), отправляя сигнал SIGTERM процессу с PID 1 в каждом контейнере этого пода (см. docker stop). При этом, кластер запускает отсчёт grace period перед тем, как жёстко убить под отправкой сигнала SIGKILL.




При этом, можно переопределить сигнал для мягкой остановки используя STOPSIGNAL в образе, из которого запускался контейнер.




Итак, процесс удаления пода выглядит следующим образом:




  1. мы выполняем kubectl delete pod или kubectl scale deployment – запускается процесс удаления подов и стартует таймер отсчёта grace period с дефолтным значением в 30 секунд



  2. API-сервер обновляет статус пода – из Running он становится Terminating (см. Container states). На WorkerNode, на которой этот под запущен, kubelet получает обновление статус этого пода и начинает процесс его остановки:

    1. если для контейнера(ов) в поде есть preStop hook – kubelet  его выполняет. Если хук продолжает выполнение после истечения grace period – добавляется ещё 2 секунды на его завершение. При необходимости можно изменить дефолтные 30 секунд используя terminationGracePeriodSeconds



    2. после завершения preStop хука – kubelet отправляет указание Docker на остановку контейнеров, и Docker отправляет сигнал SIGTERM процессу с PID 1 в каждом контейнере. При этом контейнеры в поде получают сигнал в случайном порядке.




  3. одновременно с началом процесса graceful shutdown – Kubernetes Control Plane (его kube-controller-manager) удаляет останавливаемый под из списка ендпоинтов (см. Kubernetes – Endpoints), и соответствующий Service перестаёт отправлять новые подключения на останавливаемый под



  4. по завершению grace period, kubelet триггерит force shutdown – Docker отправляет сигнал SIGKILL всем оставшимся процесам во всех контейнерах пода, который они проигнорировать не могут, и мгновенно умирают, аварийно завершая все свои операции



  5. kubelet триггерит удаление объекта пода из API-сервера



  6. API-сервер удаляет запись о поде из базы, и под становится недоступен




Наглядная табличка:





Собственно, тут возникает две проблемы:




  1. сам NGINX и PHP-FPM воспринимают SIGTERM как “жестокое убийство”, и завершают работу немедленно, не заботясь о корректном завершении текущих подключений (см. Controlling nginx и php-fpm(8) – Linux man page)



  2. шаги 2 и 3 – отправка SIGTERM и удаление ендпоинта – выполняются одновременно. Однако, обновление данных в Ingress Service происходит не моментально, и под может начать умирать раньше, чем Ingress перестанет на него слать трафик, соотвественно – получим 502, т.к. процесс в поде уже не может принимать подключения




Т.е. в первом случае, если у нас есть подключение к NGINX с keep-alive – то NGINX при выполнении fast shutdown просто обрубит его, а клиент получит 502 ошибку, см. Avoiding dropped connections in nginx containers with “STOPSIGNAL SIGQUIT”.




NGINX STOPSIGNAL и 502




Попробуем воспроизвести проблему с NGINX.




Возмём пример из поста по ссылке выше, и задеплоим его в Кубер.




Пишем Dockerfile:




FROM nginx

RUN echo 'server {n

    listen 80 default_server;n

    location / {n

      proxy_pass      http://httpbin.org/delay/10;n

    }n

}' > /etc/nginx/conf.d/default.conf

CMD ["nginx", "-g", "daemon off;"]




Тут NGINX выполняет proxy_pass на http://httpbin.org, который отвечает с задержкой в 10 секунд – эмулируем работу PHP-бекенда.




Собираем, пушим в репозиторий:




docker build -t setevoy/nginx-sigterm .

docker push setevoy/nginx-sigterm




  • Re-play



  • Copy to Clipboard



  • Pause



  • Full View




Пишем Deployment с 10 подами из собранного образа.




Тут приведу полный файл, с Namespace, Service и Ingress, далее только те части, которые будут меняться:




---

apiVersion: v1

kind: Namespace

metadata:

  name: test-namespace

---

apiVersion: apps/v1

kind: Deployment

metadata:

  name: test-deployment

  namespace: test-namespace

  labels:

    app: test

spec:

  replicas: 10

  selector:

    matchLabels:

      app: test

  template:

    metadata:

      labels:

        app: test

    spec:

      containers:

      - name: web

        image: setevoy/nginx-sigterm

        ports:

        - containerPort: 80

        resources:

          requests:

            cpu: 100m

            memory: 100Mi

        readinessProbe:

          tcpSocket:

            port: 80

---

apiVersion: v1

kind: Service

metadata:

  name: test-svc

  namespace: test-namespace

spec:

  type: NodePort

  selector:

    app: test

  ports:

    - protocol: TCP

      port: 80

      targetPort: 80

---

apiVersion: extensions/v1beta1

kind: Ingress

metadata:

  name: test-ingress

  namespace: test-namespace

  annotations:

    kubernetes.io/ingress.class: alb

    alb.ingress.kubernetes.io/scheme: internet-facing

    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}]'

spec:

  rules:

  - http:

      paths:

      - backend:

          serviceName: test-svc

          servicePort: 80




Деплоим:




kubectl apply -f test-deployment.yaml

namespace/test-namespace created

deployment.apps/test-deployment created

service/test-svc created

ingress.extensions/test-ingress created




Проверяем Ingress:




curl -I aadca942-testnamespace-tes-5874-698012771.us-east-2.elb.amazonaws.com

HTTP/1.1 200 OK




Запущено 10 подов:




kubectl -n test-namespace get pod

NAME                              READY   STATUS    RESTARTS   AGE

test-deployment-ccb7ff8b6-2d6gn   1/1     Running   0          26s

test-deployment-ccb7ff8b6-4scxc   1/1     Running   0          35s

test-deployment-ccb7ff8b6-8b2cj   1/1     Running   0          35s

test-deployment-ccb7ff8b6-bvzgz   1/1     Running   0          35s

test-deployment-ccb7ff8b6-db6jj   1/1     Running   0          35s

test-deployment-ccb7ff8b6-h9zsm   1/1     Running   0          20s

test-deployment-ccb7ff8b6-n5rhz   1/1     Running   0          23s

test-deployment-ccb7ff8b6-smpjd   1/1     Running   0          23s

test-deployment-ccb7ff8b6-x5dc2   1/1     Running   0          35s

test-deployment-ccb7ff8b6-zlqxs   1/1     Running   0          25s




Готовим load.yaml для Yandex.Tank:




phantom:

  address: aadca942-testnamespace-tes-5874-698012771.us-east-2.elb.amazonaws.com

  header_http: "1.1"

  headers:

     - "[Host: aadca942-testnamespace-tes-5874-698012771.us-east-2.elb.amazonaws.com]"

  uris:

    - /    

  load_profile:

    load_type: rps

    schedule: const(100,30m)

  ssl: false

console:

  enabled: true

telegraf:

  enabled: false

  package: yandextank.plugins.Telegraf

  config: monitoring.xml




Тут выполняем 1 запрос в секунду к подам за нашим Ingress (и NGINX в каждом будет ждать 10 секунд ответа от своего “бекенда” перед тем, как ответить 200 нашему Танку).




Запускаем тесты:





Пока всё хорошо.




Теперь скейлим поды с 10 до 1:




kubectl -n test-namespace scale deploy test-deployment --replicas=1

deployment.apps/test-deployment scaled




Поды перешли в Terminating:




kubectl -n test-namespace get pod

NAME                              READY   STATUS        RESTARTS   AGE

test-deployment-647ddf455-67gv8   1/1     Terminating   0          4m15s

test-deployment-647ddf455-6wmcq   1/1     Terminating   0          4m15s

test-deployment-647ddf455-cjvj6   1/1     Terminating   0          4m15s

test-deployment-647ddf455-dh7pc   1/1     Terminating   0          4m15s

test-deployment-647ddf455-dvh7g   1/1     Terminating   0          4m15s

test-deployment-647ddf455-gpwc6   1/1     Terminating   0          4m15s

test-deployment-647ddf455-nbgkn   1/1     Terminating   0          4m15s

test-deployment-647ddf455-tm27p   1/1     Running       0          26m

...




И ловим наши 502 ошибки:





Теперь в Dockerfile добавляем STOPSIGNAL SIGQUIT:




FROM nginx

RUN echo 'server {n

    listen 80 default_server;n

    location / {n

      proxy_pass      http://httpbin.org/delay/10;n

    }n

}' > /etc/nginx/conf.d/default.conf

STOPSIGNAL SIGQUIT

CMD ["nginx", "-g", "daemon off;"]




Билдим, пушим:




docker build -t setevoy/nginx-sigquit .

docker push setevoy/nginx-sigquit




Обновляем деплоймент:




...

    spec:

      containers:

      - name: web

        image: setevoy/nginx-sigquit

        ports:

        - containerPort: 80

...




Передеплоиваем, и проверяем ещё раз.




Запускаем тесты:





Скейлим деплоймент:




kubectl -n test-namespace scale deploy test-deployment --replicas=1

deployment.apps/test-deployment scaled




А ошибок по-прежнему нет:





Прекрасно.




Трафик, preStop и sleep




И всё-таки, если повторить тесты несколько раз, то иногда 502 ошибки всё-таки проскакивают:





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




Добавим preStop хук со sleep, что бы дать время на обновление ендпоинтов – тогда после получения команды на удаление пода, kubelet выждет 5 секунд перед отправкой SIGTERM, за которые Ingress успеет обновить список ендпоинтов:




...

    spec:

      containers:

      - name: web

        image: setevoy/nginx-sigquit

        ports:

        - containerPort: 80

        lifecycle:

          preStop:

            exec:

              command: ["/bin/sleep","5"]

...




Повторяем тесты – всё хорошо.




Выкатили фикс в Production – всё работает замечательно, ошибки пропали, автотесты теперь работают без проблем.




С PHP-FPM проблемы, как оказалось, не было вообще,  т.к. исходный образ изначально был со STOPSIGNAL SIGQUIT.




Другие варианты решения




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




preStop и nginx -s quit




Одним из вариантов было добавить в preStop хук отправку сигнала QUIT в NGINX:




lifecycle:

  preStop:

    exec:

      command:

      - /usr/sbin/nginx

      - -s

      - quit




Но не помогло. Не очень понятно почему, потому как идея вроде бы правильная – вместо того, что бы ожидать TERM от Kubernetes/Docker – мы сами мягко стопаем NGINX, отправляя ему QUIT.




Но можно попробовать.




NGINX + PHP-FPM, supervisord и stopsignal




Наше приложение работает в двух отдельных контейнерах NGINX и PHP-FPM.




Но в процессе поиска решения пробовал использовать образ, в котором оба сервиса собраны в одном образе, например trafex/alpine-nginx-php7.




В нём пробовал добавлять параметр stopsignal и для NGINX, и для PHP-FPM со значением QUIT – но тоже не помогло, хотя идея тоже вроде правильная.




Тем не менее – как вариант на попытку.




PHP-FPM и process_control_timeout




В посте Graceful shutdown in Kubernetes is not always trivial и на Stackoveflow в вопросе Nginx / PHP FPM graceful stop (SIGQUIT): not so graceful есть упоминание о том, что мастер-процесс FPM убивается не дожидаясь своих дочерних процессов, что также может приводить к 502.




Не наш случай, но стоит обратить внимание на параметр process_control_timeout.




NGINX, HTTP и keep-alive session




В принципе, можно было бы указать приложению отправлять заголовок [Connection: close] – тогда клиент по завершении передачи данных закрывал бы соединение, и это уменьшило бы вероятность 502-х. Но они всё равно будут, если NGINX получит SIGTERM в процессе обработки запроса от клиента, а т.к. у нас “сзади” ещё PHP, который ходит в базу – то некоторые запросы могут обрабатываться по 5-10 и более секунд.




См. HTTP persistent connection.




Источник: https://rtfm.co.ua/ru/kubernetes-nginx-php-fpm-graceful-shutdown-izbavlyaemsya-ot-502-oshibok/



2023-01-03T01:50:56
DevOps

Как безопасно завершить работу пода в Kubernetes: разбираемся с graceful shutdown и zero downtime деплоймент

Создание и удаление подов — распространенная задача при работе с Kubernetes. Новые поды создаются, когда вы выполняете плавающее обновление, масштабируете развертывание и релизите новую функциональность, а также при выполнении cron и других задач. Еще поды пересоздаются при каждом удалении и внесении изменений, например, когда узел помечается как непланируемый (unschedulable).




Команда Kubernetes aaS VK Cloud Solutions перевела статью о том, как безопасно завершить работу пода.




Основные термины из статьи:




Graceful shutdown — предсказуемое окончание работы системы, когда все запущенные процессы корректно завершают работу без потери данных или негативного пользовательского опыта.




Zero downtime deploy — нулевой простой во время развертывания новой версии приложения. Пользователь не заметит его недоступности.




Понятная схема, которая показывает, что происходит в кластере при удалении пода (PDF).




Создание пода




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




apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  containers:
    - name: web
      image: nginx
      ports:
        - name: web
          containerPort: 80




Вы можете создать кластер, определенный в YAML-файле с помощью команды:




$ kubectl apply -f pod.yaml




После ее выполнения kubectl передает описание пода в Kubernetes API.




Сохранение состояния кластера в базе данных




API Kubernetes получает YAML-описание пода, проверяет его и сохраняет в базе данных — etcd. Также под добавляется в очередь заданий планировщика.




Что делает планировщик:




  1. Проверяет описание в YAML-файле.



  2. Собирает сведения о нагрузке на процессор и запрошенную память контейнера.



  3. Решает, какой узел лучше всего подходит для запуска пода (через процесс, называемый фильтрами и предикатами).




В итоге:




  • Под помечается как запланированный (scheduled) в etcd.



  • Для пода определяется узел.



  • Состояние пода сохраняется в etcd.




Но сам под пока не создан!







При использовании команды kubectl apply -f YAML-файл отправляется в Kubernetes API







API сохраняет под в базе данных — etcd








Планировщик выбирает оптимальный узел для создания пода, статус пода изменяется на «Ожидание». Но под пока существует только в etcd




Создание пода в кластере Kubernetes




Предыдущие задачи выполнялись в Control Plane, а состояние сохранилось в базе данных. Кто же создает под? Kubelet — агент Kubernetes. Его задача — опрашивать об обновлениях Control Plane. При этом kubelet не создает контейнеры самостоятельно, а делегирует эту работу трем другим компонентам:




  1. The Container Runtime Interface (CRI) создает контейнер для подов.



  2. The Container Network Interface (CNI) соединяет контейнеры с сетью кластера и назначает IP-адреса.



  3. The Container Storage Interface (CSI) монтирует тома в ваших контейнерах.




В большинстве случаев The Container Runtime Interface (CRI) выполняет работу, аналогичную команде по запуску контейнера в фоновом режиме:




$ docker run -d <my-container-image> 




The Container Network Interface (CNI) устроен немного иначе, он отвечает за:




  1. создание валидного IP-адреса для пода;



  2. подключение контейнера к остальной сети.




Существует несколько способов подключить контейнер к сети и назначить ему валидный IP-адрес. Можно выбрать между IPv4 или IPv6, или назначить несколько IP-адресов.




Например, Docker создает пары виртуальных сетей Ethernet и подключает их к сети по типу мостов (bridge). AWS-CNI подключает под напрямую к остальной части виртуального частного облака (Virtual Private Cloud/VPC).




Когда CNI завершает работу, под подключается к остальной сети и получает валидный IP-адрес. Но есть проблема: kubelet знает об IP-адресе, поскольку это он вызвал Container Network Interface, а вот Control Plane не знает. Никто не сообщил главному узлу, что у пода появился IP-адрес и он готов к приему трафика — для Control Plane под все еще создается.




Так что задача kubelet — собрать информацию о поде, например IP-адрес, и передать их на уровень Control Plane. После этого через проверку etcd можно не только посмотреть, где работает под, но и узнать его валидный IP-адрес.







Kubelet выясняет у Control Plane, появились ли обновления







После того как для нового пода назначен узел, kubelet считывает информацию по поду







Kubelet не создает контейнер самостоятельно, а делегирует эту работу трем другим компонентам: Container Runtime Interface, Container Network Interface и Container Storage Interface







Как только все три компонента успешно выполнены, под запускается на узле и ему присваивается IP-адрес







Kubelet сообщает IP-адрес пода в Control Plane




Если под не является частью какого-либо сервиса, то под создан и готов к использованию. Если же он часть cервиса, то нужно выполнить еще несколько шагов, о них рассказываем дальше.




Поды и сервисы




Обычно при создании cервиса нужно обратить внимание на несколько полей:




  • selector — указывает, на какие поды будет направлен трафик.



  • targetPort — порт, который поды используют для приема трафика.




Пример стандартного YAML-файла для описания сервиса:




apiVersion: v1
kind: Service
metadata:
  name: my-service
spec:
  ports:
  - port: 80
    targetPort: 3000
  selector:
    name: app




После выполнения команды <kubectl apply.> Kubernetes находит все поды с такой же меткой, что и переданное значение в selector (name: app), и собирает их IP-адреса. При этом поды обязательно должны пройти проверку readiness-пробы.




Каждый IP-адресc конкатенируется с переданным портом для формирования endpoint. Например, если IP-адрес — 10.0.0.3, а targetPort — 3000, то Kubernetes конкатенирует два значения и формирует единый enpdoint.




IP address + port = endpoint
---------------------------------
10.0.0.3   + 3000 = 10.0.0.3:3000




Перечень endpoint хранится в etcd в специальном объекте — Endpoint.




Примечание. В Kubernetes есть два похожих по названию компонента: первый называется endpoint — это пара IP-адрес и порт (10.0.0.3:3000), а второй — Endpoint, то есть перечень endpoint.




Объект Endpoint — реальный объект в Kubernetes, оркестратор создает его автоматически для каждого сервиса Kubernetes. Посмотреть Endpoint можно с помощью команды:




$ kubectl get services,endpoints
NAME                   TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)
service/my-service-1   ClusterIP   10.105.17.65   <none>        80/TCP
service/my-service-2   ClusterIP   10.96.0.1      <none>        443/TCP

NAME                     ENDPOINTS
endpoints/my-service-1   172.17.0.6:80,172.17.0.7:80
endpoints/my-service-2   192.168.99.100:8443

$




Объект Endpoint хранит все IP-адреса и порты пода и обновляется каждый раз, когда:




  1. вы создаете под;



  2. вы удаляете под;



  3. метка пода изменяется.




Итак, Kubernetes обновляет все Endpoint после создания пода и после отправки IP-адреса главному узлу. Проверить это можно командой:




$ kubectl get services,endpoints
NAME                   TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)
service/my-service-1   ClusterIP   10.105.17.65   <none>        80/TCP
service/my-service-2   ClusterIP   10.96.0.1      <none>        443/TCP

NAME                     ENDPOINTS
endpoints/my-service-1   172.17.0.6:80,172.17.0.7:80,172.17.0.8:80
endpoints/my-service-2   192.168.99.100:8443

$




Отлично, endpoint передан в Control Plane, а объект Endpoint обновлен.







В вашем кластере развернут один под, он относится к сервису. В etcd можно найти подробную информацию как о поде, так и о сервисе







Что происходит при развертывании нового пода?







Kubernetes отслеживает под и его IP-адрес, сервис направляет трафик к новому endpoint. Так что IP-адрес и порт обновятся по всей системе







Что происходит при развертывании еще одного пода?







Процесс такой же: в базе данных появляется новая строка для пода, а новый endpoint распространяется по всей системе







Но что происходит, когда под удаляется?







Сервис немедленно удаляет endpoint, и под удаляется из базы данных







Kubernetes реагирует на любое небольшое изменение в кластере




Готов ли теперь под к использованию? Осталось еще кое-что!




Использование endpoint в Kubernetes




Endpoint используют несколько компонентов Kubernetes.




Kube-proxy использует endpoint, чтобы настраивать iptables-правила на узлах. Каждый раз, когда объект Endpoint меняется, kube-proxy получает новый список IP-адресов и портов и записывает новые iptables-правила.







Рассмотрим трехузловой кластер с двумя подами без сервисов. Состояние подов хранится в etcd







Что происходит, когда вы создаете cервис?







Kubernetes создает объект Endpoint и собирает все endpoint (пары IP-адресов и портов) из подов







Демон Kube-proxy подписан на изменения в Endpoint







Когда Endpoint добавляется, удаляется или обновляется, kube-proxy считывает новый список endpoint







Kube-proxy использует endpoint для создания iptables-правил на каждом узле кластера




Ingress-контроллер — компонент кластера, он направляет внешний трафик в кластер и использует тот же самый список endpoint. При настройке Ingress-манифеста в поле destination обычно указывают сервис. Вот пример такого файла:




apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-ingress
spec:
  rules:
  - http:
      paths:
      - backend:
          service:
            name: my-service
            port:
              number: 80
        path: /
        pathType: Prefix




На самом деле трафик на сервис не направляется. Вместо этого Ingress-контроллер подписывается на изменения Endpoint этого сервиса. 




Ingress направляет трафик непосредственно к поду, не используя сервис. Каждый раз, когда Endpoint объекта меняется, Ingress извлекает новый список IP-адресов и портов и перенастраивает контроллер для подключения новых подов. 




На изменения endpoint подписываются и другие компоненты Kubernetes, например CoreDNS — компонент DNS в кластере. Если вы используете сервис типа Headless, CoreDNS подписывается на изменения endpoint и перенастраивает себя после каждого изменения. Эти же endpoint используют Service Mesh, — например Istio или Linkerd, — поставщики облачных услуг для создания сервисов типа LoadBalancer и другие службы Kubernetes.




Несколько компонентов подписываются на изменение endpoint и могут получать уведомления об этом в разное время.







Рассмотрим Ingress-контроллер с развертыванием из двух реплик и сервисом







Если вы хотите направить внешний трафик к подам через Ingress, то отразите это в Ingress-манифесте (YAML-файле)







Как только выполнится команда <kubectl apply -f ingress.yaml>, Ingress-контроллер получит файл от Control Plane







В YAML-описании Ingress есть свойство serviceName, оно описывает, какой сервис должен быть использован







Ingress-контроллер получает список endpoint из сервиса и пропускает его. Трафик направляется непосредственно к endpoint подов







Что происходит при создании нового пода?







Вы уже знаете, как Kubernetes создает под и распространяет его endpoint







Ingress-контроллер подписывается на изменения endpoint. После входящего изменения он получает новый список endpoint







Ingress-контроллер направляет трафик к новому поду




Промежуточный итог




Вот краткий обзор того, что происходит при создании пода, если он не относится к сервису:




  1. Под сохраняется в etcd.



  2. Планировщик определяет узел и записывает этот узел в etcd.



  3. Kubelet получает уведомление о новом и запланированном поде.



  4. Kubelet делегирует создание контейнера The Container Runtime Interface (CRI).



  5. Kubelet делегирует добавление контейнера The Container Network Interface (CNI).



  6. Kubelet делегирует монтирование томов в контейнере The Container Storage Interface (CSI).



  7. CSI назначает IP-адрес.



  8. Kubelet сообщает IP-адрес Control Plane.



  9. IP-адрес сохраняется в etcd.




Если же под относится к сервису:




  1. Kubelet ждет успешного выполнения readiness-пробы.



  2. Все соответствующие Endpoint (объекты) уведомляются об изменении.



  3. В список Endpoint добавляются новые endpoint (пара IP-адрес + порт).



  4. Kube-proxy получает уведомление об изменении endpoint. Kube-proxy обновляет iptables-правила для каждого узла.



  5. Ingress-контроллер уведомляется об изменении endpoint, направляет трафик на новые IP-адреса.



  6. CoreDNS получает уведомление об изменении endpoint. Если сервис headless, то запись DNS обновляется.



  7. Облачный провайдер получает уведомление об изменении endpoint. Если тип Service — LoadBalancer, то новый endpoint становится частью балансировщика нагрузки.



  8. Все служебные сети, установленные в кластере, получают уведомление об изменении endpoint.



  9. Любая другая функциональность, подписанная на изменения endpoint, также получает уведомление.




Удаление пода




При удалении пода те же действия выполняют в обратном порядке: 




  • Сначала необходимо удалить endpoint из Endpoint (объекта).



  • Readiness-проба игнорируется, и endpoint сразу удаляется из Control Plane. Это, в свою очередь, запускает все события в kube-proxy, Ingress-контроллере, DNS, service mesh и т. д.



  • Перечисленные компоненты обновляют свое внутреннее состояние и прекращают маршрутизацию трафика на IP-адрес.




Поскольку компоненты могут быть заняты другими задачами, непонятно, сколько времени потребуется для удаления IP-адреса из их внутреннего состояния.







При удалении пода команда сначала попадает в API Kubernetes







Сообщение перехватывает Endpoint-контроллер в Control Plane







Endpoint-контроллер отправляет команду в API для удаления IP-адреса и порта из Endpoint-объекта







Кто следит за изменениями в Endpoint? Об изменениях уведомляют Kube-proxy, Ingress-контроллер, CoreDNS и другие компоненты







Некоторым компонентам, например kube-proxy, может понадобиться дополнительное время для дальнейшего оповещения об изменениях




Kubelet уведомляется об изменении и делегирует:




  1. CSI — размонтирование любых томов из контейнера.



  2. CNI — отсоединение контейнера от сети и передачу IP-адреса.



  3. CRI — уничтожение контейнера.




Таким образом, Kubernetes выполняет точно такие же шаги, как и при создании пода, но в обратном порядке.







При удалении пода команда сначала попадает в API Kubernetes







Kubelet опрашивает Control Plane на наличие обновлений и понимает, что под удален







Kubelet делегирует удаление пода Container Runtime Interface, Container Network Interface и Container Storage Interface




Есть небольшое, но существенное различие. Когда вы завершаете работу пода, удаление endpoint и сигнал для kubelet отправляется одновременно. Когда вы создаете под в первый раз, Kubernetes ждет, пока kubelet сообщит IP-адрес, а затем оповещает систему об изменении endpoint систему. Но при удалении пода события начинаются параллельно, и это может привести систему в состояние гонки.

Что, если под будет удален до распространения новых endpoint?







Удаление endpoint и пода происходит одновременно







Значит, вы можете удалить endpoint до того, как kube-proxy обновит iptables-правила







Или вам может повезти и под удалится только после того, как endpoint изменится во всей системе




Graceful shutdown




Если работа пода завершается до того, как endpoint удален из kube-proxy или Ingress-контроллера, система может перейти в режим ожидания. И, если подумать, это имеет смысл.




Kubernetes по-прежнему направляет трафик на IP-адрес, но пода больше нет. Компоненты Ingress-контроллер, kube-proxy, CoreDNS и другие компоненты не успели удалить этот IP-адрес из своей памяти.




В идеальном мире Kubernetes проверяет, что все компоненты в кластере получили обновленный список endpoint, и только после этого удаляет под. Но Kubernetes так не работает. Он предлагает надежные примитивы для управления endpoint. Endpoint-объект и более продвинутые абстракции, например, Endpoint Slices. Однако Kubernetes не проверяет, соответствуют ли компоненты, подписывающиеся на изменения endpoint, актуальному состоянию кластера.




Итак, как же убедиться, что под удален после изменения endpoint?




Перед удалением под получает сигнал SIGTERM. Ваше приложение может перехватить его и начать завершение работы. Маловероятно, что endpoint будет немедленно удалена из всех компонентов Kubernetes. Так что вы можете:




  1. Немного подождать.



  2. Продолжить обрабатывать входящий трафик, несмотря на SIGTERM.



  3. Закрыть существующие долгоживущие соединения (например, соединение с базой данных или WebSockets).



  4. Завершить процесс.




По умолчанию Kubernetes отправляет сигнал SIGTERM и ждет 30 секунд перед принудительным завершением процесса.




Таким образом, можно подождать 15 секунд перед завершением работы. Вероятно, этого времени хватит для удаления endpoint из kube-proxy, Ingress-контроллера, CoreDNS и т. д. И меньше трафика будет перенаправлено на под до его отключения. Через 15 секунд можно безопасно закрыть соединение с базой данных (или любыми постоянными соединениями) и завершить процесс.




Примечание. Если вы считаете, что вам нужно больше времени, то можете остановить процесс через 20 или 25 секунд. Но помните: Kubernetes принудительно завершит процесс через 30 секунд, если вы не измените terminationGracePeriodSeconds.




Что делать, если вы не можете изменить код, чтобы управлять процессом завершения?




Вы можете реализовать сценарий, при котором приложение ждет фиксированное время перед завершением работы. Например, перед отправкой команды SIGTERM Kubernetes вызывает хук preStop в поде. Можно настроить preStop на ожидание в 15 секунд. 




Давайте посмотрим на примере:




apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  containers:
    - name: web
      image: nginx
      ports:
        - name: web
          containerPort: 80
      lifecycle:
        preStop:
          exec:
            command: ["sleep", "15"]




Хук preStop — один из хуков жизненного цикла пода.




15-секундная задержка — рекомендованное время?




Зависит от обстоятельств, но можно оттолкнуться от этого времени и затем подобрать оптимальное в процессе тестирования. Возможные варианты:







Вы уже знаете, что при удалении пода kubelet получает уведомление об изменении







Если у пода есть preStop-хук, он будет вызван первым







Когда preStop завершается, kubelet отправляет в контейнер сигнал SIGTERM. С этого момента контейнер должен закрыть все long-lived-соединения и подготовиться к завершению







По умолчанию у процесса есть 30 секунд для завершения работы, включая хук preStop. Если к этому времени процесс не завершится, то kubelet отправит сигнал SIGKILL и принудительно завершит процесс







Kubelet уведомляет Control Plane об успешном удалении пода




Grace periods и скользящие обновления




При удалении пода применяется плавное завершение работы. Но как быть, если под так и не удален? Даже если вы этого не сделаете, Kubernetes все равно удаляет под.




Kubernetes создает и удаляет под каждый раз, когда вы развертываете новую версию приложения. Когда вы изменяете образ в своем развертывании, Kubernetes разворачивает изменение постепенно. 




apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  replicas: 3
  selector:
    matchLabels:
      name: app
  template:
    metadata:
      labels:
        name: app
    spec:
      containers:
      - name: app
        # image: nginx:1.18 OLD
        image: nginx:1.19
        ports:
          - containerPort: 3000




Допустим, у вас есть три реплики, и как только вы отправляете новый YAML-файл, Kubernetes:




  • создает под с новым образом контейнера;



  • уничтожает существующий контейнер;



  • дожидается готовности пода.




Система повторяет эти шаги до тех пор, пока все поды не будут обновлены. Kubernetes повторяет каждый цикл только после того, как новый под готов к приему трафику, то есть прошел проверку readiness-пробы.




Ждет ли Kubernetes удаления текущего пода перед переходом к следующему?




Нет, если у вас 10 подов и для одного требуется две секунды на подготовку и 20 на завершение работы, то происходит следующее:




  1. Первый под создается, а предыдущий под прекращает работу.



  2. Новый под готов через две секунды, после чего Kubernetes создает новый.



  3. Находясь в процессе завершения, под продолжает жить в течение 20 секунд.




Через 20 секунд все новые поды становятся активными, а все 10 предыдущих подов завершают работу. В общей сложности количество подов удваивается на некоторое время: одновременно работают 10 запущенных и 10 завершающихся подов.







Чем дольше graceful-период по сравнению с readiness-пробой, тем больше подов будет запущено (и завершено) одновременно.




Это плохо?




Не обязательно, пока вы не обрываете соединения.




Прерывание long-running-задач




А как насчет длительной работы? Если вы кодируете большое видео, есть ли способ отложить завершение пода?




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




Можно ли не откладывать завершение работы пода?




Вы можете увеличить terminationGracePeriodSeconds до нескольких часов. Но endpoint пода в этот момент недоступен.







Инструменты мониторинга не смогут получить доступ к метрикам пода. Почему?




Инструменты вроде Prometheus полагаются на актуальность перечня endpoint в вашем кластере. Как только вы удаляете под, удаление endpoint распространяется на весь кластер и даже на Prometheus!




Поэтому вместо увеличения параметра terminationGracePeriodSeconds рекомендуют создавать новое развертывание для каждого релиза, при этом существующее развертывание остается нетронутым. То есть long-running-задачи могут по-прежнему выполнять обработку видео. По мере выполнения задач можно удалять их вручную. Автоматическое удаление возможно при настройке автомасштабирования, которое позволяет уменьшать количество реплик до нуля по мере выполнения задач. 




Подробнее об уровнях автомасштабирования читайте в статье.




Такой способ масштабирования называют rainbow deployment. Его используют, когда работу предыдущих версий подов нужно поддерживать дольше, чем значение параметра gracePeriod.




Система поддерживает WebSocket — вам не нужно их прерывать каждый раз. Если вы часто выпускаете релизы в течение дня, это может привести к прерываниям передаче данных в реальном времени. В таком случае создание нового развертывания для каждого релиза — менее очевидный, но более оптимальный выбор.




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




Заключение




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




Под следует удалять только после того, как все endpoint в кластере обновятся в kube-proxy, Ingress-контроллере, CoreDNS и других компонентах.




Если ваши поды выполняют long-running задачи, например кодирование видео или WebSockets, возможно, будет полезно использовать rainbow deployment. В rainbow deployment вы развертываете новую версию для каждого релиза и удаляете предыдущую версию только после завершения всех ее задач.




Вы можете вручную удалить предыдущую версию после завершения long-running-задачи, а также можете автоматизировать процесс и уменьшать количество реплик до нуля.




Источник: https://habr.com/ru/company/vk/blog/654471/



2023-01-03T01:18:56
DevOps

Kubernetes tips & tricks: особенности выполнения graceful shutdown в NGINX и PHP-FPM

Типовое условие при реализации CI/CD в Kubernetes: приложение должно уметь перед полной остановкой не принимать новые клиентские запросы, а самое главное — успешно завершать уже существующие.







Соблюдение такого условия позволяет достичь нулевого простоя во время деплоя. Однако, даже при использовании очень популярных связок (вроде NGINX и PHP-FPM) можно столкнуться со сложностями, которые приведут к всплеску ошибок при каждом деплое…




Теория. Как живёт pod




Подробно о жизненном цикле pod’а мы уже публиковали эту статью. В контексте рассматриваемой темы нас интересует следующее: в тот момент, когда pod переходит в состояние Terminating, на него перестают отправляться новые запросы (pod удаляется из списка endpoints для сервиса). Таким образом, для избежания простоя во время деплоя, с нашей стороны достаточно решить проблему корректной остановки приложения.




Также следует помнить, что grace period по умолчанию равен 30 секундам: после этого pod будет терминирован и приложение должно успеть обработать все запросы до этого периода. Примечание: хотя любой запрос, который выполняется более 5-10 секунд, уже является проблемным, и graceful shutdown ему уже не поможет…




Чтобы лучше понять, что происходит, когда pod завершает свою работу, достаточно изучить следующую схему:







А1, B1 — Получение изменений о состоянии пода
A2 — Отправление SIGTERM
B2 — Удаление pod’а из endpoints
B3 — Получение изменений (изменился список endpoints)
B4 — Обновление правил iptables




Обратите внимание: удаление endpoint pod’а и посылка SIGTERM происходит не последовательно, а параллельно. А из-за того, что Ingress получает обновленный список Endpoints не сразу, в pod будут отправляться новые запросы от клиентов, что вызовет 500 ошибки во время терминации pod’а (более подробный материал по этому вопросу мы переводили). Решать эту проблему нужно следующими способами:




  • Отправлять в заголовках ответа Connection: close (если это касается HTTP-приложения).



  • Если нет возможности вносить изменения в код, то далее в статье описано решение, которое позволит обработать запросы до конца graceful period.




Теория. Как NGINX и PHP-FPM завершают свои процессы




NGINX




Начнем с NGINX, так как с ним все более-менее очевидно. Погрузившись в теорию, мы узнаем, что у NGINX есть один мастер-процесс и несколько «воркеров» — это дочерние процессы, которые и обрабатывают клиентские запросы. Предусмотрена удобная возможность: с помощью команды nginx -s <SIGNAL> завершать процессы либо в режиме fast shutdown, либо в graceful shutdown. Очевидно, что нас интересует именно последний вариант.




Дальше всё просто: требуется добавить в preStop-хук команду, которая будет посылать сигнал о graceful shutdown. Это можно сделать в Deployment, в блоке контейнера:




       lifecycle:
          preStop:
            exec:
              command:
              - /usr/sbin/nginx
              - -s
              - quit




Теперь в момент завершения работы pod’а в логах контейнера NGINX мы увидим следующее:




2018/01/25 13:58:31 [notice] 1#1: signal 3 (SIGQUIT) received, shutting down
2018/01/25 13:58:31 [notice] 11#11: gracefully shutting down




И это будет означать то, что нам нужно: NGINX ожидает завершения выполнения запросов, после чего убивает процесс. Впрочем, ниже ещё будет рассмотрена распространенная проблема, из-за которой даже при наличии команды nginx -s quit процесс завершается некорректно.




А на данном этапе с NGINX закончили: как минимум по логам можно понять, что всё работает так, как надо.




Как обстоят дела с PHP-FPM? Как он обрабатывает graceful shutdown? Давайте разбираться.




PHP-FPM




В случае с PHP-FPM информации немного меньше. Если ориентироваться на официальный мануал по PHP-FPM, то в нём будет рассказано, что принимаются следующие POSIX-сигналы:




  1. SIGINTSIGTERM — fast shutdown;



  2. SIGQUIT — graceful shutdown (то, что нам нужно).




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




        lifecycle:
          preStop:
            exec:
              command:
              - /bin/kill
              - -SIGQUIT
              - "1"




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




Практика. Возможные проблемы с graceful shutdown




NGINX




В первую очередь полезно помнить: помимо выполнения команды nginx -s quit есть ещё один этап, на который стоит обратить внимание. Мы сталкивались с проблемой, когда NGINX вместо сигнала SIGQUIT всё равно отправлял SIGTERM, из-за чего запросы не завершались корректно. Похожие случаи можно найти, например, здесь. К сожалению, конкретную причину такого поведения нам установить не удалось: было подозрение на версии NGINX, но оно не подтвердилось. Симптоматика же заключалась в том, что в логах контейнера NGINX наблюдались сообщения «open socket #10 left in connection 5», после чего pod останавливался.




Мы можем наблюдать такую проблему, например, по ответам на нужном нам Ingress’e:







Показатели статус-кодов в момент деплоя




В данном случае мы получаем как раз 503 код ошибки от самого Ingress: он не может обратиться к контейнеру NGINX, так как тот уже недоступен. Если посмотреть в логи контейнера с NGINX, в них — следующее:




[alert] 13939#0: *154 open socket #3 left in connection 16
[alert] 13939#0: *168 open socket #6 left in connection 13




После изменения стоп-сигнала контейнер начинает останавливаться корректно: это подтверждается тем, что больше не наблюдается 503 ошибка.




Если вы встретились с похожей проблемой, есть смысл разобраться, какой стоп-сигнал используется в контейнере и как именно выглядит preStop-хук. Вполне возможно, что причина кроется как раз в этом.




PHP-FPM… и не только




Проблема с PHP-FPM описывается тривиально: он не дожидается завершения дочерних процессов, терминирует их, из-за чего возникают 502-е ошибки во время деплоя и других операций. На bugs.php.net с 2005 года есть несколько сообщений об ошибках (например, здесь и здесь), в которых описывается данная проблема. А вот в логах вы, вероятнее всего, ничего не увидите: PHP-FPM объявит о завершении своего процесса без каких-либо ошибок или сторонних уведомлений.




Стоит уточнить, что сама проблема может в меньшей или большей степени зависеть от самого приложения и не проявляться, например, в мониторинге. Если вы все же столкнетесь с ней, то на ум сначала приходит простой workaround: добавить preStop-хук со sleep(30). Он позволит завершить все запросы, которые были до этого (а новые мы не принимаем, так как pod уже в состоянии Terminating), а по истечении 30 секунд сам pod завершится сигналом SIGTERM.




Получается, что lifecycle для контейнера будет выглядеть следующим образом:




    lifecycle:
      preStop:
        exec:
          command:
          - /bin/sleep
          - "30"




Однако, из-за указания 30-секундного sleep мы сильно увеличим время деплоя, так как каждый pod будет терминироваться минимум 30 секунд, что плохо. Что с этим можно сделать?




Обратимся к стороне, отвечающей за непосредственное исполнение приложения. В нашем случае это PHP-FPM, который по умолчанию не следит за выполнением своих child-процессов: мастер-процесс терминируется сразу же. Изменить это поведение можно с помощью директивы process_control_timeout, которая указывает временные лимиты для ожидания сигналов от мастера дочерними процессами. Если установить значение в 20 секунд, тем самым покроется большинство запросов, выполняемых в контейнере, и после их завершения мастер-процесс будет остановлен.




С этим знанием вернёмся к нашей последней проблеме. Как уже упоминалось, Kubernetes не является монолитной платформой: на взаимодействие между разными её компонентами требуется некоторое время. Это особенно актуально, когда мы рассматриваем работу Ingress’ов и других смежных компонентов, поскольку из-за такой задержки в момент деплоя легко получить всплеск 500-х ошибок. Например, ошибка может возникать на этапе отправки запроса к upstream’у, но сам «временной лаг» взаимодействия между компонентами довольно короткий — меньше секунды.




Поэтому, в совокупности с уже упомянутой директивой process_control_timeout можно использовать следующую конструкцию для lifecycle:




lifecycle:
  preStop:
    exec:
      command: ["/bin/bash","-c","/bin/sleep 1; kill -QUIT 1"]




В таком случае мы компенсируем задержку командой sleep и сильно не увеличиваем время деплоя: ведь заметна разница между 30 секундами и одной?.. По сути «основную работу» на себя берёт именно process_control_timeout, а lifecycle используется лишь в качестве «подстраховки» на случай лага.




Вообще говоря, описанное поведение и соответствующий workaround касаются не только PHP-FPM. Схожая ситуация может так или иначе возникать при использовании других ЯП/фреймворков. Если не получается другими способами исправить graceful shutdown — например, переписать код так, чтобы приложение корректно обрабатывало сигналы завершения, — можно применить описанный способ. Пусть он не самый красивый, но работает.




Практика. Нагрузочное тестирование для проверки работы pod’а




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




Самое главное здесь — проверять изменения поэтапно. После добавления нового исправления запускайте тестирование и смотрите, изменились ли результаты по сравнению с прошлым запуском. В противном случае будет сложно выявить неэффективные решения, а в перспективе можно и вовсе только навредить (например, увеличить время деплоя).




Другой нюанс — смотрите логи контейнера во время его терминации. Фиксируется ли там информация о graceful shutdown? Есть ли в логах ошибки при обращении к другим ресурсам (например, к соседнему контейнеру PHP-FPM)? Ошибки самого приложения (как в описанном выше случае с NGINX)? Надеюсь, что вводная информация из этой статьи поможет лучше разобраться, что же происходит с контейнером во время его терминирования.




Итак, первый запуск тестирования происходил без lifecycle и без дополнительных директив для сервера приложений (process_control_timeout в PHP-FPM). Целью этого теста было выявление приблизительного количества ошибок (и есть ли они вообще). Также из дополнительной информации следует знать, что среднее время деплоя каждого пода составляло около 5-10 секунд до состояния полной готовности. Результаты таковы:







На информационной панели Яндекс.Танка виден всплеск 502 ошибок, который произошел в момент деплоя и продолжался в среднем до 5 секунд. Предположительно это обрывались существующие запросы к старому pod’у, когда он терминировался. После этого появились 503 ошибки, что стало результатом остановленного контейнера NGINX, который также оборвал соединения из-за бэкенда (из-за чего к нему не смог подключиться Ingress).




Посмотрим, как process_control_timeout в PHP-FPM поможет нам дожидаться завершения child-процессов, т.е. исправить такие ошибки. Повторный деплой уже с использованием этой директивы:







Во время деплоя 500-х ошибок больше нет! Деплой проходит успешно, graceful shutdown работает.




Однако стоит вспомнить момент с Ingress-контейнерами, небольшой процент ошибок в которых мы можем получать из-за временного лага. Чтобы их избежать, остается добавить конструкцию со sleep и повторить деплой. Впрочем, в нашем конкретном случае изменений не было видно (ошибок снова нет).




Заключение




Для корректного завершения процесса мы ожидаем от приложения следующего поведения:




  1. Ожидать несколько секунд, после чего перестать принимать новые соединения.



  2. Дождаться завершения всех запросов и закрыть все keepalive-подключения, которые запросы не выполняют.



  3. Завершить свой процесс.




Однако не все приложения умеют так работать. Одним из решений проблемы в реалиях Kubernetes является:




  • добавление хука pre-stop, который будет ожидать несколько секунд;



  • изучение конфигурационного файла нашего бэкенда на предмет соответствующих параметров.




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




В качестве инструмента для тестирования можно использовать Яндекс.Танк совместно с любой системой мониторинга (в нашем случае для теста брались данные из Grafana с бэкендом в виде Prometheus). Проблемы с graceful shutdown хорошо видны при больших нагрузках, которую может генерировать benchmark, а мониторинг помогает детальнее разобрать ситуацию во время или после теста.




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




Источник: https://habr.com/ru/company/flant/blog/489994/



2023-01-03T00:41:02
DevOps