Когда у вас 2 собственных дата-центра, тысячи железных серверов, виртуалки и хостинг для сотен тысяч сайтов, Kubernetes может существенно упростить управление всем этим добром. Как показала практика, с помощью Kubernetes можно декларативно описывать и управлять не только приложениями, но и самой инфраструктурой. Я работаю в крупнейшем чешском хостинг-провайдере WEDOS Internet a.s и сегодня расскажу о двух своих проектах — Kubernetes-in-Kubernetes и Kubefarm.

С их помощью можно буквально за пару команд, используя Helm, развернуть полностью рабочий Kubernetes внутри другого Kubernetes-кластера. Как и зачем? Добро пожаловать под кат


Расскажу, как устроена наша инфраструктура. Все наши сервера можно разделить на две группы: control-plane и compute nodes. Control plane ноды, как правило, установлены вручную, имеют стабильную ОС и предназначены для запуска кластерных служб, в том числе и мастеров Kubernetes. Задача этих нод — обеспечивать бесперебойную работу самого кластера. Compute ноды не имеют никакой установленной операционки, а грузятся по сети прямо с control-plane нод. Их задача — выполнять полезную нагрузку.

На control-plane нодах задеплоены также PXE- и DHCP-серверы. Когда мы включаем наши compute-ноды, то первое, что они делают — обращаются к DHCP-серверу. Он, в свою очередь отвечает каждой ноде, условно говоря: «У тебя такой-то IP, грузись с такого-то PXE-сервера». После чего ноды скачивают образ системы и сохраняют его прямо в оперативную память, после чего продолжают загрузку непосредственно с него. 

Как только они загрузили этот образ, они могут продолжать работать и без связи с PXE-сервером. То есть PXE-сервер — это такая болванка, которая отдаёт образ и не содержит никакой более сложной логики. После того как наши ноды загрузились, мы можем спокойно перезагружать PXE-сервер, ничего критичного с ними уже не произойдет.

После загрузки системы первое, что делают наши ноды — это присоединяются к существующему Kubernetes-кластеру, а именно выполняют команду kubeadm join. Изначально они джойнились в тот же кластер, который использовался и для control-plane нод. После чего kube-scheduler мог шедуллить на них какие-нибудь поды и запускать различную рабочую нагрузку. 

Эта схема стабильно работала у нас более двух лет. Позже мы решили добавить в неё контейнизированный Kubernetes. И теперь мы можем спавнить разные кластера на наших control-plane нодах (теперь они все находятся в специально отведенном admin-кластере). А compute-ноды джойнятся непосредственно каждая в свой кластер — в зависимости от её конфигурации. 

Kubefarm

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

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

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

Так у нас получился проект Kubefarm. В качестве основы он использует Kubernetes-in-Kubernetes, LTSP — тот самый PXE-сервер, с которого грузятся ноды, и автоматизирует конфигурацию DHCP-сервера с помощью dnsmasq-controller:

Как это работает

Теперь посмотрим на то, как это работает. Вообще, если посмотреть на Kubernetes как на приложение — можно отметить что он соблюдает все принципы The Twelve-Factor App, и на самом деле написан довольно грамотно. Что означает — запустить его как приложение в другом Kubernetes не составляет особой проблемы.

Запуск Kubernetes в Kubernetes

Давайте посмотрим на те параметры, которые мы передаем в Helm в values-файл:

kubernetes/values.yaml
controlPlaneEndpoint:

persistence:
  enabled: true
  accessModes:
    - ReadWriteOnce
  size: 1Gi
  # storageClassName: default
  annotations: {}
  finalizers:
    - kubernetes.io/pvc-protection

  backup:
    # existingClaim: your-claim
    # subPath: backups
    accessModes:
      - ReadWriteOnce
    size: 1Gi
    # storageClassName: default
    annotations: {}
    finalizers:
      - kubernetes.io/pvc-protection

etcd:
  enabled: true
  image:
    repository: k8s.gcr.io/etcd
    tag: 3.4.13-0
    pullPolicy: IfNotPresent
    pullSecrets: []
  replicaCount: 3
  resources:
    requests:
      cpu: 100m
      memory: 128Mi
    # limits:
    #   cpu: 100m
    #   memory: 128Mi

  certSANs:
    dnsNames: []
    ipAddresses: []

  extraArgs: {}
    # advertise-address is required for kube-proxy
    #advertise-address: 10.9.8.10
  labels: {}
  annotations: {}
  podLabels: {}
  podAnnotations: {}
  nodeSelector: {}
  tolerations: []
  podAntiAffinity: soft
  podAntiAffinityTopologyKey: kubernetes.io/hostname
  affinity: {}
  extraEnv: []
  sidecars: []
  extraVolumes: []
  extraVolumeMounts: []

  ports:
    client: 2379
    peer: 2380
    metrics: 2381
  service:
    enabled: true
    type: ClusterIP
    ports:
      client: 2379
      peer: 2380
      metrics: 2381
    labels: {}
    annotations: {}
    loadBalancerIP:

  backup:
    enabled: false
    schedule: "0 */12 * * *"
    successfulJobsHistoryLimit: 3
    failedJobsHistoryLimit: 3
    extraArgs: #{}
      debug: true
    resources:
      requests:
        cpu: 100m
        memory: 128Mi
      # limits:
      #   cpu: 100m
      #   memory: 128Mi

    labels: {}
    annotations: {}
    podLabels: {}
    podAnnotations: {}
    nodeSelector: {}
    tolerations: []
    podAffinity: soft
    podAffinityTopologyKey: kubernetes.io/hostname
    affinity: {}
    extraEnv: []
    sidecars: []
    extraVolumes: []
    extraVolumeMounts: []

