这篇记录一个比较常见的内网场景:目标服务器不能访问公网,但需要安装一个三主节点 Kubernetes 高可用集群。
本文使用 kubeadm 部署,三台机器都作为 control-plane,etcd 采用 stacked 模式,也就是每个控制平面节点本地运行一个 etcd。API Server 前面用 HAProxy + Keepalived 提供一个固定 VIP。
参考官方文档时,优先看这些页面:
- Installing kubeadm
- Creating Highly Available Clusters with kubeadm
- Container runtimes
- Kubernetes releases
- Install Calico networking and network policy for on-premises deployments
环境规划
本文固定版本,避免离线环境里因为浮动版本导致重复部署结果不一致。
OS: Ubuntu Server 24.04 amd64
Kubernetes: v1.36.1
kubelet/kubeadm: 1.36.1-1.1
容器运行时: containerd
CNI: Calico v3.32.0
Pod CIDR: 10.244.0.0/16
Service CIDR: 10.96.0.0/12
API VIP: 192.168.3.217:8443
节点规划:
192.168.3.214 k8s-master1
192.168.3.215 k8s-master2
192.168.3.216 k8s-master3
192.168.3.217 k8s-vip
这里把 API 高可用入口放在 192.168.3.217:8443,而不是 6443。原因是 HAProxy 和 kube-apiserver 都运行在三台 master 上,kube-apiserver 会占用本机 6443,HAProxy 监听 8443 可以避免端口冲突。
三主节点能解决的是控制平面高可用:kube-apiserver、controller-manager、scheduler 和 etcd 在单台 master 故障时还能继续工作。它不等于业务高可用。业务应用、Ingress、数据库、Redis、存储卷和监控告警还要另外做高可用设计。
端口和权限边界
这些端口至少要在三台 master 之间互通:
| 端口 | 协议 | 说明 |
|---|---|---|
| 8443 | TCP | HAProxy 对外提供的 Kubernetes API VIP 入口 |
| 6443 | TCP | 每台 master 本地 kube-apiserver |
| 2379-2380 | TCP | etcd 客户端和 peer 通信 |
| 10250 | TCP | kubelet API |
| 10257 | TCP | kube-controller-manager |
| 10259 | TCP | kube-scheduler |
| 179 | TCP | Calico BGP,全互联默认模式会用到 |
| IPIP 4 | IP 协议号 | Calico 默认 IPIP overlay |
| VRRP 112 | IP 协议号 | Keepalived 漂移 VIP |
安全注意事项:
8443是 Kubernetes API 入口,只建议暴露在内网或运维 VPN 内,不要直接暴露到公网。admin.conf等价于集群管理员凭据,不要随便复制给普通用户。- 三节点 stacked etcd 只能容忍 1 台 master 故障,连续丢 2 台 master 时 etcd 会失去多数派。
- Keepalived 只负责 VIP 漂移,不负责业务数据恢复。
- 离线包里包含集群镜像和安装包,建议放在内网制品库或受控目录,不要放到公开下载地址。
一、在在线机器准备离线包
准备一台能访问公网的打包机。打包机要尽量和离线节点保持一致:
相同 CPU 架构: amd64
相同 Ubuntu 大版本: 24.04
如果离线节点是 Ubuntu 22.04,就用 Ubuntu 22.04 打包机重新执行下面步骤。不要用 24.04 打包出来的 deb 包直接装到 22.04 上。
1. 设置变量
在在线打包机执行:
export K8S_VERSION="v1.36.1"
export K8S_MINOR="v1.36"
export K8S_DEB_VERSION="1.36.1-1.1"
export CALICO_VERSION="v3.32.0"
export BUNDLE_DIR="$HOME/k8s-offline-${K8S_VERSION}"
mkdir -p "${BUNDLE_DIR}"/{debs,images,manifests,checksums}
2. 配置 Kubernetes apt 源
sudo apt-get update
sudo apt-get install -y ca-certificates curl gpg
sudo mkdir -p /etc/apt/keyrings
curl -fsSL "https://pkgs.k8s.io/core:/stable:/${K8S_MINOR}/deb/Release.key" \
| sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/${K8S_MINOR}/deb/ /" \
| sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
3. 下载 deb 安装包
先把需要的包下载到 apt 缓存,再复制到离线包目录:
sudo apt-get install -y --download-only \
containerd \
haproxy \
keepalived \
socat \
conntrack \
ebtables \
ethtool \
ipset \
ipvsadm \
curl \
tar \
kubelet="${K8S_DEB_VERSION}" \
kubeadm="${K8S_DEB_VERSION}" \
kubectl="${K8S_DEB_VERSION}"
cp /var/cache/apt/archives/*.deb "${BUNDLE_DIR}/debs/"
为了后面能用 kubeadm config images list 生成镜像清单,在线打包机可以临时安装 kubeadm:
sudo apt-get install -y kubeadm="${K8S_DEB_VERSION}"
如果这一步提示依赖无法下载,先不要继续做离线包。说明打包机 apt 源或系统版本不一致,需要先修好。
4. 下载 Calico YAML
这里固定下载 Calico v3.32.0 的 manifest,不使用 latest 或分支上的浮动 YAML。Calico 官方更推荐用 Tigera Operator 管理安装和升级;本文为了离线包简单可控,使用版本固定的 calico.yaml,适合小规模内网集群。
curl -fsSLo "${BUNDLE_DIR}/manifests/calico.yaml" \
"https://raw.githubusercontent.com/projectcalico/calico/${CALICO_VERSION}/manifests/calico.yaml"
sed -i \
-e 's/# - name: CALICO_IPV4POOL_CIDR/- name: CALICO_IPV4POOL_CIDR/' \
-e 's/# value: "192.168.0.0\/16"/ value: "10.244.0.0\/16"/' \
"${BUNDLE_DIR}/manifests/calico.yaml"
检查 Pod 网段,必须和后面 kubeadm 配置里的 podSubnet 一致:
grep -n "CALICO_IPV4POOL_CIDR\\|10.244.0.0/16" "${BUNDLE_DIR}/manifests/calico.yaml"
Calico manifest 默认示例常见为 192.168.0.0/16,但本文节点网段是 192.168.3.0/24,会发生重叠,所以这里显式改成 10.244.0.0/16。后面 kubeadm 配置里也会写成这个网段。如果你要改成别的网段,需要同时改 kubeadm 的 podSubnet 和 Calico manifest 里的 CALICO_IPV4POOL_CIDR。
5. 生成镜像清单
kubeadm config images list --kubernetes-version "${K8S_VERSION}" \
> "${BUNDLE_DIR}/images/kubeadm-images.txt"
awk '$1=="image:" {print $2}' "${BUNDLE_DIR}/manifests/calico.yaml" \
| tr -d '"' \
> "${BUNDLE_DIR}/images/calico-images.txt"
cat "${BUNDLE_DIR}/images/kubeadm-images.txt" \
"${BUNDLE_DIR}/images/calico-images.txt" \
| sort -u \
> "${BUNDLE_DIR}/images/all-images.txt"
cat "${BUNDLE_DIR}/images/all-images.txt"
不要手工猜镜像版本,以 kubeadm config images list 和固定的 CNI YAML 为准。
6. 拉取并导出镜像
在线打包机需要有 containerd,并且 ctr 命令可用:
sudo systemctl enable --now containerd
while read -r image; do
sudo ctr images pull "${image}"
done < "${BUNDLE_DIR}/images/all-images.txt"
sudo ctr images export \
"${BUNDLE_DIR}/images/k8s-images-${K8S_VERSION}.tar" \
$(cat "${BUNDLE_DIR}/images/all-images.txt")
导出完成后生成校验文件:
cd "${BUNDLE_DIR}"
sha256sum debs/*.deb images/*.tar manifests/*.yml > checksums/SHA256SUMS
7. 打包
cd "$(dirname "${BUNDLE_DIR}")"
tar -czf "k8s-offline-${K8S_VERSION}-ubuntu2404-amd64.tar.gz" \
"$(basename "${BUNDLE_DIR}")"
把这个文件复制到三台离线 master:
scp k8s-offline-v1.36.1-ubuntu2404-amd64.tar.gz root@192.168.3.214:/opt/
scp k8s-offline-v1.36.1-ubuntu2404-amd64.tar.gz root@192.168.3.215:/opt/
scp k8s-offline-v1.36.1-ubuntu2404-amd64.tar.gz root@192.168.3.216:/opt/
二、三台 master 安装基础组件
下面的步骤在三台 master 都执行。
1. 解压离线包
cd /opt
sudo tar -xzf k8s-offline-v1.36.1-ubuntu2404-amd64.tar.gz
cd /opt/k8s-offline-v1.36.1
sha256sum -c checksums/SHA256SUMS
校验失败不要继续安装,先重新传包。
2. 安装 deb 包
cd /opt/k8s-offline-v1.36.1
sudo dpkg -i debs/*.deb
sudo apt-mark hold kubelet kubeadm kubectl
如果 dpkg 提示缺依赖,说明在线打包时漏包了。不要在离线节点上临时乱补版本,回到在线打包机补齐 deb 包后重新打包。
3. 设置 hostname 和 hosts
分别在三台机器设置 hostname:
sudo hostnamectl set-hostname k8s-master1
第二台、第三台分别改为:
sudo hostnamectl set-hostname k8s-master2
sudo hostnamectl set-hostname k8s-master3
三台都写入 hosts:
sudo tee -a /etc/hosts >/dev/null <<'EOF'
192.168.3.214 k8s-master1
192.168.3.215 k8s-master2
192.168.3.216 k8s-master3
192.168.3.217 k8s-vip
EOF
确认解析正常:
getent hosts k8s-master1 k8s-master2 k8s-master3 k8s-vip
4. 关闭 swap
sudo swapoff -a
sudo sed -ri '/\\sswap\\s/s/^#?/#/' /etc/fstab
free -h
Swap 应该显示为 0B。
5. 配置内核模块和 sysctl
sudo tee /etc/modules-load.d/k8s.conf >/dev/null <<'EOF'
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilter
sudo tee /etc/sysctl.d/99-kubernetes-cri.conf >/dev/null <<'EOF'
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sudo sysctl --system
确认:
lsmod | egrep 'overlay|br_netfilter'
sysctl net.ipv4.ip_forward net.bridge.bridge-nf-call-iptables
6. 配置 containerd
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml >/dev/null
PAUSE_IMAGE="$(kubeadm config images list --kubernetes-version v1.36.1 | grep '/pause:')"
sudo sed -i "s#sandbox_image = .*#sandbox_image = \"${PAUSE_IMAGE}\"#" /etc/containerd/config.toml
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo systemctl enable --now containerd
sudo systemctl restart containerd
sudo systemctl status containerd --no-pager
确认 CRI 可用:
sudo crictl info | grep -i runtime
7. 导入离线镜像
cd /opt/k8s-offline-v1.36.1
sudo ctr -n k8s.io images import images/k8s-images-v1.36.1.tar
sudo ctr -n k8s.io images ls | egrep 'kube-apiserver|kube-controller|kube-scheduler|etcd|coredns|calico'
镜像必须导入到 k8s.io namespace。kubelet 通过 CRI 使用这个 namespace,导入到默认 namespace 里会导致 kubelet 仍然找不到镜像。
三、配置 HAProxy 和 Keepalived
下面的步骤仍然在三台 master 都执行。
1. 配置 HAProxy
备份原配置:
sudo cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.bak
写入 Kubernetes API 负载均衡配置:
sudo tee /etc/haproxy/haproxy.cfg >/dev/null <<'EOF'
global
log /dev/log local0
log /dev/log local1 notice
daemon
maxconn 4096
stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
defaults
log global
mode tcp
option tcplog
option dontlognull
timeout connect 5s
timeout client 1m
timeout server 1m
frontend kubernetes-apiserver
bind *:8443
default_backend kubernetes-apiserver
backend kubernetes-apiserver
balance roundrobin
option tcp-check
server k8s-master1 192.168.3.214:6443 check
server k8s-master2 192.168.3.215:6443 check
server k8s-master3 192.168.3.216:6443 check
EOF
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl enable --now haproxy
sudo systemctl restart haproxy
此时 kube-apiserver 还没有启动,HAProxy 后端检查失败是正常的。只要 HAProxy 服务本身能启动即可。
2. 配置 Keepalived
先确认网卡名:
ip -br addr
本文假设网卡名是 ens160。如果你的机器是 ens33、eth0 或其他名字,替换下面配置里的 interface ens160。
在 k8s-master1 执行:
sudo tee /etc/keepalived/keepalived.conf >/dev/null <<'EOF'
global_defs {
router_id k8s-master1
}
vrrp_script chk_haproxy {
script "pidof haproxy"
interval 2
weight -20
}
vrrp_instance VI_1 {
state MASTER
interface ens160
virtual_router_id 51
priority 120
advert_int 1
authentication {
auth_type PASS
auth_pass k8s-ha
}
virtual_ipaddress {
192.168.3.217/24
}
track_script {
chk_haproxy
}
}
EOF
在 k8s-master2 执行,注意 router_id、state 和 priority:
sudo tee /etc/keepalived/keepalived.conf >/dev/null <<'EOF'
global_defs {
router_id k8s-master2
}
vrrp_script chk_haproxy {
script "pidof haproxy"
interval 2
weight -20
}
vrrp_instance VI_1 {
state BACKUP
interface ens160
virtual_router_id 51
priority 110
advert_int 1
authentication {
auth_type PASS
auth_pass k8s-ha
}
virtual_ipaddress {
192.168.3.217/24
}
track_script {
chk_haproxy
}
}
EOF
在 k8s-master3 执行:
sudo tee /etc/keepalived/keepalived.conf >/dev/null <<'EOF'
global_defs {
router_id k8s-master3
}
vrrp_script chk_haproxy {
script "pidof haproxy"
interval 2
weight -20
}
vrrp_instance VI_1 {
state BACKUP
interface ens160
virtual_router_id 51
priority 100
advert_int 1
authentication {
auth_type PASS
auth_pass k8s-ha
}
virtual_ipaddress {
192.168.3.217/24
}
track_script {
chk_haproxy
}
}
EOF
三台都启动:
sudo systemctl enable --now keepalived
sudo systemctl restart keepalived
sudo systemctl status keepalived --no-pager
查看 VIP 当前在哪台机器:
ip addr | grep 192.168.3.217
正常情况下,优先级最高的 k8s-master1 会持有 VIP。
四、初始化第一个 control-plane
下面只在 k8s-master1 执行。
1. 编写 kubeadm 配置
cat > kubeadm-config.yaml <<'EOF'
apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
advertiseAddress: 192.168.3.214
bindPort: 6443
nodeRegistration:
criSocket: unix:///run/containerd/containerd.sock
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: v1.36.1
controlPlaneEndpoint: "192.168.3.217:8443"
networking:
podSubnet: "10.244.0.0/16"
serviceSubnet: "10.96.0.0/12"
apiServer:
certSANs:
- 192.168.3.217
- k8s-vip
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd
EOF
如果你修改了 VIP、Pod 网段或 Kubernetes 版本,这个文件也要同步修改。
2. 执行 kubeadm init
sudo kubeadm init --config kubeadm-config.yaml --upload-certs
成功后会输出两类 join 命令:
- worker 节点 join 命令。
- control-plane 节点 join 命令,带
--control-plane和--certificate-key。
把 control-plane join 命令保存下来,后面 k8s-master2 和 k8s-master3 要用。
3. 配置 kubectl
mkdir -p "$HOME/.kube"
sudo cp -i /etc/kubernetes/admin.conf "$HOME/.kube/config"
sudo chown "$(id -u):$(id -g)" "$HOME/.kube/config"
kubectl get nodes
此时节点可能是 NotReady,因为 CNI 还没有安装。
4. 安装 Calico
kubectl apply -f /opt/k8s-offline-v1.36.1/manifests/calico.yaml
如果这个集群只有三台 master,没有 worker,需要允许 control-plane 节点运行普通业务 Pod:
kubectl taint nodes --all node-role.kubernetes.io/control-plane-
如果后面会加入独立 worker,生产环境不建议轻易取消 master taint。
五、加入另外两台 control-plane
先确认 k8s-master2 和 k8s-master3 已经完成前面的基础组件、HAProxy、Keepalived 和镜像导入。
在 k8s-master2 执行第一台输出的 control-plane join 命令,并追加本机 apiserver 地址:
sudo kubeadm join 192.168.3.217:8443 \
--token xxxxxx.xxxxxxxxxxxxxxxx \
--discovery-token-ca-cert-hash sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
--control-plane \
--certificate-key xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
--apiserver-advertise-address 192.168.3.215
在 k8s-master3 执行,地址改为本机 IP:
sudo kubeadm join 192.168.3.217:8443 \
--token xxxxxx.xxxxxxxxxxxxxxxx \
--discovery-token-ca-cert-hash sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
--control-plane \
--certificate-key xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
--apiserver-advertise-address 192.168.3.216
如果 join 命令过期,可以在 k8s-master1 重新生成:
kubeadm token create --print-join-command
sudo kubeadm init phase upload-certs --upload-certs
第一条命令生成基础 join 命令,第二条命令生成新的 certificate-key。把两者组合成带 --control-plane --certificate-key 的命令。
六、验证集群
任意一台 master 上执行:
kubectl get nodes -o wide
kubectl -n kube-system get pods -o wide
期望看到三台节点都是 Ready,kube-system 下的核心 Pod 都是 Running 或 Completed。
检查 API VIP:
curl -k https://192.168.3.217:8443/readyz?verbose
检查 HAProxy 后端:
echo "show stat" | sudo socat stdio /run/haproxy/admin.sock
如果没有启用 HAProxy socket,可以直接看日志:
journalctl -u haproxy -n 100 --no-pager
检查 etcd 健康状态:
sudo crictl exec "$(sudo crictl ps --name etcd -q)" \
etcdctl \
--endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
endpoint health
检查 VIP 漂移:
ip addr | grep 192.168.3.217
sudo systemctl stop keepalived
然后到另外两台机器查看:
ip addr | grep 192.168.3.217
kubectl get nodes
如果 VIP 已经漂移,并且 kubectl get nodes 仍然可用,说明 API 高可用入口生效。
测试完成后,在刚才停止 Keepalived 的节点恢复:
sudo systemctl start keepalived
七、部署一个测试应用
kubectl create deployment nginx --image=nginx:1.27.5
kubectl expose deployment nginx --type=NodePort --port=80
kubectl get pod -o wide
kubectl get svc nginx
离线环境里如果没有提前导入 nginx:1.27.5,Pod 会卡在 ImagePullBackOff。这不是 Kubernetes 集群故障,而是业务镜像没有进入离线制品流程。
真实环境建议把业务镜像推到内网 Harbor 或其他私有镜像仓库,然后在部署 YAML 里使用内网镜像地址。
清理测试应用:
kubectl delete svc nginx
kubectl delete deployment nginx
八、常见问题
kubeadm init 卡住
先看 kubelet 日志:
journalctl -u kubelet -f
常见原因:
- containerd 没启动。
- pause 镜像没有导入到
k8s.ionamespace。 SystemdCgroup没有配置成true。- HAProxy 或 Keepalived 没启动,导致
192.168.3.217:8443不通。
节点一直 NotReady
检查 CNI:
kubectl -n kube-system get pods -o wide
kubectl -n kube-system get pods -l k8s-app=calico-node -o wide
kubectl -n kube-system get pods -l k8s-app=calico-kube-controllers -o wide
常见原因:
- Calico 镜像没有导入到
k8s.ionamespace。 - kubeadm 的
podSubnet不是10.244.0.0/16,和 Calico IP 池不一致。 - 防火墙拦截了
179/tcp或 IPIP 协议号4。 - 多网卡环境里 Calico 自动识别错了节点 IP,需要配置
IP_AUTODETECTION_METHOD。
VIP 不漂移
检查 Keepalived:
systemctl status keepalived --no-pager
journalctl -u keepalived -n 100 --no-pager
常见原因:
interface写错,比如实际网卡是ens33,配置里写了ens160。- 网络不允许 VRRP 协议。
- 多个集群用了相同的
virtual_router_id,互相干扰。 - 本机 HAProxy 停止后被
track_script降权。
Harbor 通过 Nginx Proxy Manager 反代访问失败
离线或内网集群通常会配一个内网 Harbor,用来存放 Kubernetes 组件镜像和业务镜像。我的环境里 Harbor 跑在 QNAP NAS 上,HTTP 入口是:
http://192.168.3.200:8088
同时用 Nginx Proxy Manager 暴露域名:
https://harbor.jihw.top
一开始遇到两个现象:
- 直接访问
http://192.168.3.200:8088能打开 Harbor,但登录时报CSRF token invalid。 - 通过
https://harbor.jihw.top访问时,Nginx Proxy Manager 返回502或504。
先确认 Harbor 自身是健康的:
curl -I http://192.168.3.200:8088/
curl http://192.168.3.200:8088/api/v2.0/ping
如果 /api/v2.0/ping 返回:
Pong
说明 Harbor 服务本身没有坏。再检查 Harbor 容器:
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' \
| grep -E 'harbor|registry|redis|postgres|nginx|portal|core|jobservice'
如果 Harbor 组件都是 healthy,但是 Nginx Proxy Manager 日志里出现:
upstream timed out while connecting to upstream
upstream: "http://192.168.3.200:8088/"
问题通常不是 Harbor 密码,也不是证书,而是反代容器访问上游的路径不对。Nginx Proxy Manager 自己也是 Docker 容器,它从容器内部访问 NAS 宿主机的 192.168.3.200:8088,在 QNAP / Container Station 这类环境里可能会被 Docker NAT、hairpin NAT 或网络隔离卡住。
更稳的做法是:让 Nginx Proxy Manager 容器和 Harbor 的 nginx 容器加入同一个 Docker 网络,然后用容器名访问 Harbor。
先查看网络:
docker network ls
docker inspect nginx-manager-nginxManager-1 \
--format '{{json .NetworkSettings.Networks}}'
docker inspect nginx \
--format '{{json .NetworkSettings.Networks}}'
这里 nginx 是 Harbor 自带的入口容器,不是 Nginx Proxy Manager。我的 Harbor 网络名是:
harbor-installer_harbor
把 Nginx Proxy Manager 容器接入这个网络:
docker network connect harbor-installer_harbor nginx-manager-nginxManager-1
然后在 Nginx Proxy Manager 的 Proxy Host 里把上游从:
Forward Hostname / IP: 192.168.3.200
Forward Port: 8088
改成:
Forward Hostname / IP: nginx
Forward Port: 8080
Scheme: http
也就是让访问链路变成:
browser
-> https://harbor.jihw.top
-> nginx-manager-nginxManager-1
-> http://nginx:8080
-> Harbor
不要让反代容器再绕回宿主机端口:
nginx-manager-nginxManager-1
-> http://192.168.3.200:8088
否则在 NAS 的 Docker 网络里很容易出现超时。
如果 Harbor 放在 HTTPS 域名后面,还要在 harbor.yml 里配置外部访问地址:
hostname: harbor.jihw.top
http:
port: 8088
external_url: https://harbor.jihw.top
修改后重新生成 Harbor 配置并重启:
cd /share/CACHEDEV2_DATA/DockerDatas/harbor/harbor-installer
./prepare
docker compose down
docker compose up -d
最后验证:
curl -I https://harbor.jihw.top/
curl https://harbor.jihw.top/api/v2.0/ping
正常应该返回:
HTTP/1.1 200 OK
Pong
还有一个容易误判的点:harbor_admin_password 只在 Harbor 首次初始化 admin 用户时生效。Harbor 初始化完成后,再修改 harbor.yml 里的 harbor_admin_password 不会自动改数据库里的 admin 密码。如果遇到“密码不对”,先用 API 验证认证是否成功,再看浏览器 Network 里是不是实际报了 CSRF token invalid。
如果希望这个网络连接在重建 Nginx Proxy Manager 容器后仍然存在,需要在 Nginx Proxy Manager 的 compose 文件里声明 Harbor 网络为 external network,例如:
services:
nginxManager:
networks:
- default
- harbor
networks:
harbor:
external: true
name: harbor-installer_harbor
从 Windows Docker Desktop 推送和拉取 Harbor 镜像
Harbor 域名和反代修好后,可以先在本机 Docker Desktop 上做一次最小闭环测试:构建一个不依赖外网基础镜像的 scratch 镜像,推送到 Harbor,再删除本地镜像并从 Harbor 拉回。
先确认本机能访问 Harbor:
Resolve-DnsName harbor.jihw.top
curl.exe https://harbor.jihw.top/api/v2.0/ping
正常返回:
Pong
再确认 Docker Desktop 后端已经启动:
docker version
docker context ls
如果 docker version 提示:
failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine
说明 Docker Desktop 后端没有启动,先打开 Docker Desktop,等 desktop-linux context 可用后再继续。
登录 Harbor。不要把密码直接写进脚本或博客里,使用交互输入或环境变量:
$HARBOR_USER = "admin"
$HARBOR_PASSWORD = Read-Host "Harbor password" -AsSecureString
$BSTR = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($HARBOR_PASSWORD)
$HARBOR_PASSWORD_PLAIN = [Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)
$HARBOR_PASSWORD_PLAIN | docker login harbor.jihw.top -u $HARBOR_USER --password-stdin
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR)
Remove-Variable HARBOR_PASSWORD_PLAIN
构建一个最小测试镜像:
$TAG = "harbor.jihw.top/library/dockerdesktop-push-pull-test:$(Get-Date -Format yyyyMMdd-HHmmss)"
$TMP = Join-Path $env:TEMP "harbor-test-$(Get-Date -Format yyyyMMddHHmmss)"
New-Item -ItemType Directory -Force -Path $TMP | Out-Null
Set-Content -LiteralPath (Join-Path $TMP "hello.txt") `
-Value "hello from Docker Desktop to Harbor at $(Get-Date -Format o)" `
-Encoding ASCII
Set-Content -LiteralPath (Join-Path $TMP "Dockerfile") -Value @'
FROM scratch
COPY hello.txt /hello.txt
LABEL org.opencontainers.image.title="dockerdesktop-push-pull-test"
'@ -Encoding ASCII
docker build -t $TAG $TMP
这里使用 FROM scratch,所以不需要先从 Docker Hub 拉取基础镜像,适合内网或弱网环境做 Harbor 连通性测试。
推送到 Harbor:
docker push $TAG
推送成功时会看到类似输出:
The push refers to repository [harbor.jihw.top/library/dockerdesktop-push-pull-test]
20260617-175952: digest: sha256:3670dec3a76e... size: 855
删除本地镜像,再从 Harbor 拉回:
$LOCAL_ID_BEFORE = docker image inspect $TAG --format '{{.Id}}'
docker image rm $TAG
docker pull $TAG
$LOCAL_ID_AFTER = docker image inspect $TAG --format '{{.Id}}'
"before=$LOCAL_ID_BEFORE"
"after=$LOCAL_ID_AFTER"
如果 before 和 after 相同,说明这次流程闭环成功:
Docker Desktop -> Harbor push -> 删除本地镜像 -> Harbor pull -> 本地恢复镜像
最后可以清理临时目录:
Remove-Item -LiteralPath $TMP -Recurse -Force
如果 docker login 成功但 docker push 失败,优先检查这些点:
harbor.jihw.top是否能从 Windows 解析到 NAS。- Harbor 证书是否是 Docker Desktop 信任的证书;如果是自签证书,需要把 CA 加到 Docker Desktop 信任链。
- Harbor 项目是否存在,例如这里使用的是默认
library项目。 - Nginx Proxy Manager 的上游是否是
nginx:8080,并且 NPM 容器已经加入 Harbor 的 Docker 网络。 - Harbor 的
external_url是否是https://harbor.jihw.top。
join control-plane 失败
检查 token 和 certificate key 是否过期:
kubeadm token list
重新生成:
kubeadm token create --print-join-command
sudo kubeadm init phase upload-certs --upload-certs
还要确认新节点已经导入所有镜像,否则 join 到一半也会失败。
九、备份
至少备份这些内容:
/etc/kubernetes
/var/lib/etcd 快照
离线安装包
kubeadm-config.yaml
做 etcd 快照可以在任意健康 master 上执行:
sudo mkdir -p /backup/k8s
sudo crictl exec "$(sudo crictl ps --name etcd -q)" \
etcdctl \
--endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
snapshot save /var/lib/etcd/snapshot.db
sudo cp /var/lib/etcd/snapshot.db /backup/k8s/etcd-snapshot-$(date +%F-%H%M%S).db
sudo tar -czf /backup/k8s/kubernetes-pki-$(date +%F-%H%M%S).tar.gz /etc/kubernetes
备份文件不要只放在集群节点本机,至少再复制到独立备份机或备份存储。
十、卸载或重装
如果这是实验环境,需要重装集群,三台 master 都执行:
sudo kubeadm reset -f
sudo systemctl stop kubelet || true
sudo systemctl stop haproxy || true
sudo systemctl stop keepalived || true
sudo rm -rf /etc/kubernetes
sudo rm -rf /var/lib/etcd
sudo rm -rf /var/lib/kubelet
sudo rm -rf /etc/cni/net.d
sudo rm -rf /var/lib/cni
sudo rm -rf /var/lib/calico
sudo rm -rf /var/run/calico
sudo rm -rf "$HOME/.kube"
sudo ip link delete cni0 2>/dev/null || true
sudo ip link delete tunl0 2>/dev/null || true
如果机器上有生产业务,不要直接执行这组命令。/var/lib/kubelet、/var/lib/etcd 和 CNI 网络目录会影响当前节点上的 Kubernetes 数据。
卸载软件包:
sudo apt-mark unhold kubelet kubeadm kubectl
sudo dpkg -r kubelet kubeadm kubectl || true
sudo dpkg -r haproxy keepalived containerd || true
总结
离线安装 Kubernetes 的关键不是把在线命令照搬到内网,而是把版本、deb 包、镜像、CNI YAML 和初始化配置全部固定下来。
这套三主节点方案的边界也要明确:
- 控制平面可以容忍 1 台 master 故障。
- etcd 采用三节点多数派,不能同时丢 2 台 master。
- VIP 只解决 API 入口稳定,不解决业务 Pod、数据库和存储高可用。
- 后续升级时要重新制作对应版本的离线包,并先在测试集群验证。