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.Interfaceetcd3. Хотя мы наблюдали корреляцию между операциями, поддерживаемыми 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»), механизмах обеспечения стабильности и безопасности, отказоустойчивости приложений и масштабировании. На курсе вы разберете стратегические задачи, касающиеся инфраструктуры и попробуете всё на практике.

https://slurm.club/40XCFlw

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

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

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