apiServer:
  enabled: true
  image:
    repository: k8s.gcr.io/kube-apiserver
    tag: v1.20.5
    pullPolicy: IfNotPresent
    pullSecrets: []
  replicaCount: 2
  resources:
    requests:
      cpu: 100m
      memory: 128Mi
    # limits:
    #   cpu: 100m
    #   memory: 128Mi

  certSANs:
    dnsNames: []
    ipAddresses: []

  serviceClusterIPRange: 10.96.0.0/12

  extraArgs: {}
  labels: {}
  annotations: {}
  podLabels: {}
  podAnnotations: {}
  nodeSelector: {}
  tolerations: []
  podAntiAffinity: soft
  podAntiAffinityTopologyKey: kubernetes.io/hostname
  affinity: {}
  extraEnv: []
  sidecars: []
  extraVolumes: []
  extraVolumeMounts: []


  port: 6443
  service:
    enabled: true
    type: ClusterIP # NodePort / LoadBalancer
    port: 6443
    # Specify nodePort for apiserver service (30000-32767)
    nodePort:
    labels: {}
    annotations: {}
    loadBalancerIP:

controllerManager:
  enabled: true
  image:
    repository: k8s.gcr.io/kube-controller-manager
    tag: v1.20.5
    pullPolicy: IfNotPresent
    pullSecrets: []
  replicaCount: 2
  resources:
    requests:
      cpu: 100m
      memory: 128Mi
    # limits:
    #   cpu: 100m
    #   memory: 128Mi

  extraArgs: {}
  labels: {}
  annotations: {}
  podLabels: {}
  podAnnotations: {}
  nodeSelector: {}
  tolerations: []
  podAntiAffinity: soft
  podAntiAffinityTopologyKey: kubernetes.io/hostname
  affinity: {}
  extraEnv: []
  sidecars: []
  extraVolumes: []
  extraVolumeMounts: []

  port: 10257
  service:
    enabled: true
    type: ClusterIP
    port: 10257
    labels: {}
    annotations: {}
    loadBalancerIP:

scheduler:
  enabled: true
  image:
    repository: k8s.gcr.io/kube-scheduler
    tag: v1.20.5
    pullPolicy: IfNotPresent
    pullSecrets: []
  replicaCount: 2
  resources:
    requests:
      cpu: 100m
      memory: 128Mi
    # limits:
    #   cpu: 100m
    #   memory: 128Mi

  extraArgs: {}
  labels: {}
  annotations: {}
  podLabels: {}
  podAnnotations: {}
  nodeSelector: {}
  tolerations: []
  podAntiAffinity: soft
  podAntiAffinityTopologyKey: kubernetes.io/hostname
  affinity: {}
  extraEnv: []
  sidecars: []
  extraVolumes: []
  extraVolumeMounts: []

  port: 10259
  service:
    enabled: true
    type: ClusterIP
    port: 10259
    labels: {}
    annotations: {}
    loadBalancerIP:

admin:
  enabled: true
  image:
    repository: ghcr.io/kvaps/kubernetes-tools
    tag: v0.9.2
    pullPolicy: IfNotPresent
    pullSecrets: []
  replicaCount: 1
  resources:
    requests:
      cpu: 100m
      memory: 128Mi
    # limits:
    #   cpu: 100m
    #   memory: 128Mi

  job:
    enabled: true
    schedule: "0 0 1 */6 *"
    successfulJobsHistoryLimit: 3
    failedJobsHistoryLimit: 3

  labels: {}
  annotations: {}
  podLabels: {}
  podAnnotations: {}
  nodeSelector: {}
  tolerations: []
  podAntiAffinity: soft
  podAntiAffinityTopologyKey: kubernetes.io/hostname
  affinity: {}
  extraEnv: []
  sidecars: []
  extraVolumes: []
  extraVolumeMounts: []

kubeProxy:
  enabled: true

coredns:
  enabled: true

