Перейти к основному содержимому

Создание контроллера доступа Kubertes на Java

· 12 мин. чтения

1. Введение

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

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

В этой статье мы рассмотрим, как мы можем придерживаться принципа DRY и избежать всего этого повторяющегося кода, используя контроллеры доступа Kubernetes.

2. Что такое контроллер доступа?

Контроллеры доступа — это механизм, используемый Kubernetes для предварительной обработки запросов API после их аутентификации, но до их выполнения.

Серверный процесс API ( kube-apiserver ) уже поставляется с несколькими встроенными контроллерами, каждый из которых отвечает за определенный аспект обработки API.

Хорошим примером является AllwaysPullImage : этот контроллер доступа изменяет запросы на создание модуля, поэтому политика извлечения изображений становится «всегда», независимо от информированного значения. Документация Kubernetes содержит полный список стандартных контроллеров доступа.

Помимо встроенных контроллеров, которые на самом деле работают как часть процесса kubeapi-server , Kubernetes также поддерживает внешние контроллеры доступа. В данном случае контроллер допуска — это просто служба HTTP, которая обрабатывает запросы, поступающие от сервера API.

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

./6d2e8bcdca58de0245f3062c4cd1ed0f.png

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

3. Типы контроллеров доступа

В настоящее время существует два типа контроллеров допуска:

  • Мутирующие контроллеры допуска
  • Контроллеры допуска валидации

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

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

3.1. Запросы на рассмотрение при приеме

Встроенные контроллеры доступа (изменяющие и проверяющие) взаимодействуют с внешними контроллерами доступа, используя простой шаблон запроса/ответа HTTP:

  • Запрос: объект JSON AdmissionReview , содержащий вызов API для обработки в свойстве запроса .
  • Ответ: объект JSON AdmissionReview , содержащий результат в свойстве ответа .

Вот пример запроса:

{
"kind": "AdmissionReview",
"apiVersion": "admission.k8s.io/v1",
"request": {
"uid": "c46a6607-129d-425b-af2f-c6f87a0756da",
"kind": {
"group": "apps",
"version": "v1",
"kind": "Deployment"
},
"resource": {
"group": "apps",
"version": "v1",
"resource": "deployments"
},
"requestKind": {
"group": "apps",
"version": "v1",
"kind": "Deployment"
},
"requestResource": {
"group": "apps",
"version": "v1",
"resource": "deployments"
},
"name": "test-deployment",
"namespace": "test-namespace",
"operation": "CREATE",
"object": {
"kind": "Deployment",
... deployment fields omitted
},
"oldObject": null,
"dryRun": false,
"options": {
"kind": "CreateOptions",
"apiVersion": "meta.k8s.io/v1"
}
}
}

Среди доступных полей некоторые особенно важны:

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

Ожидаемый ответ также является JSON-объектом AdmissionReview с полем ответа вместо ответа:

{
"apiVersion": "admission.k8s.io/v1",
"kind": "AdmissionReview",
"response": {
"uid": "c46a6607-129d-425b-af2f-c6f87a0756da",
"allowed": true,
"patchType": "JSONPatch",
"patch": "W3sib3A ... Base64 patch data omitted"
}
}

Разберем поля объекта ответа :

  • uid : значение этого поля должно соответствовать соответствующему полю, присутствующему в поле входящего запроса .
  • разрешено: результат проверки. true означает, что обработка вызова API может перейти к следующему шагу
  • patchType: Допустимо только для мутирующих контроллеров доступа. Указывает тип исправления, возвращаемый запросом AdmissionReview .
  • patch : Патчи для применения к входящему объекту. Подробности в следующем разделе

3.2. Патч данных

Поле исправления , присутствующее в ответе от изменяющегося контроллера доступа, сообщает серверу API, что необходимо изменить, прежде чем запрос сможет быть обработан . Его значение представляет собой объект JSONPatch в кодировке Base64 , содержащий массив инструкций, которые сервер API использует для изменения тела входящего вызова API:

[
{
"op": "add",
"path": "/spec/template/spec/volumes/-",
"value":{
"name": "migration-data",
"emptyDir": {}
}
}
]

В этом примере у нас есть единственная инструкция, которая добавляет том в массив томов спецификации развертывания. Распространенной проблемой при работе с исправлениями является тот факт, что невозможно добавить элемент в существующий массив, если он уже не существует в исходном объекте . Это особенно раздражает при работе с объектами API Kubernetes, поскольку наиболее распространенные из них (например, развертывания) включают необязательные массивы.

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

[
{
"op": "add",
"path": "/spec/template/spec/volumes",
"value": [{
"name": "migration-data",
"emptyDir": {}
}]
}
]

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

4. Пример использования: ожидание

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

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

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

4.1. Дело о контроллере допуска

Вот что добавляется в типичное развертывание с контейнером инициализации с ожиданием :

apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
labels:
app: nginx
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
initContainers:
- name: wait-backend
image: willwill/wait-for-it
args:
-
www.google.com:80
containers: 
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80

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

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

apiVersion: apps/v1 
kind: Deployment
metadata:
name: frontend
labels:
app: nginx
annotations:
com.foreach/wait-for-it: "www.google.com:80"
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80

Здесь мы используем аннотацию com.foreach/wait-for-it , чтобы указать хост и порт, которые мы должны протестировать. Однако важно то, что ничто не говорит нам, как следует проводить тест. Теоретически мы могли бы изменить тест как угодно, сохранив спецификацию развертывания неизменной.

Теперь приступим к реализации.

4.2. Структура проекта

Как обсуждалось ранее, внешний контроллер доступа — это просто служба HTTP. Таким образом, мы создадим проект Spring Boot в качестве нашей базовой структуры. Для этого примера нам нужен только стартер Spring Web Reactive , но для реального приложения также может быть полезно добавить такие функции, как Actuator и/или некоторые зависимости Cloud Config .

4.3. Обработка запросов

Точка входа для запроса на допуск — это простой контроллер Spring REST, который делегирует обработку входящей полезной нагрузки сервису:

@RestController
@RequiredArgsConstructor
public class AdmissionReviewController {

private final AdmissionService admissionService;

@PostMapping(path = "/mutate")
public Mono<AdmissionReviewResponse> processAdmissionReviewRequest(@RequestBody Mono<ObjectNode> request) {
return request.map((body) -> admissionService.processAdmission(body));
}
}

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

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

4.4. Изменение запросов на допуск

Основная часть обработки происходит в классе AdmissionService . Это класс @Component , введенный в контроллер с помощью одного общедоступного метода: processAdmission. Этот метод обрабатывает входящий запрос на проверку и возвращает соответствующий ответ.

Полный код доступен в Интернете и в основном состоит из длинной последовательности манипуляций с JSON. Большинство из них тривиальны, но некоторые выдержки заслуживают некоторого пояснения:

if (admissionControllerProperties.isDisabled()) {
data = createSimpleAllowedReview(body);
} else if (annotations.isMissingNode()) {
data = createSimpleAllowedReview(body);
} else {
data = processAnnotations(body, annotations);
}

Во-первых, зачем добавлять свойство «отключено»? Что ж, оказывается, в некоторых строго контролируемых средах может быть намного проще изменить параметр конфигурации существующего развертывания, чем удалить и/или обновить его . Поскольку мы используем механизм @ConfigurationProperties для заполнения этого свойства, его фактическое значение может поступать из различных источников.

Затем мы проверяем наличие отсутствующих аннотаций, что мы будем рассматривать как признак того, что мы должны оставить развертывание без изменений. Такой подход обеспечивает поведение «opt-in», которое нам нужно в данном случае.

Еще один интересный фрагмент исходит из логики генерации JSONPatch в методе injectInitContainer() :

JsonNode maybeInitContainers = originalSpec.path("initContainers");
ArrayNode initContainers =
maybeInitContainers.isMissingNode() ?
om.createArrayNode() : (ArrayNode) maybeInitContainers;
ArrayNode patchArray = om.createArrayNode();
ObjectNode addNode = patchArray.addObject();

