Pull to refresh

Строим загружаемую по сети ферму серверов для Kubernetes с помощью LTSP

Reading time 12 min
Views 8.5K
Original author: kvaps


В этой статье, я хотел бы показать вам одну крутую технологию, я успешно использую ее для Kubernetes. Она может быть реально полезна для построения больших кластеров.


С этого момента вам больше не придется думать об установке ОС и отдельных пакетов на каждую ноду. Зачем? Вы можете сделать все это автоматически через Dockerfile!


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


Заинтриговал? Теперь давайте обо всем по порядку.


Резюме


Для начала, нам нужно понимать, как именно эта схема работает.


Если кратко, то для всех нод мы подготавливаем единый образ с ОС, Docker, Kubelet и всем остальным.
Этот образ системы вместе с ядром собирается автоматически CI, используя Dockerfile.
Конечные ноды загружают ОС и ядро из этого образа прямо через сеть.


Ноды используют overlayfs в качестве корневой файловой системы, так что в случае перезагрузки любые изменения будут потеряны (так же как и в случае с docker-контейнерами).
Есть основной конфиг, в нем можно описать точки монтирования и некоторые команды, которые должны выполняться во время загрузки ноды (например, команда для добавления ssh-ключа и kubeadm join)


Процесс подготовки образа


Мы будем использовать проект LTSP, потому что он дает нам все что нужно для организации сетевой загрузки.
В целом LTSP это пачка shell-скриптов, который делает нашу жизнь намного проще.


Он предоставляет модуль initramfs, несколько вспомогательных скриптов и некую систему настройки, которая подготавливает систему на ранней стадии загрузки, еще перед вызовом init.


Так выглядит процедура подготовки образа:


  • Разворачиваем базовую систему в chroot-окружении.
  • Вносим необходимые изменения, устанавливаем софт.
  • Запускаем команду ltsp-build-image

Сразу после этого вы получите сжатый образ из этого chroot со всем установленным софтом внутри.
Каждая нода будет скачивать этот образ во время загрузки и использовать его в качестве rootfs.
Для обновления, достаточно просто перезагрузить ноду, новый образ будет загружен и использован для rootfs.


Серверные компоненты


Серверная часть LTSP в нашем случае включает всего два компонента:


  • TFTP-сервер — TFTP является протоколом инициализации, он используется для загрузки ядра, initramfs и основного конфига — lts.conf.
  • NBD-сервер — протокол NBD, используется для доставки сжатого образа rootfs клиентам. Это самый быстрый метод, но при желании, его можно заменить на NFS или AoE.

Вам также необходимо иметь:


  • DHCP-сервер — он будет раздавать IP-конфигурацию и несколько дополнительных опций, которые понадобятся нашим клиентам, чтобы те смогли загружаться с нашего LTSP-сервера.

Процесс загрузки ноды


Описание процесса загрузки ноды


  • Первым делом нода запросит с DHCP IP-адрес и опции next-server,filename.
  • Затем нода применит настройки и скачает загрузчик (pxelinux или grub)
  • Загрузчик скачает и загрузит конфиг с ядром и initramfs.
  • Затем он загрузит ядро и initramfs с определенными опциями указанными для ядра.
  • Во время загрузки модули initramfs обработают параметры из cmdline и выполнят некоторые действия, такие как подключение nbd-устройсва, подготовка overlay rootfs и т. д.
  • После этого, вместо обычного init, будет вызван специальный ltsp-init.
  • Скрипты ltsp-init подготовят систему на ранней стадии, прежде чем будет вызван основной init. В основном здесь применяются опции из lts.conf (основной файл конфигурации): это обновление записей в fstab и rc.local и т.п.
  • Дальше будет вызов основного init (systemd), который загрузит уже настроенную систему как обычно, смонтирует общие ресурсы из fstab, запустит таргеты и сервисы, выполнит команды из rc.local.
  • В итоге мы получим полностью сконфигурированную и загруженную систему, готовую к дальнейшим действиям.

Подготовка сервера


