技术文章2026 年 9 月 9 日约 17.7 分钟

5 台服务器,从二进制装 K8s 到 Spring Cloud 上线

用 5 台服务器把整套平台搭出来:二进制部署 Kubernetes 1.36 高可用集群,装完 Harbor、Gitea、Nexus、Jenkins、Argo CD、监控日志链路追踪,最后把 Spring Cloud 微服务项目发布上线。每一步都给可执行命令。

手里 5 台物理机或者虚机,要求二进制装 Kubernetes,然后 DevOps 那一整套工具链全上,最后跑起来一个 Spring Cloud 微服务项目。这篇文章就是把这套活儿从头到尾做一遍,命令都是能直接复制执行的。

我先说清楚一件事:二进制部署不是炫技。kubeadm 能用就用,但它把证书、静态 Pod、启动参数都藏起来了,出问题时你不知道该看哪儿。二进制装一遍,apiserver 起不来你看 journalctl 就知道是证书 SANs 少了还是 etcd 连不上,这个排障能力是买不来的。代价是安装包得自己管、升级得自己滚,规模上来之后维护成本高。所以这套方案适合几百节点以下、没有专职 K8s 团队、但又想完全掌控的场景。

#一、5 台机器怎么切

3 台控制平面 + 2 台工作节点,etcd 与控制平面堆叠部署(stacked),不单独拆 etcd 节点。

主机名IP角色建议配置
k8s-m110.0.0.11control-plane + etcd + NFS8C16G,200G SSD
k8s-m210.0.0.12control-plane + etcd8C16G,200G SSD
k8s-m310.0.0.13control-plane + etcd8C16G,200G SSD
k8s-n110.0.0.14worker16C64G,1T NVMe
k8s-n210.0.0.15worker16C64G,1T NVMe
k8s-vip10.0.0.10apiserver VIP(keepalived)—

几个取舍说明:

为什么不拆独立的 etcd 节点。 5 台机器拆出 3 台纯 etcd 就只剩 2 台跑业务,浪费。堆叠部署下 etcd 和 apiserver 同机,apiserver 通过 127.0.0.1 访问本地 etcd,延迟最低。风险是 master 整机宕机时同时丢一个 etcd 成员,3 节点还能扛住;真要更高的隔离度,上 7 台再说。

为什么工作节点给 64G。 后面 DevOps 那一堆东西(GitLab 级别的代码仓库先不算,Gitea 轻很多,但 Harbor、Jenkins、Nexus、监控、日志、Nacos、MySQL、Redis)全部要落在 n1/n2 上,加上业务 Pod,32G 会很紧张。两个节点各有 64G,总 128G,扣掉系统预留还能有 100G 左右可用,紧巴巴但够跑。

为什么只有 2 个 worker 还要 3 个 master。 etcd 需要奇数个成员做 quorum,3 是最小的可靠配置。2 个 master 的 etcd 没有任何意义,坏一台整个集群就不可写了。

网络规划。 Pod CIDR 用 172.16.0.0/16,Calico 按节点切 /24,也就是每个节点最多 254 个 Pod;Service CIDR 用 10.96.0.0/12,CoreDNS 固定 10.96.0.10。这两个网段不能和宿主机网段 10.0.0.0/24 重叠。NodePort 范围保持默认 30000-32767。

#版本清单

版本号是 2026 年 9 月的取值,装之前建议去各自 release 页面核一下补丁号。

组件版本备注
OSRocky Linux 9.xRHEL 系通吃,内核 ≥ 5.10
Kubernetesv1.36.41.37 刚 GA,生产再等两个补丁
etcdv3.6.133.6 起 --v2-deprecation 默认 write-only
containerdv2.3.3 (LTS)配置文件 schema 是 version = 3
runcv1.3.x取与 containerd 2.x 匹配的最新稳定版
CNI pluginsv1.7.x只装二进制,网络由 Calico 管
cfsslv1.6.5只在 m1 上装,签完可以删
Calicov3.32.2用 release tgz 里的 manifests
CoreDNSv1.13.2与 k8s 1.36 官方 addon 对齐
metrics-serverv0.8.xHPA 依赖它
ingress-nginxv1.13.x裸金属用 DaemonSet + hostNetwork
Helmv3.17.x
Harborv2.15.2镜像仓库
Gitea1.24.x代码仓库
Nexus3.8xMaven 私服
JenkinsLTS (jdk21)CI
SonarQubeCommunity 25.x可选,吃 4G 内存
Argo CDv3.5.1GitOps CD
Prometheus / Grafana3.x / 12.xkube-prometheus-stack
Loki / Alloy3.x日志,Alloy 已替代 Promtail
Nacosv3.2.4注册中心 + 配置中心
JDK / Boot / Cloud21 / 3.5.x / 2025.0.xSCA 2025.0.0.0

内网域名统一走 *.apps.lxhhub.top,在内网 DNS 上 A 记录指到 n1/n2(或者再加一个 VIP)。本文所有命令里的 10.0.0.x、apps.lxhhub.top 按你自己的环境替换。

#二、5 台机器都要做的系统初始化

这一段在 m1、m2、m3、n1、n2 上全部执行一遍,参数只有一个主机名。

bash
cat > /root/init-node.sh <<'SCRIPT'
#!/bin/bash
set -e
HOST=$1
[ -z "$HOST" ] && { echo "用法: $0 <主机名>"; exit 1; }

hostnamectl set-hostname ${HOST}

cat >> /etc/hosts <<'EOF'
10.0.0.10  k8s-vip
10.0.0.11  k8s-m1
10.0.0.12  k8s-m2
10.0.0.13  k8s-m3
10.0.0.14  k8s-n1
10.0.0.15  k8s-n2
EOF

# 防火墙。有严格的合规要求就别关,改成放行 6443/2379/2380/10250/179/8472 等端口
systemctl disable --now firewalld || true

setenforce 0
sed -i 's/^SELINUX=.*/SELINUX=disabled/' /etc/selinux/config

swapoff -a
sed -i '/ swap /s/^/#/' /etc/fstab

dnf install -y ipvsadm ipset conntrack-tools socat ebtables chrony \
               curl wget tar jq bash-completion nfs-utils

systemctl enable --now chronyd

cat > /etc/modules-load.d/k8s.conf <<'EOF'
overlay
br_netfilter
ip_vs
ip_vs_rr
ip_vs_wrr
ip_vs_sh
nf_conntrack
EOF
modprobe overlay
modprobe br_netfilter
modprobe ip_vs
modprobe ip_vs_rr
modprobe ip_vs_wrr
modprobe ip_vs_sh

cat > /etc/sysctl.d/99-k8s.conf <<'EOF'
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
net.ipv4.ip_local_port_range        = 1024 65535
net.netfilter.nf_conntrack_max      = 1000000
net.netfilter.nf_conntrack_tcp_timeout_established = 86400
net.core.somaxconn                  = 32768
net.ipv4.neigh.default.gc_thresh1   = 8192
net.ipv4.neigh.default.gc_thresh2   = 32768
net.ipv4.neigh.default.gc_thresh3   = 65536
fs.file-max                         = 1000000
fs.inotify.max_user_instances       = 8192
fs.inotify.max_user_watches         = 524288
vm.swappiness                       = 0
vm.max_map_count                    = 262144
EOF
sysctl --system

cat > /etc/security/limits.d/99-k8s.conf <<'EOF'
*       soft    nofile    1048576
*       hard    nofile    1048576
*       soft    nproc     655360
*       hard    nproc     655360
*       soft    memlock   unlimited
*       hard    memlock   unlimited
EOF

mkdir -p /etc/kubernetes/pki /var/lib/kubelet /var/log/kubernetes
echo "init done: ${HOST}"
SCRIPT

chmod +x /root/init-node.sh

然后按主机分别执行并重启:

bash
# 各自执行
/root/init-node.sh k8s-m1   # 10.0.0.11
/root/init-node.sh k8s-m2   # 10.0.0.12
/root/init-node.sh k8s-m3   # 10.0.0.13
/root/init-node.sh k8s-n1   # 10.0.0.14
/root/init-node.sh k8s-n2   # 10.0.0.15

reboot

重启后验一遍,三件事必须成立:

bash
getenforce                 # Permissive 或 Disabled
free -h | grep Swap        # 必须是 0
chronyc tracking | grep Leap  # 时间同步正常
hostname                   # 五台各不相同

时间同步这条我单独强调一下:证书校验强依赖时间,5 台机器差个几分钟,etcd 的 peer 通信和 kubelet 的 bootstrap 会以各种你想不到的姿势失败,而且报错信息里一个字都不会提"时间"。

#三、5 台机器都要装的容器运行时

containerd 2.x 开始配置文件 schema 是 version = 3,从 1.x 升级上来的老配置文件直接套会报 unsupported config version,别偷懒复用。

bash
cat > /root/install-containerd.sh <<'SCRIPT'
#!/bin/bash
set -e
CD_VER=2.3.3
RUNC_VER=1.3.0      # 换成官方 release 页最新的 1.3.x
CNI_VER=1.7.0       # 换成官方 release 页最新的 1.7.x
PAUSE_VER=3.10

cd /usr/local/src

# containerd
wget -q https://github.com/containerd/containerd/releases/download/v${CD_VER}/containerd-${CD_VER}-linux-amd64.tar.gz
tar -C /usr/local -xzf containerd-${CD_VER}-linux-amd64.tar.gz

# runc
wget -q -O /usr/local/sbin/runc https://github.com/opencontainers/runc/releases/download/v${RUNC_VER}/runc.amd64
chmod +x /usr/local/sbin/runc

# CNI plugins
mkdir -p /opt/cni/bin /etc/cni/net.d
wget -q https://github.com/containernetworking/plugins/releases/download/v${CNI_VER}/cni-plugins-linux-amd64-v${CNI_VER}.tgz
tar -C /opt/cni/bin -xzf cni-plugins-linux-amd64-v${CNI_VER}.tgz

# systemd unit
cat > /usr/lib/systemd/system/containerd.service <<'EOF'
[Unit]
Description=containerd container runtime
Documentation=https://containerd.io
After=network.target local-fs.target

[Service]
ExecStartPre=-/sbin/modprobe overlay
ExecStart=/usr/local/bin/containerd
Type=notify
Delegate=yes
KillMode=process
Restart=always
RestartSec=5
LimitNPROC=infinity
LimitCORE=infinity
LimitNOFILE=1048576
TasksMax=infinity
OOMScoreAdjust=-999

[Install]
WantedBy=multi-user.target
EOF

mkdir -p /etc/containerd /etc/containerd/certs.d

cat > /etc/containerd/config.toml <<EOF
version = 3
root = "/var/lib/containerd"
state = "/run/containerd"

[grpc]
  max_recv_message_size = 16777216
  max_send_message_size = 16777216

[debug]
  level = "warn"

[plugins."io.containerd.cri.v1.images"]
  snapshotter = "overlayfs"
  [plugins."io.containerd.cri.v1.images".pinned_images]
    sandbox = "registry.k8s.io/pause:${PAUSE_VER}"

[plugins."io.containerd.cri.v1.runtime"]
  [plugins."io.containerd.cri.v1.runtime".containerd]
    default_runtime_name = "runc"
    [plugins."io.containerd.cri.v1.runtime".containerd.runtimes.runc]
      runtime_type = "io.containerd.runc.v2"
      [plugins."io.containerd.cri.v1.runtime".containerd.runtimes.runc.options]
        SystemdCgroup = true

[plugins."io.containerd.cri.v1.runtime".cni]
  bin_dir = "/opt/cni/bin"
  conf_dir = "/etc/cni/net.d"
EOF

systemctl daemon-reload
systemctl enable --now containerd
systemctl is-active containerd
SCRIPT

chmod +x /root/install-containerd.sh && /root/install-containerd.sh

注意上面 pinned_images.sandbox 这个写法——containerd 2.x 里 sandbox 镜像通过 pinned images 指定,老文章里的 sandbox_image 字段在新版已经挪位置了,写错的话 Pod 沙箱会卡在 ImagePullBackOff 并且报的是 pause 镜像拉不下来。

#镜像加速和私有仓库信任

Harbor 我们用 HTTP(内网自签太麻烦,内网场景没必要),所以每台节点都要告诉 containerd 这个仓库是"可信的"。这个配置 kubelet 拉镜像时也生效,漏配一台,调度到那台的 Pod 就起不来。

bash
# 公共镜像加速,按你的网络环境选一个或几个
mkdir -p /etc/containerd/certs.d/docker.io
cat > /etc/containerd/certs.d/docker.io/hosts.toml <<'EOF'
server = "https://registry-1.docker.io"
[host."https://docker.m.daocloud.io"]
  capabilities = ["pull", "resolve"]
[host."https://registry-1.docker.io"]
  capabilities = ["pull", "resolve"]
EOF

# 内网 Harbor,HTTP
mkdir -p /etc/containerd/certs.d/harbor.apps.lxhhub.top
cat > /etc/containerd/certs.d/harbor.apps.lxhhub.top/hosts.toml <<'EOF'
server = "http://harbor.apps.lxhhub.top"
[host."http://harbor.apps.lxhhub.top"]
  capabilities = ["pull", "resolve", "push"]
  skip_verify = true