konnectivityServer:
  enabled: false
  image:
    repository: us.gcr.io/k8s-artifacts-prod/kas-network-proxy/proxy-server
    tag: v0.0.14
    pullPolicy: IfNotPresent
    pullSecrets: []
  replicaCount: 2
  resources:
    requests:
      cpu: 100m
      memory: 128Mi
    # limits:
    #   cpu: 100m
    #   memory: 128Mi

  extraArgs: {}
  labels: {}
  annotations: {}
  podLabels: {}
  podAnnotations: {}
  nodeSelector: {}
  tolerations: []
  podAntiAffinity: soft
  podAntiAffinityTopologyKey: kubernetes.io/hostname
  affinity: {}
  extraEnv: []
  sidecars: []
  extraVolumes: []
  extraVolumeMounts: []

  ports:
    server: 8131
    agent: 8132
    admin: 8133
    health: 8134
  service:
    enabled: true
    type: ClusterIP
    ports:
      server: 8131
      agent: 8132
      admin: 8133
    nodePorts:
      server:
      agent:
      admin:
    labels: {}
    annotations: {}
    loadBalancerIP:

konnectivityAgent:
  enabled: false
  image:
    repository: us.gcr.io/k8s-artifacts-prod/kas-network-proxy/proxy-agent
    tag: v0.0.14
    pullPolicy: IfNotPresent
    pullSecrets: []
  replicaCount: 2
  hostNetwork: true

  extraArgs: {}
  labels: {}
  annotations: {}
  podLabels: {}
  podAnnotations: {}
  nodeSelector: {}
  tolerations: []
  podAntiAffinity: soft
  podAntiAffinityTopologyKey: kubernetes.io/hostname
  affinity: {}
  extraEnv: []
  sidecars: []
  extraVolumes: []
  extraVolumeMounts: []

  ports:
    admin: 8133
    health: 8134

# these manifests will be applied inside the cluster
extraManifests: {}
  #namespace.yaml:
  #  apiVersion: v1
  #  kind: Namespace
  #  metadata:
  #    name: example

Помимо persistence (настроек хранения данных кластера) тут описаны компоненты нашего control-plane: а именно: etcd-кластер, apiserver, controller-manager и scheduler. Это в общем-то стандартные компоненты Kubernetes. Все мы знаем шутку, что Kubernetes — это всего 5 бинарей. Так вот здесь описывается конфигурация для этих самых бинарей. 

Если вы когда-то уже устанавливали кластер с помощью kubeadm, то этот конфиг вам его очень напомнит. Но помимо сущностей Kubernetes у нас есть еще admin-контейнер. По сути это контейнер, внутри которого находятся два бинарника: kubectl и kubeadm. Используются они для того, чтобы сгенерировать kubeconfig’и для вышеперечисленных компонентов и произвести начальную настройку кластера. Также, в случае чего, к нему всегда можно подключиться и посмотреть, что происходит внутри кластера.

После того как релиз задеплоился, мы увидим список подов: admin-контейнер, apiserver в двух репликах, controller-manager, etcd-кластер, scheduller и та самая начальная джоба, которая инициализирует наш кластер. А в ответ мы получаем команду, выполнив которую сможем попасть в admin-контейнер и посмотреть, что там происходит:

Давайте ещё взглянем на сертификаты. Если вы когда-либо устанавливали Kubernetes, то вы знаете, что у него есть страшная папочка /etc/kubernetes/pki с кучей непонятных сертификатов. Но в нашем случае мы полностью автоматизировали управление ими. Достаточно передать в Helm, какие нам нужны сертификаты, и cert-manager автоматически сгенерирует их для нашего кластера.

Посмотрев на один из сертификатов, например для apiserver, можно увидеть что внутри него имеется список DNS-имен и IP-адресов. Если вы в дальнейшем захотите сделать этот кластер доступным извне, то просто опишите дополнительные DNS-имена в values-файле и обновите релиз — это обновит ресурс сертификата, а cert-manager его перевыпустит — и вам больше не придется думать об этом. Если в kubeadm сертификаты нужно обновлять не реже чем раз в год, то здесь cert-manager сам обновляет их автоматически по мере необходимости.

Теперь давайте залогинимся в admin-контейнер и посмотрим на наш кластер и ноды. Нод, конечно, пока нет, потому что на данный момент мы задеплоили только control-plane для Kubernetes. Но в kube-system уже появились пока никуда не зашедулленные coredns-поды и конфигмапы — то есть делаем вывод, что наш кластер работает:

Вот так выглядит схема задеплоеного кластера. Вы можете увидеть сервисы для всех компонентов Kubernetes: apiserver, controller-manager, etcd-кластер и scheduler. А справа — поды, в которые они ведут. Схема, кстати, нарисована в ArgoCD — GitOps-инструмент, который мы используем для управления кластерами, и классные схемы — одна из его фишек: 

Оркестрация физических серверов