Как я уже говорил, я подготавливаю LTSP-сервер со squashed образом автоматически, используя Dockerfile. Этот метод неплох, потому что все шаги для сборки могут быть описаны в вашем git репозитории. Вы можете управлять версиями, использовать тэги, применять CI и все тоже самое что бы вы использовали для подготовки обычных Docker-проектов.


С другой стороны вы можете развернуть LTSP-сервер вручную, выполнив все шаги вручную, это может быть хорошо в целях обучения и для понимания основных принципов.
Выполните перечисленные в статье команды вручную, если вы хотите просто попробовать LTSP без Dockerfile.


Список использованных патчей


На данный момент у LTSP есть некоторые недоработки, а авторы проекта не очень охотно принимают исправления. К счастью LTSP легко кастомизируется, поэтому я подготовил несколько патчей для себя, я приведу их здесь.
Возможно когда-нибудь я созрею на форк, если сообщество тепло примет мое решение.


  • feature-grub.diff
    По умолчанию LTSP не поддерживает EFI, поэтому я подготовил патч, который добавляет GRUB2 с поддержкой EFI.
  • feature_preinit.diff
    Этот патч добавляет опцию PREINIT в lts.conf, которая позволяет запускать произвольные команды перед вызовом основного init. Это может быть полезно для модификации systemd юнитов и настройки сети. Примечательно, что все переменные из загрузочной среды сохраняются, и вы можете использовать их в своих скриптах вызванных через эту опцию.
  • feature_initramfs_params_from_lts_conf.diff
    Решает вопрос с неработающей опцией NBD_TO_RAM, после этого патча вы можете указать ее в lts.conf внутри chroot. (не тот который в директории tftp)
  • nbd-server-wrapper.sh
    Это не патч, а просто shell-скрипт, он позволяет запускать nbd-server в foregroud, он вам понадобится если вы захотите запускать nbd-сервер внутри Docker-контейнера.

Dockerfile stages


Мы будем использовать stage building в нашем Dockerfile для сохранения только необходимых частей нашего docker-образа, остальные неиспользованные части будут исключены из конечного образа.


ltsp-base
(установка основного софта для ltsp сервера)
   |
   |---basesystem
   |   (подготовка chroot-окружения с основным софтом и ядром)
   |     |
   |     |---builder
   |     |   (сборка дополнительного софта из исходников, при необходимости)
   |     |
   |     '---ltsp-image
   |         (установка дополнительного софта, docker, kubelet и сборка squashed образа)
   |
   '---final-stage
       (копирование squashed образа, ядра и initramfs в первый stage)

Stage 1: ltsp-base


ОК, давайте начнем, это первая часть нашего Dockerfile:


FROM ubuntu:16.04 as ltsp-base

ADD nbd-server-wrapper.sh /bin/
ADD /patches/feature-grub.diff /patches/feature-grub.diff
RUN apt-get -y update \
 && apt-get -y install \
      ltsp-server \
      tftpd-hpa \
      nbd-server \
      grub-common \
      grub-pc-bin \
      grub-efi-amd64-bin \
      curl \
      patch \
 && sed -i 's|in_target mount|in_target_nofail mount|' \
      /usr/share/debootstrap/functions \
  # Добавим поддержку EFI и загрузчик Grub (#1745251)
 && patch -p2 -d /usr/sbin < /patches/feature-grub.diff \
 && rm -rf /var/lib/apt/lists \
 && apt-get clean

На данный момент наш docker-образ уже имеет установленные:


  • NBD-сервер
  • TFTP-сервер
  • LTSP-скрипты с поддержкой загрузчика grub (для EFI)

Stage 2: basesystem


На этом этапе мы подготовим chroot окружение с базовой системой и установим основной софт с ядром.
Мы будем использовать обычный debootstrap вместо ltsp-build-client для подготовки образа, потому что ltsp-build-client установит GUI и некоторые другие ненужные вещи, которые нам явна не пригодятся для развертывания серверов.


FROM ltsp-base as basesystem