EOF

systemctl restart containerd

装 crictl,后面排障全靠它:

bash
CRICTL_VER=v1.32.0
wget -q https://github.com/kubernetes-sigs/crictl/releases/download/${CRICTL_VER}/crictl-${CRICTL_VER}-linux-amd64.tar.gz
tar -C /usr/local/bin -xzf crictl-${CRICTL_VER}-linux-amd64.tar.gz

cat > /etc/crictl.yaml <<'EOF'
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 10
debug: false
EOF

crictl info | head -20
crictl pull registry.k8s.io/pause:3.10

crictl pull 成功说明运行时这条链路通了。失败先查 journalctl -u containerd -f,九成是镜像源或者证书的问题。

#四、在 m1 上一次性签完所有证书

证书这块的组织方式:etcd 和 Kubernetes 用两套独立的 CA。一套 CA 打天下也能跑,但 etcd 的证书和 K8s 的证书混在一起,将来轮换 etcd CA 时会连带影响 apiserver,拆开省事。

只在 m1 上操作,签完 scp 到各节点。

bash
# m1 上执行
wget -q -O /usr/local/bin/cfssl     https://github.com/cloudflare/cfssl/releases/download/v1.6.5/cfssl_1.6.5_linux_amd64
wget -q -O /usr/local/bin/cfssljson https://github.com/cloudflare/cfssl/releases/download/v1.6.5/cfssljson_1.6.5_linux_amd64
chmod +x /usr/local/bin/cfssl /usr/local/bin/cfssljson

mkdir -p /root/ssl && cd /root/ssl

cat > ca-config.json <<'EOF'
{
  "signing": {
    "default": { "expiry": "87600h" },
    "profiles": {
      "kubernetes": {
        "expiry": "87600h",
        "usages": ["signing", "key encipherment", "server auth", "client auth"]
      },
      "etcd": {
        "expiry": "87600h",
        "usages": ["signing", "key encipherment", "server auth", "client auth"]
      }
    }
  }
}
EOF

#两套 CA

bash
cat > etcd-ca-csr.json <<'EOF'
{
  "CN": "etcd-ca",
  "key": { "algo": "rsa", "size": 4096 },
  "names": [{ "C": "CN", "ST": "Beijing", "L": "Beijing", "O": "etcd", "OU": "Ops" }]
}
EOF

cat > k8s-ca-csr.json <<'EOF'
{
  "CN": "kubernetes-ca",
  "key": { "algo": "rsa", "size": 4096 },
  "names": [{ "C": "CN", "ST": "Beijing", "L": "Beijing", "O": "Kubernetes", "OU": "Ops" }]
}
EOF

cfssl gencert -initca etcd-ca-csr.json | cfssljson -bare etcd-ca
cfssl gencert -initca k8s-ca-csr.json  | cfssljson -bare k8s-ca

#etcd 三张证书

etcd 的 server 证书要同时用于 peer 通信(2380)和 client 通信(2379),SANs 里必须把三个节点 IP、127.0.0.1、以及 k8s-vip 相关的名字都写进去。

bash
cat > etcd-server-csr.json <<'EOF'
{
  "CN": "etcd-server",
  "hosts": [
    "127.0.0.1",
    "10.0.0.11", "10.0.0.12", "10.0.0.13",
    "k8s-m1", "k8s-m2", "k8s-m3",
    "k8s-m1.apps.lxhhub.top", "k8s-m2.apps.lxhhub.top", "k8s-m3.apps.lxhhub.top"
  ],
  "key": { "algo": "rsa", "size": 2048 },
  "names": [{ "C": "CN", "ST": "Beijing", "L": "Beijing", "O": "etcd", "OU": "Ops" }]
}
EOF

cat > etcd-client-csr.json <<'EOF'
{
  "CN": "etcd-client",
  "key": { "algo": "rsa", "size": 2048 },
  "names": [{ "C": "CN", "ST": "Beijing", "L": "Beijing", "O": "etcd", "OU": "Ops" }]
}
EOF

cfssl gencert -ca=etcd-ca.pem -ca-key=etcd-ca-key.pem -config=ca-config.json -profile=etcd \
  etcd-server-csr.json | cfssljson -bare etcd-server
cfssl gencert -ca=etcd-ca.pem -ca-key=etcd-ca-key.pem -config=ca-config.json -profile=etcd \
  etcd-client-csr.json | cfssljson -bare etcd-client

#Kubernetes 组件证书

apiserver 那张是重点,SANs 少写一个,kubectl 用 VIP 访问就会报 x509: certificate is valid for ..., not k8s-vip。

bash
cat > apiserver-csr.json <<'EOF'
{
  "CN": "kube-apiserver",
  "hosts": [
    "127.0.0.1",
    "10.0.0.10", "10.0.0.11", "10.0.0.12", "10.0.0.13",
    "10.96.0.1",
    "k8s-vip", "k8s-m1", "k8s-m2", "k8s-m3",
    "kubernetes", "kubernetes.default", "kubernetes.default.svc",
    "kubernetes.default.svc.cluster", "kubernetes.default.svc.cluster.local"
  ],
  "key": { "algo": "rsa", "size": 2048 },
  "names": [{ "C": "CN", "ST": "Beijing", "L": "Beijing", "O": "Kubernetes", "OU": "Ops" }]
}
EOF

cfssl gencert -ca=k8s-ca.pem -ca-key=k8s-ca-key.pem -config=ca-config.json -profile=kubernetes \
  apiserver-csr.json | cfssljson -bare apiserver

# apiserver -> kubelet
cat > apiserver-kubelet-client-csr.json <<'EOF'
{
  "CN": "apiserver-kubelet-client",
  "key": { "algo": "rsa", "size": 2048 },
  "names": [{ "C": "CN", "ST": "Beijing", "L": "Beijing", "O": "system:masters", "OU": "Ops" }]
}
EOF
cfssl gencert -ca=k8s-ca.pem -ca-key=k8s-ca-key.pem -config=ca-config.json -profile=kubernetes \
  apiserver-kubelet-client-csr.json | cfssljson -bare apiserver-kubelet-client

# controller-manager / scheduler / admin / kube-proxy
gen_client () {
  cat > ${1}-csr.json <<EOF
{
  "CN": "${2}",
  "key": { "algo": "rsa", "size": 2048 },
  "names": [{ "C": "CN", "ST": "Beijing", "L": "Beijing", "O": "${3}", "OU": "Ops" }]
}
EOF
  cfssl gencert -ca=k8s-ca.pem -ca-key=k8s-ca-key.pem -config=ca-config.json -profile=kubernetes \
    ${1}-csr.json | cfssljson -bare ${1}
}

gen_client kube-controller-manager  system:kube-controller-manager  system:kube-controller-manager
gen_client kube-scheduler           system:kube-scheduler           system:kube-scheduler
gen_client admin                    admin                           system:masters
gen_client kube-proxy               system:kube-proxy               system:node-proxier

#front-proxy CA(聚合层用)

metrics-server、以及任何以 APIService 方式聚合进来的组件都靠它。不配的话 kubectl top 会报 unauthorized。

bash
cat > front-proxy-ca-csr.json <<'EOF'
{
  "CN": "front-proxy-ca",
  "key": { "algo": "rsa", "size": 4096 },
  "names": [{ "C": "CN", "ST": "Beijing", "L": "Beijing", "O": "Kubernetes", "OU": "Ops" }]
}
EOF
cfssl gencert -initca front-proxy-ca-csr.json | cfssljson -bare front-proxy-ca

cat > front-proxy-client-csr.json <<'EOF'
{
  "CN": "front-proxy-client",
  "key": { "algo": "rsa", "size": 2048 },
  "names": [{ "C": "CN", "ST": "Beijing", "L": "Beijing", "O": "Kubernetes", "OU": "Ops" }]
}
EOF
cfssl gencert -ca=front-proxy-ca.pem -ca-key=front-proxy-ca-key.pem -config=ca-config.json \
  -profile=kubernetes front-proxy-client-csr.json | cfssljson -bare front-proxy-client

#ServiceAccount 密钥对

这对密钥不是 x509,直接用 openssl 生成:

bash
openssl genrsa -out sa.key 2048
openssl rsa -in sa.key -pubout -out sa.pub

#分发

bash
cd /root/ssl

# 三个 master:etcd 证书 + k8s 证书 + sa 密钥
for h in k8s-m1 k8s-m2 k8s-m3; do
  ssh $h "mkdir -p /etc/etcd/ssl /etc/kubernetes/pki"
  scp etcd-ca.pem etcd-server.pem etcd-server-key.pem etcd-client.pem etcd-client-key.pem \
      $h:/etc/etcd/ssl/
  scp k8s-ca.pem k8s-ca-key.pem apiserver.pem apiserver-key.pem \
      apiserver-kubelet-client.pem apiserver-kubelet-client-key.pem \
      front-proxy-ca.pem front-proxy-client.pem front-proxy-client-key.pem \
      sa.key sa.pub $h:/etc/kubernetes/pki/
done

# 两个 worker:只需要 CA 公钥
for h in k8s-n1 k8s-n2; do
  ssh $h "mkdir -p /etc/kubernetes/pki"
  scp k8s-ca.pem $h:/etc/kubernetes/pki/
done

k8s-ca-key.pem 只放 master,worker 上不需要。私钥泄漏等于集群门户大开,能不放就别放。

#五、部署 etcd 集群(m1/m2/m3)

先下二进制:

bash
ETCD_VER=v3.6.13
wget -q https://github.com/etcd-io/etcd/releases/download/${ETCD_VER}/etcd-${ETCD_VER}-linux-amd64.tar.gz
tar -xzf etcd-${ETCD_VER}-linux-amd64.tar.gz
cp etcd-${ETCD_VER}-linux-amd64/etcd{,-ctl} /usr/local/bin/
etcd --version

三台 master 上写 unit 文件,只有 --name、--listen-*、--initial-advertise-peer-urls 三处 IP 不同,我用一个变量生成:

bash
cat > /root/install-etcd.sh <<'SCRIPT'
#!/bin/bash
set -e
NODE_IP=$1
NODE_NAME=$(hostname)

cat > /usr/lib/systemd/system/etcd.service <<EOF
[Unit]
Description=Etcd Server
Documentation=https://etcd.io
After=network.target

[Service]
Type=notify
ExecStart=/usr/local/bin/etcd \\
  --name=${NODE_NAME} \\
  --data-dir=/var/lib/etcd \\
  --wal-dir=/var/lib/etcd/wal \\
  --listen-peer-urls=https://${NODE_IP}:2380 \\
  --listen-client-urls=https://${NODE_IP}:2379,https://127.0.0.1:2379 \\
  --advertise-client-urls=https://${NODE_IP}:2379 \\
  --initial-advertise-peer-urls=https://${NODE_IP}:2380 \\
  --initial-cluster=k8s-m1=https://10.0.0.11:2380,k8s-m2=https://10.0.0.12:2380,k8s-m3=https://10.0.0.13:2380 \\
  --initial-cluster-state=new \\
  --initial-cluster-token=etcd-k8s-cluster \\
  --cert-file=/etc/etcd/ssl/etcd-server.pem \\
  --key-file=/etc/etcd/ssl/etcd-server-key.pem \\
  --peer-cert-file=/etc/etcd/ssl/etcd-server.pem \\
  --peer-key-file=/etc/etcd/ssl/etcd-server-key.pem \\
  --trusted-ca-file=/etc/etcd/ssl/etcd-ca.pem \\
  --peer-trusted-ca-file=/etc/etcd/ssl/etcd-ca.pem \\
  --client-cert-auth=true \\
  --peer-client-cert-auth=true \\
  --snapshot-count=10000 \\
  --quota-backend-bytes=8589934592 \\
  --auto-compaction-mode=revision \\
  --auto-compaction-retention=1000 \\
  --v2-deprecation=write-only \\
  --log-level=info \\
  --logger=zap
Restart=always
RestartSec=10
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now etcd
sleep 3
systemctl is-active etcd
SCRIPT

chmod +x /root/install-etcd.sh
/root/install-etcd.sh 10.0.0.11   # m1

m2、m3 上把 IP 换成自己的,三台启动间隔别超过 30 秒,先起的那台会在 initial-cluster-state=new 下等 peer,等太久会自己退出。

验证:

bash
export ETCDCTL_API=3
etcdctl --endpoints=https://10.0.0.11:2379,https://10.0.0.12:2379,https://10.0.0.13:2379 \
  --cacert=/etc/etcd/ssl/etcd-ca.pem \
  --cert=/etc/etcd/ssl/etcd-client.pem \
  --key=/etc/etcd/ssl/etcd-client-key.pem \
  endpoint health --write-out=table

etcdctl --endpoints=https://10.0.0.11:2379 \
  --cacert=/etc/etcd/ssl/etcd-ca.pem \
  --cert=/etc/etcd/ssl/etcd-client.pem \
  --key=/etc/etcd/ssl/etcd-client-key.pem \
  member list --write-out=table

endpoint health 三行都是 true 且只有一个 leader 才算完事。顺手配个别名,后面天天用:

bash
cat >> /root/.bashrc <<'EOF'
export ETCDCTL_API=3
alias etcdctl-prod='etcdctl --endpoints=https://10.0.0.11:2379,https://10.0.0.12:2379,https://10.0.0.13:2379 --cacert=/etc/etcd/ssl/etcd-ca.pem --cert=/etc/etcd/ssl/etcd-client.pem --key=/etc/etcd/ssl/etcd-client-key.pem'
EOF
source /root/.bashrc

etcd 3.6 有两个变化值得记一下:--v2-deprecation 默认变成 write-only(v2 API 只能写不能读),--snapshot-count 默认从 100000 降到 10000。前者意味着老的 v2 数据已经在被清理的路上,后者意味着快照更频繁、磁盘 IO 略增但恢复更快。

#六、部署控制平面(m1/m2/m3)

下载 k8s 二进制。只需要下 server 包,kubernetes-server-linux-amd64.tar.gz 里已经包含 kube-apiserver、kube-controller-manager、kube-scheduler、kubelet、kube-proxy、kubectl。

bash
K8S_VER=v1.36.4
wget -q https://dl.k8s.io/${K8S_VER}/kubernetes-server-linux-amd64.tar.gz
tar -xzf kubernetes-server-linux-amd64.tar.gz
cd kubernetes/server/bin
cp kube-apiserver kube-controller-manager kube-scheduler kubelet kube-proxy kubectl /usr/local/bin/
chmod +x /usr/local/bin/kube*
kube-apiserver --version

#6.1 生成 kubeconfig

kubeconfig 本质就是"用哪张证书、连哪个地址、以什么身份"的打包。在 m1 上统一生成再分发。

bash
cd /root/ssl
KUBE_APISERVER="https://10.0.0.10:16443"   # 走 VIP,后面 haproxy 起在这个端口

# 通用函数
gen_kubeconfig () {
  local NAME=$1 CN=$2
  kubectl config set-cluster kubernetes \
    --certificate-authority=k8s-ca.pem \
    --embed-certs=true \
    --server=${KUBE_APISERVER} \
    --kubeconfig=${NAME}.kubeconfig
  kubectl config set-credentials ${CN} \
    --client-certificate=${NAME}.pem \
    --client-key=${NAME}-key.pem \
    --embed-certs=true \
    --kubeconfig=${NAME}.kubeconfig
  kubectl config set-context ${CN}@kubernetes \
    --cluster=kubernetes --user=${CN} \
    --kubeconfig=${NAME}.kubeconfig
  kubectl config use-context ${CN}@kubernetes --kubeconfig=${NAME}.kubeconfig
}

gen_kubeconfig kube-controller-manager system:kube-controller-manager
gen_kubeconfig kube-scheduler           system:kube-scheduler
gen_kubeconfig admin                    admin
gen_kubeconfig kube-proxy               system:kube-proxy

kubelet 用的是 bootstrap 机制,不走证书直配。先造一个 bootstrap token:

bash
TOKEN=$(head -c 16 /dev/urandom | od -An -t x | tr -d ' \n' | head -c 6).$(head -c 24 /dev/urandom | base64 | tr -d '=+/' | head -c 16)
echo "bootstrap token: ${TOKEN}"

cat > token.csv <<EOF
${TOKEN},kubelet-bootstrap,10001,"system:kubelet-bootstrap"
EOF

kubectl config set-cluster kubernetes \
  --certificate-authority=k8s-ca.pem --embed-certs=true \
  --server=${KUBE_APISERVER} \
  --kubeconfig=bootstrap-kubelet.kubeconfig
kubectl config set-credentials kubelet-bootstrap \
  --token=${TOKEN} \
  --kubeconfig=bootstrap-kubelet.kubeconfig
kubectl config set-context kubelet-bootstrap@kubernetes \
  --cluster=kubernetes --user=kubelet-bootstrap \
  --kubeconfig=bootstrap-kubelet.kubeconfig
kubectl config use-context kubelet-bootstrap@kubernetes \
  --kubeconfig=bootstrap-kubelet.kubeconfig

把 token.csv 和所有 kubeconfig 分发到三个 master:

bash
for h in k8s-m1 k8s-m2 k8s-m3; do
  scp token.csv bootstrap-kubelet.kubeconfig \
      kube-controller-manager.kubeconfig kube-scheduler.kubeconfig \
      admin.kubeconfig kube-proxy.kubeconfig $h:/etc/kubernetes/
done

#6.2 kube-apiserver

bash
cat > /usr/lib/systemd/system/kube-apiserver.service <<'EOF'
[Unit]
Description=Kubernetes API Server
Documentation=https://kubernetes.io
After=network.target etcd.service

[Service]
ExecStart=/usr/local/bin/kube-apiserver \
  --advertise-address=NODE_IP_PLACEHOLDER \
  --bind-address=0.0.0.0 \
  --secure-port=6443 \
  --allow-privileged=true \
  --authorization-mode=Node,RBAC \
  --enable-admission-plugins=NodeRestriction \
  --enable-bootstrap-token-auth=true \
  --token-auth-file=/etc/kubernetes/token.csv \
  --service-cluster-ip-range=10.96.0.0/12 \
  --service-node-port-range=30000-32767 \
  --etcd-servers=https://10.0.0.11:2379,https://10.0.0.12:2379,https://10.0.0.13:2379 \
  --etcd-cafile=/etc/etcd/ssl/etcd-ca.pem \
  --etcd-certfile=/etc/etcd/ssl/etcd-client.pem \
  --etcd-keyfile=/etc/etcd/ssl/etcd-client-key.pem \
  --client-ca-file=/etc/kubernetes/pki/k8s-ca.pem \
  --tls-cert-file=/etc/kubernetes/pki/apiserver.pem \
  --tls-private-key-file=/etc/kubernetes/pki/apiserver-key.pem \
  --kubelet-client-certificate=/etc/kubernetes/pki/apiserver-kubelet-client.pem \
  --kubelet-client-key=/etc/kubernetes/pki/apiserver-kubelet-client-key.pem \
  --service-account-key-file=/etc/kubernetes/pki/sa.pub \
  --service-account-signing-key-file=/etc/kubernetes/pki/sa.key \
  --service-account-issuer=https://kubernetes.default.svc.cluster.local \
  --requestheader-client-ca-file=/etc/kubernetes/pki/front-proxy-ca.pem \
  --requestheader-username-headers=X-Remote-User \
  --requestheader-group-headers=X-Remote-Group \
  --requestheader-extra-headers-prefix=X-Remote-Extra- \
  --requestheader-allowed-names=front-proxy-client \
  --proxy-client-cert-file=/etc/kubernetes/pki/front-proxy-client.pem \
  --proxy-client-key-file=/etc/kubernetes/pki/front-proxy-client-key.pem \
  --enable-aggregator-routing=true \
  --kubelet-preferred-address-types=InternalIP,ExternalIP,Hostname \
  --max-mutating-requests-inflight=2000 \
  --max-requests-inflight=4000 \
  --default-not-ready-toleration-seconds=30 \
  --default-unreachable-toleration-seconds=30 \
  --audit-log-maxage=15 --audit-log-maxbackup=5 --audit-log-maxsize=200 \
  --audit-log-path=/var/log/kubernetes/apiserver-audit.log \
  --v=2
Restart=always
RestartSec=10
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target
EOF

sed -i "s/NODE_IP_PLACEHOLDER/$(hostname -i | awk '{print $1}')/" /usr/lib/systemd/system/kube-apiserver.service
systemctl daemon-reload && systemctl enable --now kube-apiserver

--advertise-address 必须是本机 IP,不能写 0.0.0.0。三个 master 各自不同。

#6.3 kube-controller-manager

bash
cat > /usr/lib/systemd/system/kube-controller-manager.service <<'EOF'
[Unit]
Description=Kubernetes Controller Manager
Documentation=https://kubernetes.io
After=network.target kube-apiserver.service

[Service]
ExecStart=/usr/local/bin/kube-controller-manager \
  --kubeconfig=/etc/kubernetes/kube-controller-manager.kubeconfig \
  --authentication-kubeconfig=/etc/kubernetes/kube-controller-manager.kubeconfig \
  --authorization-kubeconfig=/etc/kubernetes/kube-controller-manager.kubeconfig \
  --client-ca-file=/etc/kubernetes/pki/k8s-ca.pem \
  --requestheader-client-ca-file=/etc/kubernetes/pki/front-proxy-ca.pem \
  --root-ca-file=/etc/kubernetes/pki/k8s-ca.pem \
  --service-account-private-key-file=/etc/kubernetes/pki/sa.key \
  --cluster-signing-cert-file=/etc/kubernetes/pki/k8s-ca.pem \
  --cluster-signing-key-file=/etc/kubernetes/pki/k8s-ca-key.pem \
  --cluster-signing-duration=87600h \
  --cluster-name=kubernetes \
  --cluster-cidr=172.16.0.0/16 \
  --service-cluster-ip-range=10.96.0.0/12 \
  --allocate-node-cidrs=true \
  --controllers=*,bootstrapsigner,tokencleaner \
  --use-service-account-credentials=true \
  --node-monitor-period=5s \
  --node-monitor-grace-period=40s \
  --terminated-pod-gc-threshold=100 \
  --leader-elect=true \
  --leader-elect-lease-duration=15s \
  --leader-elect-renew-deadline=10s \
  --leader-elect-retry-period=2s \
  --v=2
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload && systemctl enable --now kube-controller-manager

--cluster-cidr=172.16.0.0/16 这个值和后面 Calico 的 IP 池必须一字不差。不一致的话,节点能就绪,但 Pod 会一直 ContainerCreating,kubectl describe node 里看不到已分配的 CIDR。

#6.4 kube-scheduler

bash
cat > /etc/kubernetes/kube-scheduler.yaml <<'EOF'
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
clientConnection:
  kubeconfig: /etc/kubernetes/kube-scheduler.kubeconfig
leaderElection:
  leaderElect: true
  leaseDuration: 15s
  renewDeadline: 10s
  retryPeriod: 2s
percentageOfNodesToScore: 100
EOF

cat > /usr/lib/systemd/system/kube-scheduler.service <<'EOF'
[Unit]
Description=Kubernetes Scheduler
Documentation=https://kubernetes.io
After=network.target kube-apiserver.service

[Service]
ExecStart=/usr/local/bin/kube-scheduler \
  --config=/etc/kubernetes/kube-scheduler.yaml \
  --v=2
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload && systemctl enable --now kube-scheduler

#6.5 配 kubectl 并授权

bash
mkdir -p ~/.kube
cp /etc/kubernetes/admin.kubeconfig ~/.kube/config
kubectl completion bash > /etc/bash_completion.d/kubectl
source /etc/bash_completion.d/kubectl

kubectl get cs    # 老命令可能已废弃,改用下面这条
kubectl get --raw='/readyz?verbose' | grep -E 'etcd|readyz'

给 kubelet 的 bootstrap 授权,以及让 kubectl logs/exec/top 能用(system:kube-apiserver-to-kubelet 这个 ClusterRole 在新版集群里默认不存在,得自己建):

bash
cat > /root/rbac-bootstrap.yaml <<'EOF'
# 允许 bootstrap token 发起 CSR
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: kubelet-bootstrap
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:node-bootstrapper
subjects:
- apiGroup: rbac.authorization.k8s.io
  kind: Group
  name: system:kubelet-bootstrap
---
# 自动批准首次 CSR
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: auto-approve-csrs-for-group
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:certificates.k8s.io:certificatesigningrequests:nodeclient
subjects:
- apiGroup: rbac.authorization.k8s.io
  kind: Group
  name: system:kubelet-bootstrap
---
# 自动批准 kubelet 客户端证书续期
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: auto-approve-renewals-for-nodes
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:certificates.k8s.io:certificatesigningrequests:selfnodeclient
subjects:
- apiGroup: rbac.authorization.k8s.io
  kind: Group
  name: system:nodes
---
# 自动批准 kubelet server 证书(serverTLSBootstrap 打开时需要)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: auto-approve-server-renewals-for-nodes
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:certificates.k8s.io:certificatesigningrequests:selfnodeserver
subjects:
- apiGroup: rbac.authorization.k8s.io
  kind: Group
  name: system:nodes
---
# apiserver 访问 kubelet 的 logs/exec/metrics
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: system:kube-apiserver-to-kubelet
rules:
- apiGroups: [""]
  resources: ["nodes/proxy", "nodes/stats", "nodes/log", "nodes/spec", "nodes/metrics"]
  verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: system:kube-apiserver
  namespace: ""
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:kube-apiserver-to-kubelet
subjects:
- apiGroup: rbac.authorization.k8s.io
  kind: User
  name: kube-apiserver
EOF

kubectl apply -f /root/rbac-bootstrap.yaml

#七、apiserver 高可用:haproxy + keepalived

三台 apiserver 都跑着,但 kubelet 和 kubectl 需要一个地址。在三个 master 上各跑一份 haproxy(本机 16443 转发到三台 6443)+ keepalived(VIP 漂移)。

bash
dnf install -y haproxy keepalived