ОК, теперь у нас есть control-plane Kubernetes, но что насчёт worker-нод, как мы будем их добавлять? Как я уже говорил все сервера у нас bare metal — мы не используем виртуализацию для запуска Kubernetes, а оркестрируем все физические сервера самостоятельно.

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

Для этого и был создан проект Kubefarm, который позволяет автоматизировать это. Наиболее часто используемые примеры вы сможете найти в директории examples. Самый стандартный из них — generic. Посмотрим на values.yaml:

generic/values.yaml
# ------------------------------------------------------------------------------
# Kubernetes control-plane
# ------------------------------------------------------------------------------
kubernetes:
  apiServer:
    certSANs:
      ipAddresses:
      - 10.28.36.72
      #dnsNames:
      #- generic-cluster.example.org
    extraArgs:
      advertise-address: 10.28.36.72

# ------------------------------------------------------------------------------
# Network boot server configuration
# ------------------------------------------------------------------------------
ltsp:

  config:

    # from /usr/share/zoneinfo/<timezone> (eg. Europe/Moscow)
    timezone: Europe/Prague

    # SSH-keys authorized to access the nodes
    sshAuthorizedKeys:
      - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDSdizJARrlcOWVYswKPYbQ3FMa2eLJeE1utgUH7sDcOCsbpQh+Pu09biGkvO3QYGmeRXf64rQDx8adSf1Y/AutVJ6b044GEYoGWrenAPnt2VKyu7IrPvLr+0QLTuPsUPhKQToxs84j7eJ+Yzaro6etM2aPMlCuqHv9ZWFda198YQT3xr8ceAk6+Ni/q5vw5FDkmKtGVNl2UXHKovFxnGOkwYaPUlizTnj4WpK8e4FUQ95p75IQulGWGkL5RTbABhyFDuiVlGlW71qit79EaZDO4WA1tkdXYIQauIHQ073/ogI3YBlkD1QDsLXobjDHDaD3XMXK7lePudUkhiUng225 id_rsa

    # Hashed password for root, use `openssl passwd -1` to generate one
    rootPasswd: $1$jaKnTiEb$IhpsNUfssXQ8eQg8orald0 # hackme

    # Modules to load during startup
    modules:
      - br_netfilter
      - ip_vs
      - ip_vs_rr
      - ip_vs_wrr
      - ip_vs_sh

    # Extra options for ltsp.conf
    options:
      DEBUG_SHELL: 1
      MENU_TIMEOUT: 0
      KERNEL_PARAMETERS: "forcepae console=tty1 console=ttyS0,9600n8"

# ------------------------------------------------------------------------------
# Nodes configuration
# ------------------------------------------------------------------------------
nodePools:
  -
    # DHCP range for the node pool, required for issuing leases.
    # See --dhcp-range option syntax on dnsmasq-man page.
    # Note: the range will automatically be appended with the set:{{ .Release.Name }}-ltsp option.
    #
    # WARNING setting broadcast-address is required! (see: https://www.mail-archive.com/dnsmasq-discuss@lists.thekelleys.org.uk/msg14137.html)
    range: 10.28.0.0,static,255.255.0.0,10.28.0.0,infinite

    # DHCP configuration for each node
    nodes:
      - name: m1c29
        mac: 94:57:a5:d3:ec:f2,94:57:a5:d3:ec:f3
        ip: 10.28.36.173
      - name: m1c31
        mac: 94:57:a5:d3:ef:92,94:57:a5:d3:ef:93
        ip: 10.28.36.174
      - name: m1c35
        mac: 94:57:a5:d3:d9:de,94:57:a5:d3:d9:df
        ip: 10.28.36.175
      - name: m1c37
        mac: 94:57:a5:d3:ed:ee,94:57:a5:d3:ed:ef
        ip: 10.28.36.176
      - name: m1c41
        mac: 94:57:a5:d3:f3:9e,94:57:a5:d3:f3:9f
        ip: 10.28.36.177

  - nodes:
      - name: m1c43
        mac: 94:57:a5:d3:ee:5a,94:57:a5:d3:ee:5b
        ip: 10.28.36.178
    tags:
      - debug
      - foo

# ------------------------------------------------------------------------------
# Extra options can be specified for each tag
# ("all" options are aplicable for any node)
# ------------------------------------------------------------------------------
tags:
  dhcpOptions:
    # dnsmasq options
    # see all available options list (https://git.io/JJ0dH)
    all:
      router: 	10.28.0.1
      dns-server: 10.28.0.1

  ltspOptions:
    debug:
      DEBUG_SHELL: "1"

  kubernetesLabels:
    all: {}
    foo:
      label1: value1
      label2: value2

  kubernetesTaints:
    all: {}
    foo:
      - effect: NoSchedule
        key: foo
        value: bar

Здесь мы указываем параметры, которые прокидываются в вышестоящий чарт Kubernetes-in-Kubernetes. Для того чтобы наш control-plane был доступен снаружи здесь достаточно указать IP-адрес, но при желании можно указать и какое-нибудь DNS-имя.

