Прим. перев.: Этой статьёй, написанной Scott Rahner — инженером в Dow Jones, мы продолжаем цикл многочисленных материалов, доступно рассказывающих о том, как устроен Kubernetes, как работают, взаимосвязаны и используются его базовые компоненты. На сей раз это практическая заметка с примером кода для создания хука в Kubernetes, демонстрируемого автором «под предлогом» автоматического создания sidecar-контейнеров.


(Автор фото — Gordon A. Maxwell, найдено на просторах интернета.)

Когда я начал изучать sidecar-контейнеры и service mesh'и, мне потребовалось разобраться в том, как работает ключевой механизм — автоматическая вставка sidecar-контейнера. Ведь в случае использования систем вроде Istio или Consul, при деплое контейнера с приложением внезапно в его pod'е появляется и уже настроенный контейнер Envoy (схожая ситуация происходит и у Conduit, о котором мы писали в начале года — прим. перев.). Что? Как? Так начались мои исследования…

Для тех, кто не знает, sidecar-контейнер — контейнер, который деплоится рядом с контейнерами приложения, чтобы каким-либо образом «помогать» этому приложению. Примером такого использования может служить прокси для управления трафиком и завершения TLS-сессий, контейнер для стриминга логов и метрик, контейнер для сканирования проблем в безопасности… Идея в том, чтобы изолировать различные аспекты всего приложения от бизнес-логики с помощью применения отдельных контейнеров для каждой функции.

Перед тем, как продолжить, обозначу свои ожидания. Цель этой статьи — не объяснить хитросплетения и сценарии использования Docker, Kubernetes, service mesh'ей и т.п., а наглядно показать один мощный подход к расширению возможностей этих технологий. Статья — для тех, кто уже знаком с применением данных технологий или, по крайней мере, немало о них прочитал. Чтобы попробовать практическую часть в действии, потребуется машина с уже настроенными Docker и Kubernetes. Простейший способ для этого — https://docs.docker.com/docker-for-windows/kubernetes/ (инструкция для Windows, которая работает и в Docker for Mac). (Прим. перев.: В качестве альтернативы пользователям Linux и *nix-систем можем предложить Minikube.)

Общая картина


Для начала давайте немного разберёмся с Kubernetes:


Kube Arch, лицензированная под CC BY 4.0

Когда вы собираетесь задеплоить что-либо в Kubernetes, необходимо отправить объект в kube-apiserver. Чаще всего это делают передачей аргументов или YAML-файла в kubectl. В таком случае сервер API перед тем, как непосредственно помещать данные в etcd и планировать соответствующие задания, проходит через несколько этапов:



Эта последовательность важна, чтобы разобраться, как работает вставка sidecar-контейнеров. В частности, нужно обратить внимание на Admission Control, в рамках которого Kubernetes валидирует и, если необходимо, модифицирует объекты перед тем, как сохранять их (подробнее об этом этапе см. в главе «Контроль допуска» этой статьи — прим. перев.). Kubernetes также позволяет регистрировать webhooks, которые могут выполнять определяемую пользователем валидацию и изменения (mutations).

Однако процесс создания и регистрации своих хуков не так-то уж прост и хорошо документирован. Мне пришлось потратить несколько дней на чтение и перечитывание документации, а также на анализ кода Istio и Consul. А когда дело дошло до кода для некоторых из ответов API, я провёл не менее половины дня на выполнение случайных проб и ошибок.

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

Код


Название webhook говорит само за себя ­— это HTTP endpoint, реализующий API, определённый в Kubernetes. Вы создаёте API-сервер, который Kubernetes может вызывать перед тем, как разбираться с Deployment'ами. Здесь мне пришлось столкнуться со сложностями, поскольку доступны всего несколько примеров, некоторые из которых — просто unit-тесты Kubernetes, другие — спрятаны посреди огромной кодовой базы… и все написаны на Go. Но я выбрал более доступный вариант — ?Node.js:

const app = express();
app.use(bodyParser.json());

app.post('/mutate', (req, res) => {
	console.log(req.body)
	console.log(req.body.request.object)
	let adminResp = {response:{
          allowed: true,
          patch: Buffer.from("[{ \"op\": \"add\", \"path\": \"/metadata/labels/foo\", \"value\": \"bar\" }]").toString('base64'),
          patchType: "JSONPatch",
        }}
        console.log(adminResp)
	res.send(adminResp)
})

const server = https.createServer(options, app);

(index.js)

Путь к API — в данном случае это /mutate — может быть произвольным (должен лишь в дальнейшем соответствовать YAML, передаваемому в Kubernetes)?. Для него важно видеть и понимать JSON, получаемый от API-сервера. В данном случае мы не вытаскиваем ничего из JSON, но это может пригодиться в других сценариях. В приведённом же выше коде мы обновляем JSON. Для этого нужно две вещи:

  1. Изучить и понять JSON Patch.
  2. Правильно сконвертировать выражение JSON Patch в массив байтов, закодированный с base64.

Как только это сделано, достаточно лишь передать API-серверу ответ с очень простым объектом. В данном случае мы добавляем лейбл foo=bar любому попадающему к нам pod'у.

Deployment


Хорошо, у нас есть код, который принимает запросы от API-сервера Kubernetes и отвечает на них, но как его задеплоить? И как заставить Kubernetes перенаправлять нам эти запросы? Развернуть такой endpoint можно везде, до чего может «достучаться» API-сервер Kubernetes. Простейшим способом является деплой кода в сам кластер Kubernetes, что мы и сделаем в данного примере. Я постарался сделать пример максимально простым, поэтому для всех действий использую лишь Docker и kubectl. Начнём с создания контейнера, в котором будет запускаться код:

FROM node:8

USER node
WORKDIR /home/node

COPY index.js .
COPY package.json .

RUN npm install

# позже сюда добавятся дополнительные команды для TLS

CMD node index.js

(Dockerfile)

Как видно, тут всё очень просто. Возьмите образ с node от сообщества и забросьте в него код. Теперь можно выполнить простую сборку:

docker build . -t localserver

Следующим шагом создадим Deployment в Kubernetes:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: webhook-server
spec:
  replicas: 1
  selector:
    matchLabels:
      component: webhook-server
  template:
    metadata:
      labels:
        component: webhook-server
    spec:
      containers:
      - name: webhook-server
        imagePullPolicy: Never
image: localserver

(deployment.yaml)

Заметили, как мы сослались на только что созданный образ? Так же просто тут мог быть и pod, и что-либо иное, к чему мы можем подключить сервис в Kubernetes. Теперь определим этот Service:

apiVersion: v1
kind: Service
metadata:
  name: webhook-service
spec:
  ports:
  - port: 443
    targetPort: 8443
  selector:
component: webhook-server

Так в Kubernetes появится endpoint с внутренним именем, который указывает на наш контейнер. Финальный шаг — сообщить Kubernetes'у, что мы хотим, чтобы API-сервер вызывал этот сервис, когда он готов производить изменения (mutations):

apiVersion: admissionregistration.k8s.io/v1beta1
kind: MutatingWebhookConfiguration
metadata:
  name: webhook
