本文发表已超过一年。较旧的文章可能包含过时的内容。请检查页面中的信息自发布以来是否已不再准确。

使用 LTSP 为 Kubernetes 构建网络可引导服务器集群

k8s+ltsp

在这篇文章中,我将向大家介绍一项用于 Kubernetes 的很酷的技术:LTSP。它对于大型裸金属 Kubernetes 部署非常有用。

你不再需要考虑在每个节点上安装操作系统和二进制文件了。为什么?你可以通过 Dockerfile 自动完成!

你可以购买并立即将 100 台新服务器投入生产环境并让它们工作起来——这真的很神奇!

好奇吗?让我为你讲解它的工作原理。

概述

请注意:这是一个很酷的技巧,但在 Kubernetes 中并未得到官方支持。

首先,我们需要了解它具体是如何工作的。

简而言之,我们为所有节点准备了一个包含操作系统、Docker、Kubelet 以及你所需一切的镜像。这个包含内核的镜像通过 CI 使用 Dockerfile 自动构建。最终节点通过网络从该镜像启动内核和操作系统。

节点使用 Overlay 作为根文件系统,并且重启后任何更改都将丢失(就像 Docker 容器一样)。你有一个配置文件,可以在其中描述挂载点以及一些应该在节点启动期间执行的初始命令(例如:设置 root 用户 SSH 密钥和 kubeadm join 命令)。

镜像准备过程

我们将使用 LTSP 项目,因为它提供了组织网络启动环境所需的一切。基本上,LTSP 是一系列 shell 脚本,极大地简化了我们的工作。

LTSP 提供了一个 initramfs 模块、一些辅助脚本以及配置系统,这些工具在启动的早期阶段、主 init 进程调用之前准备系统。

镜像准备过程如下所示:

  • 你在 chroot 环境中部署基础系统。
  • 在其中进行任何必要的更改,安装软件。
  • 运行 ltsp-build-image 命令

之后,你将从 chroot 环境中获得一个包含所有软件的 squashfs 镜像。每个节点在启动时会下载此镜像并将其用作 rootfs。要更新节点,只需重启它。新的 squashfs 镜像将被下载并挂载到 rootfs 中。

服务端组件

在我们的案例中,LTSP 的服务端包含两个组件:

  • TFTP 服务器 - TFTP 是初始协议,用于下载内核、initramfs 和主要配置文件 - lts.conf。
  • NBD 服务器 - NBD 协议用于将 squashfs 根文件系统镜像分发给客户端。这是最快的方式,但如果需要,可以用 NFS 或 AoE 协议替换。

你还需要

  • DHCP 服务器 - 它将向客户端分发 IP 设置和一些特定选项,使它们能够从我们的 LTSP 服务器启动。

节点启动过程

节点启动过程如下:

  • 第一次启动时,节点会向 DHCP 请求 IP 设置以及 next-serverfilename 选项。
  • 接着,节点应用设置并下载引导加载程序 (pxelinux 或 grub)
  • 引导加载程序将下载并读取包含内核和 initramfs 镜像的配置文件。
  • 然后引导加载程序将下载内核和 initramfs,并带特定命令行选项执行。
  • 在启动过程中,initramfs 模块将处理命令行选项并执行一些操作,例如连接 NBD 设备、准备 overlay rootfs 等。
  • 之后,它将调用 ltsp-init 系统,而不是标准的 init。
  • ltsp-init 脚本将在主 init 调用之前的早期阶段准备系统。基本上,它应用 lts.conf (主要配置文件) 中的设置:写入 fstab 和 rc.local 条目等。
  • 调用主 init (systemd),它将像往常一样启动配置好的系统,从 fstab 挂载共享、启动 Target 和服务、执行 rc.local 文件中的命令。
  • 最后,你就拥有了一个完全配置并启动好的系统,随时可以进行进一步的操作。

准备服务器

如前所述,我使用 Dockerfile 自动准备包含 squashfs 镜像的 LTSP 服务器。这种方法非常好,因为所有步骤都描述在你的 Git 仓库中。你可以拥有版本控制、分支、CI 以及准备常规 Docker 项目时习惯使用的一切。

