记录用 K3s 搭一个三节点高可用(etcd HA)集群,并跑通第一个 HTTP 服务的完整过程。
集群规划
| 节点 | IP | 安装模式 | 角色 |
|---|---|---|---|
| node1 | 10.10.0.x | server | control-plane + etcd + worker |
| node2 | 10.10.0.x | server | control-plane + etcd + worker |
| node3 | 10.10.0.x | server | control-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-init | node1 最特殊的参数:初始化 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 ~/.bashrck9s
wget https://github.com/derailed/k9s/releases/latest/download/k9s_Linux_amd64.deb
apt install ./k9s_Linux_amd64.deb
rm k9s_Linux_amd64.debK3s 的 kubeconfig 不在 ~/.kube/config,而 kubectl / k9s 这类工具默认要显式指定它:
echo 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml' >> ~/.bashrc
source ~/.bashrc6. 部署测试
创建 Deployment
先来一个 Deployment:
kubectl create deployment hello --image=nginx:alpine这条命令是在创建一个 Deployment:
Deployment: hello
↓
负责创建并维护 Pod
↓
Pod
↓
运行 nginx:alpine 容器各部分含义:
kubectl:操作 Kubernetescreate deployment:创建一个 Deploymenthello:Deployment 名字--image=nginx:alpine:Pod 里运行这个镜像
默认副本数是 1,所以它会维护一个 nginx Pod。
创建 Service
kubectl expose deployment hello \
--name=hello \
--port=80 \
--target-port=80Service hello :80
↓ 通过 label 找到
hello Pod :80参数含义:
expose deployment hello:基于helloDeployment 创建 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-service、order-service、payment-service、inventory-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 |
| ServiceLB | Node 网络 → 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 ServiceTraefik = "这个 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"这条命令真正表达的规则是:
如果请求的 Host 是hello.kaiwen.dev,并且路径匹配/,就把请求转发到 Servicehello的 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意思是:
只有请求的Host是hello.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.xhttp://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 Pod8. cert-manager 与 HTTPS 证书
cert-manager 是什么
cert-manager 是 Kubernetes 里的一个证书自动化控制器,负责:
申请证书
续期证书
创建 TLS Secret
更新证书如果没有它,就得自己走一遍:
用 certbot 申请
下载 crt/key
创建 Kubernetes Secret
快过期时重新申请
再更新 Secretcert-manager 把这些全自动化了:
Ingress:“我要 hello.kaiwen.dev 的证书”
↓
cert-manager
↓
去 Let's Encrypt 申请
↓
验证域名
↓
拿到证书
↓
创建 TLS Secretannotation 是怎么触发 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 账号私钥的 Secretsolvers:域名所有权验证方式,这里用的是 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 devdescribe 应该能看到:
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_USERNAME和DB_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-123ConfigMap 和 Secret 的区别
这里最重要的是理解两者的定位:
ConfigMap = 非敏感配置
Secret = 敏感配置典型例子:
| 类型 | 典型键 |
|---|---|
ConfigMap | LOG_LEVEL、APP_ENV、API_BASE_URL |
Secret | DB_PASSWORD、JWT_SECRET、API_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 kubesealkubeseal 就是本地的加密工具。
加密:普通 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 YAMLGit 里最后保存的是:
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.yamlKubernetes 先创建一个 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 → GitHubK3s 集群侧(解密):
git clone → kubectl apply
↓
SealedSecret 资源
↓
Sealed Secrets Controller(持有私钥,私钥永不离开集群)
↓ 解密
真正的 Secret
↓
Pod最核心的安全模型可以记成一句:
Mac 负责用公钥加密,Git 只保存密文,K3s Controller 负责用私钥解密。