cat > /etc/haproxy/haproxy.cfg <<'EOF'
global
    log         127.0.0.1 local2
    maxconn     32768
    user        haproxy
    group       haproxy
    daemon

defaults
    mode                    tcp
    log                     global
    option                  tcplog
    timeout connect         10s
    timeout client          1m
    timeout server          1m

frontend k8s-apiserver
    bind *:16443
    mode tcp
    default_backend k8s-apiserver

backend k8s-apiserver
    mode tcp
    balance roundrobin
    option tcp-check
    server k8s-m1 10.0.0.11:6443 check inter 2000 fall 3 rise 2
    server k8s-m2 10.0.0.12:6443 check inter 2000 fall 3 rise 2
    server k8s-m3 10.0.0.13:6443 check inter 2000 fall 3 rise 2
EOF

cat > /etc/keepalived/keepalived.conf <<EOF
! Configuration File for keepalived
global_defs {
    router_id LVS_K8S_$(hostname | tr 'a-z' 'A-Z')
    script_user root
    enable_script_security
}

vrrp_script check_apiserver {
    script "/etc/keepalived/check_apiserver.sh"
    interval 3
    weight -30
    fall 2
    rise 2
}

vrrp_instance VI_1 {
    state BACKUP
    interface eth0              # 改成你的网卡名
    virtual_router_id 51
    priority 100                # m1=100 m2=99 m3=98
    advert_int 1
    nopreempt
    authentication {
        auth_type PASS
        auth_pass k8s_vip_2026
    }
    virtual_ipaddress {
        10.0.0.10/24 dev eth0
    }
    track_script {
        check_apiserver
    }
}
EOF

cat > /etc/keepalived/check_apiserver.sh <<'EOF'
#!/bin/bash
if curl -sk --max-time 2 https://127.0.0.1:16443/readyz >/dev/null 2>&1; then
  exit 0
fi
exit 1
EOF
chmod +x /etc/keepalived/check_apiserver.sh

systemctl enable --now haproxy keepalived

关键点:keepalived 检测的是本机 haproxy 的 16443,不是本机 apiserver 的 6443。这样即使本机 apiserver 挂了,只要 haproxy 能转发到其他两台,VIP 就不用漂,避免了无谓的抖动。

验证:

bash
ip addr show | grep 10.0.0.10        # 只在某台上出现
curl -sk https://10.0.0.10:16443/readyz
systemctl stop kube-apiserver        # 在持有 VIP 的机器上停掉,看 VIP 是否漂走

#八、全部 5 台:kubelet + kube-proxy

#8.1 kubelet

bash
cat > /etc/kubernetes/kubelet-config.yaml <<'EOF'
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
address: 0.0.0.0
port: 10250
readOnlyPort: 0
authentication:
  anonymous:
    enabled: false
  webhook:
    enabled: true
    cacheTTL: 2m0s
  x509:
    clientCAFile: /etc/kubernetes/pki/k8s-ca.pem
authorization:
  mode: Webhook
  webhook:
    cacheAuthorizedTTL: 5m0s
    cacheUnauthorizedTTL: 30s
cgroupDriver: systemd
clusterDNS:
- 10.96.0.10
clusterDomain: cluster.local
containerLogMaxFiles: 5
containerLogMaxSize: 100Mi
maxPods: 110
failSwapOn: true
serializeImagePulls: false
registryPullQPS: 10
registryBurst: 20
rotateCertificates: true
serverTLSBootstrap: true
evictionHard:
  memory.available: 500Mi
  nodefs.available: 10%
  imagefs.available: 15%
  nodefs.inodesFree: 5%
evictionSoft:
  memory.available: 1Gi
evictionSoftGracePeriod:
  memory.available: 1m30s
kubeReserved:
  cpu: 200m
  memory: 1Gi
  ephemeral-storage: 1Gi
systemReserved:
  cpu: 200m
  memory: 1Gi
  ephemeral-storage: 1Gi
EOF

cat > /usr/lib/systemd/system/kubelet.service <<'EOF'
[Unit]
Description=Kubernetes Kubelet
Documentation=https://kubernetes.io
After=containerd.service
Requires=containerd.service

[Service]
ExecStart=/usr/local/bin/kubelet \
  --bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.kubeconfig \
  --kubeconfig=/etc/kubernetes/kubelet.kubeconfig \
  --config=/etc/kubernetes/kubelet-config.yaml \
  --cert-dir=/etc/kubernetes/pki \
  --container-runtime-endpoint=unix:///run/containerd/containerd.sock \
  --node-ip=NODE_IP_PLACEHOLDER \
  --hostname-override=HOSTNAME_PLACEHOLDER \
  --pod-infra-container-image=registry.k8s.io/pause:3.10 \
  --root-dir=/var/lib/kubelet \
  --v=2
Restart=always
RestartSec=10
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target
EOF

sed -i "s/NODE_IP_PLACEHOLDER/$(hostname -i | awk '{print $1}')/" /usr/lib/systemd/system/kubelet.service
sed -i "s/HOSTNAME_PLACEHOLDER/$(hostname)/" /usr/lib/systemd/system/kubelet.service

systemctl daemon-reload && systemctl enable --now kubelet

worker 节点上需要先把 /etc/kubernetes/bootstrap-kubelet.kubeconfig 从 m1 scp 过去:

bash
# m1 上执行
scp /etc/kubernetes/bootstrap-kubelet.kubeconfig /etc/kubernetes/pki/k8s-ca.pem k8s-n1:/etc/kubernetes/
scp /etc/kubernetes/bootstrap-kubelet.kubeconfig /etc/kubernetes/pki/k8s-ca.pem k8s-n2:/etc/kubernetes/
# n1/n2 上把 k8s-ca.pem 放到 /etc/kubernetes/pki/

kubelet 起来后会自己申请证书。在 m1 上看:

bash
kubectl get csr
# 会出现 Pending -> Approved,Issued
# 因为开了 serverTLSBootstrap,还会多一个 selfnodeserver 的 CSR,同样会自动批准

如果一直是 Pending,去看 kubelet 日志 journalctl -u kubelet -f,十有八九是 bootstrap token 不对或者 kubeconfig 里的 apiserver 地址不通。

#8.2 kube-proxy

bash
cat > /etc/kubernetes/kube-proxy-config.yaml <<'EOF'
apiVersion: kubeproxy.config.k8s.io/v1alpha1
kind: KubeProxyConfiguration
bindAddress: 0.0.0.0
clientConnection:
  kubeconfig: /etc/kubernetes/kube-proxy.kubeconfig
clusterCIDR: 172.16.0.0/16
mode: ipvs
healthzBindAddress: 0.0.0.0:10256
metricsBindAddress: 0.0.0.0:10249
ipvs:
  scheduler: rr
  strictARP: true
  tcpTimeout: 0s
  tcpFinTimeout: 0s
  udpTimeout: 0s
conntrack:
  maxPerCore: 32768
  min: 131072
  tcpEstablishedTimeout: 0s
  tcpCloseWaitTimeout: 0s
EOF

cat > /usr/lib/systemd/system/kube-proxy.service <<'EOF'
[Unit]
Description=Kubernetes Kube Proxy
Documentation=https://kubernetes.io
After=network.target

[Service]
ExecStart=/usr/local/bin/kube-proxy \
  --config=/etc/kubernetes/kube-proxy-config.yaml \
  --hostname-override=HOSTNAME_PLACEHOLDER \
  --v=2
Restart=always
RestartSec=10
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target
EOF

sed -i "s/HOSTNAME_PLACEHOLDER/$(hostname)/" /usr/lib/systemd/system/kube-proxy.service
systemctl daemon-reload && systemctl enable --now kube-proxy

worker 节点需要 kube-proxy.kubeconfig:

bash
scp /etc/kubernetes/kube-proxy.kubeconfig k8s-n1:/etc/kubernetes/
scp /etc/kubernetes/kube-proxy.kubeconfig k8s-n2:/etc/kubernetes/

#8.3 给 master 打污点、打角色标签

bash
kubectl label node k8s-m1 node-role.kubernetes.io/control-plane=
kubectl label node k8s-m2 node-role.kubernetes.io/control-plane=
kubectl label node k8s-m3 node-role.kubernetes.io/control-plane=
kubectl label node k8s-n1 node-role.kubernetes.io/worker=
kubectl label node k8s-n2 node-role.kubernetes.io/worker=

kubectl taint nodes k8s-m1 node-role.kubernetes.io/control-plane:NoSchedule
kubectl taint nodes k8s-m2 node-role.kubernetes.io/control-plane:NoSchedule
kubectl taint nodes k8s-m3 node-role.kubernetes.io/control-plane:NoSchedule

kubectl get nodes -o wide

此时 kubectl get nodes 应该 5 台都是 Ready。到这里,一个"能跑 Pod 但没网络"的 K8s 集群就立起来了。

#九、集群网络与核心插件

#9.1 Calico

bash
CALICO_VER=v3.32.2
wget -q https://github.com/projectcalico/calico/releases/download/${CALICO_VER}/release-${CALICO_VER}.tgz
tar -xzf release-${CALICO_VER}.tgz
cd release-${CALICO_VER}/manifests/

# IP 池必须和 kube-controller-manager 的 --cluster-cidr 一致
sed -i 's|# - name: CALICO_IPV4POOL_CIDR|  - name: CALICO_IPV4POOL_CIDR|; s|#   value: "192.168.0.0/16"|  value: "172.16.0.0/16"|' calico.yaml
grep -A1 'CALICO_IPV4POOL_CIDR' calico.yaml

kubectl apply -f calico.yaml
kubectl -n kube-system get pod -l k8s-app=calico-node -w

默认单网卡环境不用改网卡发现规则。如果你的机器是多网卡(比如同时有管理网和业务网),加一条:

bash
sed -i '/# - name: IP_AUTODETECTION_METHOD/{n; s|#   value: "first-found"|  value: "interface=eth0"|}' calico.yaml
sed -i 's|# - name: IP_AUTODETECTION_METHOD|  - name: IP_AUTODETECTION_METHOD|' calico.yaml

装 calicoctl 备用:

bash
wget -q -O /usr/local/bin/calicoctl https://github.com/projectcalico/calico/releases/download/${CALICO_VER}/calicoctl-linux-amd64
chmod +x /usr/local/bin/calicoctl
calicoctl node status

#9.2 CoreDNS

bash
cat > /root/coredns.yaml <<'EOF'
apiVersion: v1
kind: ServiceAccount
metadata:
  name: coredns
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: system:coredns
rules:
- apiGroups: [""]
  resources: ["endpoints", "services", "pods", "namespaces"]
  verbs: ["list", "watch"]
- apiGroups: ["discovery.k8s.io"]
  resources: ["endpointslices"]
  verbs: ["list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: system:coredns
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:coredns
subjects:
- kind: ServiceAccount
  name: coredns
  namespace: kube-system
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: coredns
  namespace: kube-system
data:
  Corefile: |
    .:53 {
        errors
        health {
          lameduck 5s
        }
        ready
        kubernetes cluster.local in-addr.arpa ip6.arpa {
          pods insecure
          fallthrough in-addr.arpa ip6.arpa
          ttl 30
        }
        prometheus :9153
        forward . /etc/resolv.conf {
          max_concurrent 1000
        }
        cache 30
        loop
        reload
        loadbalance
    }
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: coredns
  namespace: kube-system
  labels:
    k8s-app: kube-dns
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
  selector:
    matchLabels:
      k8s-app: kube-dns
  template:
    metadata:
      labels:
        k8s-app: kube-dns
    spec:
      priorityClassName: system-cluster-critical
      serviceAccountName: coredns
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: k8s-app
                operator: In
                values: ["kube-dns"]
            topologyKey: kubernetes.io/hostname
      containers:
      - name: coredns
        image: registry.k8s.io/coredns/coredns:v1.13.2
        imagePullPolicy: IfNotPresent
        args: ["-conf", "/etc/coredns/Corefile"]
        resources:
          limits:
            memory: 512Mi
          requests:
            cpu: 100m
            memory: 128Mi
        ports:
        - containerPort: 53
          name: dns
          protocol: UDP
        - containerPort: 53
          name: dns-tcp
          protocol: TCP
        - containerPort: 9153
          name: metrics
          protocol: TCP
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
            scheme: HTTP
          initialDelaySeconds: 60
          periodSeconds: 10
          timeoutSeconds: 5
          successThreshold: 1
          failureThreshold: 5
        readinessProbe:
          httpGet:
            path: /ready
            port: 8181
            scheme: HTTP
          periodSeconds: 2
          timeoutSeconds: 3
        volumeMounts:
        - name: config-volume
          mountPath: /etc/coredns
          readOnly: true
      volumes:
      - name: config-volume
        configMap:
          name: coredns
          items:
          - key: Corefile
            path: Corefile
---
apiVersion: v1
kind: Service
metadata:
  name: kube-dns
  namespace: kube-system
  annotations:
    prometheus.io/port: "9153"
    prometheus.io/scrape: "true"
  labels:
    k8s-app: kube-dns
spec:
  selector:
    k8s-app: kube-dns
  clusterIP: 10.96.0.10
  ports:
  - name: dns
    port: 53
    protocol: UDP
    targetPort: 53
  - name: dns-tcp
    port: 53
    protocol: TCP
    targetPort: 53
  - name: metrics
    port: 9153
    protocol: TCP
    targetPort: 9153
EOF

kubectl apply -f /root/coredns.yaml

clusterIP: 10.96.0.10 必须和 kubelet 配置里的 clusterDNS 一致,且落在 Service CIDR 内。

#9.3 metrics-server

bash
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml

# 二进制集群的 kubelet 证书不是集群 CA 签发的场景,加上这个参数
kubectl -n kube-system patch deployment metrics-server --type='json' -p='[
  {"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"},
  {"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-preferred-address-types=InternalIP,ExternalIP,Hostname"}
]'