webhooks:
  - name: webhook-service.default.svc
    failurePolicy: Fail
    clientConfig:
      service:
        name: webhook-service
        namespace: default
        path: "/mutate"
      # далее записан результат base64-кодирования файла rootCA.crt
      # с помощью команды `cat rootCA.crt | base64 | tr -d '\n'`
      # подробнее об этом см. ниже
      caBundle: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUdHekNDQkFPZ0F3SUJBZ0lKQU1jcTN6UHZDQUd0TUEwR0NTcUdTSWIzRFFFQkN3VUFNSUdqTVFzd0NRWUQKVlFRR0V3SlZVekVUTUJFR0ExVUVDQXdLVG1WM0lFcGxjbk5sZVRFVE1CRUdBMVVFQnd3S1VISnBibU5sZEc5dQpJREVTTUJBR0ExVUVDZ3dKUkc5M0lFcHZibVZ6TVF3d0NnWURWUVFMREFOUVNVSXhIakFjQmdOVkJBTU1GWGRsClltaHZiMnN0S2k1a1pXWmhkV3gwTG5OMll6RW9NQ1lHQ1NxR1NJYjNEUUVKQVJZWmMyTnZkSFF1Y21Gb2JtVnkKUUdSdmQycHZibVZ6TG1OdmJUQWVGdzB4T0RFd016RXhOalU1TURWYUZ3MHlNVEE0TWpBeE5qVTVNRFZhTUlHagpNUXN3Q1FZRFZRUUdFd0pWVXpFVE1CRUdBMVVFQ0F3S1RtVjNJRXBsY25ObGVURVRNQkVHQTFVRUJ3d0tVSEpwCmJtTmxkRzl1SURFU01CQUdBMVVFQ2d3SlJHOTNJRXB2Ym1Wek1Rd3dDZ1lEVlFRTERBTlFTVUl4SGpBY0JnTlYKQkFNTUZYZGxZbWh2YjJzdEtpNWtaV1poZFd4MExuTjJZekVvTUNZR0NTcUdTSWIzRFFFSkFSWVpjMk52ZEhRdQpjbUZvYm1WeVFHUnZkMnB2Ym1WekxtTnZiVENDQWlJd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dJUEFEQ0NBZ29DCmdnSUJBTHRpTU5mL1l3d0RkcHlPSUhja2FQK3J6NmdxYXBhWmZ2a0JndHVZK3BYQVZnNWc5M1RISmlPdlJYUnAKeG9UZ1o0RlA4N0V3R0NXRUZxZTRFRjh5UUxCK1NvWHBxUmRrWlVLYlM3eDVJNnNDb0h1dFJXaURpd3piV3lGawp3UnppeXpyMTQzN2wzYWxadU9VNkl5bU9mVDlETzdRaDNnY01HOEprQ09aVlVOelVIN3J4WmtieGg3M1lXNW5ZCjhSMU5tZDJ3cm1IWkVWc2JmS21GTlhvZjFueWtRcXMyMUQxT1FwQ3A1VDB5QU9penZlaW9OS3VsQVVpcjNVQ0EKSmNYYWpMMGZVS1ZIcGVTbGlhWXdKZmZNSDFqOElqSDZTdm5TdG9qQWlWdnJHb1ZKUlFqRXFLQkpYVGMyaHZCWQpCcjJqdGdQb25WWnBBTFphbktha0JTV1cyZ25oZVFKaHpKOGhkMXlEU0x6dFFKb2JkOHZUMEZ5bHZaQzY3aURnCmROb1NWbHBaQlpDSVIxTldaRVdGbTlTWWtKLzZ6emVqMFZpWnp2aFBYdm9GelZEVGZoMEwzQWljUTZlWTNzcEMKV0Fmb2VTcFUxaEVJeG92SmdwVkpMbnRaWkhyN1RJQ05CNlV5QnFVUzhEa0lTMkhnWkh2MTd1VjA3bTFzZDZDMApDUnV5YmZHQ0l2RGNwMCtzMjF6TENXemJuS3BzaFo5UkYvYWhXMW11cVN2dGt0WXlYOFVySlpKT1h3Z0NKenhLCmdwZGs3YlA4Y3ZkRWxUZDduQXRJbjZPcm42VWlVUnFpSXY1VSt0bmIvOVlrNDIxVzdlT2NxZ3JqTEY4eUo5ckIKN0hBYlhGRjM5OW5NMlBtYkZIV2FROG1xeWo0L0kxNm9tTHVsUGZvekVWK0xvMXVwQWdNQkFBR2pVREJPTUIwRwpBMVVkRGdRV0JCUnVKaTcyS0U5bWhpejZvYVhkSXlpbGpTeXhkVEFmQmdOVkhTTUVHREFXZ0JSdUppNzJLRTltCmhpejZvYVhkSXlpbGpTeXhkVEFNQmdOVkhSTUVCVEFEQVFIL01BMEdDU3FHU0liM0RRRUJDd1VBQTRJQ0FRQlQKS28wczJTTWZkSzdkRS9ZdFBwQ2lQNDVBK0xJSjVKd0l2dWdiUlNGeVRUSEU0akhVRTdQdWc3VHdGNC93YnJFZwpNN1F3OWUxbDA1M2lheWRFOS9sUlVDbzN4TnVVcU5jU2lCK3RIOE54dURHUUw5NHBuWTdTR3FuRjBDMlZ2d2x2CmxaYUQxNU41cVdvTVJrQU54VXRPRGFaWEdLcS94VVBSQWdNMHFtbXc5ZnIwaXAvQzFjVGMyVVhlejlGNTMvV2cKV1FNempWbUNTNGlnckR1a1FBNWxodFRlYUlzK3pxNk9ZeWNiN01KR1JBL0NhcnpDL1VuZExMbmhsdEtITkJhMwp0TDFVVUJCTzBMdmdMaE8zVk9nRENOazJYVmZzVHFueEUrTGp6R2dmUnRqYjE5L0p1d2V2OW00Y3ZzUlZESGVMCk9oQ0lvenorUHRLWHBwVDFWd1VRbFZlOG5ic2RiVnNZWmt4Q3llcGpMUTJ5TXNUUXdoa2NncGRiTnYzbTMvRC8Kc3N5ZS9iZnphUGFXVEE1R0d5emhXdXlENDZPT1lCUFlhZzd0aFFneXRvOWRpSWNDSHNMQ3BVZm1FQ1d6TERBYgozK2NadnZnYXZybFJCZjN2cVhrVlZxT1NLNGxna25iUEZJc0YvbnFIanM2WXI5Tktiai9sRGlBalRYaVdQdFRmClJzd0JodndveDJnK21zd0prQytId0cvckZ1RXFDdklTaFJGWlEvMDgyL0F5ekpYRlE3SlV3eHluL0dTQXlGZUsKL1Y3T01XTEhUeVd4Vkg4eVBCZ1JSVE1CK3NrOEVQQndveFRLSjZnLytTbmdkNXM1ZEx6ZDhpSTlsVHdxWDZBTApzNU1OY2NobFRWVU9RYnFGWXBKc3FTUTlIVlB2bjZDckRlTGlxTlNKQVE9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="
    rules:
      - operations: [ "CREATE" ]
        apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["pods"]
