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-m1 | 10.0.0.11 | control-plane + etcd + NFS | 8C16G,200G SSD |
| k8s-m2 | 10.0.0.12 | control-plane + etcd | 8C16G,200G SSD |
| k8s-m3 | 10.0.0.13 | control-plane + etcd | 8C16G,200G SSD |
| k8s-n1 | 10.0.0.14 | worker | 16C64G,1T NVMe |
| k8s-n2 | 10.0.0.15 | worker | 16C64G,1T NVMe |
| k8s-vip | 10.0.0.10 | apiserver 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 页面核一下补丁号。
| 组件 | 版本 | 备注 |
|---|---|---|
| OS | Rocky Linux 9.x | RHEL 系通吃,内核 ≥ 5.10 |
| Kubernetes | v1.36.4 | 1.37 刚 GA,生产再等两个补丁 |
| etcd | v3.6.13 | 3.6 起 --v2-deprecation 默认 write-only |
| containerd | v2.3.3 (LTS) | 配置文件 schema 是 version = 3 |
| runc | v1.3.x | 取与 containerd 2.x 匹配的最新稳定版 |
| CNI plugins | v1.7.x | 只装二进制,网络由 Calico 管 |
| cfssl | v1.6.5 | 只在 m1 上装,签完可以删 |
| Calico | v3.32.2 | 用 release tgz 里的 manifests |
| CoreDNS | v1.13.2 | 与 k8s 1.36 官方 addon 对齐 |
| metrics-server | v0.8.x | HPA 依赖它 |
| ingress-nginx | v1.13.x | 裸金属用 DaemonSet + hostNetwork |
| Helm | v3.17.x | |
| Harbor | v2.15.2 | 镜像仓库 |
| Gitea | 1.24.x | 代码仓库 |
| Nexus | 3.8x | Maven 私服 |
| Jenkins | LTS (jdk21) | CI |
| SonarQube | Community 25.x | 可选,吃 4G 内存 |
| Argo CD | v3.5.1 | GitOps CD |
| Prometheus / Grafana | 3.x / 12.x | kube-prometheus-stack |
| Loki / Alloy | 3.x | 日志,Alloy 已替代 Promtail |
| Nacos | v3.2.4 | 注册中心 + 配置中心 |
| JDK / Boot / Cloud | 21 / 3.5.x / 2025.0.x | SCA 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 上全部执行一遍,参数只有一个主机名。
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然后按主机分别执行并重启:
# 各自执行
/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重启后验一遍,三件事必须成立:
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,别偷懒复用。
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 就起不来。
# 公共镜像加速,按你的网络环境选一个或几个
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,后面排障全靠它:
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.10crictl pull 成功说明运行时这条链路通了。失败先查 journalctl -u containerd -f,九成是镜像源或者证书的问题。
#四、在 m1 上一次性签完所有证书
证书这块的组织方式:etcd 和 Kubernetes 用两套独立的 CA。一套 CA 打天下也能跑,但 etcd 的证书和 K8s 的证书混在一起,将来轮换 etcd CA 时会连带影响 apiserver,拆开省事。
只在 m1 上操作,签完 scp 到各节点。
# 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
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 相关的名字都写进去。
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。
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。
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 生成:
openssl genrsa -out sa.key 2048
openssl rsa -in sa.key -pubout -out sa.pub#分发
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/
donek8s-ca-key.pem 只放 master,worker 上不需要。私钥泄漏等于集群门户大开,能不放就别放。
#五、部署 etcd 集群(m1/m2/m3)
先下二进制:
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 不同,我用一个变量生成:
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 # m1m2、m3 上把 IP 换成自己的,三台启动间隔别超过 30 秒,先起的那台会在 initial-cluster-state=new 下等 peer,等太久会自己退出。
验证:
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=tableendpoint health 三行都是 true 且只有一个 leader 才算完事。顺手配个别名,后面天天用:
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/.bashrcetcd 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。
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 上统一生成再分发。
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-proxykubelet 用的是 bootstrap 机制,不走证书直配。先造一个 bootstrap token:
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:
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
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
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
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 并授权
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 在新版集群里默认不存在,得自己建):
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 漂移)。
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 就不用漂,避免了无谓的抖动。
验证:
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
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 kubeletworker 节点上需要先把 /etc/kubernetes/bootstrap-kubelet.kubeconfig 从 m1 scp 过去:
# 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 上看:
kubectl get csr
# 会出现 Pending -> Approved,Issued
# 因为开了 serverTLSBootstrap,还会多一个 selfnodeserver 的 CSR,同样会自动批准如果一直是 Pending,去看 kubelet 日志 journalctl -u kubelet -f,十有八九是 bootstrap token 不对或者 kubeconfig 里的 apiserver 地址不通。
#8.2 kube-proxy
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-proxyworker 节点需要 kube-proxy.kubeconfig:
scp /etc/kubernetes/kube-proxy.kubeconfig k8s-n1:/etc/kubernetes/
scp /etc/kubernetes/kube-proxy.kubeconfig k8s-n2:/etc/kubernetes/#8.3 给 master 打污点、打角色标签
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
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默认单网卡环境不用改网卡发现规则。如果你的机器是多网卡(比如同时有管理网和业务网),加一条:
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 备用:
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
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.yamlclusterIP: 10.96.0.10 必须和 kubelet 配置里的 clusterDNS 一致,且落在 Service CIDR 内。
#9.3 metrics-server
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"}
]'等半分钟:
kubectl top nodes
kubectl top pod -A#9.4 Ingress-NGINX
裸金属没有云 LoadBalancer,用 DaemonSet + hostNetwork 直接占住 worker 的 80/443。master 有 NoSchedule 污点,所以只会落在 n1/n2 上。
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 台机器的规模,这个妥协可以接受,别拿它当长期方案。
# 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.11provisioner 用 Helm 装:
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 screclaimPolicy=Retain 是有意为之:PVC 误删时数据不会跟着没。
另外给需要高性能本地盘的东西(比如 Nacos 的 JRaft 日志、MySQL)再准备一个 local 类型的 StorageClass 可选。真要上生产,换 Rook-Ceph 或 Longhorn,但那至少再要 3 块独立磁盘,5 台机器吃不消。
#十一、DevOps 工具链
装 Helm 客户端(在 m1 上):
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命名空间规划,一次性建好:
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(镜像仓库)
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 了:
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(每个业务命名空间都要建):
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:
kubectl -n prod patch serviceaccount default \
-p '{"imagePullSecrets":[{"name":"harbor-pull"}]}'#11.2 Gitea(代码仓库)
GitLab 太重,5 台机器跑不动;Gitea 一个二进制搞定,带 Web 界面、PR、Actions,够用。
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 10mDISABLE_REGISTRATION: true 建议开着,内网仓库别让人随便注册。
#11.3 Nexus(Maven 私服)
官方 Helm chart 维护得一般,我直接写清单:
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-publicgroup 聚合上面三个,Maven 的settings.xml只配这一个镜像地址
Nexus 起得慢,ready 探针给了 90 秒初始延迟和 20 次失败容忍,别改小。
#11.4 Jenkins(CI)
Jenkins 用 Helm 装,重点是让它能动态起 Pod 当构建节点。
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装完之后要做的三件事:
配 Kubernetes Cloud。 系统管理 → Clouds → New cloud,Kubernetes 类型,命名空间
cicd,Jenkins 地址http://jenkins.cicd.svc.cluster.local:8080。不用填 kubeconfig,Pod 里的 ServiceAccount 已经够用(Helm chart 自动建了 RBAC)。配 Maven 缓存 PVC。 每个构建 Pod 都重新下依赖会慢到让人放弃。建一个 PVC 挂到
/root/.m2,第一次慢,之后就快了。
kubectl -n cicd create pvc maven-repo --storage-class=nfs-client --access-mode=ReadWriteMany注意要 ReadWriteMany,因为可能同时跑多个构建。NFS 支持 RWX,这也是我选 NFS 而不是 local-path 的原因之一。
- 配凭据。 Gitea 账号、Harbor 机器人账号、Sonar token 都加到 Jenkins credentials 里。
#11.5 SonarQube(可选)
4G 内存起步,机器紧张可以跳过,不影响主线。
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。
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
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 抓它:
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# /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']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 采集。
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 10mAlloy 采集器:
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 查了:
{namespace="prod", app="shop-order"} |= "ERROR"#11.9 SkyWalking(链路追踪,可选)
Spring Cloud 项目在国内基本绕不开 SkyWalking。它吃资源(OAP + ES),机器紧张可以先只装 OAP + UI,存储用已有的 ES 或者直接用 H2 做验证。
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 20mOAP 的 gRPC 端口 11800 暴露出去(NodePort 或 Ingress),Java 应用通过 agent 接入。
#十二、Spring Cloud 项目上线
#12.1 项目和技术栈
以一个拆得比较标准的电商 demo 为例,五个服务:
| 服务 | 端口 | 职责 |
|---|---|---|
| shop-gateway | 8080 | Spring Cloud Gateway,统一入口、鉴权、限流 |
| shop-auth | 8080 | 认证授权,发 JWT |
| shop-user | 8080 | 用户、余额 |
| shop-product | 8080 | 商品、库存 |
| shop-order | 8080 | 订单,Feign 调 user 和 product |
版本组合(2026 年主流稳定线):
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 锁版本,子模块不许单独写版本号,这是避免"能编译但运行时炸"的最有效手段:
<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
# /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 和业务各一个:
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.sqlRedis 7
# /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: 20GiNacos 3.2.4 集群
3 副本 StatefulSet + headless Service。Nacos 3.x 用 headless 域名互相发现,比 2.x 的 NACOS_SERVERS 列表可靠。
# /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: 20GiYAML 里这种一行写完的 map 写法({ cpu: 500m, memory: 2Gi })看着紧凑,但漏一个右括号很难一眼看出来。所有清单落地前先过一遍:
kubectl apply --dry-run=client -f nacos.yamlNacos 3.x 的健康检查路径和 2.x 不一样,如果探针一直失败,先 kubectl exec 进去 curl localhost:8080/nacos/v3/admin/core/state/health 确认真实路径。
#12.3 镜像构建
用分层 JAR,改动代码时只有应用层重建,CI 里能省一半时间。
# 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。
不想折腾分层就直接:
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 为例,其余四个照抄改名字。
# 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,不打散的话滚动更新完了两个副本可能都挤在一台上,那台一挂服务全断。
网关单独暴露:
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 里跑什么代码都能拿下节点。
// 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 的结构:
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:
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: 3mJenkins 改完 tag 一 push,Argo CD 默认 3 分钟内检测到并同步。想立刻生效就在 Gitea 上配一个 webhook 打到 https://argocd.apps.lxhhub.top/api/webhook。
回滚。 两种方式:
# 方式一: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 副本),然后:
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。
#十三、验收清单
集群层面:
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 正常高可用:
# 停掉持有 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:
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业务层面:
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数据备份演练(别等出事才做):
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
- DevOps 运维中 AI 那些事把 AI 放进运维的真实分工:从服务器硬件、网络、GPU、内存,到虚拟化、Kubernetes、应用构建、CI/CD、可观测性、资源分配,再到 AI 自身的部署与算力调度。讲清楚哪些活它能接、边界划在哪、以及我踩过的那些坑。
- 把博客从 docker-compose 搬到 k3s:单机迁移与上线全过程在 4C3.6G 的云主机上把博客从 docker-compose 迁到单机 k3s。附完整 YAML 清单(Deployment、Service、PVC、Ingress、Certificate 等)与全部配置命令,记录部署、数据迁移与切换上线的踩坑过程。
- 机房网络和 K8S 网络,其实是同一套东西从 Underlay 与 Overlay 的分层讲到 VXLAN 封装和 EVPN 控制平面,再对照 K8S 的 CNI、Service、EndpointSlice 和 Ingress,最后给出一张可以直接拿去排障的同构映射表。
留言 · remarks
00 条还没有留言,来说点什么吧。