Когда у вас 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
Здесь мы рассмотрим проект Kubernetes-in-Kubernetes, который предоставляет готовый Helm-чарт для запуска 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-образ:
После загрузки ядра нода скачивает rootfs-образ и передает управление systemd. Дальше загрузка идет как обычно, а сама нода присоединяется к Kubernetes:
Если посмотреть на fstab, то можно увидеть всего две записи: /var/lib/docker и /var/lib/kubelet, они смонтированы как tmpfs (по сути — из оперативной памяти). При этом корень у нас смонтирован как overlayfs, поэтому все изменения, которые вы сделаете здесь в системе, при следующей перезагрузке будут потеряны.
Из блочных устройств на ноде есть один nvme-диск, но он пока что никуда не смонтирован. Есть также loop device — это тот самый rootfs-образ загруженный с сервера. На данный момент он лежит в оперативной памяти, занимает 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 layer
Отдельное внимание хочу уделить проблеме вебхуков и 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-подхода! Расписание здесь.