ARG DEBIAN_FRONTEND=noninteractive

# Подготовим основную систему
RUN mkdir -p /opt/ltsp/amd64/proc/self/fd \
 && touch /opt/ltsp/amd64/proc/self/fd/3 \
 && debootstrap --arch amd64 xenial /opt/ltsp/amd64 \
 && rm -rf /opt/ltsp/amd64/proc/*

# Установим обновления
RUN echo "\
      deb http://archive.ubuntu.com/ubuntu xenial main restricted universe multiverse\n\
      deb http://archive.ubuntu.com/ubuntu xenial-updates main restricted universe multiverse\n\
      deb http://archive.ubuntu.com/ubuntu xenial-security main restricted universe multiverse" \
      > /opt/ltsp/amd64/etc/apt/sources.list \
 && ltsp-chroot apt-get -y update \
 && ltsp-chroot apt-get -y upgrade

# Установим пакеты LTSP
RUN ltsp-chroot apt-get -y install ltsp-client-core

# Применим патчи initramfs
# 1: Чтение параметров из /etc/lts.conf во время загрузки (#1680490)
# 2: Добавим поддержку PREINIT опций в lts.conf
ADD /patches /patches
RUN patch -p4 -d /opt/ltsp/amd64/usr/share < /patches/feature_initramfs_params_from_lts_conf.diff \
 && patch -p3 -d /opt/ltsp/amd64/usr/share < /patches/feature_preinit.diff

# Запишем LTSP_NBD_TO_RAM опцию в локальный конфиг, для загрузки образа в ram:
RUN echo "[Default]\nLTSP_NBD_TO_RAM = true" \
      > /opt/ltsp/amd64/etc/lts.conf

# Установим пакеты
RUN echo 'APT::Install-Recommends "0";\nAPT::Install-Suggests "0";' \
      >> /opt/ltsp/amd64/etc/apt/apt.conf.d/01norecommend \
 && ltsp-chroot apt-get -y install \
      software-properties-common \
      apt-transport-https \
      ca-certificates \
      ssh \
      bridge-utils \
      pv \
      jq \
      vlan \
      bash-completion \
      screen \
      vim \
      mc \
      lm-sensors \
      htop \
      jnettop \
      rsync \
      curl \
      wget \
      tcpdump \
      arping \
      apparmor-utils \
      nfs-common \
      telnet \
      sysstat \
      ipvsadm \
      ipset \
      make

# Установим ядро
RUN ltsp-chroot apt-get -y install linux-generic-hwe-16.04

Обратите внимание, что с некоторыми пакетами, например lvm2, могут возникнуть проблемы. Они не полностью оптимизированы для установки в непривилегированном chroot. Их postinstall-скрипты пытаются вызвать привилегированные команды, которые могут завершаться с ошибками и блокировать установку всего пакета.


Решение:


  • Некоторые могут установиться без проблем если устанавливать их до установки ядра (например, lvm2)
  • Но для некоторых из них вам потребуется использовать такой обходной путь для установки без postinstall-скрипта.

Stage 3: builder


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


Приведу пример установки последней версии MLNX_EN драйвера:


FROM basesystem as builder

# Скопируем cpuinfo (для сборки из исходников)
RUN cp /proc/cpuinfo /opt/ltsp/amd64/proc/cpuinfo

# Скачаем и скомпилируем Mellanox driver
RUN ltsp-chroot sh -cx \
   '  VERSION=4.3-1.0.1.0-ubuntu16.04-x86_64 \
   && curl -L http://www.mellanox.com/downloads/ofed/MLNX_EN-${VERSION%%-ubuntu*}/mlnx-en-${VERSION}.tgz \
      | tar xzf - \
   && export \
        DRIVER_DIR="$(ls -1 | grep "MLNX_OFED_LINUX-\|mlnx-en-")" \
        KERNEL="$(ls -1t /lib/modules/ | head -n1)" \
   && cd "$DRIVER_DIR" \
   && ./*install --kernel "$KERNEL" --without-dkms --add-kernel-support \
   && cd - \
   && rm -rf "$DRIVER_DIR" /tmp/mlnx-en* /tmp/ofed*'

# Сохраним модули ядра
RUN ltsp-chroot sh -c \
    ' export KERNEL="$(ls -1t /usr/src/ | grep -m1 "^linux-headers" | sed "s/^linux-headers-//g")" \
   && tar cpzf /modules.tar.gz /lib/modules/${KERNEL}/updates'

Stage 4: ltsp-image


На этом этапе мы установим то, что мы собрали в предыдущем шаге:


FROM basesystem as ltsp-image

# Получим модули ядра
COPY --from=builder /opt/ltsp/amd64/modules.tar.gz /opt/ltsp/amd64/modules.tar.gz

# Установим модули ядра
RUN ltsp-chroot sh -c \
    ' export KERNEL="$(ls -1t /usr/src/ | grep -m1 "^linux-headers" | sed "s/^linux-headers-//g")" \
   && tar xpzf /modules.tar.gz \
   && depmod -a "${KERNEL}" \
   && rm -f /modules.tar.gz'

Теперь внесем дополнительные изменения чтобы завершить наш LTSP-образ:


# Установим docker
RUN ltsp-chroot sh -c \
   '  curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add - \
   && echo "deb https://download.docker.com/linux/ubuntu xenial stable" \
        > /etc/apt/sources.list.d/docker.list \
   && apt-get -y update \
   && apt-get -y install \
        docker-ce=$(apt-cache madison docker-ce | grep 18.06 | head -1 | awk "{print $ 3}")'

# Настроим опции для docker
RUN DOCKER_OPTS="$(echo \
      --storage-driver=overlay2 \
      --iptables=false \
      --ip-masq=false \
      --log-driver=json-file \
      --log-opt=max-size=10m \
      --log-opt=max-file=5 \
      )" \
 && sed "/^ExecStart=/ s|$| $DOCKER_OPTS|g" \
      /opt/ltsp/amd64/lib/systemd/system/docker.service \
      > /opt/ltsp/amd64/etc/systemd/system/docker.service

# Установим kubeadm, kubelet и kubectl
RUN ltsp-chroot sh -c \
      '  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 -y update \
      && apt-get -y install kubelet kubeadm kubectl cri-tools'

# Отключим автоматические обновления
RUN rm -f /opt/ltsp/amd64/etc/apt/apt.conf.d/20auto-upgrades

# Отключим профили apparmor
RUN ltsp-chroot find /etc/apparmor.d \
      -maxdepth 1 \
      -type f \
      -name "sbin.*" \
      -o -name "usr.*" \
      -exec ln -sf "{}" /etc/apparmor.d/disable/ \;

# Опишем опции ядра (cmdline)
RUN KERNEL_OPTIONS="$(echo \
      init=/sbin/init-ltsp \
      forcepae \
      console=tty1 \
      console=ttyS0,9600n8 \
      nvme_core.default_ps_max_latency_us=0 \
    )" \
 && sed -i "/^CMDLINE_LINUX_DEFAULT=/ s|=.*|=\"${KERNEL_OPTIONS}\"|" \
      "/opt/ltsp/amd64/etc/ltsp/update-kernels.conf"

Теперь сделаем squased-образ из нашего chroot:


# Очистим кэши
RUN rm -rf /opt/ltsp/amd64/var/lib/apt/lists \
 && ltsp-chroot apt-get clean

# Соберем squashed образ
RUN ltsp-update-image

Stage 5: Final stage


На финальной стадии мы сохраним только наш squashed-образ и ядро с initramfs


FROM ltsp-base
COPY --from=ltsp-image /opt/ltsp/images /opt/ltsp/images
COPY --from=ltsp-image /etc/nbd-server/conf.d /etc/nbd-server/conf.d
COPY --from=ltsp-image /var/lib/tftpboot /var/lib/tftpboot

Отлично, теперь у нас есть docker-образ который включает:


  • TFTP-сервер
  • NBD-сервер
  • настроенный загрузчик
  • ядро с initramfs
  • squashed образ rootfs

Использование


ОК, теперь когда наш Docker-образ с LTSP-сервером, ядром, initramfs и squashed rootfs полностью готов, мы можем запустить deployment с ним.


Мы можем сделать это как обычно, но есть еще один вопрос который нам предстоит решить.
К сожалению мы не можем использовать обычный Kubernetes service для нашего deployment, потому что во время загрузки ноды не являются частью Kubernetes кластера и им необходимо использовать externalIP, но Kubernetes всегда применяет NAT для externalIP и на данный момент нет возможности изменить это поведение.


Я знаю два способа что бы избежать этого: использовать hostNetwork: true или использовать pipework, второй вариант предоставит нам также отказоустойчивость, т.к. в случае отказа IP-адрес переедет на другую ноду вместе контейнером. К сожалению pipework — это не нативный и менее безопасный метод.
Если вы знаете о каком-нибудь более подходящем решении, пожалуйста расскажите о нем.


Приведу пример deployment с hostNetwork:


apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: ltsp-server
  labels:
      app: ltsp-server
spec:
  selector:
    matchLabels:
      name: ltsp-server
  replicas: 1
  template:
    metadata:
      labels:
        name: ltsp-server
    spec:
      hostNetwork: true
      containers:
      - name: tftpd
        image: registry.example.org/example/ltsp:latest
        command: [ "/usr/sbin/in.tftpd", "-L", "-u", "tftp", "-a", ":69", "-s", "/var/lib/tftpboot" ]
        lifecycle:
          postStart:
            exec:
              command: ["/bin/sh", "-c", "cd /var/lib/tftpboot/ltsp/amd64; ln -sf config/lts.conf ." ]
        volumeMounts:
        - name: config
          mountPath: "/var/lib/tftpboot/ltsp/amd64/config"

      - name: nbd-server
        image: registry.example.org/example/ltsp:latest
        command: [ "/bin/nbd-server-wrapper.sh" ]

      volumes:
      - name: config
        configMap:
          name: ltsp-config

Как вы могли бы заметить, здесь также используется configmap с lts.conf файлом.
В качестве примера, приведу часть моего конфига:


apiVersion: v1
kind: ConfigMap
metadata:
  name: ltsp-config
data:
  lts.conf: |
    [default]
    KEEP_SYSTEM_SERVICES           = "ssh ureadahead dbus-org.freedesktop.login1 systemd-logind polkitd cgmanager ufw rpcbind nfs-kernel-server"

    PREINIT_00_TIME                = "ln -sf /usr/share/zoneinfo/Europe/Prague /etc/localtime"
    PREINIT_01_FIX_HOSTNAME        = "sed -i '/^127.0.0.2/d' /etc/hosts"
    PREINIT_02_DOCKER_OPTIONS      = "sed -i 's|^ExecStart=.*|ExecStart=/usr/bin/dockerd -H fd:// --storage-driver overlay2 --iptables=false --ip-masq=false --log-driver=json-file --log-opt=max-size=10m --log-opt=max-file=5|' /etc/systemd/system/docker.service"

    FSTAB_01_SSH                   = "/dev/data/ssh     /etc/ssh          ext4 nofail,noatime,nodiratime 0 0"
    FSTAB_02_JOURNALD              = "/dev/data/journal /var/log/journal  ext4 nofail,noatime,nodiratime 0 0"
    FSTAB_03_DOCKER                = "/dev/data/docker  /var/lib/docker   ext4 nofail,noatime,nodiratime 0 0"

    # Each command will stop script execution when fail
    RCFILE_01_SSH_SERVER           = "cp /rofs/etc/ssh/*_config /etc/ssh; ssh-keygen -A"
    RCFILE_02_SSH_CLIENT           = "mkdir -p /root/.ssh/; echo 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDBSLYRaORL2znr1V4a3rjDn3HDHn2CsvUNK1nv8+CctoICtJOPXl6zQycI9KXNhANfJpc6iQG1ZPZUR74IiNhNIKvOpnNRPyLZ5opm01MVIDIZgi9g0DUks1g5gLV5LKzED8xYKMBmAfXMxh/nsP9KEvxGvTJB3OD+/bBxpliTl5xY3Eu41+VmZqVOz3Yl98+X8cZTgqx2dmsHUk7VKN9OZuCjIZL9MtJCZyOSRbjuo4HFEssotR1mvANyz+BUXkjqv2pEa0I2vGQPk1VDul5TpzGaN3nOfu83URZLJgCrX+8whS1fzMepUYrbEuIWq95esjn0gR6G4J7qlxyguAb9 admin@kubernetes' >> /root/.ssh/authorized_keys"
    RCFILE_03_KERNEL_DEBUG         = "sysctl -w kernel.unknown_nmi_panic=1 kernel.softlockup_panic=1; modprobe netconsole netconsole=@/vmbr0,@10.9.0.15/"
    RCFILE_04_SYSCTL               = "sysctl -w fs.file-max=20000000 fs.nr_open=20000000 net.ipv4.neigh.default.gc_thresh1=80000 net.ipv4.neigh.default.gc_thresh2=90000 net.ipv4.neigh.default.gc_thresh3=100000"
    RCFILE_05_FORWARD              = "echo 1 > /proc/sys/net/ipv4/ip_forward"
    RCFILE_06_MODULES              = "modprobe br_netfilter"
    RCFILE_07_JOIN_K8S             = "kubeadm join --token 2a4576.504356e45fa3d365 10.9.0.20:6443 --discovery-token-ca-cert-hash sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"

  • KEEP_SYSTEM_SERVICES — во время загрузки LTSP автоматически удаляет некоторые сервисы, эта переменная нужна что бы не допустить этого поведения, для перечисленных здесь сервисов.
  • PREINIT_* — команды перечисленные здесь будут выполнены перед запуском systemd (эта возможность была добавлена feature_preinit.diff патчем)
  • FSTAB_* — строки перечисленные здесь будут добавлены в /etc/fstab файл.
    Вы можете заметить что я использую nofail опцию, она дает следующее поведение, что если раздел не существует загрузка продолжается без ошибок.
  • RCFILE_* — эти команды будут добавлены в rc.local файл, который будет вызван systemd во время загрузки.
    Тут я загружаю необходимые модули ядра, запускаю некоторые sysctl-настройки и затем выполняю kubeadm join команду, которая добавляет ноду в kubernetes кластер.

Более детальную информацию обо всех переменных вы можете получить из lts.conf страницы руководства.


Теперь вы можете настроить ваш DHCP. По сути вче что там нужно — это указать next-server и filename опции.


Я использую ISC-DHCP сервер, приведу пример dhcpd.conf:


shared-network ltsp-netowrk {
    subnet 10.9.0.0 netmask 255.255.0.0 {
        authoritative;
        default-lease-time -1;
        max-lease-time -1;

        option domain-name              "example.org";
        option domain-name-servers      10.9.0.1;
        option routers                  10.9.0.1;
        next-server                     ltsp-1;  # write ltsp-server hostname here

        if option architecture = 00:07 {
            filename "/ltsp/amd64/grub/x86_64-efi/core.efi";
        } else {
            filename "/ltsp/amd64/grub/i386-pc/core.0";
        }

        range 10.9.200.0 10.9.250.254; 
    }

Можно начинать с этого, но что касается меня у меня есть несколько LTSP-серверов и для каждой ноды я настраиваю отдельный статический IP-адрес и нужные опции с помощью Ansible-плейбука.


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


Теперь вы можете попробовать внести свои собственные изменения.


Если вам нужно что-то большее, обратите внимание что LTSP может быть очень легко изменен под ваши нужды. Не стесняйтесь заглядывать в исходники, там вы сможете найти довольно много ответов.


Присоединяйтесь к нашему Telegram-каналу: @ltsp_ru.

Tags:
Hubs:
+6
Comments 5
Comments Comments 5

Articles