В конфигурации для PXE-сервера указываем какую-либо timezone. Можно еще добавить SSH-ключ для входа без пароля (но можно указать и пароль), а также модули и параметры ядра, которые должны использоваться при загрузке системы.

Дальше идет конфигурация nodePools, т.е. самих нод. Если вы когда-то пользовались terraform-модулем для gke, то эта логика вам его напомнит. Здесь мы статически описываем все ноды набором параметров: 

  • Имя (host name);

  • MAC-адрес — у нас ноды с двумя сетевыми карточками, и каждая может загрузиться с любого из указанных здесь MAC-адресов; 

  • IP-адрес, который DHCP-сервер должен выдать этой ноде.

В данном примере у нас два пула: в первом — пять нод, во втором — всего одна, но к нему добавлены еще два тэга конфигурации. Тэги — это способ описать конфигурацию для конкретных нод. Например, мы можем на какие-то пулы вешать отдельные DHCP-опции, опции для PXE-сервера для загрузки (здесь мы просто включаем debug) и пару опций KubernetesLabels и KubernetesTaints. Что это значит?

Например, у нас есть nodePool с одной нодой, к которой добавлены тэги debug и foo. Смотрим KubernetesLabels, тэг foo. Это значит, что нода m1c43 загрузится с этими двумя установленными labels и с определенным taint. Вроде все просто. Теперь давайте сделаем это на практике.

Демо

Переходим в examples и выполняем обновление нашего предыдущего задеплоенного чарта до Kubefarm. Устанавливаем из параметров generic и смотрим на поды. Видим, что у нас добавился PXE-сервер и одна джоба, которая по сути идет в Kubernetes и создаёт новый токен. Теперь она будет запускаться каждые 12 часов и генерировать новый токен для того, чтобы ноды могли подключиться в наш кластер.

Графически это выглядит примерно также, только теперь у нас apiserver стал смотреть наружу. 

На схеме зеленым выделен IP, по которому стал доступен наш PXE-сервер. На данный момент Kubernetes по умолчанию не позволяет создавать единый LoadBalancer-сервис для TCP- и UDP-протоколов, поэтому приходится создавать два разных сервиса, но с одним IP-адресом. Один используется для TFTP, а второй для HTTP, по которым, собственно, и скачивается образ.

Но этого не всегда бывает достаточно, и мы можем модифицировать логику при загрузке. Для примера есть директория advanced_network, внутри которого есть values-файл с простеньким shell-скриптом. У нас он называется network.sh.

network.sh
# ------------------------------------------------------------------------------
# Network boot server configuration
# ------------------------------------------------------------------------------
ltsp:

  config:

    # Extra options for ltsp.conf
    options:
      POST_INIT_NETWORKING: ". /etc/ltsp/network.sh"

    extraFiles:
      network.sh: |
        mask2cidr() {
          nbits=0
          IFS=.
          for dec in $1 ; do
            case $dec in
              255) nbits=$((nbits+8));;
              254) nbits=$((nbits+7));;
              252) nbits=$((nbits+6));;
              248) nbits=$((nbits+5));;
              240) nbits=$((nbits+4));;
              224) nbits=$((nbits+3));;
              192) nbits=$((nbits+2));;
              128) nbits=$((nbits+1));;
              0);;
              *) echo "Error: $dec is not recognised"; exit 1
            esac
          done
          echo "$nbits"
        }
        set -e
        # Load additional parameters
        IPCONFIG_IPV4CIDR=$(mask2cidr $IPCONFIG_IPV4NETMASK)
        DATA_IPV4ADDR=$(echo ${IPCONFIG_IPV4ADDR} | sed 's/^10\.28\./10.29./')
        # Remove on-boot config
        rm -rf /run/netplan
        # Write new netplan config
        mkdir -p /etc/netplan
        cat >/etc/netplan/00-config.yaml <<EOT
        network:
          version: 2
          renderer: networkd
          ethernets:
            eno1:
              mtu: 9000
              dhcp4: no
              optional: true
            eno1d1:
              mtu: 9000
              dhcp4: no
              optional: true
          bonds:
            bond0:
              mtu: 9000
              dhcp4: no
              macaddress: ${MAC_ADDRESS}
              interfaces: [eno1, eno1d1]
              parameters:
                mode: 802.3ad
                mii-monitor-interval: 100
                down-delay: 200
                up-delay: 200
                lacp-rate: fast
                transmit-hash-policy: layer3+4
                ad-select: bandwidth
              addresses: [${IPCONFIG_IPV4ADDR}/${IPCONFIG_IPV4CIDR}]
              gateway4: ${IPCONFIG_IPV4GATEWAY}
          vlans:
            bond0.2:
              addresses: [${DATA_IPV4ADDR}/${IPCONFIG_IPV4CIDR}]
              id: 2
              link: bond0
        EOT

