这篇记录一个比较常见的内网场景:目标服务器不能访问公网,但需要安装一个三主节点 Kubernetes 高可用集群。

本文使用 kubeadm 部署,三台机器都作为 control-plane,etcd 采用 stacked 模式,也就是每个控制平面节点本地运行一个 etcd。API Server 前面用 HAProxy + Keepalived 提供一个固定 VIP。

参考官方文档时,优先看这些页面:

环境规划

本文固定版本,避免离线环境里因为浮动版本导致重复部署结果不一致。

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-apiservercontroller-managerscheduleretcd 在单台 master 故障时还能继续工作。它不等于业务高可用。业务应用、Ingress、数据库、Redis、存储卷和监控告警还要另外做高可用设计。

端口和权限边界

这些端口至少要在三台 master 之间互通:

端口协议说明
8443TCPHAProxy 对外提供的 Kubernetes API VIP 入口
6443TCP每台 master 本地 kube-apiserver
2379-2380TCPetcd 客户端和 peer 通信
10250TCPkubelet API
10257TCPkube-controller-manager
10259TCPkube-scheduler
179TCPCalico BGP,全互联默认模式会用到
IPIP 4IP 协议号Calico 默认 IPIP overlay
VRRP 112IP 协议号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。如果你的机器是 ens33eth0 或其他名字,替换下面配置里的 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_idstatepriority

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-master2k8s-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-master2k8s-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

期望看到三台节点都是 Readykube-system 下的核心 Pod 都是 RunningCompleted

检查 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.io namespace。
  • 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.io namespace。
  • 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 返回 502504

先确认 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"

如果 beforeafter 相同,说明这次流程闭环成功:

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、数据库和存储高可用。
  • 后续升级时要重新制作对应版本的离线包,并先在测试集群验证。