记录用 K3s 搭一个三节点高可用(etcd HA)集群,并跑通第一个 HTTP 服务的完整过程。

集群规划

节点IP安装模式角色
node110.10.0.xservercontrol-plane + etcd + worker
node210.10.0.xservercontrol-plane + etcd + worker
node310.10.0.xservercontrol-plane + etcd + worker
三台为同网段真实 IP,已统一打码为 10.10.0.x(实际末位并不相同)。

三台节点都以 server 模式安装,因此每台同时承担 control-plane + etcd + worker 三种角色:

  • node1:负责"创建集群"(--cluster-init
  • node2 / node3:负责"加入集群"(--server 指向 node1)

1. node1:创建集群

curl -sfL https://get.k3s.io | sh -s - server \
  --cluster-init \
  --node-ip 10.10.0.x \
  --advertise-address 10.10.0.x \
  --flannel-iface eth1

参数说明

参数含义
--cluster-initnode1 最特殊的参数:初始化 etcd 集群(HA 模式),仅创建集群的节点使用
--node-ip 10.10.0.x告诉 Kubernetes:本节点的内部节点地址是 10.10.0.x
--advertise-address 10.10.0.x告诉 control plane:对其他组件公布自己地址时,使用 10.10.0.x
--flannel-iface eth1告诉 K3s 自带的 Flannel:节点间的容器网络通信走 eth1

2. 获取集群 Token

cat /var/lib/rancher/k3s/server/node-token

这是其他节点加入该 K3s 集群所需的共享凭据,注意保密,不要公开


3. node2 / node3:加入集群

# node2
curl -sfL https://get.k3s.io | K3S_TOKEN='YOUR_TOKEN' sh -s - server \
  --server https://10.10.0.x:6443 \
  --node-ip 10.10.0.x \
  --advertise-address 10.10.0.x \
  --flannel-iface eth1

# node3(结构同 node2,实际 IP 末位不同,已打码)
curl -sfL https://get.k3s.io | K3S_TOKEN='YOUR_TOKEN' sh -s - server \
  --server https://10.10.0.x:6443 \
  --node-ip 10.10.0.x \
  --advertise-address 10.10.0.x \
  --flannel-iface eth1

与 node1 命令的区别

  • 没有 --cluster-init,因为集群已经存在;
  • 改用 --server https://10.10.0.x:6443,以 node1 作为加入集群的入口。

4. 验证

kubectl get nodes -o wide -w

看到三台节点都是 Ready 状态,且 INTERNAL-IP 是我们指定的 10.10.0.x,集群就搭好了。


5. 便利化配置

alias

echo "alias k=kubectl" >> ~/.bashrc
source ~/.bashrc

之后 k 就等价于 kubectl

命令补全

apt install -y bash-completion

echo 'source <(kubectl completion bash)' >> ~/.bashrc
echo 'complete -o default -F __start_kubectl k' >> ~/.bashrc
source ~/.bashrc

k9s

wget https://github.com/derailed/k9s/releases/latest/download/k9s_Linux_amd64.deb
apt install ./k9s_Linux_amd64.deb
rm k9s_Linux_amd64.deb

K3s 的 kubeconfig 不在 ~/.kube/config,而 kubectl / k9s 这类工具默认要显式指定它:

echo 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml' >> ~/.bashrc
source ~/.bashrc

6. 部署测试

创建 Deployment

先来一个 Deployment:

kubectl create deployment hello --image=nginx:alpine

这条命令是在创建一个 Deployment:

Deployment: hello
   ↓
负责创建并维护 Pod
   ↓
Pod
   ↓
运行 nginx:alpine 容器

各部分含义:

  • kubectl:操作 Kubernetes
  • create deployment:创建一个 Deployment
  • hello:Deployment 名字
  • --image=nginx:alpine:Pod 里运行这个镜像

默认副本数是 1,所以它会维护一个 nginx Pod。

创建 Service

kubectl expose deployment hello \
  --name=hello \
  --port=80 \
  --target-port=80
Service hello :80
   ↓ 通过 label 找到
hello Pod :80

参数含义:

  • expose deployment hello:基于 hello Deployment 创建 Service
  • --name=hello:Service 名字叫 hello
  • --port=80:Service 自己提供 80 端口
  • --target-port=80:流量转发到 Pod 的 80 端口

默认类型:ClusterIP

因为上面没有指定 --type,所以默认创建的是 type: ClusterIP —— 给一组 Pod 提供一个稳定的集群内部访问入口

hello Service 10.43.x.x:80
      ↓
hello Pod :80

几个要点:

  • 这个 Service IP 不是局域网里某台机器的真实 IP,而是集群内部的虚拟 IP(Service 网段 10.43.0.0/16)——节点本机上 curl 是通的,局域网里其他机器访问不到。
  • Pod IP 会变,但 Service 的名字和 ClusterIP 相对稳定,所以集群内其他服务应该访问 http://hello:80,而不是记 Pod IP。这种简写只在 Pod 里、同一个 namespace 下有效;跨 namespace 要用全名 hello.default.svc.cluster.local
  • 宿主机不能用 http://hello:80 访问——宿主机不使用集群 DNS,解析不了 hello 这个 Service 名字(要用虚拟 IP 才行)。

一句话总结:

Deployment = 管 Pod
Service    = 给 Pod 提供稳定网络入口

三种 Service 类型

Kubernetes 里最常见的三种 Service 类型,各自负责一层:

类型主要职责从哪里访问
ClusterIP集群内部服务发现和负载均衡集群内部
NodePort把 Service 暴露到每个 Node 的某个端口Node IP + 高位端口
LoadBalancer请求一个真正的外部入口外部网络 / 公网

ClusterIP

Client Pod
    ↓
hello:80
    ↓
ClusterIP Service
    ↓
Pod

典型用途是微服务之间互相调用:

user-service
    ↓
http://order-service
    ↓
order-service Service
    ↓
order-service Pods

以后 user-serviceorder-servicepayment-serviceinventory-service 这类内部微服务,绝大多数都应该是 ClusterIP

NodePort

NodePort 会在每台 Kubernetes Node 上开放一个端口

例如:

Service port: 80
NodePort: 31080
Pod targetPort: 8080

链路:

node1公网IP:31080 ─┐
node2公网IP:31080 ─┼→ Service :80 → Pod :8080
node3公网IP:31080 ─┘

所以可以直接从外部访问:

http://152.53.x.x:31080

它的职责可以理解为:

把 Node 网络和 Kubernetes Service 网络接起来。

LoadBalancer

LoadBalancer 类型的潜台词是:

"我希望这个 Service 有一个集群外部的入口。"

理想云环境(比如 AWS)里是这样的:

Internet
    ↓
Cloud Load Balancer
    ↓
Kubernetes Service
    ↓
Pods

而 K3s 没有云厂商的负载均衡器,所以 K3s 自带 ServiceLB 来实现 type: LoadBalancer

当前的完整链路是:

公网 Node :80/:443
       ↓
K3s ServiceLB
       ↓
Traefik LoadBalancer Service
       ↓
Traefik Pod
       ↓
Ingress
       ↓
hello ClusterIP Service
       ↓
nginx Pod

三种类型各自负责哪一层:

ClusterIP    = Pod / Service 之间的内部稳定入口
NodePort     = Node 网络 → Service
LoadBalancer = 外部网络 → Kubernetes Service

实践上的标准做法:

入口网关(如 Traefik)      → LoadBalancer
业务微服务(user-service 等)→ ClusterIP

这样整个集群对公网通常只开放一个统一入口,后面由 Traefik 根据域名和路径把请求分给不同 Service。

ServiceLB

公网 IP 152.53.x.x 首先接到的是 K3s ServiceLB

ServiceLB 是 K3s 自带的、"让 type: LoadBalancer 的 Service 真正能从节点外部进入"的实现。

之前看到的这些 Pod:

svclb-traefik-xxxxx   node1
svclb-traefik-xxxxx   node2
svclb-traefik-xxxxx   node3

就是 ServiceLB 创建的。它们大致负责:

Node :80 / Node :443
   ↓
把流量送进 Kubernetes 的 traefik Service

可以把 ServiceLB 理解成:

把宿主机网络入口接到 Kubernetes Service 网络。

Traefik Service

接下来是 Traefik LoadBalancer Service。注意,它不是 Traefik 程序本身,而是 Kubernetes 里的一个 Service,职责是:

找到真正运行 Traefik 的 Pod,并把流量送过去。

Traefik Pod 里运行着 Traefik 进程,它真正会做:

接收 HTTP/HTTPS
解析 Host
解析 URL path
处理 TLS
反向代理

比如来了一个请求 Host: api.kaiwen.dev + Path: /users,Traefik 就会去看 Kubernetes 里的路由配置——也就是 Ingress(见下一节)。

各层职责一览:

职责
公网 Node :80/:443请求进入这台 VPS
ServiceLBNode 网络 → Kubernetes LoadBalancer Service
Traefik Service找到 Traefik Pod
Traefik Pod真正处理 HTTP/HTTPS
Ingress告诉 Traefik 请求应该去哪里
hello Service找到 hello Pod
hello Pod真正处理业务请求

负载均衡不止一层

这里说的"负载均衡"不止一层,而且不同组件负责不同层。

第一层是 Kubernetes Service

hello Service
      ↓
 ┌────┼────┐
 ↓    ↓    ↓
Pod1 Pod2 Pod3

如果把 Deployment 扩成 3 个副本:

kubectl scale deployment hello --replicas=3

那么 hello Service 会把请求分发到这些 Pod。底层通常由 Kubernetes 的 Service 网络机制(例如 kube-proxy)实现。

第二层是 Traefik,它主要做 HTTP 层/L7 的路由和负载均衡

Traefik
├── hello.kaiwen.dev  → hello Service
├── api.kaiwen.dev    → api Service
└── /orders           → order Service
Traefik            = "这个 HTTP 请求应该去哪个 Service?"
Kubernetes Service = "这个 Service 后面有多个 Pod,具体给哪个 Pod?"

另外 ServiceLB 也有点特别:它名字里虽然有 LB,但在 VPS 场景下,它更重要的作用其实是:

把 Node 的 80/443 接到 LoadBalancer 类型的 Kubernetes Service。

7. Ingress

K3s 默认自带 Traefik 作为 Ingress Controller(这里的 traefik 是一个 IngressClass),所以不需要额外安装,直接创建 Ingress 规则即可:

kubectl create ingress hello \
  --class=traefik \
  --rule="hello.kaiwen.dev/=hello:80"

这条命令真正表达的规则是:

如果请求的 Hosthello.kaiwen.dev,并且路径匹配 /,就把请求转发到 Service hello 的 80 端口。

对应链路:

hello.kaiwen.dev/
   ↓ Ingress hello(由 Traefik 匹配规则)
   ↓ hello Service :80
   ↓ nginx Pod

查看这个 Ingress:

kubectl get ingress hello
kubectl get ingress hello -o yaml   # 完整内容

用 YAML 定义(含 TLS)

更常见的做法是直接写 YAML。用 kubectl get ingress hello -o yaml 看到的,就是我们最终生效的这份清单(加上了 TLS 和 cert-manager 注解):

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: hello
  namespace: default
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: traefik
  tls:
    - hosts:
        - hello.kaiwen.dev
      secretName: hello-kaiwen-dev-tls
  rules:
    - host: hello.kaiwen.dev
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: hello
                port:
                  number: 80

路由部分(最核心)

rules:
  - host: hello.kaiwen.dev

意思是:

只有请求的 Hosthello.kaiwen.dev 时,才匹配这条规则。
http:
  paths:

表示下面开始定义 HTTP 路径规则。

path: /
pathType: Prefix

表示:

匹配以 / 开头的所有路径。
ingressClassName: traefik

表示:

这条 Ingress 规则由 Traefik 这个 Ingress Controller 来执行。

TLS 部分

tls:
  - hosts:
      - hello.kaiwen.dev
    secretName: hello-kaiwen-dev-tls

意思是:

hello.kaiwen.dev 使用 TLS/HTTPS,证书放在名为 hello-kaiwen-dev-tls 的 Kubernetes Secret 里。

所以 Traefik 最后会读取这个 Secret 里的 tls.crt / tls.key,然后对外提供 https://hello.kaiwen.dev

但这个 Secret 不是手工创建的——这就轮到 cert-manager 了(见下一节)。

测试:不做 DNS,只写 Host 头

测试时不必真的配置 DNS,只要把 Host 头写对:

curl -H 'Host: hello.kaiwen.dev' http://152.53.x.x
  • http://152.53.x.x:实际连接 node1 公网 IP 的 80 端口;
  • -H 'Host: hello.kaiwen.dev':告诉 Traefik 这次请求的域名是 hello.kaiwen.dev,让它去匹配对应的 Ingress 规则。

也可以顺便测 HTTPS,用 --resolve 把域名强行指向这台机器:

curl --resolve hello.kaiwen.dev:443:152.53.x.x https://hello.kaiwen.dev

于是整条链就是:

curl
 ↓
152.53.x.x:80(node1 公网 IP)
 ↓
ServiceLB
 ↓
Traefik
 ↓ 检查 Host: hello.kaiwen.dev
匹配 Ingress hello
 ↓
hello Service :80
 ↓
nginx Pod

8. cert-manager 与 HTTPS 证书

cert-manager 是什么

cert-manager 是 Kubernetes 里的一个证书自动化控制器,负责:

申请证书
续期证书
创建 TLS Secret
更新证书

如果没有它,就得自己走一遍:

用 certbot 申请
下载 crt/key
创建 Kubernetes Secret
快过期时重新申请
再更新 Secret

cert-manager 把这些全自动化了:

Ingress:“我要 hello.kaiwen.dev 的证书”
        ↓
cert-manager
        ↓
去 Let's Encrypt 申请
        ↓
验证域名
        ↓
拿到证书
        ↓
创建 TLS Secret

annotation 是怎么触发 cert-manager 的

Ingress 里这一句:

annotations:
  cert-manager.io/cluster-issuer: letsencrypt-prod

意思是:请 cert-manager 用名为 letsencrypt-prod 的 ClusterIssuer,为这条 Ingress 申请并维护证书。

ClusterIssuer 是什么

我们创建的是:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    email: ...
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
      - http01:
          ingress:
            class: traefik

一句话理解:

“证书应该找谁签、怎么验证”的配置模板。

各字段的含义:

  • email:Let's Encrypt 账号邮箱(用于证书过期提醒)
  • server:ACME 服务地址,这里是 Let's Encrypt 的生产环境目录
  • privateKeySecretRef:存放 ACME 账号私钥的 Secret
  • solvers:域名所有权验证方式,这里用的是 HTTP-01

Let's Encrypt 是什么

Let's Encrypt 是真正的 Certificate Authority(CA,证书颁发机构)。三者的关系:

cert-manager  = 自动化工具(你的“办事员”)
ClusterIssuer = 怎么申请的配置模板(“办证流程说明”)
Let's Encrypt = 真正签发证书的 CA(“发证机关”)

HTTP-01 challenge

ClusterIssuer 里这一段:

solvers:
  - http01:
      ingress:
        class: traefik

表示使用 ACME 的 HTTP-01 challenge:Let's Encrypt 要求你证明自己真的控制 hello.kaiwen.dev,它会去访问:

http://hello.kaiwen.dev/.well-known/acme-challenge/xxxx

验证期间,cert-manager 会临时创建:

  • 临时 Pod
  • 临时 Service
  • 临时 Ingress

Traefik 把这个请求送进去。如果 Let's Encrypt 成功拿到正确内容:

域名验证成功
        ↓
证书签发
前提:hello.kaiwen.dev 的 DNS A 记录要先指向 152.53.x.x,否则 Let's Encrypt 根本访问不到验证路径,签发会一直卡在 pending。

最终整个 HTTPS 流程

把全部串起来:

Ingress
hello.kaiwen.dev
        │
        │ annotation
        ▼
ClusterIssuer
letsencrypt-prod
        │
        ▼
cert-manager
        │
        │ ACME HTTP-01
        ▼
Let's Encrypt
        │
        │ 验证
        ▼
hello.kaiwen.dev
        │
        ▼
签发证书
        │
        ▼
Secret
hello-kaiwen-dev-tls
        │
        ▼
Traefik
        │
        ▼
HTTPS

最后只需要记住四句话:

组件一句话职责
Ingress域名和路径怎么转发
Traefik真正处理 HTTP/HTTPS 的程序
cert-manager自动申请和续期证书
ClusterIssuer告诉 cert-manager 去哪里、用什么方式申请证书

Let's Encrypt 是最终真正签证书的人


9. ConfigMap 与 Secret

创建 ConfigMap

kubectl create configmap demo-config \
  -n dev \
  --from-literal=APP_ENV=dev \
  --from-literal=GREETING='hello from dev'

查看:

kubectl get configmap -n dev
kubectl describe configmap demo-config -n dev

describe 应该能看到:

APP_ENV:
----
dev

GREETING:
----
hello from dev

注入到 Deployment

先起一个常驻的测试容器:

kubectl create deployment config-demo \
  -n dev \
  --image=busybox:1.36 \
  -- sleep 3600

然后把 ConfigMap 的键值注入进去:

kubectl set env deployment/config-demo \
  -n dev \
  --from=configmap/demo-config

这会把 ConfigMap 的键值注入到 Deployment 的 Pod 模板里——也就是说,以后新建出来的 Pod 都会带这些环境变量。

验证:

kubectl exec -n dev deployment/config-demo -- env | grep -E 'APP_ENV|GREETING'

创建 Secret

接下来学 Secret——它和 ConfigMap 非常像,但专门用来放敏感信息:

kubectl create secret generic demo-secret \
  -n dev \
  --from-literal=DB_USERNAME=demo \
  --from-literal=DB_PASSWORD='test-password-123'
kubectl get secret demo-secret -n dev

实际输出:

root@node1:~# k get secret demo-secret -n dev
NAME          TYPE     DATA   AGE
demo-secret   Opaque   2      16s
root@node1:~#

这里:

  • TYPE = Opaque:普通通用型 Secret(用户自定义的键值对);
  • DATA = 2:里面有两个键——DB_USERNAMEDB_PASSWORD

注入 Secret 并验证

kubectl set env deployment/config-demo \
  -n dev \
  --from=secret/demo-secret
kubectl exec -n dev deployment/config-demo -- env | grep -E 'DB_USERNAME|DB_PASSWORD'

实际输出:

root@node1:~# k set env deployment/config-demo \
  -n dev \
  --from=secret/demo-secret
deployment.apps/config-demo env updated
root@node1:~# k exec -n dev deployment/config-demo -- env | grep -E 'DB_USERNAME|DB_PASSWORD'
DB_PASSWORD=test-password-123
DB_USERNAME=demo
root@node1:~#

config-demo Pod 现在同时拿到了两类配置:

ConfigMap
├── APP_ENV=dev
└── GREETING=hello from dev

Secret
├── DB_USERNAME=demo
└── DB_PASSWORD=test-password-123

ConfigMap 和 Secret 的区别

这里最重要的是理解两者的定位:

ConfigMap = 非敏感配置
Secret    = 敏感配置

典型例子:

类型典型键
ConfigMapLOG_LEVELAPP_ENVAPI_BASE_URL
SecretDB_PASSWORDJWT_SECRETAPI_TOKEN

注意:Secret 默认并不是"加密文件"

Kubernetes 的 Secret 默认只是 Base64 编码,不是加密:

test-password-123
        ↓ base64
dGVzdC1wYXNzd29yZC0xMjM=

所以:

  • 拿到 Secret YAML / 值的人,base64 -d 一下就能还原;
  • 真正的加密要靠额外的方案(比如 Sealed Secrets、Vault、KMS 加密 etcd 等);
  • 至少要做到:给 Secret 收紧 RBAC 权限,别把它提交到 Git 仓库里。
以上都是 create 命令(命令式)的用法。ConfigMap 和 Secret 同样可以用 YAML(声明式)来定义,kubectl get configmap demo-config -n dev -o yaml 就能看到对应结构。

10. Sealed Secrets:把 Secret 安全地放进 Git

上一节说过,Secret 不要提交到 Git——Sealed Secrets 就是解决这个问题的:把 Secret 加密成可以进 Git 的 SealedSecret,由集群里的 Controller 负责解密还原。

安装 Controller

在 node1 上安装:

kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.39.1/controller.yaml

安装后,集群里会多出一个 Sealed Secrets Controller Pod。它启动时自带一套密钥:

公钥:可以给外面使用,用来加密
私钥:保留在集群里,用来解密

把公钥导出到 Mac

node1 上把 Controller 的公钥证书导出来:

kubectl get secret -n kube-system \
  -l sealedsecrets.bitnami.com/sealed-secrets-key \
  -o jsonpath='{.items[0].data.tls\.crt}' \
  | base64 -d > /root/sealed-secrets-public.pem

然后在 Mac 上把这个公钥复制回来:

scp root@152.53.x.x:/root/sealed-secrets-public.pem \
  ~/sealed-secrets-public.pem

所以这时 Mac 手里只有公钥,没有私钥。

在 Mac 上安装 kubeseal

brew install kubeseal

kubeseal 就是本地的加密工具。

加密:普通 Secret → SealedSecret

先在 Mac 上临时生成一个普通 Secret YAML,但不真的创建

kubectl create secret generic demo-secret \
  --namespace dev \
  --from-literal=DB_USERNAME=demo \
  --from-literal=DB_PASSWORD='test-password-123' \
  --dry-run=client -o yaml

这里非常关键的是 --dry-run=client

只在本地生成 YAML,不会真的创建 Secret。

然后把这个 YAML 通过管道交给 kubeseal

kubectl create secret generic demo-secret \
  --namespace dev \
  --from-literal=DB_USERNAME=demo \
  --from-literal=DB_PASSWORD='test-password-123' \
  --dry-run=client -o yaml \
| kubeseal \
  --cert ~/sealed-secrets-public.pem \
  --format yaml \
> apps/config-demo/sealed-secret.yaml

这里发生的是:

明文密码
   ↓
kubectl 生成 Secret YAML
   ↓
kubeseal 使用公钥加密
   ↓
SealedSecret YAML

Git 里最后保存的是:

kind: SealedSecret
spec:
  encryptedData:
    DB_USERNAME: Ag...
    DB_PASSWORD: Ag...

而不是明文:

demo
test-password-123

所以这份 sealed-secret.yaml 可以放心提交 Git。

解密:在集群里 apply

回到 node1 / K3s 集群执行:

kubectl apply -f sealed-secret.yaml

Kubernetes 先创建一个 SealedSecret 资源;然后一直监听这类资源的 Sealed Secrets Controller 会:

读取 encryptedData
        ↓
使用集群里的私钥解密
        ↓
生成真正的 Kubernetes Secret

所以最后集群里会同时存在两个资源:

SealedSecret  demo-secret   ← 密文,来自 Git
Secret        demo-secret   ← 明文,由 Controller 解密生成

而你的 Deployment 根本不需要知道 Sealed Secrets 的存在,还是普通写法:

envFrom:
  - secretRef:
      name: demo-secret

完整链路

分成两侧看更清楚。

Mac 侧(加密):

Controller 公钥(导出的 pem)
        ↓
kubectl --dry-run=client 生成普通 Secret YAML(明文)
        ↓
kubeseal 用公钥加密
        ↓
sealed-secret.yaml(只有密文)
        ↓
git push → GitHub

K3s 集群侧(解密):

git clone → kubectl apply
        ↓
SealedSecret 资源
        ↓
Sealed Secrets Controller(持有私钥,私钥永不离开集群)
        ↓ 解密
真正的 Secret
        ↓
Pod

最核心的安全模型可以记成一句:

Mac 负责用公钥加密,Git 只保存密文,K3s Controller 负责用私钥解密。
最后修改:2026 年 08 月 31 日
如果觉得我的文章对你有用,请随意赞赏