等半分钟:

bash
kubectl top nodes
kubectl top pod -A

#9.4 Ingress-NGINX

裸金属没有云 LoadBalancer,用 DaemonSet + hostNetwork 直接占住 worker 的 80/443。master 有 NoSchedule 污点,所以只会落在 n1/n2 上。

bash
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update

helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \
  -n ingress-nginx --create-namespace \
  --set controller.kind=DaemonSet \
  --set controller.hostNetwork=true \
  --set controller.dnsPolicy=ClusterFirstWithHostNet \
  --set controller.service.type=ClusterIP \
  --set controller.ingressClassResource.default=true \
  --set controller.metrics.enabled=true \
  --set controller.config.proxy-body-size=100m \
  --set controller.config.use-forwarded-headers=true

kubectl -n ingress-nginx get pod -o wide

两个 worker 都有 ingress controller,外网流量怎么进来:在内网 DNS 上把 *.apps.lxhhub.top 做成两条 A 记录分别指向 n1、n2(DNS 轮询),或者在 n1/n2 上再跑一组 keepalived 提供第二个 VIP。前者简单,后者能摘掉故障节点,看你对可用性要求到哪一步。

顺带一句,Ingress-NGINX 社区近两年维护节奏放缓,Gateway API 是明确的方向。新集群如果不想中途迁移,可以一开始就用 Nginx Gateway Fabric 或者 Traefik,本文为了贴国内主流做法还是用 Ingress-NGINX。

#十、存储:给有状态服务一个落脚点

5 台机器,没有独立的存储节点。Harbor、Jenkins、Nacos、MySQL 全都要 PVC,所以先解决供给问题。

我的做法:在 m1 上起一个 NFS Server,用 nfs-subdir-external-provisioner 提供动态 PV。这是个明显的单点——m1 挂了新 PVC 就创建不了,但已有 PV 上的数据还在,正在跑的服务不受影响。对 5 台机器的规模,这个妥协可以接受,别拿它当长期方案。

bash
# m1 上
mkdir -p /data/nfs && chmod 777 /data/nfs
dnf install -y nfs-utils
systemctl enable --now nfs-server

cat > /etc/exports <<'EOF'
/data/nfs 10.0.0.0/24(rw,sync,no_root_squash,no_subtree_check)
EOF
exportfs -arv
showmount -e 10.0.0.11

# n1/n2 上验证
showmount -e 10.0.0.11

provisioner 用 Helm 装:

bash
helm repo add nfs-subdir-external-provisioner \
  https://kubernetes-sigs.github.io/nfs-subdir-external-provisioner/

helm upgrade --install nfs-client nfs-subdir-external-provisioner/nfs-subdir-external-provisioner \
  -n kube-system \
  --set nfs.server=10.0.0.11 \
  --set nfs.path=/data/nfs \
  --set storageClass.name=nfs-client \
  --set storageClass.defaultClass=true \
  --set storageClass.reclaimPolicy=Retain \
  --set storageClass.archiveOnDelete=false \
  --set replicaCount=1

kubectl get sc

reclaimPolicy=Retain 是有意为之:PVC 误删时数据不会跟着没。

另外给需要高性能本地盘的东西(比如 Nacos 的 JRaft 日志、MySQL)再准备一个 local 类型的 StorageClass 可选。真要上生产,换 Rook-Ceph 或 Longhorn,但那至少再要 3 块独立磁盘,5 台机器吃不消。

#十一、DevOps 工具链

装 Helm 客户端(在 m1 上):

bash
HELM_VER=v3.17.3
wget -q https://get.helm.sh/helm-${HELM_VER}-linux-amd64.tar.gz
tar -xzf helm-${HELM_VER}-linux-amd64.tar.gz
mv linux-amd64/helm /usr/local/bin/
helm version

命名空间规划,一次性建好:

bash
for ns in devops harbor git cicd observability logging middleware prod; do
  kubectl create ns $ns --dry-run=client -o yaml | kubectl apply -f -
done

#11.1 Harbor(镜像仓库)

bash
helm repo add harbor https://helm.goharbor.io
helm repo update

cat > /root/harbor-values.yaml <<'EOF'
expose:
  type: ingress
  tls:
    enabled: false
  ingress:
    className: nginx
    hosts:
      core: harbor.apps.lxhhub.top
    annotations:
      nginx.ingress.kubernetes.io/proxy-body-size: "0"
      nginx.ingress.kubernetes.io/proxy-read-timeout: "900"
      nginx.ingress.kubernetes.io/proxy-send-timeout: "900"

externalURL: http://harbor.apps.lxhhub.top
harborAdminPassword: "Harbor@2026!ChangeMe"

persistence:
  enabled: true
  resourcePolicy: "keep"
  persistentVolumeClaim:
    registry:
      storageClass: "nfs-client"
      size: 200Gi
    jobservice:
      jobLog:
        storageClass: "nfs-client"
        size: 10Gi
    database:
      storageClass: "nfs-client"
      size: 10Gi
    redis:
      storageClass: "nfs-client"
      size: 10Gi
    trivy:
      storageClass: "nfs-client"
      size: 10Gi

notary:
  enabled: false
trivy:
  enabled: true
EOF

helm upgrade --install harbor harbor/harbor \
  -n harbor -f /root/harbor-values.yaml --wait --timeout 15m

起来之后在 Harbor 里建两个项目:

  • library:公共基础镜像(pause、nginx 等)
  • shop:业务镜像

Helm chart 现在走 OCI,不用 ChartMuseum 了:

bash
helm package ./charts/shop && helm push shop-0.1.0.tgz oci://harbor.apps.lxhhub.top/shop/charts

再建一个机器人账号 robot$shop-pusher 给 Jenkins 用,一个 robot$shop-puller 给集群拉镜像用。机器人账号的权限最小化,比拿 admin 账号到处配要安全。

集群拉镜像用的 secret(每个业务命名空间都要建):

bash
kubectl create secret docker-registry harbor-pull \
  -n prod \
  --docker-server=harbor.apps.lxhhub.top \
  --docker-username='robot$shop-puller' \
  --docker-password='<token>' \
  --dry-run=client -o yaml | kubectl apply -f -

想省事就把 secret 挂到 default ServiceAccount 上,Pod 不用每个都写 imagePullSecrets:

bash
kubectl -n prod patch serviceaccount default \
  -p '{"imagePullSecrets":[{"name":"harbor-pull"}]}'

#11.2 Gitea(代码仓库)

GitLab 太重,5 台机器跑不动;Gitea 一个二进制搞定,带 Web 界面、PR、Actions,够用。

bash
helm repo add gitea https://dl.gitea.com/charts
helm repo update

cat > /root/gitea-values.yaml <<'EOF'
ingress:
  enabled: true
  className: nginx
  hosts:
  - host: git.apps.lxhhub.top
    paths:
    - path: /
      pathType: Prefix
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "512m"

persistence:
  enabled: true
  storageClass: nfs-client
  size: 50Gi

gitea:
  admin:
    username: gitea-admin
    password: "Gitea@2026!ChangeMe"
    email: admin@lxhhub.top
  config:
    server:
      ROOT_URL: http://git.apps.lxhhub.top/
      SSH_DOMAIN: git.apps.lxhhub.top
    repository:
      ROOT: /data/git/repositories
    service:
      DISABLE_REGISTRATION: true
    actions:
      ENABLED: true

redis-cluster:
  enabled: false
postgresql:
  enabled: true
  persistence:
    enabled: true
    storageClass: nfs-client
    size: 10Gi
  auth:
    database: gitea
EOF

helm upgrade --install gitea gitea/gitea -n git -f /root/gitea-values.yaml --wait --timeout 10m

DISABLE_REGISTRATION: true 建议开着,内网仓库别让人随便注册。

#11.3 Nexus(Maven 私服)

官方 Helm chart 维护得一般,我直接写清单:

bash
cat > /root/nexus.yaml <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nexus-data
  namespace: devops
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: nfs-client
  resources:
    requests:
      storage: 200Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nexus
  namespace: devops
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nexus
  template:
    metadata:
      labels:
        app: nexus
    spec:
      initContainers:
      - name: fix-perm
        image: busybox:1.37
        command: ["sh", "-c", "chown -R 200:200 /nexus-data"]
        volumeMounts:
        - name: data
          mountPath: /nexus-data
      containers:
      - name: nexus
        image: sonatype/nexus3:3.83.1
        ports:
        - containerPort: 8081
        env:
        - name: INSTALL4J_ADD_VM_PARAMS
          value: "-Xms2g -Xmx2g -XX:MaxDirectMemorySize=2g -Djava.util.prefs.userRoot=/nexus-data/javaprefs"
        readinessProbe:
          httpGet: { path: /service/rest/v1/status, port: 8081 }
          initialDelaySeconds: 90
          periodSeconds: 15
          failureThreshold: 20
        livenessProbe:
          httpGet: { path: /service/rest/v1/status, port: 8081 }
          initialDelaySeconds: 180
          periodSeconds: 30
        resources:
          requests: { cpu: 1, memory: 4Gi }
          limits:   { cpu: 2, memory: 5Gi }
        volumeMounts:
        - name: data
          mountPath: /nexus-data
      volumes:
      - name: data
        persistentVolumeClaim:
          claimName: nexus-data
---
apiVersion: v1
kind: Service
metadata:
  name: nexus
  namespace: devops
spec:
  selector: { app: nexus }
  ports:
  - port: 8081
    targetPort: 8081
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nexus
  namespace: devops
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "1g"
spec:
  ingressClassName: nginx
  rules:
  - host: nexus.apps.lxhhub.top
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: nexus
            port: { number: 8081 }
EOF

kubectl apply -f /root/nexus.yaml

初始化密码在 /nexus-data/admin.password,kubectl exec 进去拿。之后在 Nexus 上建:

  • maven-proxy 代理 https://maven.aliyun.com/repository/public(国内速度快很多)
  • maven-releases / maven-snapshots 两个 hosted 仓库
  • maven-public group 聚合上面三个,Maven 的 settings.xml 只配这一个镜像地址

Nexus 起得慢,ready 探针给了 90 秒初始延迟和 20 次失败容忍,别改小。

#11.4 Jenkins(CI)

Jenkins 用 Helm 装,重点是让它能动态起 Pod 当构建节点。

bash
helm repo add jenkins https://charts.jenkins.io
helm repo update

cat > /root/jenkins-values.yaml <<'EOF'
controller:
  image: "jenkins/jenkins"
  tag: "lts-jdk21"
  imagePullPolicy: "IfNotPresent"
  numExecutors: 0          # 构建全丢给 agent pod
  adminUser: "admin"
  adminPassword: "Jenkins@2026!ChangeMe"
  ingress:
    enabled: true
    className: nginx
    hostName: jenkins.apps.lxhhub.top
    annotations:
      nginx.ingress.kubernetes.io/proxy-body-size: "100m"
  additionalPlugins:
  - kubernetes:latest
  - workflow-aggregator:latest
  - git:latest
  - configuration-as-code:latest
  - credentials-binding:latest
  - sonar:latest
  - docker-workflow:latest
  - blueocean:latest
  installLatestPlugins: false
  JCasC:
    enabled: false
  persistence:
    enabled: true
    storageClass: nfs-client
    size: 50Gi
  resources:
    requests: { cpu: 500m, memory: 2Gi }
    limits:   { cpu: 2,    memory: 4Gi }
  serviceType: ClusterIP
  healthProbes:
    livenessFailureThreshold: 12

agent:
  enabled: true
  image: "jenkins/inbound-agent"
  tag: "latest-jdk21"
  resources:
    requests: { cpu: 500m, memory: 1Gi }
    limits:   { cpu: 2,    memory: 3Gi }
EOF

helm upgrade --install jenkins jenkins/jenkins -n cicd -f /root/jenkins-values.yaml --wait --timeout 15m

装完之后要做的三件事:

  1. 配 Kubernetes Cloud。 系统管理 → Clouds → New cloud,Kubernetes 类型,命名空间 cicd,Jenkins 地址 http://jenkins.cicd.svc.cluster.local:8080。不用填 kubeconfig,Pod 里的 ServiceAccount 已经够用(Helm chart 自动建了 RBAC)。

  2. 配 Maven 缓存 PVC。 每个构建 Pod 都重新下依赖会慢到让人放弃。建一个 PVC 挂到 /root/.m2,第一次慢,之后就快了。

bash
kubectl -n cicd create pvc maven-repo --storage-class=nfs-client --access-mode=ReadWriteMany