Все, что этот скрипт делает — берет переменные окружения во время загрузки, и исходя из них, генерирует конфигурацию для сети: создает директорию и кладет туда конфиг netplan. Например, тут создается bonding-интерфейс. В принципе, в этом скрипте может быть абсолютно все что угодно — от конфигурации сети до генерации системных сервисов и описания любой логики. Все, что можно описать в bash или в shell, можно положить сюда, и оно будет исполнено в момент загрузки.

Посмотрим, как это можно задеплоить. Первым параметром мы передаём generic values-файл, а вторым параметром дополнительный values-файл. Для Helm — это стандартная возможность. Так можно, например, передавать секреты, но в нашем случае происходит расширение конфигурации:

Смотрим configmap foo-kubernetes-ltsp для нашего netboot-сервера и видим, что здесь находится наш скрипт network.sh и те самые команды использующиеся для конфигурации сети во время загрузки:

Здесь вы можете увидеть, как это работает в принципе. В интерфейсе шасси, где у нас находятся ноды (мы используем HPE Moonshots 1500), можно ввести команду show node list и увидеть список всех нод. Сейчас мы и будем их загружать.

Здесь также можно посмотреть их MAC-адреса — show node macaddr all. У нас есть хитрый оператор, который делает это автоматически. Эти адреса мы используем для конфигурации DHCP, копируя их в виде ресурсов для dnsmasq-controller в Kubernetes. И отсюда же мы можем управлять самими нодами, включать и выключать их.

Если у вас нет такой же возможности, как у нас, чтобы зайти на шасси через iLO и собрать список MAC-адресов для всех нод, вы можете использовать паттерн с catchall-кластером — в нашем случае это просто кластер с динамическим DHCP-пулом. Таким образом все ноды, не описанные в конфигурации к другим кластерам, будут автоматически подключаться в этот кластер.

Например, здесь у нас уже есть какие-то ноды. Они добавляются в кластер с автоматически сгенерированным именем на основе MAC-адреса. Мы можем подключиться к ним и посмотреть, что там происходит. Здесь их можно как-то подготавливать, например нарезать файловую систему и после этого переподключать к другому кластеру.

После подключения к ноде посмотрим, как происходит загрузка. После загрузки BIOS происходит конфигурация сетевой карты, здесь она с такого-то MAC-адреса отправляет запрос к DHCP-серверу, а тот ее отправляет на определенный PXE-сервер. По стандартному HTTP-протоколу ей отдаются ядро и initrd-образ:

После загрузки ядра нода скачивает initramfs-образ и передает управление systemd. Дальше загрузка идет как обычно, а сама нода присоединяется к Kubernetes:

Если посмотреть на fstab, то можно увидеть всего две записи: /var/lib/docker и /var/lib/kubelet, они смонтированы как tmpfs (по сути — из оперативной памяти). При этом корень у нас смонтирован как overlayfs, поэтому все изменения, которые вы сделаете здесь в системе, при следующей перезагрузке будут потеряны. 

Из блочных устройств на ноде есть один nvme-диск, но он пока что никуда не смонтирован. Есть также loop device — это тот самый initramfs-образ загруженный с сервера. На данный момент он лежит в оперативной памяти, занимает 653 Мб и смонтирован с опцией loop.

Если посмотрим в /etc/ltsp, мы найдем наш файл network.sh, который был выполнен при загрузке. Из контейнеров у нас запустились kube-proxy, а также pause-контейнер для него.

Детали

Образ для загрузки по сети

Но откуда же берется основной образ? Здесь есть небольшая хитрость — образ для нод собирается через Dockerfile вместе с сервером. Возможность Docker multi-stage build позволяет легко добавлять любые пакеты и модули ядра именно на стадии сборки образа. Выглядит это так:

Dockerfile
#-------------------------------------------------------------------------------
# Base part used for ltsp-server and ltsp-client
#-------------------------------------------------------------------------------

FROM ubuntu:20.04 as ltsp

ENV VERSION=v0.10.2
ENV DEBIAN_FRONTEND=noninteractive

# Install updates and LTSP package
RUN apt-get -y update  && apt-get -y upgrade  && apt-get -y install       curl  && apt-get clean

RUN printf '%s\n'       'deb http://ppa.launchpad.net/ltsp/ppa/ubuntu focal main'       'deb http://ppa.launchpad.net/ltsp/proposed/ubuntu focal main'       > /etc/apt/sources.list.d/ltsp.list  && curl -L https://ltsp.org/misc/ltsp_ubuntu_ppa.gpg       -o /etc/apt/trusted.gpg.d/ltsp_ubuntu_ppa.gpg

RUN apt-get -y update  && apt-get -y install       ltsp-cloud  && apt-get clean

#-------------------------------------------------------------------------------
# Installing Kernel and basic software
#-------------------------------------------------------------------------------