另外,你也可以手动执行所有步骤来部署 LTSP 服务器。这是学习和理解基本原理的良好实践。

你可以手动重复此处列出的所有步骤,尝试在没有 Dockerfile 的情况下安装 LTSP。

使用的补丁列表

LTSP 仍然有一些问题,作者暂时不想应用。然而,LTSP 很容易定制,所以我为自己准备了一些补丁,并在这里分享。

如果社区能够热情地接受我的方案,我将创建一个 Fork。

  • feature-grub.diff LTSP 默认不支持 EFI,所以我准备了一个补丁,为 GRUB2 添加了 EFI 支持。
  • feature_preinit.diff 这个补丁在 lts.conf 中添加了一个 PREINIT 选项,允许你在主 init 调用之前运行自定义命令。这对于修改 systemd Unit 和配置网络可能很有用。值得注意的是,所有来自启动环境的环境变量都被保存下来,你可以在脚本中使用它们。
  • feature_initramfs_params_from_lts_conf.diff 解决了 NBD_TO_RAM 选项的问题,应用此补丁后,你可以在 chroot 环境内的 lts.conf 中指定它。(而不是在 tftp 目录中)
  • nbd-server-wrapper.sh 这不是一个补丁,而是一个特殊的 Wrapper 脚本,它允许你在前台运行 NBD 服务器。如果你想在 Docker 容器内运行它,这很有用。

Dockerfile 阶段

我们将在 Dockerfile 中使用多阶段构建 (stage building),以便在我们的 Docker 镜像中只保留所需的部分。未使用的部分将从最终镜像中移除。

