Kubernetes состоит из нескольких компонентов, где значительная часть взаимодействий итогового пользователя с системой осуществляется при помощи API-сервера. Он представляет собой отправную точку для многих функций, которые проект создал за время своего существования, и может быть расширен для поддержания дополнительной произвольной функциональности. Поэтому API-сервер иногда оказывается ограниченным местом, которое изначально не было задумано для размещения, или, по крайней мере, хорошего размещения.
Когда поведение API-сервера Kubernetes не соответствует ожиданиям, не всегда можно понять, почему система выполняет нежелательные действия, особенно когда речь идёт о производительности. Хотя у нас есть доступ к исходному коду проекта, может быть непросто осуществлять навигацию по нему и определять, какие ветви кода используются. Как и в большинстве сложных систем, разрушение кода на более мелкие компоненты и их обратная сборка помогают гораздо быстрее определить, где именно происходит системный сбой. Дополнительно можно модифицировать компоненты в соответствии с пользовательским кейсом.
В новой серии статей «Мир приключений по API-серверу Kubernetes» («k8s ASA») мы погрузимся во все детали работы API-сервера, получим представление о том, как они работают, а также с какими компонентами взаимодействуют. Параллельно поэкспериментируем с заменой пользовательских компонентов, создадим инструментарий и рассмотрим, как другие пользователи модифицировали его в соответствии с пользовательскими кейсами. Кто знает, возможно, даже построим наш собственный. ????
K8s AS: Интерфейс хранилища
Как и у большинства API-серверов, базовая функция API-сервера Kubernetes — приём данных, хранение и возврат по запросу. Сосредоточимся на том, как API-сервер хранит данные.
О чём пойдёт речь в статье:
Модуле
apiserver
(???? «Мне нужны все подробности»)Пакет
storage
Наш хороший друг
etcd
Сервер API и
etcd
Вызов
storage.Interface
напрямую (???? «Можно ли пропустить справочную информацию?» )Разработка
runtime.Scheme
Кодирование и декодирование
Прохождение трансформации
Определение
ConfigMap
(???? «Я занятой человек» )Итоговые мысли
Модуль apiserver
В первых нескольких параграфах этой серии мы сосредоточимся на одном модуле в кодовой базе Kubernetes. Модуль apiserver представляет собой самопровозглашённую «универсальную библиотеку для создания агрегированного API-сервера Kubernetes» . Там есть набор пакетов с универсальной функциональностью. Её ожидают от API-сервера: audit, authentication, authorization
и многих других. Некоторые из пакетов содержат дополнительные пакеты, которые следуют знакомой схеме предоставления интерфейса, а затем одной или нескольких конкретных реализаций интерфейса. Один из таких примеров — пакет storage.
Пакет storage
Пакет storage
нужен для хранения данных. Если говорить формально, он предоставляет «интерфейсы для операций, связанных с базой данных». В центре вышеупомянутых интерфейсов находится storage.Interface
, который «скрывает за собой все операции, связанные с хранилищем». Будучи фундаментальным компонентом в слоях API-сервера, интерфейс довольно простой. Он содержит всего 8 методов, в основном для операций, которые ожидались бы для хранения и извлечения. Эти методы включают:
Versioner() Versioner
Versioner
— это метод доступа, который возвращает реализацию интерфейса Versioner
, предоставляемого пакетом storage
. Последний используется для абстрагированного управления версиями ресурсов.
Create(ctx context.Context, key string, obj, out runtime.Object, ttl uint64) error
Create
отвечает за добавление данных в форме runtime.Object.
Обратитесь к базовой реализации хранилища с предоставленным key. Он поддерживает установку «time to live» (ttl
), если срок действия данных для key
должен истечь через определенный период, и позволяет передавать отдельный runtime.Object (out)
, который используют для представления состояния объекта после его подготовки и сохранения.
Delete(ctx context.Context, key string, out runtime.Object, preconditions *Preconditions, validateDeletion ValidateObjectFunc, cachedExistingObject runtime.Object) error
Delete
отвечает за удаление данных по заданному key
и возврат данных в виде runtime.Object.
Это позволяет задать preconditions
, которые должны быть выполнены для успешного выполнения операции (могут быть предоставлены UUID
объекта и ResourceVersion
. Задать нужно и функцию проверки объекта (validateDeletion
). Последний представляет собой простой хук в виде func(ctx context.Context,
obj runtime.Object error
. Последний предоставленный аргумент, cachedExistingObject,
можно использовать для оптимизации процесса удаления в том случае, если состояние желаемой версии объекта, подлежащего удалению, уже известно.
Если версия кэшированного объекта не соответствует версии ResourceVersion
в preconditions
или мы не выполнили проверку, это может быть связано с устаревшей версией кэшированного объекта. В этом случае нам нужно извлечь новую версию перед повторной попыткой.
Watch(ctx context.Context, key string, opts ListOptions) (watch.Interface, error)
Watch
возвращает watch.Interface, который позволяет наблюдать за изменениями данных по предоставленному key
в течение определенного периода времени. Интерфейс watch
представляет собой простую, но мощную абстракцию, поддерживающую только две операции (ResultChan() <-chan Event и Stop()
Они обеспечивают высокую степень свободы реализации. Однако ListOptions
, которые позволяют указать версию ресурса с запуском просмотра, пропускают немного базовой реализации с такими параметрами, как Recursive bool
. Они указывают, что предоставленный key
— префикс, и все объекты, которые соответствуют, должны быть включены в просмотр. Если вы когда-либо устанавливали флаг -w
(«watch» ) при выполнении команды kubectl
, то могли увидеть ответ типа error: you may only specify a single resource type
. Это пример базового механизма хранения и организации данных внутри него, которые передаются конечному пользователю.
Get(ctx context.Context, key string, opts GetOptions, objPtr runtime.Object) error
Get
извлекает данные по заданному key
и возвращает их в objPtr
. Параметры позволяют игнорировать ошибки, связанные с тем, что key
не найден, а также указывать ограничения на версию ресурса извлекаемого объекта.
GetList(ctx context.Context, key string, opts ListOptions, listObj runtime.Object) error
GetList
похож на Watch
по набору опций. Но вместо того, чтобы предоставлять интерфейс для получения обновлений по ресурсам, он возвращает их в виде списка объектов.
GuaranteedUpdate(ctx context.Context, key string, destination runtime.Object, ignoreNotFound bool, preconditions *Preconditions, tryUpdate UpdateFunc, cachedExistingObject runtime.Object) error
GuaranteedUpdate
похож на Delete
, но немного сложнее, так как принимает определенную вызывающей стороной UpdateFunc
в форме func(input runtime.Object
, res ResponseMeta) (output runtime.Object, ttl *uint64, err error)
. Эту функцию вызывают повторно после проверки предварительных условий, повторяя любые попытки сохранения измененного объекта при сбое. Но tryUpdate
может выбрать выход из цикла, вернув сообщение об ошибке. Такое обновление обеспечивает более устойчивые и гибкие операции, ведь у вызывающего объекта больше возможностей для определения того, должно ли текущее состояние приводить к конфликту или нет.
Count(key string) (int64, error)
Count
— это самый простой из доступных методов, возвращающий количество объектов для предоставленного key
. Он часто бывает префиксом, но, в отличие от других методов, вызывающей стороне не нужно явно определять, что это такое.
Хотя мы не углублялись ни в один из обработчиков для конечных точек Kubernetes API, с которыми мы все знакомы, можно увидеть, что некоторые из команд kubectl
или методы client-go
, которые мы вызываем, могут быть сопоставлены с выполнением этих операций хранения за кулисами. И хотя интерфейс storage
сообщает нам, как мы можем взаимодействовать с решением для хранения данных, он ничего не говорит нам о том, как на самом деле они сохраняются.
Наш хороший друг etcd
Прежде чем мы пойдем дальше, я должен порекомендовать вам пойти и прочитать пост Майкла Гаша о внутренностях etcd
. После этого можно будет сосредоточиться на взаимодействии Kubernetes с etcd
.
Во время storage.Interface
вы заметите высокую степень сходства между gRPC API, предоставляемым etcd
, и методами интерфейса. Настолько, что реализация etcd
становится относительно легкой. Чтобы получить представление о том, как работает etcd
, мы можем загрузить последнюю версию вместе с etcdctl
, официальным роликом.
$ ETCD_VER=v3.5.6
$ curl -L https://github.com/etcd-io/etcd/releases/download/${ETCD_VER}/etcd-${ETCD_VER}-linux-amd64.tar.gz -o /tmp/etcd-${ETCD_VER}-linux-amd64.tar.gz
$ tar xzvf /tmp/etcd-${ETCD_VER}-linux-amd64.tar.gz etcd-v3.5.6-linux-amd64/etcd etcd-v3.5.6-linux-amd64/etcdctl --strip-components=1
$ rm -f /tmp/etcd-${ETCD_VER}-linux-amd64.tar.gz
Примечание: обязательно переместите etcd
и etcdctl
в каталог по вашему PATH
.
$ etcd --version
etcd Version: 3.5.6
Git SHA: cecbe35ce
Go Version: go1.16.15
Go OS/Arch: linux/amd64
Если мы запустим etcd
в фоновом режиме, то начнем выполнять операции с etcdctl
.
$ etcd > /dev/null 2>&1 &
[1] 442084
$ etcdctl member list
8e9e05c52164694d, started, default, http://localhost:2380, http://localhost:2379, false
$ etcdctl get "" --prefix
$ etcdctl put hello world
OK
$ etcdctl get hello
hello
world
$ etcdctl put well hi
OK
$ etcdctl get "" --prefix
hello
world
well
hi
$ etcd del hello
1
$ etcdctl get "" --prefix
well
hi
С помощью всего нескольких команд можно познакомиться с основами Create
, Delete
, Get
, и GetList
. Мы также можем открыть новый терминал и следить за изменениями в заданном префиксе.
# terminal 1
$ etcdctl watch "" --prefix
# terminal 2
$ etcdctl put isee you
OK
# terminal 1
$ etcdctl watch "" --prefix
PUT
isee
you
Прежде чем перейти к серверу API, предлагаем завершить ваше расследование, получив количество ключей с заданным префиксом.
$ etcdctl get "" --prefix --count-only --write-out fields
"ClusterID" : 14841639068965178418
"MemberID" : 10276657743932975437
"Revision" : 5
"RaftTerm" : 6
"More" : false
"Count" : 2
Убедитесь, что вы выключили etcd
, прежде чем продолжить.
$ jobs
[1]+ Running etcd > /dev/null 2>&1 &
$ kill %1
API-сервер и etcd
Один из самых быстрых способов увидеть, как Kubernetes взаимодействует с etcd
— развернуть кластер kind
и взаимодействовать с etcd
, пока сервер API кооперирует с ним.
$ kind create cluster
Когда ваш кластер запущен, вы должны увидеть, что отдельный узел работает как контейнер.
$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
d5ec8483dd79 kindest/node:v1.25.3 "/usr/local/bin/entr…" About a minute ago Up About a minute 127.0.0.1:34285->6443/tcp kind-control-plane
Если мы запустим exec в контейнер, то сможем взглянуть на компоненты Kubernetes в действии.
$ docker exec -it kind-control-plane /bin/bash
root@kind-control-plane:/#
Используя top
, мы видим, что все обычные подозреваемые присутствуют: kube-controller, kube-apiserver, kube-scheduler, etcd
и т.п.
root@kind-control-plane:/# top -b -n 1
top - 02:53:43 up 3 days, 6:13, 0 users, load average: 1.50, 2.11, 1.73
Tasks: 33 total, 1 running, 32 sleeping, 0 stopped, 0 zombie
%Cpu(s): 5.3 us, 3.2 sy, 0.0 ni, 90.0 id, 0.0 wa, 0.0 hi, 1.6 si, 0.0 st
MiB Mem : 15650.6 total, 487.8 free, 8350.3 used, 6812.5 buff/cache
MiB Swap: 980.0 total, 446.4 free, 533.6 used. 4547.0 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
624 root 20 0 773984 86460 46600 S 6.7 0.5 0:45.27 kube-controller
634 root 20 0 1061468 317036 57176 S 6.7 2.0 1:37.29 kube-apiserver
640 root 20 0 761668 48252 32684 S 6.7 0.3 0:08.51 kube-scheduler
742 root 20 0 10.7g 47092 18844 S 6.7 0.3 0:55.33 etcd
1 root 20 0 19564 12572 8248 S 0.0 0.1 0:00.75 systemd
206 root 19 -1 23140 10652 9720 S 0.0 0.1 0:00.10 systemd-journal
219 root 20 0 2826620 61292 34524 S 0.0 0.4 0:19.46 containerd
392 root 20 0 712476 10900 7868 S 0.0 0.1 0:00.59 containerd-shim
419 root 20 0 712476 10188 7676 S 0.0 0.1 0:00.65 containerd-shim
438 root 20 0 712220 10340 7932 S 0.0 0.1 0:00.61 containerd-shim
472 root 20 0 712476 11112 8228 S 0.0 0.1 0:00.60 containerd-shim
494 65535 20 0 988 4 0 S 0.0 0.0 0:00.02 pause
504 65535 20 0 988 4 0 S 0.0 0.0 0:00.02 pause
511 65535 20 0 988 4 0 S 0.0 0.0 0:00.01 pause
520 65535 20 0 988 4 0 S 0.0 0.0 0:00.01 pause
806 root 20 0 2473840 85548 49088 S 0.0 0.5 0:59.64 kubelet
973 root 20 0 712476 10488 8060 S 0.0 0.1 0:00.58 containerd-shim
998 root 20 0 712220 10788 8244 S 0.0 0.1 0:00.59 containerd-shim
1022 65535 20 0 988 4 0 S 0.0 0.0 0:00.03 pause
1029 65535 20 0 988 4 0 S 0.0 0.0 0:00.03 pause
1087 root 20 0 754980 36596 29032 S 0.0 0.2 0:00.59 kube-proxy
1089 root 20 0 733024 24224 18136 S 0.0 0.2 0:00.68 kindnetd
1358 root 20 0 712476 10324 7800 S 0.0 0.1 0:00.54 containerd-shim
1359 root 20 0 712732 10572 8228 S 0.0 0.1 0:00.57 containerd-shim
1402 65535 20 0 988 4 0 S 0.0 0.0 0:00.03 pause
1410 65535 20 0 988 4 0 S 0.0 0.0 0:00.02 pause
1466 root 20 0 712476 10184 7612 S 0.0 0.1 0:00.55 containerd-shim
1491 65535 20 0 988 4 0 S 0.0 0.0 0:00.02 pause
1543 root 20 0 754816 46016 33928 S 0.0 0.3 0:05.68 coredns
1551 root 20 0 730964 23700 18072 S 0.0 0.1 0:01.09 local-path-prov
1579 root 20 0 754816 45580 34120 S 0.0 0.3 0:05.57 coredns
1912 root 20 0 4616 3780 3184 S 0.0 0.0 0:00.02 bash
1948 root 20 0 7304 2940 2612 R 0.0 0.0 0:00.00 top
Наша цель сегодня — изучить взаимодействие между kube-apiserver
и etcd
. Как мы видели ранее, etcdctl
полезен для запросов и отслеживания изменений. Мы можем скопировать инструмент на наш узел kind
для взаимодействия с нашим запущенным экземпляром etcd
.
$ docker cp /usr/local/bin/etcdctl kind-control-plane:/usr/local/bin/
Вернувшись на узел, мы должны увидеть, что etcdctl
теперь присутствует.
$ root@kind-control-plane:/# which etcdctl
/usr/local/bin/etcdctl
Но если мы попытаемся подключиться к экземпляру etcd
, работающему как часть нашего кластера Kubernetes, то увидим ошибку.
$ root@kind-control-plane:/# etcdctl get "" --prefix
{"level":"warn","ts":"2023-01-19T03:48:14.046Z","logger":"etcd-client","caller":"v3@v3.5.6/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"etcd-endpoints://0xc0001b2000/127.0.0.1:2379","attempt":0,"error":"rpc error: code = DeadlineExceeded desc = latest balancer error: last connection error: connection closed before server preface received"}
Error: context deadline exceeded
Итак, мы знаем, что kube-apiserver
способен взаимодействовать с etcd
, поэтому давайте посмотрим, как он настроен.
$ root@kind-control-plane:/# kubectl get pods -A
NAMESPACE NAME READY STATUS RESTARTS AGE
kube-system coredns-565d847f94-wdnpq 1/1 Running 0 74m
kube-system coredns-565d847f94-zcmx5 1/1 Running 0 74m
kube-system etcd-kind-control-plane 1/1 Running 0 74m
kube-system kindnet-kjx7c 1/1 Running 0 74m
kube-system kube-apiserver-kind-control-plane 1/1 Running 0 74m
kube-system kube-controller-manager-kind-control-plane 1/1 Running 0 74m
kube-system kube-proxy-tmhqp 1/1 Running 0 74m
kube-system kube-scheduler-kind-control-plane 1/1 Running 0 74m
local-path-storage local-path-provisioner-684f458cdd-n6z9z 1/1 Running 0 74m
$ root@kind-control-plane:/# kubectl get pods -n kube-system kube-apiserver-kind-control-plane -o=jsonpath={.spec.containers[0].command} | jq . | grep etcd
"--etcd-cafile=/etc/kubernetes/pki/etcd/ca.crt",
"--etcd-certfile=/etc/kubernetes/pki/apiserver-etcd-client.crt",
"--etcd-keyfile=/etc/kubernetes/pki/apiserver-etcd-client.key",
"--etcd-servers=https://127.0.0.1:2379",
Хотя в ошибке, которую мы видели, явно не указано, можно уверенно предположить, что etcd
требует аутентификации для подключения. Используйте тот же ключ и сертификаты, что и сервер API.
$ root@kind-control-plane:/# etcdctl --cacert /etc/kubernetes/pki/etcd/ca.crt --cert /etc/kubernetes/pki/apiserver-etcd-client.crt --key /etc/kubernetes/pki/apiserver-etcd-client.key get "" --prefix --keys-only --limit 5
/registry/apiregistration.k8s.io/apiservices/v1.
/registry/apiregistration.k8s.io/apiservices/v1.admissionregistration.k8s.io
/registry/apiregistration.k8s.io/apiservices/v1.apiextensions.k8s.io
/registry/apiregistration.k8s.io/apiservices/v1.apps
/registry/apiregistration.k8s.io/apiservices/v1.authentication.k8s.io
Теперь, когда мы можем подключиться, использование префикса ""
позволит просмотреть все ключи, присутствующие в нашем экземпляре etcd
. Первые несколько ключей, которые мы видим, указывают, что мы смотрим на объекты APIService
, сообщающие серверу API, где обслуживаются обработчики для группы. Если мы проверим значение для одного из этих ключей, мы увидим данные, которые похожи на то, что мы получаем обратно, когда запускаем kubectl get.
$ root@kind-control-plane:/# etcdctl --cacert /etc/kubernetes/pki/etcd/ca.crt --cert /etc/kubernetes/pki/apiserver-etcd-client.crt --key /etc/kubernetes/pki/apiserver-etcd-client.key get /registry/apiregistration.k8s.io/apiservices/v1.apiextensions.k8s.io --print-value-only | jq .
{
"kind": "APIService",
"apiVersion": "apiregistration.k8s.io/v1",
"metadata": {
"name": "v1.apiextensions.k8s.io",
"uid": "6e6f1ae3-7eb6-415e-9c40-d13f37d4c184",
"creationTimestamp": "2023-01-22T16:28:14Z",
"labels": {
"kube-aggregator.kubernetes.io/automanaged": "onstart"
},
"managedFields": [
{
"manager": "kube-apiserver",
"operation": "Update",
"apiVersion": "apiregistration.k8s.io/v1",
"time": "2023-01-22T16:28:14Z",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:metadata": {
"f:labels": {
".": {},
"f:kube-aggregator.kubernetes.io/automanaged": {}
}
},
"f:spec": {
"f:group": {},
"f:groupPriorityMinimum": {},
"f:version": {},
"f:versionPriority": {}
}
}
}
]
},
"spec": {
"group": "apiextensions.k8s.io",
"version": "v1",
"groupPriorityMinimum": 16700,
"versionPriority": 15
},
"status": {
"conditions": [
{
"type": "Available",
"status": "True",
"lastTransitionTime": "2023-01-22T16:28:14Z",
"reason": "Local",
"message": "Local APIServices are always available"
}
]
}
}
Формат ключей, которые мы наблюдали, — /registry/<group>/<kind>/<metadata.name
. Но не все API имеют кластерную область, как APIService
. Давайте взглянем на ConfigMap
, API с областью имен.
$ root@kind-control-plane:/# etcdctl --cacert /etc/kubernetes/pki/etcd/ca.crt --cert /etc/kubernetes/pki/apiserver-etcd-client.crt --key /etc/kubernetes/pki/apiserver-etcd-client.key get /registry/configmaps --prefix --keys-only
/registry/configmaps/default/kube-root-ca.crt
/registry/configmaps/kube-node-lease/kube-root-ca.crt
/registry/configmaps/kube-public/cluster-info
/registry/configmaps/kube-public/kube-root-ca.crt
/registry/configmaps/kube-system/coredns
/registry/configmaps/kube-system/extension-apiserver-authentication
/registry/configmaps/kube-system/kube-proxy
/registry/configmaps/kube-system/kube-root-ca.crt
/registry/configmaps/kube-system/kubeadm-config
/registry/configmaps/kube-system/kubelet-config
/registry/configmaps/local-path-storage/kube-root-ca.crt
/registry/configmaps/local-path-storage/local-path-config
Поскольку ConfigMap
находится в core
группе API, мы не видим никакой <group>
в имени ключа. Но замечаем дополнительный элемент в пути: <namespace>
. Это перенесет наш полный формат в /registry/[<group>/]<kind>/[<namespace>/]/<metadata.name>.
Если бы мы перечислили все объекты ConfigMap
в нашем кластере, мы должны были бы увидеть тот же список.
$ root@kind-control-plane:/# kubectl get configmaps -A
NAMESPACE NAME DATA AGE
default kube-root-ca.crt 1 18m
kube-node-lease kube-root-ca.crt 1 18m
kube-public cluster-info 2 18m
kube-public kube-root-ca.crt 1 18m
kube-system coredns 1 18m
kube-system extension-apiserver-authentication 6 18m
kube-system kube-proxy 2 18m
kube-system kube-root-ca.crt 1 18m
kube-system kubeadm-config 1 18m
kube-system kubelet-config 1 18m
local-path-storage kube-root-ca.crt 1 18m
local-path-storage local-path-config 4 18m
Заметим, что если мы создадим новую ConfigMap
в кластере, её впоследствии можно будет просмотреть в etcd
с предоставленными нами данными.
$ root@kind-control-plane:/# kubectl create configmap k8s-asa --from-literal hello=world
configmap/k8s-asa created
root@kind-control-plane:/# etcdctl --cacert /etc/kubernetes/pki/etcd/ca.crt --cert /etc/kubernetes/pki/apiserver-etcd-client.crt --key /etc/kubernetes/pki/apiserver-etcd-client.key get /registry/configmaps/default/k8s-asa --print-value-only
k8s
v1 ConfigMap�
�
k8s-asa�default"*$49e53b5d-ad63-4c58-b005-7fde4ad240002���V
kubectl-createUpdate�v��FieldsV1:"
{"f:data":{".":{},"f:hello":{}}}B
helloworld�"
На этот раз мы не получаем красивый JSON, возвращаемый из значений APIService
. Почему это так, если они оба проходят через одно и то же storage.Interface
.? Чтобы понять, нужно изучить реализацию интерфейса etcd3
.
Вызываем storage.Interface напрямую
Единственная реализация storage.Interface
— etcd3.
Хотя мы наблюдали корреляцию между операциями, поддерживаемыми etcd
и storage.Interface
, есть нюанс в том, как данные, отправленные на сервер API, в итоге сохраняются и возвращаются. Сигнатура функции для создания нового серверного хранилища etcd
дает подсказки относительно его внутренних операций.
func New(c *clientv3.Client, codec runtime.Codec, newFunc func() runtime.Object, prefix string, groupResource schema.GroupResource, transformer value.Transformer, pagingEnabled bool, leaseManagerConfig LeaseManagerConfig) storage.
В то время как некоторые аргументы не требуют пояснений (например, передача клиента, указание префикса или указание того, включена ли подкачка по страницам), другие кажутся немного более непрозрачными без дополнительного контекста. Тут сосредоточимся на codec
и transformer
, поскольку в первую очередь мы заинтересованы в успешной выборке данных из etcd
, которые были помещены туда APIServer
. Как можно было бы заключить из их названий, codec
и transformer
— это то, что определяет разницу между данными, передаваемыми обработчикам APIServer и из них, и теми, которые хранятся в etcd
. Чтобы мотивировать исследование, можно создать программу, которая создает экземпляр реализации хранилища etcd3. storage.Interface
и извлекает ранее созданную ConfigMap
. Вместо того, чтобы копировать двоичный файл в ваш узел kind
, мы собираемся скопировать PCI-данные из контейнера и перенаправить на сервер etcd Pod
.
# make a directory outside the container to copy PKI data
$ mkdir pki
# find the root directory for the kind node container
$ sudo ls /proc/$(docker inspect kind-control-plane | jq .[0].State.Pid)/root
bin boot dev etc home kind lib lib32 lib64 libx32 media mnt opt proc root run sbin srv sys tmp usr var
# copy PKI data out of container
$ sudo cp -r /proc/$(docker inspect kind-control-plane | jq .[0].State.Pid)/root/etc/kubernetes/pki/. ./pki/.
# change ownership from root to current user / group
$ sudo chown $(id -un):$(id -gn) -R ./pki
Примечание: поскольку демон Docker запущен от имени root
, все файлы в контейнере принадлежат root:root
. Изменив владельца скопированных файлов, вы получите к ним доступ. Это подходит для нашего исследования на локальном компьютере, но в любой значимой настройке с данными Kubernetes PKI следует обращаться осторожно, чтобы избежать ущерба безопасности вашего кластера.
Теперь в отдельном терминале мы начинаем перенаправить порт etcd Pod
.
$ kubectl port-forward -n kube-system pod/etcd-kind-control-plane 2379:2379
Forwarding from 127.0.0.1:2379 -> 2379
Forwarding from [::1]:2379 -> 2379
Вне кластера мы создаём программу, которая будет извлекать ConfigMap
непосредственно из etcd
.
main.go
package main
import (
"context"
"fmt"
"go.etcd.io/etcd/client/pkg/v3/transport"
clientv3 "go.etcd.io/etcd/client/v3"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apiserver/pkg/storage"
"k8s.io/apiserver/pkg/storage/etcd3"
"k8s.io/apiserver/pkg/storage/value/encrypt/identity"
)
func main() {
tlsConfig, err := (transport.TLSInfo{
CertFile: "./pki/etcd/server.crt",
KeyFile: "./pki/etcd/server.key",
TrustedCAFile: "./pki/etcd/ca.crt",
ClientCertFile: "./pki/apiserver-etcd-client.crt",
ClientKeyFile: "./pki/apiserver-etcd-client.key",
}).ClientConfig()
if err != nil {
panic(err)
}
c, err := clientv3.New(clientv3.Config{
Endpoints: []string{"https://127.0.0.1:2379"},
TLS: tlsConfig,
})
if err != nil {
panic(err)
}
scheme := runtime.NewScheme()
v1.AddToScheme(scheme)
s := etcd3.New(c, serializer.NewCodecFactory(scheme).LegacyCodec(v1.SchemeGroupVersion), nil, "registry", v1.Resource("ConfigMap"), identity.NewEncryptCheckTransformer(), true, etcd3.NewDefaultLeaseManagerConfig())
cm := &v1.ConfigMap{}
if err := s.Get(context.Background(), "configmaps/default/k8s-asa", storage.GetOptions{}, cm); err != nil {
panic(err)
}
fmt.Println(cm.Data)
}
Разрабатывая runtime.Scheme
Для подключения к etcd
, укажите пути к различным сертификатам и ключам, скопированным в данных PKI из контейнера. Необходимо указать адрес(ы) сервера(ов) etcd
. Так мы перенаправляем порт на один сервер на localhost
. Как только у нас появится наш клиент etcd
, нужно создать экземпляр реализации хранилища. Совершаем это после того, как создали среду runtime.Scheme
. Если вы когда-либо писали контроллер Kubernetes, то, скорее всего, уже знакомы со схемами. Короче говоря, scheme
информирует потребителя о том, как преобразовать тип Kubernetes во внутреннее представление Go этого типа.
type Scheme struct {
// gvkToType allows one to figure out the go type of an object with
// the given version and name.
gvkToType map[schema.GroupVersionKind]reflect.Type
// typeToGVK allows one to find metadata for a given go object.
// The reflect.Type we index by should *not* be a pointer.
typeToGVK map[reflect.Type][]schema.GroupVersionKind
// unversionedTypes are transformed without conversion in ConvertToVersion.
unversionedTypes map[reflect.Type]schema.GroupVersionKind
// unversionedKinds are the names of kinds that can be created in the context of any group
// or version
// TODO: resolve the status of unversioned types.
unversionedKinds map[string]reflect.Type
// Map from version and resource to the corresponding func to convert
// resource field labels in that version to internal version.
fieldLabelConversionFuncs map[schema.GroupVersionKind]FieldLabelConversionFunc
// defaulterFuncs is a map to funcs to be called with an object to provide defaulting
// the provided object must be a pointer.
defaulterFuncs map[reflect.Type]func(interface{})
// converter stores all registered conversion functions. It also has
// default converting behavior.
converter *conversion.Converter
// versionPriority is a map of groups to ordered lists of versions for those groups indicating the
// default priorities of these versions as registered in the scheme
versionPriority map[string][]string
// observedVersions keeps track of the order we've seen versions during type registration
observedVersions []schema.GroupVersion
// schemeName is the name of this scheme. If you don't specify a name, the stack of the NewScheme caller will be used.
// This is useful for error reporting to indicate the origin of the scheme.
schemeName string
}
scheme
— пустой, но можно добавлять новые типы, «обучая» scheme
преобразованию. В нашем примере программы мы используем удобную функцию core API v1.AddToScheme
для регистрации всех типов core
API. В итоге функция вызывает AddKnownTypeWithName()
во время runtime.Scheme
для каждого регистрируемого типа. Это добавляет тип к картам преобразования и регистрирует функцию самостоятельного преобразования, если она доступна.
func (s *Scheme) AddKnownTypeWithName(gvk schema.GroupVersionKind, obj Object) {
s.addObservedVersion(gvk.GroupVersion())
t := reflect.TypeOf(obj)
if len(gvk.Version) == 0 {
panic(fmt.Sprintf("version is required on all types: %s %v", gvk, t))
}
if t.Kind() != reflect.Pointer {
panic("All types must be pointers to structs.")
}
t = t.Elem()
if t.Kind() != reflect.Struct {
panic("All types must be pointers to structs.")
}
if oldT, found := s.gvkToType[gvk]; found && oldT != t {
panic(fmt.Sprintf("Double registration of different types for %v: old=%v.%v, new=%v.%v in scheme %q", gvk, oldT.PkgPath(), oldT.Name(), t.PkgPath(), t.Name(), s.schemeName))
}
s.gvkToType[gvk] = t
for _, existingGvk := range s.typeToGVK[t] {
if existingGvk == gvk {
return
}
}
s.typeToGVK[t] = append(s.typeToGVK[t], gvk)
// if the type implements DeepCopyInto(<obj>), register a self-conversion
if m := reflect.ValueOf(obj).MethodByName("DeepCopyInto"); m.IsValid() && m.Type().NumIn() == 1 && m.Type().NumOut() == 0 && m.Type().In(0) == reflect.TypeOf(obj) {
if err := s.AddGeneratedConversionFunc(obj, obj, func(a, b interface{}, scope conversion.Scope) error {
// copy a to b
reflect.ValueOf(a).MethodByName("DeepCopyInto").Call([]reflect.Value{reflect.ValueOf(b)})
// clear TypeMeta to match legacy reflective conversion
b.(Object).GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{})
return nil
}); err != nil {
panic(err)
}
}
}
Вы когда-нибудь задумывались, где используются все методы в ваших файлах zz_generated.deepcopy.go
? Это здесь! Функция использует отражение переданного указателя struct
, чтобы определить, присутствует ли метод DeepCopyInto()
. Если да, то она регистрирует его. Для типа ConfigMap
метод DeepCopyInto()
выглядит следующим образом.
func (in *ConfigMap) DeepCopyInto(out *ConfigMap) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
if in.Immutable != nil {
in, out := &in.Immutable, &out.Immutable
*out = new(bool)
**out = **in
}
if in.Data != nil {
in, out := &in.Data, &out.Data
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.BinaryData != nil {
in, out := &in.BinaryData, &out.BinaryData
*out = make(map[string][]byte, len(*in))
for key, val := range *in {
var outVal []byte
if val == nil {
(*out)[key] = nil
} else {
in, out := &val, &outVal
*out = make([]byte, len(*in))
copy(*out, *in)
}
(*out)[key] = outVal
}
}
return
}
Кодирование и декодирование
Мы можем использовать нашу scheme
, которая разбирается в core типах API, для создания первой интересующей нас утилиты: codec
. Как и любой codec
, созданный нами, кодек будет способен к кодированию и декодированию. runtime.Codec
— псевдоним для другого интерфейса, runtime.Serializer
. Причем последний указывает, что при кодировании и декодировании следует учитывать управление версиями.
// Serializer is the core interface for transforming objects into a serialized format and back.
// Implementations may choose to perform conversion of the object, but no assumptions should be made.
type Serializer interface {
Encoder
Decoder
}
// Codec is a Serializer that deals with the details of versioning objects. It offers the same
// interface as Serializer, so this is a marker to consumers that care about the version of the objects
// they receive.
type Codec Serializer
Наш вызов serializer.NewCodecFactory()
создает набор сериализаторов для каждого из следующих форматов:
Затем мы вызываем LegacyCodec()
для доступа к кодеру и декодеру, которые используют базовую схему и сериализаторы. Отметим, что при задании указателя struct
этот кодек всегда будет кодироваться в JSON. Мы выполняем только Get()
в программе, но можем продемонстрировать кодирование с помощью программы меньшего размера, которая просто отправляет выходные данные в stdout
.
encode.go
package main
import (
"os"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
)
func main() {
scheme := runtime.NewScheme()
v1.AddToScheme(scheme)
if err := serializer.NewCodecFactory(scheme).LegacyCodec(v1.SchemeGroupVersion).Encode(&v1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "hello",
},
TypeMeta: metav1.TypeMeta{
Kind: "ConfigMap",
APIVersion: "v1",
},
}, os.Stdout); err != nil {
panic(err)
}
}
Запуск программы демонстрирует кодировку по умолчанию с использованием сериализатора JSON.
$ go run encode.go
{"kind":"ConfigMap","apiVersion":"v1","metadata":{"name":"hello","creationTimestamp":null}}
Это согласуется с поведением, которое мы наблюдали с APIService
, но не с ConfigMap
. Причина в том, что эти два API обслуживаются разными серверами. В предыдущем посте о проверке пользовательских ресурсов мы описали структуру цепочки серверов API. Первый сервер в цепочке — kube-aggregator
. Он отвечает за обслуживание APIService
. За ним следует kube-apiserver,
за которым следует apiextensions-apiserver
. Однако при построении цепочки делегирования серверы создаются в обратном порядке. При создании ServerRunOptions
, которые доступны конечному пользователю с помощью флагов, тип носителя по умолчанию в параметрах etcd
устанавливается равным application/vnd.kubernetes.protobuf.
Для kube-apiserver
это в конечном итоге передается NewDefaultStorageFactory()
, затем NewStorageCodec()
, который использует его для извлечения сериализатора, соответствующего типу носителя. Результат этого — runtime.Codec
, которая кодирует в protobuf для хранения, поддерживая при этом декодирование из JSON, YAML или protobuf.
mediaType, _, err := mime.ParseMediaType(opts.StorageMediaType)
if err != nil {
return nil, nil, fmt.Errorf("%q is not a valid mime-type", opts.StorageMediaType)
}
supportedMediaTypes := opts.StorageSerializer.SupportedMediaTypes()
serializer, ok := runtime.SerializerInfoForMediaType(supportedMediaTypes, mediaType)
if !ok {
supportedMediaTypeList := make([]string, len(supportedMediaTypes))
for i, mediaType := range supportedMediaTypes {
supportedMediaTypeList[i] = mediaType.MediaType
}
return nil, nil, fmt.Errorf("unable to find serializer for %q, supported media types: %v", mediaType, supportedMediaTypeList)
}
То же самое неверно для kube-aggregator
, который копирует конфигурацию etcd
и создает экземпляр LegacyCodec
, но не учитывает тип носителя по умолчанию. Это значит, что объекты APIService
возвращаются к кодировке по умолчанию в JSON.
Претерпевая трансформацию
По сравнению с кодированием и декодированием преобразователи намного проще. value.Transformer
включает в себя всего два метода: TransformFromStorage()
и TransformToStorage()
.
// Transformer allows a value to be transformed before being read from or written to the underlying store. The methods
// must be able to undo the transformation caused by the other.
type Transformer interface {
// TransformFromStorage may transform the provided data from its underlying storage representation or return an error.
// Stale is true if the object on disk is stale and a write to etcd should be issued, even if the contents of the object
// have not changed.
TransformFromStorage(ctx context.Context, data []byte, dataCtx Context) (out []byte, stale bool, err error)
// TransformToStorage may transform the provided data into the appropriate form in storage or return an error.
TransformToStorage(ctx context.Context, data []byte, dataCtx Context) (out []byte, err error)
}
Каноническим примером считается преобразователь, который выполняет некоторую форму шифрования хранимых данных. Существует множество реализаций , включая поддержку внешних служб управления ключами. Но мы используем identity
трансформер, который ничего не делает при преобразовании в хранилище, а просто проверяет, что данные не зашифрованы при преобразовании из хранилища.
Поиск ConfigMap
Самое время посмотреть на это в действии! Пошаговое выполнение программы в отладчике поможет выявить скрытые сложности. Устанавливаем точку останова в местоположении, в котором мы создаем экземпляр реализации хранилища etcd3. storage.Interface
к созданию нашего codec
.
Мы не передаем никаких мутаторов, поэтому сразу переходим к созданию сериализаторов JSON, YAML и protobuf
для нашей scheme
.
Затем вызывается newCodecFactory()
, которую добавляют все сериализаторы в качестве decoders
и устанавливает сериализатор JSON в качестве legacySerializer
. В конечном итоге он будет использоваться как наш encoder
.
Когда мы вызываем LegacyCodec()
с соответствующей schema.GroupVersion
, то получаем реализацию runtime.Codec.
Установив наш codec
, мы готовы извлечь данные ConfigMap
из etcd
и декодировать их в переданный нами указатель struct
. Вызов Get()
может опустить префикс /registry/
, поскольку мы установили его по умолчанию при создании реализации. Основная часть работы в этом методе выполняется transformer
и codec
, но поскольку transformer
в основном не работает, то нас в первую очередь интересует декодирование.
Можно сказать, что данные, извлеченные из etcd
, находятся в формате protobuf на основе магического числа в начале значения.
"k8s\x00\n\x0f\n\x02v1\x12\tConfigMap\x12\xb6\x01\n\xa3\x01\n\ak8s-asa\x12\x00\x1a\adefault\"\x00*$ebc5e916-fba3-4a27-be4b-f505c12562462\x008\x00B\b\b\x99\xfb\xbb\x9e\x06\x10\x00\x8a\x01V\n\x0ekubectl-create\x12\x06Update\x1a\x02v1\"\b\b\x99\xfb\xbb\x9e\x06\x10\x002\bFieldsV1:\"\n {\"f:data\":{\".\":{},\"f:hello\":{}}}B\x00\x12\x0e\n\x05hello\x12\x05world\x1a\x00\"\x00"
Сериализатор protobuf использует префикс, чтобы определить, можно ли успешно декодировать значение в свое определение Go.
var (
// protoEncodingPrefix serves as a magic number for an encoded protobuf message on this serializer. All
// proto messages serialized by this schema will be preceded by the bytes 0x6b 0x38 0x73, with the fourth
// byte being reserved for the encoding style. The only encoding style defined is 0x00, which means that
// the rest of the byte stream is a message of type k8s.io.kubernetes.pkg.runtime.Unknown (proto2).
//
// See k8s.io/apimachinery/pkg/runtime/generated.proto for details of the runtime.Unknown message.
//
// This encoding scheme is experimental, and is subject to change at any time.
protoEncodingPrefix = []byte{0x6b, 0x38, 0x73, 0x00}
)
Когда мы перебираем декодеры, то пропускаем наши варианты JSON и YAML, прежде чем декодер protobuf распознает данные.
После проверки длины данных мы возвращаемся к использованию нашей runtime.Scheme
(runtime.ObjectTyper
и runtime.ObjectCreater
) для того, чтобы преобразовать данные в наше Go-представление ConfigMap
.
Теперь, когда у нас есть наш объект, распечатываем данные и убеждаемся, что они соответствуют ConfigMap
, которую мы создали вначале с помощью kubectl
.
Примечание: мы должны использовать переадресацию портов etcd
, чтобы иметь возможность успешно подключиться.
$ go run main.go
map[hello:world]
Заключительные мысли
Это была первая часть серии приключений сервера Kubernetes API. Хотя существует много информации о том, как использовать Kubernetes и расширить его, информации о самом коде относительно мало.
Но о Kubernetes можно рассказать куда больше.
Например, на курсе «Kubernetes: Мега» мы говорим о тонкостях установки и конфигурации production-ready кластера («the-not-so-easy-way»), механизмах обеспечения стабильности и безопасности, отказоустойчивости приложений и масштабировании. На курсе вы разберете стратегические задачи, касающиеся инфраструктуры и попробуете всё на практике.
Как мы увидим, многие компоненты в Kubernetes стали иметь тонкие зависимости друг от друга. Утверждение, что какой-либо один компонент — универсальный, указывает на возможность использовать компонент в нескольких областях кодовой базы Kubernetes, но не обязательно за ее пределами.
Основная мотивация этой серии — получение понимания того, где выходят из строя различные части сервера API. etcd
вполне может быть ограничивающим фактором в некоторых случаях использования, которые мы исследуем.