注意要 ReadWriteMany,因为可能同时跑多个构建。NFS 支持 RWX,这也是我选 NFS 而不是 local-path 的原因之一。

  1. 配凭据。 Gitea 账号、Harbor 机器人账号、Sonar token 都加到 Jenkins credentials 里。

#11.5 SonarQube(可选)

4G 内存起步,机器紧张可以跳过,不影响主线。

bash
helm repo add sonarqube https://SonarSource.github.io/helm-chart-sonarqube
helm repo update

helm upgrade --install sonarqube sonarqube/sonarqube -n devops \
  --set edition=community \
  --set ingress.enabled=true \
  --set ingress.className=nginx \
  --set ingress.hosts[0].name=sonar.apps.lxhhub.top \
  --set persistence.enabled=true \
  --set persistence.storageClass=nfs-client \
  --set persistence.size=20Gi \
  --set postgresql.persistence.enabled=true \
  --set postgresql.persistence.storageClass=nfs-client \
  --wait --timeout 15m

#11.6 Argo CD(GitOps CD)

发布环节我用 Argo CD,Jenkins 只负责"构建镜像 + 改 GitOps 仓库里的 tag",不直接碰集群。这样集群里跑了什么,Git 仓库里一清二楚,回滚就是一次 Git revert。

bash
helm repo add argo https://argoproj.github.io/argo-helm
helm repo update

cat > /root/argocd-values.yaml <<'EOF'
configs:
  params:
    server.insecure: true
  repositories:
    gitea-shop:
      url: http://git.apps.lxhhub.top/gitea-admin/shop-gitops.git

server:
  ingress:
    enabled: true
    ingressClassName: nginx
    hostname: argocd.apps.lxhhub.top
    extraAnnotations:
      nginx.ingress.kubernetes.io/ssl-passthrough: "false"
      nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
  resources:
    requests: { cpu: 250m, memory: 512Mi }

controller:
  resources:
    requests: { cpu: 250m, memory: 1Gi }

repoServer:
  resources:
    requests: { cpu: 250m, memory: 1Gi }
EOF

helm upgrade --install argocd argo/argo-cd -n argocd --create-namespace \
  -f /root/argocd-values.yaml --wait --timeout 10m

# 初始密码
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d; echo

#11.7 监控:Prometheus + Grafana

bash
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

cat > /root/prom-values.yaml <<'EOF'
prometheus:
  prometheusSpec:
    retention: 15d
    retentionSize: 45GiB
    scrapeInterval: 30s
    evaluationInterval: 30s
    storageSpec:
      volumeClaimTemplate:
        spec:
          storageClassName: nfs-client
          accessModes: ["ReadWriteOnce"]
          resources:
            requests:
              storage: 100Gi
    resources:
      requests: { cpu: 500m, memory: 2Gi }
      limits:   { cpu: 2,    memory: 6Gi }

grafana:
  enabled: true
  adminPassword: "Grafana@2026!ChangeMe"
  ingress:
    enabled: true
    ingressClassName: nginx
    hosts: ["grafana.apps.lxhhub.top"]
  persistence:
    enabled: true
    storageClassName: nfs-client
    size: 10Gi
  grafana.ini:
    date_formats:
      default_timezone: Asia/Shanghai

alertmanager:
  alertmanagerSpec:
    storage:
      volumeClaimTemplate:
        spec:
          storageClassName: nfs-client
          accessModes: ["ReadWriteOnce"]
          resources:
            requests:
              storage: 10Gi

kubeStateMetrics:
  enabled: true
nodeExporter:
  enabled: true
prometheusOperator:
  enabled: true
EOF

helm upgrade --install kube-prom prometheus-community/kube-prometheus-stack \
  -n observability -f /root/prom-values.yaml --wait --timeout 15m

二进制部署的 etcd 不在集群里,默认抓不到。把 etcd 客户端证书做成 Secret,Prometheus 就能通过节点 IP 抓它:

bash
kubectl -n observability create secret generic etcd-client-certs \
  --from-file=ca.crt=/etc/etcd/ssl/etcd-ca.pem \
  --from-file=tls.crt=/etc/etcd/ssl/etcd-client.pem \
  --from-file=tls.key=/etc/etcd/ssl/etcd-client-key.pem
yaml
# /root/prom-etcd.yaml
prometheus:
  prometheusSpec:
    secrets: ["etcd-client-certs"]
    additionalScrapeConfigs:
    - job_name: 'etcd'
      scheme: https
      tls_config:
        ca_file: /etc/prometheus/secrets/etcd-client-certs/ca.crt
        cert_file: /etc/prometheus/secrets/etcd-client-certs/tls.crt
        key_file: /etc/prometheus/secrets/etcd-client-certs/tls.key
      static_configs:
      - targets: ['10.0.0.11:2379','10.0.0.12:2379','10.0.0.13:2379']
bash
helm upgrade kube-prom prometheus-community/kube-prometheus-stack \
  -n observability -f /root/prom-values.yaml -f /root/prom-etcd.yaml

告警推送到企业微信/钉钉,改 alertmanager.config,这里不展开。

#11.8 日志:Loki + Alloy

Promtail 已经进入维护尾声,新部署直接用 Grafana Alloy 采集。

bash
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

cat > /root/loki-values.yaml <<'EOF'
deploymentMode: SingleBinary
loki:
  auth_enabled: false
  commonConfig:
    replication_factor: 1
  storage:
    type: filesystem
    bucketNames:
      chunks: chunks
      ruler: ruler
      admin: admin
  limits_config:
    retention_period: 15d
  schemaConfig:
    configs:
    - from: "2024-01-01"
      store: tsdb
      object_store: filesystem
      schema: v13
      index:
        prefix: index_
        period: 24h
  pattern_ingester:
    enabled: false

singleBinary:
  replicas: 1
  persistence:
    enabled: true
    storageClass: nfs-client
    size: 100Gi
  resources:
    requests: { cpu: 500m, memory: 1Gi }
    limits:   { cpu: 2,    memory: 3Gi }

gateway:
  enabled: true
monitoring:
  selfMonitoring:
    enabled: false
    grafanaAgent:
      installOperator: false
  lokiCanary:
    enabled: false
test:
  enabled: false
EOF

helm upgrade --install loki grafana/loki -n logging -f /root/loki-values.yaml --wait --timeout 10m

Alloy 采集器:

bash
cat > /root/alloy-values.yaml <<'EOF'
alloy:
  configMap:
    create: true
    content: |
      logging {
        level = "info"
      }

      discovery.kubernetes "pods" {
        role = "pod"
      }

      discovery.relabel "pod_logs" {
        targets = discovery.kubernetes.pods.targets

        rule {
          source_labels = ["__meta_kubernetes_namespace"]
          action        = "replace"
          target_label  = "namespace"
        }
        rule {
          source_labels = ["__meta_kubernetes_pod_name"]
          action        = "replace"
          target_label  = "pod"
        }
        rule {
          source_labels = ["__meta_kubernetes_pod_container_name"]
          action        = "replace"
          target_label  = "container"
        }
        rule {
          source_labels = ["__meta_kubernetes_pod_uid", "__meta_kubernetes_pod_container_name"]
          action        = "replace"
          separator     = "/"
          replacement   = "/var/log/pods/*$1/*.log"
          target_label  = "__path__"
        }
        rule {
          action       = "labelmap"
          regex        = "__meta_kubernetes_pod_label_(.+)"
        }
      }

      loki.source.file "pod_logs" {
        targets    = discovery.relabel.pod_logs.output
        forward_to = [loki.write.default.receiver]
      }

      loki.write "default" {
        endpoint {
          url = "http://loki-gateway.logging.svc.cluster.local/loki/api/v1/push"
        }
      }

  mounts:
    varlog: true

controller:
  type: daemonset
EOF

helm upgrade --install alloy grafana/alloy -n logging -f /root/alloy-values.yaml --wait --timeout 10m

最后在 Grafana 里加 Loki 数据源 http://loki-gateway.logging.svc.cluster.local,就能用 LogQL 查了:

text
{namespace="prod", app="shop-order"} |= "ERROR"

#11.9 SkyWalking(链路追踪,可选)

Spring Cloud 项目在国内基本绕不开 SkyWalking。它吃资源(OAP + ES),机器紧张可以先只装 OAP + UI,存储用已有的 ES 或者直接用 H2 做验证。

bash
helm repo add skywalking https://apache.jfrog.io/artifactory/skywalking-helm
helm repo update
# 如果这个源哪天不通了,直接从 GitHub 拉 chart 源码装:
# git clone https://github.com/apache/skywalking-helm && cd skywalking-helm/chart/skywalking

helm upgrade --install skywalking skywalking/skywalking -n observability \
  --set oap.image.tag=10.2.0 \
  --set oap.storageType=elasticsearch \
  --set oap.replicas=1 \
  --set ui.image.tag=10.2.0 \
  --set elasticsearch.enabled=true \
  --set elasticsearch.imageTag=8.15.2 \
  --set elasticsearch.persistence.enabled=true \
  --set elasticsearch.persistence.storageClass=nfs-client \
  --wait --timeout 20m

OAP 的 gRPC 端口 11800 暴露出去(NodePort 或 Ingress),Java 应用通过 agent 接入。

#十二、Spring Cloud 项目上线

#12.1 项目和技术栈

以一个拆得比较标准的电商 demo 为例,五个服务:

服务端口职责
shop-gateway8080Spring Cloud Gateway,统一入口、鉴权、限流
shop-auth8080认证授权,发 JWT
shop-user8080用户、余额
shop-product8080商品、库存
shop-order8080订单,Feign 调 user 和 product

版本组合(2026 年主流稳定线):

text
JDK             21
Spring Boot     3.5.x
Spring Cloud    2025.0.x
Spring Cloud Alibaba  2025.0.0.0
Nacos Server/Client   3.2.4

父工程用三个 BOM 锁版本,子模块不许单独写版本号,这是避免"能编译但运行时炸"的最有效手段:

xml
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>3.5.6</version>
      <type>pom</type><scope>import</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>2025.0.2</version>
      <type>pom</type><scope>import</scope>
    </dependency>
    <dependency>
      <groupId>com.alibaba.cloud</groupId>
      <artifactId>spring-cloud-alibaba-dependencies</artifactId>
      <version>2025.0.0.0</version>
      <type>pom</type><scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

#12.2 中间件:MySQL、Redis、Nacos

三个都放 middleware 命名空间。MySQL 和 Redis 这里给单实例 StatefulSet,生产环境要主从/哨兵,逻辑一样,副本和探针改一改。

MySQL 8.4

yaml
# /root/mysql.yaml
apiVersion: v1
kind: Secret
metadata:
  name: mysql-secret
  namespace: middleware
type: Opaque
stringData:
  root-password: "Mysql@2026!ChangeMe"
---
apiVersion: v1
kind: Service
metadata:
  name: mysql
  namespace: middleware
spec:
  clusterIP: None
  selector: { app: mysql }
  ports:
  - port: 3306
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
  namespace: middleware
spec:
  serviceName: mysql
  replicas: 1
  selector:
    matchLabels: { app: mysql }
  template:
    metadata:
      labels: { app: mysql }
    spec:
      containers:
      - name: mysql
        image: mysql:8.4
        env:
        - name: MYSQL_ROOT_PASSWORD
          valueFrom:
            secretKeyRef: { name: mysql-secret, key: root-password }
        - name: TZ
          value: Asia/Shanghai
        args:
        - --character-set-server=utf8mb4
        - --collation-server=utf8mb4_unicode_ci
        - --default-time-zone=+08:00
        - --max_connections=1000
        - --innodb_buffer_pool_size=2G
        ports:
        - containerPort: 3306
        readinessProbe:
          exec:
            command: ["mysqladmin", "ping", "-h", "127.0.0.1", "-p$(MYSQL_ROOT_PASSWORD)"]
          initialDelaySeconds: 30
          periodSeconds: 10
        resources:
          requests: { cpu: 1, memory: 4Gi }
          limits:   { cpu: 2, memory: 6Gi }
        volumeMounts:
        - name: data
          mountPath: /var/lib/mysql
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      storageClassName: nfs-client
      resources:
        requests:
          storage: 100Gi

起来之后建库,Nacos 和业务各一个:

bash
kubectl -n middleware exec -it mysql-0 -- mysql -uroot -p'Mysql@2026!ChangeMe' -e "
CREATE DATABASE IF NOT EXISTS nacos_config DEFAULT CHARSET utf8mb4;
CREATE DATABASE IF NOT EXISTS shop DEFAULT CHARSET utf8mb4;
"
# Nacos 建表语句从这个地址拿,对应版本 3.2.4
# https://github.com/alibaba/nacos/blob/3.2.4/distribution/conf/mysql-schema.sql
kubectl -n middleware exec -i mysql-0 -- mysql -uroot -p'Mysql@2026!ChangeMe' nacos_config < mysql-schema.sql

Redis 7

yaml
# /root/redis.yaml
apiVersion: v1
kind: Secret
metadata:
  name: redis-secret
  namespace: middleware
type: Opaque
stringData:
  password: "Redis@2026!ChangeMe"