FROM ltsp as rootfs-pre

# Install packages
RUN echo 'APT::Install-Recommends "0";\nAPT::Install-Suggests "0";'       >> /etc/apt/apt.conf.d/01norecommend  && mkdir -p /var/lib/resolvconf  && touch /var/lib/resolvconf/linkified  && apt-get update  && apt-get -y install       adduser       apparmor-utils       apt-transport-https       arping       bash-completion       bridge-utils       ca-certificates       curl       dbus-user-session       gnupg       gpg-agent       htop       ifenslave       initramfs-tools       ipset       ipvsadm       jnettop       jq       linux-image-generic       lm-sensors       lvm2       openssh-server       nano       net-tools       nfs-common       pciutils       resolvconf       rsync       screen       squashfs-tools       ssh       sysstat       systemd       sudo       tcpdump       telnet       thin-provisioning-tools       ubuntu-minimal       vim       vlan       wget       zfsutils-linux  && apt-get clean  && rm -rf /var/lib/apt/lists/*

# Disable systemd-resolved
RUN systemctl disable systemd-resolved.service  && systemctl mask systemd-resolved.service

# Install docker
ARG DOCKER_VERSION=20.10
RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add -  && echo "deb https://download.docker.com/linux/ubuntu focal stable"       > /etc/apt/sources.list.d/docker.list  && apt-get update  && DOCKER_VERSION=$(apt-cache madison docker-ce | awk '{print $3}' | grep -m1 "$DOCKER_VERSION")  && apt-get -y install docker-ce="$DOCKER_VERSION"  && apt-mark hold docker-ce

# Install kubeadm, kubelet and kubectl
# https://kubernetes.io/docs/setup/independent/install-kubeadm/#installing-kubeadm-kubelet-and-kubectl
ARG KUBE_VERSION=1.20
RUN curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add -  && echo "deb http://apt.kubernetes.io/ kubernetes-xenial main"       > /etc/apt/sources.list.d/kubernetes.list  && apt-get update  && KUBE_VERSION=$(apt-cache madison kubelet | awk '{print $3}' | grep -m1 "$KUBE_VERSION")  && apt-get -y install kubelet=$KUBE_VERSION kubeadm=$KUBE_VERSION kubectl=$KUBE_VERSION cri-tools  && apt-mark hold kubelet kubeadm kubectl

# Disable automatic updates
RUN rm -f /etc/apt/apt.conf.d/20auto-upgrades

# Disable apparmor profiles
RUN find /etc/apparmor.d       -maxdepth 1       -type f       -name "sbin.*"       -o -name "usr.*"       -exec ln -sf "{}" /etc/apparmor.d/disable/ \;

# Setup locales
RUN printf '%s\n'       'LANG=en_US.UTF-8'       'LC_TIME=en_DK.UTF-8'       'LC_CTYPE=en_US.UTF-8'       > /etc/locale.conf  && locale-gen en_US.UTF-8 en_DK.UTF-8

#-------------------------------------------------------------------------------
# Build kernel modules
#-------------------------------------------------------------------------------

FROM rootfs-pre as modules

# Install kernel-headers and dkms
RUN apt-get update  && KERNEL_VERSION="$(ls -1 /lib/modules/ | tail -n1)"  && apt-get -y install "linux-headers-${KERNEL_VERSION}" dkms  && apt-get clean

# Install DRBD modules
RUN apt-key adv --keyserver keyserver.ubuntu.com --recv-keys CC1B5A793C04BB3905AD837734893610CEAA9512  && echo "deb http://ppa.launchpad.net/linbit/linbit-drbd9-stack/ubuntu focal main"       > /etc/apt/sources.list.d/linbit.list  && apt-get update  && apt-get -y install drbd-dkms

#-------------------------------------------------------------------------------
# Build rootfs image
#-------------------------------------------------------------------------------

FROM rootfs-pre as rootfs

# Copy kernel modules
COPY --from=modules /lib/modules/ /lib/modules/

# Generate initramfs with new modules
RUN update-initramfs -u

# Generate motd
COPY motd /etc/motd
RUN sed -i "s/\${VERSION}/${VERSION}/" /etc/motd

# Generate image
ENV OMIT_FUNCTIONS="remove_users"
RUN ltsp image -I /

#-------------------------------------------------------------------------------
# Build dnsmasq with tftp single port support
#-------------------------------------------------------------------------------

FROM ltsp as builder

# Common build-dependencies
RUN apt-get -y update  && apt-get -y install       git       build-essential  && apt-get clean  && rm -rf /var/lib/apt/lists/* 
# Build dnsmasq
ARG DNSMAQ_VERSION=2.81-12-g619000a
RUN git clone git://thekelleys.org.uk/dnsmasq.git  && cd dnsmasq/  && git checkout ${DNSMAQ_VERSION}  && make

#-------------------------------------------------------------------------------
# LTSP-Server
#-------------------------------------------------------------------------------

FROM ltsp

RUN apt-get -y update  && apt-get -y install       grub-pc-bin       grub-efi-amd64-bin       inotify-tools       nginx  && apt-get clean  && rm -rf /var/lib/apt/lists/*

# Generate nginx config
RUN ltsp http -I

COPY --from=builder /dnsmasq/src/dnsmasq /usr/sbin/dnsmasq
COPY --from=rootfs /srv/ltsp/images /srv/ltsp/images
COPY --from=rootfs /srv/tftp/ltsp /srv/tftp/ltsp

Что здесь происходит? Во-первых, мы берем обычную Ubuntu 20.04 и устанавливаем туда все необходимые нам пакеты. В первую очередь — ядро, lvm, systemd, SSH — в общем, всё то, что вы хотите увидеть на окончательной ноде. Для этого есть отдельный stage. Здесь мы также устанавливаем Docker с Kubernetes kubelet и kubeadm, которые используются для джойна ноды к кластеру.

А дальше производим дополнительную настройку. В последнем stage мы просто устанавливаем туда tftp с nginx (которые отдают наш образ клиентам), grub (наш загрузчик), и в этот же образ копируем корень предыдущих stages. То есть по сути у нас получается образ, внутри которого находится как сервер, так и загрузочный образ для наших нод. В тоже время, изменяя Dockerfile, его можно легко обновить.

Вебхуки и API aggregation layerapiserver

Отдельное внимание хочу уделить проблеме вебхуков и aggregation layer. Вообще вебхуки — это возможность Kubernetes, которая позволяет реагировать на создание или изменение каких-либо ресурсов. То есть можно повесить обработчик, чтобы при применении ресурсов Kubernetes ходил к какому-нибудь поду и проверял, верна ли его конфигурация, или вносил бы в него дополнительные изменения. 

Но дело в том, что для работы вебхуков apiserver должен иметь доступ непосредственно к кластеру, в которым он работает. А если он запущен в отдельном кластере, как у нас, или вообще отдельно от кластера, то здесь нам может помочь Konnectivity. Konnectivity — это один из необязательных, но официально поддерживаемых компонентов Kubernetes.

Возьмем для примера 4 ноды, на каждой из которых запущен kubelet и другие компоненты Kubernetes: apiserver, scheduller и controller-manager. По умолчанию все эти компоненты ходят и взаимодействуют с apiserver’ом напрямую — это наиболее понятная часть логики работы Kubernetes. Но на самом деле есть ещё и обратный режим. Например, иногда, когда вы хотите посмотреть логи какого-то пода или выполнить kubectl exec, то apiserver самостоятельно устанавливает соединение с каким-либо из kubelet’ов:

Но проблема в том, что если у нас есть вебхук, то он, как правило, запущен в виде стандартного пода с сервисом в нашем кластере. И когда apiserver попробует к нему достучаться, то у него ничего не получится — потому что он будет пытаться обратиться к in-cluster сервису по имени webhook.namespace.svc:

И здесь нам на помощь приходит Konnectivity — хитрый прокси-сервер созданный специально для Kubernetes. Он деплоится в виде сервера рядом с apiserver. А Konnectivity-agent деплоится уже непосредственно в кластере, в который вы хотите ходить, также в нескольких репликах. Агент устанавливает подключение к серверу и позволяет apiserver наладить стабильный канал, чтобы тот смог ходить через него и имел возможность обращаться со всеми вебхуками и всеми kubelet’ам в кластере. Таким образом теперь всё общение с кластером будет происходить через Konnectivity-server:

Наши планы

Конечно, на этом этапе мы останавливаться не собираемся. Мне достаточно часто пишут заинтересованные в проекте люди. И если наберется критическая масса, я надеюсь переместить Kubernetes-in-Kubernetes под крыло Kubernetes-SIGs, представив его в виде официального Kubernetes Helm-чарта. И, возможно, так мы соберем ещё большее комьюнити. 

Также я подумываю сделать интеграцию с Machine Controller Manager, что позволило бы создавать worker’ы, не только на физических серверах, а например создавать виртуалки используя kubevirt и запускать их в том же Kubernetes-кластере. Им кстати, можно также создавать виртуалки в облаках, имея control-plane, задеплоенный у себя локально. 

Также я рассматриваю вариант интеграции с Cluster-API, чтобы можно было создавать физические кластера Kubefarm прямо через окружение Kubernetes. Но на данный момент я не до конца уверен в этой идее. Если у вас есть мысли на этот счет, с радостью их выслушаю.

В этом году DevOpsConf пройдёт в гибридном формате. Это значит, что и онлайн- и офлайн-участники смогут общаться с партнёрами конференции, задавать вопросы спикерам и участвовать в активностях в зоне выставки.

Бронируйте билеты и присоединяйтесь к сообществу фанатов DevOps-подхода! Расписание здесь.