ltsp-base
(install basic LTSP server software)
   |
   |---basesystem
   |   (prepare chroot with main software and kernel)
   |     |
   |     |---builder
   |     |   (build additional software from sources, if needed)
   |     |
   |     '---ltsp-image
   |         (install additional software, docker, kubelet and build squashed image)
   |
   '---final-stage
       (copy squashed image, kernel and initramfs into first 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 \
  # Add EFI support and Grub bootloader (#1745251)
 && patch -p2 -d /usr/sbin < /patches/feature-grub.diff \
 && rm -rf /var/lib/apt/lists \
 && apt-get clean

在此阶段,我们的 Docker 镜像已安装了:

  • NBD 服务器
  • TFTP 服务器
  • 支持 grub 引导加载程序的 LTSP 脚本 (用于 EFI)

阶段 2: basesystem

在此阶段,我们将准备一个包含基础系统的 chroot 环境,并安装包含内核的基础软件。

我们将使用经典的 debootstrap 而不是 ltsp-build-client 来准备基础镜像,因为 ltsp-build-client 会安装 GUI 和其他一些我们在服务器部署中不需要的东西。

FROM ltsp-base as basesystem

ARG DEBIAN_FRONTEND=noninteractive

# Prepare base system
RUN debootstrap --arch amd64 xenial /opt/ltsp/amd64

# Install updates
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

# Installing LTSP-packages
RUN ltsp-chroot apt-get -y install ltsp-client-core

# Apply initramfs patches
# 1: Read params from /etc/lts.conf during the boot (#1680490)
# 2: Add support for PREINIT variables in 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

# Write new local client config for boot NBD image to ram:
RUN echo "[Default]\nLTSP_NBD_TO_RAM = true" \
      > /opt/ltsp/amd64/etc/lts.conf

# Install packages
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

# Install kernel
RUN ltsp-chroot apt-get -y install linux-generic-hwe-16.04

请注意,你可能会遇到一些软件包的问题,例如 lvm2。它们尚未完全针对在非特权 chroot 环境中安装进行优化。它们的安装后脚本会尝试调用一些特权命令,这可能会失败并报错,从而阻塞软件包的安装。

解决方案

  • 其中一些可以在内核安装之前安装,没有任何问题(例如 lvm2
  • 但对于其中一些,你需要使用此解决方法跳过安装后脚本进行安装。

阶段 3: builder

现在我们可以构建所有必要的软件和内核模块了。你可以在这个阶段自动完成这些工作,这真的很棒。如果你在此没有其他操作,可以跳过此阶段。

以下是安装最新 MLNX_EN 驱动程序的示例:

FROM basesystem as builder

# Set cpuinfo (for building from sources)
RUN cp /proc/cpuinfo /opt/ltsp/amd64/proc/cpuinfo

# Compile 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*'

# Save kernel modules
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'

阶段 4: ltsp-image

在此阶段,我们将安装在上一步中构建的内容

FROM basesystem as ltsp-image

# Retrieve kernel modules
COPY --from=builder /opt/ltsp/amd64/modules.tar.gz /opt/ltsp/amd64/modules.tar.gz

# Install kernel modules
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-image

# Install 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}")'

# Configure docker options
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

# Install kubeadm, kubelet and 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'

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

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

# Write kernel cmdline options
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"

然后我们将从 chroot 环境中创建 squashfs 镜像

# Cleanup caches
RUN rm -rf /opt/ltsp/amd64/var/lib/apt/lists \
 && ltsp-chroot apt-get clean

# Build squashed image
RUN ltsp-update-image

阶段 5: 最终阶段

在最终阶段,我们将仅保存 squashfs 镜像以及包含 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 的内核
  • squashfs 根文件系统镜像

使用方法

好的,现在包含 LTSP 服务器、内核、initramfs 和 squashfs 根文件系统的 docker 镜像已完全准备就绪,我们可以使用它来运行部署。

我们可以像往常一样进行,但还有一个问题是网络。遗憾的是,我们不能对我们的部署使用标准的 Kubernetes Service 抽象,因为 TFTP 在 NAT 后面无法工作。在启动期间,我们的节点不是 Kubernetes 集群的一部分,它们需要 ExternalIP,但 Kubernetes 总是为 ExternalIP 启用 NAT,并且没有办法覆盖此行为。

目前我有两种方法可以避免这种情况:使用 hostNetwork: true 或使用 pipework。第二个选项还能提供冗余,因为在故障情况下,IP 将随 Pod 移动到另一个节点。遗憾的是,pipework 不是原生的方法,安全性也较低。如果你有更好的选项,请告诉我。

以下是使用 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

如你所见,它还需要一个包含 lts.conf 文件的 configmap。以下是我的示例片段:

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 选项,这意味着如果分区不存在,启动会继续而不报错。如果你的节点是完全无盘的,可以移除 FSTAB 设置或在此处配置远程文件系统。
  • RCFILE_* - 这些命令将写入 rc.local 文件,该文件将在启动期间由 systemd 调用。在这里,我加载内核模块并添加一些 sysctl 调优,然后调用 kubeadm join 命令,将我的节点添加到 Kubernetes 集群。

你可以从 lts.conf 手册页获取所有使用变量的更多详细信息。

现在你可以配置你的 DHCP。基本上,你应该设置 next-serverfilename 选项。

我使用 ISC-DHCP 服务器,以下是一个 dhcpd.conf 示例:

shared-network ltsp-network {
    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 服务器,我通过 Ansible playbook 为每个节点静态配置租约。

尝试运行你的第一个节点。如果一切顺利,你将有一个运行中的系统。该节点也将被添加到你的 Kubernetes 集群中。

现在你可以尝试进行自己的修改。

如果你需要更多功能,请注意 LTSP 可以轻松修改以满足你的需求。随时查看源代码,你可以在其中找到许多答案。

更新:许多人问我:为什么不直接使用 CoreOS 和 Ignition?

我可以回答。这里的主要特点是镜像准备过程,而不是配置。使用 LTSP 时,你拥有经典的 Ubuntu 系统,任何可以在 Ubuntu 上安装的东西都可以在这里的 Dockerfile 中写入。使用 CoreOS 时,你没有那么多自由度,并且无法在启动镜像的构建阶段轻松添加自定义内核模块和软件包。