---
apiVersion: v1
kind: Service
metadata:
  name: redis
  namespace: middleware
spec:
  selector: { app: redis }
  ports:
  - port: 6379
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis
  namespace: middleware
spec:
  serviceName: redis
  replicas: 1
  selector:
    matchLabels: { app: redis }
  template:
    metadata:
      labels: { app: redis }
    spec:
      containers:
      - name: redis
        image: redis:7.4-alpine
        command:
        - sh
        - -c
        - redis-server --requirepass $(REDIS_PASSWORD) --appendonly yes --maxmemory 2gb --maxmemory-policy allkeys-lru
        env:
        - name: REDIS_PASSWORD
          valueFrom:
            secretKeyRef: { name: redis-secret, key: password }
        ports:
        - containerPort: 6379
        readinessProbe:
          exec:
            command: ["redis-cli", "-a", "$(REDIS_PASSWORD)", "ping"]
          initialDelaySeconds: 15
          periodSeconds: 10
        resources:
          requests: { cpu: 200m, memory: 512Mi }
          limits:   { cpu: 1,    memory: 3Gi }
        volumeMounts:
        - name: data
          mountPath: /data
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      storageClassName: nfs-client
      resources:
        requests:
          storage: 20Gi

Nacos 3.2.4 集群

3 副本 StatefulSet + headless Service。Nacos 3.x 用 headless 域名互相发现,比 2.x 的 NACOS_SERVERS 列表可靠。

yaml
# /root/nacos.yaml
apiVersion: v1
kind: Secret
metadata:
  name: nacos-secret
  namespace: middleware
type: Opaque
stringData:
  mysql-password: "Mysql@2026!ChangeMe"
  auth-token: "NacosAuthTokenSecretKeyMustBeLongerThan32Chars2026"
  identity-key: "serverIdentity"
  identity-value: "security"
---
apiVersion: v1
kind: Service
metadata:
  name: nacos-headless
  namespace: middleware
spec:
  clusterIP: None
  selector: { app: nacos }
  ports:
  - { name: http,   port: 8848, targetPort: 8848 }
  - { name: grpc,   port: 9848, targetPort: 9848 }
  - { name: jraft,  port: 9849, targetPort: 9849 }
  - { name: console, port: 8080, targetPort: 8080 }
---
apiVersion: v1
kind: Service
metadata:
  name: nacos
  namespace: middleware
spec:
  selector: { app: nacos }
  ports:
  - { name: http,   port: 8848, targetPort: 8848 }
  - { name: grpc,   port: 9848, targetPort: 9848 }
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: nacos
  namespace: middleware
spec:
  serviceName: nacos-headless
  replicas: 3
  podManagementPolicy: Parallel
  selector:
    matchLabels: { app: nacos }
  template:
    metadata:
      labels: { app: nacos }
    spec:
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 100
            podAffinityTerm:
              topologyKey: kubernetes.io/hostname
              labelSelector:
                matchExpressions:
                - { key: app, operator: In, values: ["nacos"] }
      containers:
      - name: nacos
        image: nacos/nacos-server:3.2.4
        env:
        - name: MODE
          value: cluster
        - name: NACOS_SERVERS
          value: "nacos-0.nacos-headless.middleware.svc.cluster.local:8848 nacos-1.nacos-headless.middleware.svc.cluster.local:8848 nacos-2.nacos-headless.middleware.svc.cluster.local:8848"
        - name: SPRING_DATASOURCE_PLATFORM
          value: mysql
        - name: MYSQL_SERVICE_HOST
          value: mysql.middleware.svc.cluster.local
        - name: MYSQL_SERVICE_PORT
          value: "3306"
        - name: MYSQL_SERVICE_DB_NAME
          value: nacos_config
        - name: MYSQL_SERVICE_USER
          value: root
        - name: MYSQL_SERVICE_PASSWORD
          valueFrom:
            secretKeyRef: { name: nacos-secret, key: mysql-password }
        - name: MYSQL_SERVICE_DB_PARAM
          value: "characterEncoding=utf8&connectTimeout=10000&socketTimeout=30000&allowPublicKeyRetrieval=true&useSSL=false"
        - name: NACOS_AUTH_ENABLE
          value: "true"
        - name: NACOS_AUTH_TOKEN
          valueFrom:
            secretKeyRef: { name: nacos-secret, key: auth-token }
        - name: NACOS_AUTH_IDENTITY_KEY
          valueFrom:
            secretKeyRef: { name: nacos-secret, key: identity-key }
        - name: NACOS_AUTH_IDENTITY_VALUE
          valueFrom:
            secretKeyRef: { name: nacos-secret, key: identity-value }
        - name: JVM_XMS
          value: 1g
        - name: JVM_XMX
          value: 1g
        - name: JVM_XMN
          value: 512m
        ports:
        - { containerPort: 8848, name: http }
        - { containerPort: 9848, name: grpc }
        - { containerPort: 9849, name: jraft }
        - { containerPort: 8080, name: console }
        readinessProbe:
          httpGet: { path: /nacos/v3/admin/core/state/health, port: 8080 }
          initialDelaySeconds: 40
          periodSeconds: 10
          failureThreshold: 15
        livenessProbe:
          httpGet: { path: /nacos/v3/admin/core/state/health, port: 8080 }
          initialDelaySeconds: 90
          periodSeconds: 20
          failureThreshold: 10
        resources:
          requests: { cpu: 500m, memory: 2Gi }
          limits:   { cpu: 2,    memory: 3Gi }
        volumeMounts:
        - name: data
          mountPath: /home/nacos/data
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      storageClassName: nfs-client
      resources:
        requests:
          storage: 20Gi

YAML 里这种一行写完的 map 写法({ cpu: 500m, memory: 2Gi })看着紧凑,但漏一个右括号很难一眼看出来。所有清单落地前先过一遍:

bash
kubectl apply --dry-run=client -f nacos.yaml

Nacos 3.x 的健康检查路径和 2.x 不一样,如果探针一直失败,先 kubectl exec 进去 curl localhost:8080/nacos/v3/admin/core/state/health 确认真实路径。

#12.3 镜像构建

用分层 JAR,改动代码时只有应用层重建,CI 里能省一半时间。

dockerfile
# Dockerfile
FROM eclipse-temurin:21-jre-alpine AS extract
WORKDIR /build
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
RUN java -Djarmode=layertools -jar app.jar extract

FROM eclipse-temurin:21-jre-alpine
RUN apk add --no-cache tzdata curl \
 && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
 && echo "Asia/Shanghai" > /etc/timezone
WORKDIR /app
COPY --from=extract /build/dependencies/ ./
COPY --from=extract /build/spring-boot-loader/ ./
COPY --from=extract /build/snapshot-dependencies/ ./
COPY --from=extract /build/application/ ./
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=70.0 \
-XX:+UseG1GC -XX:MaxGCPauseMillis=200 \
-XX:+ExitOnOutOfMemoryError \
-Djava.security.egd=file:/dev/./urandom \
-Dfile.encoding=UTF-8"
EXPOSE 8080
ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]

分层镜像最容易写错的就是最后这行 Entrypoint:目录已经被 extract 拆开了,不能再 -jar app.jar;而且 Spring Boot 3.2 之后 launcher 主类从 org.springframework.boot.loader.JarLauncher 挪到了 org.springframework.boot.loader.launch.JarLauncher,少一个 launch 包路径就会报 ClassNotFoundException。

不想折腾分层就直接:

dockerfile
FROM eclipse-temurin:21-jre-alpine
RUN apk add --no-cache tzdata curl \
 && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