addNode.put("op", "add");
addNode.put("path", "/spec/template/spec/initContainers");
ArrayNode values = addNode.putArray("values");
values.addAll(initContainers);

Поскольку нет гарантии, что входящая спецификация содержит поле initContainers , мы должны обрабатывать два случая: они могут либо отсутствовать, либо присутствовать. Если он отсутствует, мы используем экземпляр ObjectMapper (om во фрагменте выше) для создания нового ArrayNode . В противном случае мы просто используем входящий массив.

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

ObjectNode wfi = values.addObject();
wfi.put("name", "wait-for-it-" + UUID.randomUUID())
// ... additional container fields added (omitted)

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

4.5. Развертывание

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

  • Развертывание для запуска нашего контроллера допуска. Рекомендуется запускать более одной реплики этой службы, так как сбои могут заблокировать любое новое развертывание.
  • Служба для маршрутизации запросов с сервера API на доступный модуль, на котором запущен контроллер допуска.
  • Ресурс MutationWebhookConfiguration , описывающий, какие вызовы API должны быть перенаправлены в нашу Службу .

Например, предположим, что мы хотим, чтобы Kubernetes использовал наш контроллер доступа каждый раз, когда создается или обновляется развертывание. В документах MutationWebhookConfiguration мы увидим такое определение правила :

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: "wait-for-it.foreach.com"
webhooks:
- name: "wait-for-it.foreach.com"
rules:
- apiGroups: ["*"]
apiVersions: ["*"]
operations: ["CREATE","UPDATE"]
resources: ["deployments"]
... other fields omitted

Важный момент о нашем сервере: Kubernetes требует HTTPS для связи с внешними контроллерами доступа . Это означает, что нам нужно предоставить нашему серверу SpringBoot правильный сертификат и закрытый ключ. Пожалуйста, проверьте сценарий Terraform, используемый для развертывания примера контроллера допуска, чтобы увидеть один из способов сделать это.

Кроме того, небольшой совет: хотя это нигде не упоминается в документации, некоторые реализации Kubernetes (например, GCP) требуют использования порта 443 , поэтому нам нужно изменить HTTPS-порт SpringBoot со значения по умолчанию (8443).

4.6. Тестирование

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

$ terraform apply -auto-approve

После завершения мы можем проверить состояние контроллера развертывания и допуска с помощью kubectl :

$ kubectl get mutatingwebhookconfigurations
NAME WEBHOOKS AGE
wait-for-it-admission-controller 1 58s
$ kubectl get deployments wait-for-it-admission-controller
NAME READY UP-TO-DATE AVAILABLE AGE
wait-for-it-admission-controller 1/1 1 1 10m

Теперь давайте создадим простое развертывание nginx, включая нашу аннотацию:

$ kubectl apply -f nginx.yaml
deployment.apps/frontend created

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

$ kubectl logs --since=1h --all-containers deployment/frontend
wait-for-it.sh: waiting 15 seconds for www.google.com:80
wait-for-it.sh: www.google.com:80 is available after 0 seconds

Чтобы быть уверенным, давайте проверим YAML развертывания:

$ kubectl get deployment/frontend -o yaml
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
com.foreach/wait-for-it: www.google.com:80
deployment.kubernetes.io/revision: "1"
... fields omitted
spec:
... fields omitted
template:
... metadata omitted
spec:
containers:
- image: nginx:1.14.2
name: nginx
... some fields omitted
initContainers:
- args:
- www.google.com:80
image: willwill/wait-for-it
imagePullPolicy: Always
name: wait-for-it-b86c1ced-71cf-4607-b22b-acb33a548bb2
... fields omitted
... fields omitted
status:
... status fields omitted

Эти выходные данные показывают initContainer , который наш контроллер доступа добавил к развертыванию.

5. Вывод

В этой статье мы рассмотрели, как создать контроллер допуска Kubernetes на Java и развернуть его в существующем кластере.

Как обычно, полный исходный код примеров можно найти на GitHub .