(hook.yaml)

Название и путь здесь могут быть любыми, но я постарался сделать их настолько осмысленными, насколько возможно. Изменение пути будет означать необходимость модификации соответствующего кода в JavaScript. Важен и webhook failurePolicy — он определяет, должен ли объект сохраняться, если хук возвращает ошибку или не срабатывает. Мы в данном случае говорим Kubernetes'у не продолжать обработку. Наконец, правила (rules): они будут меняться в зависимости от того, на какие вызовы API вы ожидаете действий от Kubernetes. В данном случае, поскольку мы пытаемся эмулировать вставку sidecar-контейнера, нам требуется перехват запросов на создание pod'а.

Вот и всё! Так просто… но что насчёт безопасности? RBAC — это один из аспектов, который не затронут в статье. Я предполагаю, что вы запускаете пример в Minikube или же в Kubernetes, что идёт в поставке Docker for Windows/Mac. Однако расскажу ещё об одном необходимом элементе. API-сервер Kubernetes обращается только к endpoint'ам с HTTPS, поэтому для приложения потребуется наличие SSL-сертификатов. Также потребуется сообщить Kubernetes'у, кто является удостоверяющим центром корневого сертификата.

TLS


Только для демонстрационных целей(!!!) я добавил в Dockerfile немного кода, чтобы создать root CA и воспользоваться им для подписи сертификата:

RUN openssl genrsa -out rootCA.key 4096
RUN openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 1024 -out rootCA.crt  -subj "/C=US/ST=New Jersey/L=Princeton /O=Dow Jones/OU=PIB/CN=*.default.svc/emailAddress=scott.rahner@dowjones.com"
RUN openssl genrsa -out webhook.key 4096
RUN openssl req -new -key webhook.key -out webhook.csr  -subj "/C=US/ST=New Jersey/L=Princeton /O=Dow Jones/OU=PIB/CN=webhook-service.default.svc/emailAddress=scott.rahner@dowjones.com"
RUN openssl x509 -req -in webhook.csr -CA rootCA.crt -CAkey rootCA.key -CAcreateserial -out webhook.crt -days 1024 -sha256
RUN cat rootCA.crt | base64 | tr -d '\n'

(Dockerfile)

Обратите внимание: последний этап — выводит единственную строку с root CA, закодированным в base64. Именно это требуется для конфигурации хука, так что в своих дальнейших тестах убедитесь, что скопировали эту строку в поле caBundle файла hook.yaml. Dockerfile забрасывает сертификаты прямо в WORKDIR, так что JavaScript просто забирает их оттуда и использует для сервера:

const privateKey = fs.readFileSync('webhook.key').toString();
const certificate = fs.readFileSync('webhook.crt').toString();
//…
const options = {key: privateKey, cert: certificate};
const server = https.createServer(options, app);

Теперь код поддерживает запуск HTTPS, а также сообщил Kubernetes'у, где найти нас и какому удостоверяющему центру доверять. Осталось лишь задеплоить всё это в кластер:

kubectl create -f deployment.yaml
kubectl create -f service.yaml
kubectl create -f hook.yaml

Резюмируем


  • Deployment.yaml запускает контейнер, который обслуживает hook API по HTTPS и возвращает JSON Patch для изменения объекта.
  • Service.yaml обеспечивает для контейнера endpoint — webhook-service.default.svc.
  • Hook.yaml говорит API-серверу, где нас найти: https://webhook-service.default.svc/mutate.

Попробуем в деле!


Всё развёрнуто в кластере — время попробовать код в действии, что мы сделаем добавлением нового pod/Deployment. Если всё работает правильно, хук должен будет добавить дополнительный лейбл foo:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: test
spec:
  replicas: 1
  selector:
    matchLabels:
      component: test
  template:
    metadata:
      labels:
        component: test
    spec:
      containers:
      - name: test
        image: node:8
        command: [ "/bin/sh", "-c", "--" ]
args: [ "while true; do sleep 30; done;" ]

(test.yaml)

kubectl create -f test.yaml

Ок, мы увидели deployment.apps test created… но всё ли получилось?

kubectl describe pods test

Name: test-6f79f9f8bd-r7tbd
Namespace: default
Node: docker-for-desktop/192.168.65.3
Start Time: Sat, 10 Nov 2018 16:08:47 -0500
Labels: component=test
 foo=bar

Замечательно! Хотя у test.yaml был задан единственный лейбл (component), результирующий pod получил два: component и foo.

Домашнее задание


Но подождите! Разве мы собирались использовать этот код, чтобы создать sidecar-контейнер? Я предупреждал, что покажу, как добавить sidecar… А теперь, с полученным знанием и кодом: https://github.com/dowjones/k8s-webhook — смело экспериментируйте и разбирайтесь в том, как сделать свой автоматически вставляемый sidecar. Это довольно просто: необходимо лишь подготовить правильный JSON Patch, который будет добавлять дополнительный контейнер в тестовом Deployment'е. Счастливой оркестровки!

P.S. от переводчика


Читайте также в нашем блоге:

Комментарии (1)


  1. celebrate
    30.11.2018 20:24

    Спасибо, полезная статья!