WORKDIR /app
COPY target/*.jar app.jar
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=70.0 -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
EXPOSE 8080
ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS -jar /app/app.jar"]

两条我都在用:本地反复调试用简单版,CI 正式出镜像用分层版(配 Spring Boot 3.3+ 的 CDS 还能再快一点)。

MaxRAMPercentage=70 这条必须写。不写的话 JVM 按宿主机内存算堆大小,Pod limits 给 2G 但宿主机 64G,JVM 会以为自己能用到 16G,然后被 cgroup 直接 OOMKill,Kubernetes 事件里只看到 OOMKilled,jvm 日志里什么都不留。

#12.4 K8s 部署清单

以 shop-order 为例,其余四个照抄改名字。

yaml
# shop-order.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: shop-common-env
  namespace: prod
data:
  SPRING_PROFILES_ACTIVE: "prod"
  SPRING_CLOUD_NACOS_SERVER_ADDR: "nacos.middleware.svc.cluster.local:8848"
  SPRING_CLOUD_NACOS_DISCOVERY_NAMESPACE: "prod"
  SPRING_CLOUD_NACOS_CONFIG_NAMESPACE: "prod"
  SPRING_CLOUD_NACOS_USERNAME: "nacos"
  SPRING_DATASOURCE_URL: "jdbc:mysql://mysql.middleware.svc.cluster.local:3306/shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false"
  SPRING_DATA_REDIS_HOST: "redis.middleware.svc.cluster.local"
  SPRING_DATA_REDIS_PORT: "6379"
  SERVER_SHUTDOWN: "graceful"
  SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: "30s"
  MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: "health,info,prometheus"
  MANAGEMENT_ENDPOINT_HEALTH_PROBES_ENABLED: "true"
---
apiVersion: v1
kind: Secret
metadata:
  name: shop-secret
  namespace: prod
type: Opaque
stringData:
  SPRING_DATASOURCE_USERNAME: "root"
  SPRING_DATASOURCE_PASSWORD: "Mysql@2026!ChangeMe"
  SPRING_DATA_REDIS_PASSWORD: "Redis@2026!ChangeMe"
  SPRING_CLOUD_NACOS_PASSWORD: "Nacos@2026!ChangeMe"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-order
  namespace: prod
spec:
  replicas: 2
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels: { app: shop-order }
  template:
    metadata:
      labels:
        app: shop-order
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/path: /actuator/prometheus
        prometheus.io/port: "8080"
    spec:
      terminationGracePeriodSeconds: 60
      imagePullSecrets:
      - name: harbor-pull
      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels: { app: shop-order }
      containers:
      - name: shop-order
        image: harbor.apps.lxhhub.top/shop/shop-order:20260909-a1b2c3d
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 8080
        envFrom:
        - configMapRef: { name: shop-common-env }
        - secretRef: { name: shop-secret }
        env:
        - name: POD_IP
          valueFrom:
            fieldRef: { fieldPath: status.podIP }
        - name: SPRING_CLOUD_NACOS_DISCOVERY_IP
          value: $(POD_IP)
        startupProbe:
          httpGet: { path: /actuator/health/liveness, port: 8080 }
          failureThreshold: 60
          periodSeconds: 5
        readinessProbe:
          httpGet: { path: /actuator/health/readiness, port: 8080 }
          periodSeconds: 5
          failureThreshold: 3
        livenessProbe:
          httpGet: { path: /actuator/health/liveness, port: 8080 }
          periodSeconds: 15
          failureThreshold: 3
        lifecycle:
          preStop:
            exec:
              command: ["sh", "-c", "sleep 25"]
        resources:
          requests: { cpu: 500m, memory: 1Gi }
          limits:   { cpu: 2,    memory: 2Gi }
---
apiVersion: v1
kind: Service
metadata:
  name: shop-order
  namespace: prod
spec:
  selector: { app: shop-order }
  ports:
  - port: 8080
    targetPort: 8080
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: shop-order
  namespace: prod
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: shop-order
  minReplicas: 2
  maxReplicas: 8
  metrics:
  - type: Resource
    resource:
      name: cpu
      target: { type: Utilization, averageUtilization: 70 }
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: shop-order
  namespace: prod
spec:
  minAvailable: 1
  selector:
    matchLabels: { app: shop-order }

几个不能省的点:

startupProbe。 Spring Boot 微服务冷启动 40~90 秒很正常。没有 startupProbe 的话,livenessProbe 会在启动过程中失败并杀掉容器,Pod 陷入 CrashLoopBackOff 死循环。startupProbe 给了 60×5=300 秒的窗口,而且它成功之前 liveness/readiness 都不会介入。

preStop sleep 25。 这是微服务优雅下线的关键。K8s 发 SIGTERM 和删除 Endpoint 是并发的,Endpoint 传播到 kube-proxy、Ingress 需要几秒。这期间还在往 Pod 上打流量,如果 JVM 立刻关闭,用户就会看到 502。sleep 25 让容器先"装死":不再接新请求(已从 endpoint 摘除),但已有的请求能跑完,同时 Nacos 的心跳也在这段时间里超时,服务端完成反注册。25 秒这个值要大于"Endpoint 传播时间 + Nacos 心跳周期(默认 5s)× 2",小于 terminationGracePeriodSeconds。

注册 IP。 SPRING_CLOUD_NACOS_DISCOVERY_IP=$(POD_IP) 让 Nacos 里注册的是 Pod IP。不配的话 Nacos 会拿到容器 hostname 或者错误的网卡 IP,网关转发时连接不上。

topologySpreadConstraints。 只有 2 个 worker,不打散的话滚动更新完了两个副本可能都挤在一台上,那台一挂服务全断。

网关单独暴露:

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop-gateway
  namespace: prod
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "20m"
    nginx.ingress.kubernetes.io/proxy-connect-timeout: "10"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "120"
spec:
  ingressClassName: nginx
  rules:
  - host: shop.apps.lxhhub.top
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: shop-gateway
            port: { number: 8080 }

#12.5 Jenkinsfile

用 Kaniko 构建镜像,不挂 docker.sock。挂载 docker socket 的写法虽然简单,但等于把宿主机 root 交给构建容器,CI 里跑什么代码都能拿下节点。

groovy
// Jenkinsfile
pipeline {
  agent {
    kubernetes {
      cloud 'kubernetes'
      defaultContainer 'maven'
      yaml '''
apiVersion: v1
kind: Pod
spec:
  serviceAccountName: jenkins
  containers:
  - name: maven
    image: maven:3.9.9-eclipse-temurin-21
    command: ["cat"]
    tty: true
    volumeMounts:
    - name: m2
      mountPath: /root/.m2
    - name: settings
      mountPath: /root/.m2/settings.xml
      subPath: settings.xml
  - name: kaniko
    image: gcr.io/kaniko-project/executor:debug
    command: ["cat"]
    tty: true
    volumeMounts:
    - name: docker-config
      mountPath: /kaniko/.docker
  volumes:
  - name: m2
    persistentVolumeClaim:
      claimName: maven-repo
  - name: settings
    configMap:
      name: maven-settings
  - name: docker-config
    configMap:
      name: docker-config
'''
    }
  }

  environment {
    HARBOR   = 'harbor.apps.lxhhub.top'
    NAMESPACE= 'shop'
    SERVICES = 'shop-gateway shop-auth shop-user shop-product shop-order'
  }

  stages {
    stage('Checkout') {
      steps {
        checkout scm
        script {
          env.GIT_SHA = sh(script: 'git rev-parse --short=7 HEAD', returnStdout: true).trim()
          env.IMAGE_TAG = "${BUILD_NUMBER}-${GIT_SHA}"
        }
        echo "本次构建标签: ${IMAGE_TAG}"
      }
    }

    stage('Unit Test') {
      steps {
        container('maven') {
          sh 'mvn -B -ntp clean test'
        }
      }
      post {
        always {
          junit allowEmptyResults: true, testResults: '**/target/surefire-reports/*.xml'
        }
      }
    }

    stage('Package') {
      steps {
        container('maven') {
          sh 'mvn -B -ntp package -DskipTests'
        }
      }
    }

    stage('SonarQube') {
      when { expression { return false } }   // 装了 Sonar 就改成 true
      steps {
        container('maven') {
          withSonarQubeEnv('sonarqube') {
            sh 'mvn -B -ntp sonar:sonar'
          }
        }
      }
    }

    stage('Build & Push Images') {
      steps {
        container('kaniko') {
          script {
            def jobs = [:]
            env.SERVICES.split(' ').each { svc ->
              jobs[svc] = {
                sh """
                  /kaniko/executor \
                    --context ${WORKSPACE}/${svc} \
                    --dockerfile ${WORKSPACE}/${svc}/Dockerfile \
                    --destination ${HARBOR}/${NAMESPACE}/${svc}:${IMAGE_TAG} \
                    --destination ${HARBOR}/${NAMESPACE}/${svc}:latest \
                    --insecure --insecure-pull --skip-tls-verify \
                    --cache=true --cache-repo ${HARBOR}/${NAMESPACE}/kaniko-cache \
                    --snapshot-mode=redo \
                    --reproducible
                """
              }
            }
            parallel jobs
          }
        }
      }
    }

    stage('Update GitOps Repo') {
      steps {
        container('maven') {
          withCredentials([usernamePassword(credentialsId: 'gitea-cred',
                           usernameVariable: 'GIT_USER', passwordVariable: 'GIT_PASS')]) {
            sh '''
              git clone http://${GIT_USER}:${GIT_PASS}@git.apps.lxhhub.top/gitea-admin/shop-gitops.git
              cd shop-gitops
              cd overlays/prod
              for svc in ${SERVICES}; do
                kustomize edit set image ${HARBOR}/${NAMESPACE}/${svc}:${IMAGE_TAG}
              done
              git config user.email "jenkins@lxhhub.top"
              git config user.name  "jenkins"
              git commit -am "chore: bump image to ${IMAGE_TAG}" || echo "没有变化"
              git push origin main
            '''
          }
        }
      }
    }
  }

  post {
    success { echo "发布已提交给 Argo CD,镜像标签 ${IMAGE_TAG}" }
    failure { echo "构建失败,集群未发生变更" }
  }
}

镜像标签用 BUILD_NUMBER-GITSHA,可追溯也便于回滚。latest 同时推一份只是为了方便本地调试,生产清单里永远钉死具体 tag。

#12.6 Argo CD 接管发布

GitOps 仓库 shop-gitops 的结构:

text
shop-gitops/
├── base/
│   ├── kustomization.yaml
│   ├── configmap.yaml
│   ├── secret.yaml
│   ├── shop-gateway.yaml
│   └── ...
└── overlays/
    ├── prod/
    │   ├── kustomization.yaml     # kustomize edit set image 改的就是这里
    │   └── replica-patch.yaml
    └── test/
        └── kustomization.yaml

在 Argo CD 里建 Application:

yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: shop-prod
  namespace: argocd
spec:
  project: default
  source:
    repoURL: http://git.apps.lxhhub.top/gitea-admin/shop-gitops.git
    targetRevision: main
    path: overlays/prod
  destination:
    server: https://kubernetes.default.svc
    namespace: prod
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
    - CreateNamespace=true
    - ApplyOutOfSyncOnly=true
    retry:
      limit: 5
      backoff:
        duration: 5s
        factor: 2
        maxDuration: 3m

Jenkins 改完 tag 一 push,Argo CD 默认 3 分钟内检测到并同步。想立刻生效就在 Gitea 上配一个 webhook 打到 https://argocd.apps.lxhhub.top/api/webhook。

回滚。 两种方式:

bash
# 方式一:Argo CD 回滚到上一版
argocd app history shop-prod
argocd app rollback shop-prod <REVISION>

# 方式二:Git revert,更推荐,因为 Git 才是事实来源
git revert HEAD && git push

灰度。 不想引入 Argo Rollouts 的话,用 Ingress-NGINX 的 canary 注解最省事:再部署一份 shop-gateway-canary(新版本,1 副本),然后:

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop-gateway-canary
  namespace: prod
  annotations:
    nginx.ingress.kubernetes.io/canary: "true"
    nginx.ingress.kubernetes.io/canary-weight: "10"
spec:
  ingressClassName: nginx
  rules:
  - host: shop.apps.lxhhub.top
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: shop-gateway-canary
            port: { number: 8080 }

10% 流量进新版本,观察 Grafana 里的错误率和 SkyWalking 里的链路,没问题就把稳定版的镜像 tag 也改成新版本、删掉 canary Ingress。

#十三、验收清单

集群层面:

bash
kubectl get nodes -o wide                      # 5 台 Ready
kubectl get pod -A                             # 无 Pending / CrashLoopBackOff
kubectl get --raw='/readyz?verbose' | tail -20
etcdctl-prod endpoint health --write-out=table # 3 个 true
kubectl -n kube-system logs -l k8s-app=kube-dns --tail=20
kubectl top nodes                              # metrics-server 正常

高可用:

bash
# 停掉持有 VIP 的 master 上的 apiserver,VIP 应漂走,kubectl 仍可用
systemctl stop kube-apiserver
kubectl get nodes

# 停掉一个 master 整机,etcd 应仍可写(3 节点坏 1 个仍然 quorum)
kubectl create sa test-ha && kubectl delete sa test-ha

# 恢复后 etcd 成员应自动追平
etcdctl-prod endpoint status --write-out=table

网络与 DNS:

bash
kubectl run dns-test --image=busybox:1.37 -it --rm --restart=Never -- \
  nslookup kubernetes.default.svc.cluster.local
kubectl run dns-test2 --image=busybox:1.37 -it --rm --restart=Never -- \
  nslookup shop-order.prod.svc.cluster.local

# Pod 之间跨节点互通
kubectl -n prod exec -it <pod1> -- curl -s http://shop-order:8080/actuator/health

业务层面:

bash
kubectl -n prod get pod -o wide
kubectl -n prod get hpa
curl -H "Host: shop.apps.lxhhub.top" http://10.0.0.14/actuator/health

# 压测触发 HPA
kubectl run -i --tty load --rm --image=busybox:1.37 --restart=Never -- \
  /bin/sh -c "while true; do wget -q -O- http://shop-order.prod:8080/api/order/list; done"
kubectl -n prod get hpa -w

数据备份演练(别等出事才做):

bash
etcdctl-prod snapshot save /backup/etcd-$(date +%Y%m%d).db
etcdctl-prod snapshot status /backup/etcd-$(date +%Y%m%d).db --write-out=table

# 加个定时
cat > /etc/systemd/system/etcd-backup.service <<'EOF'
[Unit]
Description=etcd snapshot backup
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'etcdctl-prod snapshot save /backup/etcd-$(date +%%Y%%m%%d-%%H%%M).db'
EOF
cat > /etc/systemd/system/etcd-backup.timer <<'EOF'
[Unit]
Description=Daily etcd backup
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl enable --now etcd-backup.timer

#十四、踩过的坑

按我实际遇到的频率排序。

kubelet 一直 NotReady,报 cgroup driver "cgroupfs" is different。 containerd 的 SystemdCgroup 和 kubelet 的 cgroupDriver 必须都是 systemd。改完要重启 containerd 和 kubelet,并且 kubeadm 那种改法在这儿不适用——直接改 /etc/containerd/config.toml。

CSR 一直 Pending。 三连查:bootstrap token 是不是和 token.csv 里的一致;apiserver 有没有 --enable-bootstrap-token-auth=true;ClusterRoleBinding 里的 group 名是不是 system:kubelet-bootstrap。

Pod 一直 ContainerCreating,failed to allocate for range 0。 通常是 kube-controller-manager 的 --cluster-cidr 和 Calico 的 CALICO_IPV4POOL_CIDR 不一致,或者 master 上没跑 kube-controller-manager。

CoreDNS 起不来,no endpoints available for service "kube-dns"。 集群还没有可用的 CNI 时 CoreDNS 的 Pod 拿不到 IP,会一直重启。正确的顺序是:先 Calico,等 calico-node 全部 Running,再上 CoreDNS。

kubectl exec/logs 报 Unauthorized。 缺 system:kube-apiserver-to-kubelet 这个 ClusterRole/ClusterRoleBinding,二进制集群不会自动创建。

kubectl top 报 metrics not available yet。 要么 metrics-server 没装,要么 kubelet 的 serving 证书是自签的(开了 serverTLSBootstrap 且 CSR 没批准)。kubectl get csr 看一下有没有 pending 的 selfnodeserver 请求。

Harbor 镜像推得上但 K8s 拉不下来。 containerd 的 hosts.toml 是每个节点的配置,只在 m1 上配了没用。5 台都要配,改完 systemctl restart containerd。

Jenkins 构建镜像时报 Cannot connect to the Docker daemon。 用 Kaniko,别挂 docker.sock。真要用 Docker 就跑 DinD sidecar,但那需要 privileged,风险自担。

Spring Boot Pod 反复 OOMKilled,本地跑得好好的。 JVM 没设 MaxRAMPercentage,按宿主机内存算堆。limits 给 2G 就配 MaxRAMPercentage=70,也就是堆上限 1.4G,剩下的留给元空间、线程栈、直接内存。

滚动更新期间短暂 502。 preStop 没配或者 sleep 太短。25 秒这个值配合 terminationGracePeriodSeconds: 60 用。另外 readinessProbe 的 periodSeconds 别设太大,否则摘除不及时。

Nacos 里服务实例数是 Pod 数的好几倍。 服务名写错或者命名空间不一致会导致重复注册;也有可能是旧 Pod 没走完优雅下线就消失了。查 Nacos 控制台的实例 IP 和 Pod IP 是否一一对应。

NFS 挂了之后 PVC 创建不出来。 nfs-subdir-external-provisioner 是单点。监控上给 m1 的 nfs-server 加个存活告警,比事后排查有用。

etcd 磁盘写满。 --quota-backend-bytes 给了 8G,但碎片不会自动回收。定期 etcdctl defrag + compact,或者靠 --auto-compaction-retention 自动压缩。生产环境加一条磁盘使用率告警,超过 80% 就处理。

证书到期的那天。 上面签的证书是 10 年(87600h),但 kubelet 的客户端证书默认 1 年,靠 rotateCertificates: true 自动续。真正容易忘的是 etcd 那套——它没有自动轮换机制。在日历上记一笔,到期前半年重签并滚动重启,别等到 x509: certificate has expired 才发现整个集群写不进去。

写于 2026 年 9 月 9 日

栏目
技术文章
约
17.7 分钟
字数
7W
阅读
69

本文为原创记录,转载请注明出处。如果这篇替你省了时间,欢迎留言说说你踩到的坑。

同题 · related

留言 · remarks

00 条

还没有留言,来说点什么吧。

5 台服务器,从二进制装 K8s 到 Spring Cloud 上线 · LXH·BLOG