学习目标
学完本章,你应该能够:
- 理解多实例部署下 Session 的痛点,能用 Redis 作为 Session Store 解决分布式登录态问题。
- 掌握 Session 过期刷新的几种策略(每次刷新 / 快过期刷新 / 固定间隔刷新 / 长短 token),能在 middleware 中实现刷新逻辑。
- 彻底搞懂 JWT(Header / Payload / Signature)的原理与使用,能完成 JWT 登录、校验、跨域改造。
- 能从工程角度对比 Session 和 JWT 的优缺点,知道何时混用。
- 掌握基本的系统保护手段:限流(基于 IP、基于 Redis 集群限流)、登录安全增强(User-Agent 校验)。
- 理解 Kubernetes 的核心概念(Pod / Deployment / Service / Ingress / PV / PVC),能把 webook + MySQL + Redis 部署到 K8s。
前置知识(如果下面任意一点生疏,先回看对应章):
- 第01章 Gin 基础:知道一个 HTTP 接口怎么写、middleware 怎么串。
- 第02章 GORM + MySQL:知道表怎么建、DAO 怎么分层。
- 第02章 Cookie Session:知道
sess.Get/Set/Save的基本用法。 - 基本的 Redis 概念(KV、TTL、
INCR/EXPIRE)。 - 一点网络常识:负载均衡、端口、域名。
本章你会动手做的事:
- 把单机的 cookie Session 换成 Redis Session,观察多实例下登录态不再丢失。
- 亲手签发一个 JWT,用 jwt.io 把它拆成三段,确认 Payload 里能直接看到
user_id。 - 写一份
k8s-webook-deployment.yaml+Service+Ingress,在本地 Docker Desktop 的 K8s 里把 webook 跑起来。
一、多实例部署的 Session 问题
1.1 单机到多实例的演变
上一章我们用 cookie store 实现了登录态。这在单实例下工作正常,但生产环境通常部署多个实例:
一句话:登录态本质上是"服务端记住你是谁"。单机时它记在自己内存里;一旦有了多个实例,记忆就分了家。
flowchart LR
U[用户] --> LB[负载均衡]
LB --> P1[Pod1
Session 在内存]
LB --> P2[Pod2
Session 在内存]
LB --> P3[Pod3
Session 在内存]问题来了:用户在 Pod1 登录,Session 数据存在 Pod1 内存里。下次请求被负载均衡转到 Pod2,Pod2 找不到 Session,用户被踢回登录页。
类比:你只在 A 银行网点开了户,却拿着卡去 B 网点取钱,B 网点不认你——除非所有的网点背后是同一个中央账本。Redis 就是这个"中央账本"。
1.2 解决方案:共享 Session 存储
Gin Session 提供了多种 store 实现:
| Store | 场景 |
|---|---|
| cookie | 单机开发 |
| memstore | 单实例部署 |
| redis | 多实例部署,推荐 |
| gorm/mysql | 已有 MySQL |
| memcached | 已有 Memcached |
| mongo | 已有 MongoDB |
| postgres | 已有 PostgreSQL |
注意:sess_id 一定放在 Cookie 里,但 Session 数据(如 user_id)才是 store 真正存储的内容。
1.3 启动 Redis
# docker-compose.yaml
services:
redis:
image: redis:7
ports:
- "6379:6379"
environment:
# 测试环境允许空密码,生产必须设密码
ALLOW_EMPTY_PASSWORD: "yes"
docker compose up -d
1.4 用 Redis Store
package web
import (
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/redis"
)
func InitSessionStore() sessions.Store {
// 步骤 1:两个 key:
// Authentication:身份认证(防篡改)
// Encryption:数据加密(防泄露)
// size=32 表示用 32 字节长度的 key
// 步骤 2:连接本地 Redis(测试环境无密码,传空串)
store, err := redis.NewStore(16, "tcp",
"localhost:6379", "",
[]byte("authentication-key-must-be-32-bytes!"),
[]byte("encryption-key-must-be-32-bytes!!"),
)
if err != nil {
panic(err)
}
return store
}
面向接口编程的好处:Gin Session 的 store 是接口,我们可以从 cookie store 切换到 redis store,业务代码完全不动。设计自己的系统时也要想:将来会不会需要不同实现?如果会,就抽象成接口。
⚠️ 新手必踩的坑:key 长度必须是 32 字节。
redis.NewStore的两个 key 都要求恰好 32 字节,短了/长了都会 panic。而生产环境的 key 必须保密,不能像示例里写死在代码里——应当从配置/Secret 读取。
二、Session 过期刷新
2.1 问题
如果 Cookie 过期时间是 10 分钟,用户在 9:59 还能访问,10:00 突然被踢下线。这种体验很差,需要在用户活跃时刷新过期时间。
2.2 刷新策略对比
| 策略 | 优点 | 缺点 |
|---|---|---|
| 每次访问都刷新 | 体验好 | 性能差,Redis 压力大 |
| 快过期才刷新(如剩 1 分钟) | 性能好 | 万一刷新后用户不再访问? |
| 固定间隔刷新(如 1 分钟内首次访问刷新) | 折中,推荐 | 实现稍复杂 |
| 长短 token | 体验最佳 | 实现最复杂,后续课程讲 |
flowchart TD
R[用户请求到达] --> C{距离上次刷新
超过 60 秒?}
C -- 否 --> N[放行, 不碰 Redis]
C -- 是 --> S[刷新 Session 过期时间
并 Save]
S --> N2.3 在 Middleware 中刷新
package web
import (
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
// LoginRequired 登录校验 + 过期刷新
func (h *UserHandler) LoginRequired(c *gin.Context) {
// 步骤 1:取出 user_id,拿不到说明没登录
sess := sessions.Default(c)
userID, ok := sess.Get("user_id").(int64)
if !ok || userID == 0 {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
// 步骤 2:固定间隔刷新——1 分钟内只刷新一次
// 用 sess_id 的最后刷新时间判断
now := time.Now().UnixMilli()
lastRefresh, _ := sess.Get("last_refresh").(int64)
if now-lastRefresh > 60*time.Second.Milliseconds() {
// 步骤 3:更新刷新时间 + 重设过期时间 + 落盘
sess.Set("last_refresh", now)
sess.Options(sessions.Options{MaxAge: 60 * 60 * 24})
// 刷新 Redis 中的 Session 过期时间
_ = sess.Save()
}
// 步骤 4:把 user_id 注入 context,供后续 handler 使用
c.Set("user_id", userID)
c.Next()
}
为什么放在 middleware:刷新过期时间是所有业务都要做的事,不该散落到每个 handler 里。Middleware 是天然的位置。
2.4 登录状态保持多久
没有标准答案,取决于产品经理和系统其他安全措施。如果有二次验证、风控系统,可以保持较长时间;否则建议较短(如 1-7 天)。
三、JWT 原理与实现
3.1 JWT 是什么
JWT(JSON Web Token) 是一种常用于身份认证的机制。和 Session 不同,JWT 把登录态直接编码成一个 token 字符串,客户端每次请求带上它,服务端解析即知用户身份,不需要查询任何存储。
类比:Session 像"门禁卡 + 前台登记簿"(每次进门前台查簿子);JWT 像"带照片和公章的工作证"——门卫看一眼证上的章和照片就知道是你,不用翻登记簿。证本身携带了身份信息,但证丢了别人也能冒用(除非加防伪/绑定)。
3.2 JWT 三段结构
一个 JWT 长这样:
xxxxx.yyyyy.zzzzz
用 . 分隔成三段:
第一段:Header(头部)
描述 token 元数据的 JSON,经 Base64URL 编码:
{
"alg": "HS256", // 签名算法
"typ": "JWT" // 类型
}
第二段:Payload(负载)
存放实际数据的 JSON,经 Base64URL 编码:
{
"user_id": 123,
"user_agent": "Mozilla/...",
"exp": 1699999999, // 过期时间
"iat": 1690000000, // 签发时间
"nbf": 1690000000 // 生效时间
}
注意:Payload 默认只编码不加密!绝对不能放密码、身份证号等敏感信息。任何人拿到 token 都能解码 Payload。
flowchart LR
H[Header JSON] --> HB[Base64URL]
P[Payload JSON] --> PB[Base64URL]
S[Signature] --> SB[HMACSHA256]
HB --> T[xxxxx.yyyyy.zzzzz]
PB --> T
SB --> T第三段:Signature(签名)
用 Header 中指定的算法(如 HS256)+ 一个服务端密钥,对 Base64(Header) + "." + Base64(Payload) 计算签名:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
签名的作用是防篡改:如果有人改了 Payload,签名就对不上,服务端解析时会失败。
⚠️ 新手必踩的坑:Payload 不是密文。Base64URL 只是编码,随便找个在线工具就能解出明文。所以
user_id、user_agent这类非敏感信息可以放,密码、手机号、身份证绝对不行。另外secret一旦泄露,攻击者可以伪造任意用户的 token——务必保密、用足够长的随机串。
3.3 JWT 工作流程
sequenceDiagram
participant U as 用户/前端
participant S as 服务端
U->>S: 1. 登录(账号密码)
S->>S: 2. 校验通过, 用 secret 签发 JWT
S-->>U: 3. 返回 token(Header 携带)
U->>S: 4. 后续请求 Authorization: Bearer xxx
S->>S: 5. 用 secret 验签 + 解析 Payload 拿 user_id
S-->>U: 6. 返回业务数据3.4 优缺点对比
| 维度 | Session | JWT |
|---|---|---|
| 服务端存储 | 需要(Redis 等) | 不需要 |
| 分布式 | 需要共享存储 | 天然支持 |
| 性能 | 多一次存储访问 | 解析即可,更快 |
| 撤销 | 直接删 Session | 难(需要黑名单) |
| 安全性 | 较高(数据在后端) | 较低(Payload 易泄露) |
| 续期 | 直接刷新 | 难(需重新签发或用 refresh token) |
3.5 JWT 实现
go get github.com/golang-jwt/jwt/v5
package web
import (
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// UserClaims JWT 中携带的数据
type UserClaims struct {
UserID int64 `json:"user_id"`
UserAgent string `json:"user_agent"` // 用于增强安全
jwt.RegisteredClaims // 嵌入标准声明(exp, iat, nbf...)
}
type UserHandler struct {
svc *service.UserService
jwtSecret []byte // JWT 签名密钥,必须保密
}
func NewUserHandler(svc *service.UserService) *UserHandler {
return &UserHandler{
svc: svc,
jwtSecret: []byte("my-jwt-secret-key-keep-it-safe"),
}
}
// Login 用 JWT 改造的登录接口
func (h *UserHandler) Login(c *gin.Context) {
// 步骤 1:绑定请求体
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.Bind(&req); err != nil {
return
}
// 步骤 2:校验账号密码
user, err := h.svc.Login(req.Email, req.Password)
if err != nil {
c.JSON(http.StatusOK, gin.H{"msg": "用户名或密码不对"})
return
}
// 步骤 3:填充 Claims(user_id + User-Agent + 过期/签发时间)
claims := UserClaims{
UserID: user.ID,
UserAgent: c.GetHeader("User-Agent"),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
// 步骤 4:用 HS256 + secret 签发 token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, err := token.SignedString(h.jwtSecret)
if err != nil {
c.JSON(http.StatusOK, gin.H{"msg": "系统错误"})
return
}
// 步骤 5:通过响应头 x-jwt-token 返回,前端存好后续带上
c.Header("x-jwt-token", tokenStr)
c.JSON(http.StatusOK, gin.H{"msg": "登录成功"})
}
3.6 JWT 登录校验 Middleware
package web
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
func (h *UserHandler) LoginRequiredJWT(c *gin.Context) {
// 步骤 1:从 Authorization 头部读取
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
// 步骤 2:按 "Bearer xxxxxx" 格式拆分
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
tokenStr := parts[1]
// 步骤 3:解析 + 验证签名
claims := &UserClaims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
// 这里返回用于验签的密钥
return h.jwtSecret, nil
})
if err != nil || !token.Valid {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
// 步骤 4:增强安全——校验 User-Agent 是否和登录时一致
// 防止 token 被盗后在不同设备使用
if claims.UserAgent != c.GetHeader("User-Agent") {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
// 步骤 5:固定间隔刷新 token(把新 token 放响应头)
// JWT 不能像 Session 那样在服务端刷新,只能重新签发
if claims.ExpiresAt != nil &&
claims.ExpiresAt.Sub(time.Now()) < time.Hour {
newClaims := UserClaims{
UserID: claims.UserID,
UserAgent: claims.UserAgent,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
newToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims)
if newTokenStr, err := newToken.SignedString(h.jwtSecret); err == nil {
c.Header("x-jwt-token", newTokenStr)
}
}
// 步骤 6:注入 context,放行
c.Set("user_id", claims.UserID)
c.Next()
}
3.7 跨域改造
JWT 模式下需要改造 CORS 配置:
engine.Use(cors.New(cors.Config{
AllowOriginFunc: func(origin string) bool {
return origin == "http://localhost:3000"
},
AllowCredentials: true,
// 业务请求允许带的头:加上 Authorization
AllowHeaders: []string{"Content-Type", "Authorization"},
// 允许前端读到的响应头:加上 x-jwt-token
ExposeHeaders: []string{"x-jwt-token"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
MaxAge: 12 * time.Hour,
}))
3.8 接入 JWT 步骤总结
- 在 Login 接口登录成功后,生成 JWT token
- 通过响应头
x-jwt-token返回给前端 - 改造 CORS:
AllowHeaders加Authorization,ExposeHeaders加x-jwt-token - 接入 JWT 登录校验 middleware:读取
Authorization: Bearer xxx、验签、校验 User-Agent - 在 middleware 中刷新 token(快过期时重新签发并通过
x-jwt-token返回) - 前端在每次请求的
Authorization头带上 token
四、JWT 与 Session 混用
JWT 不能放敏感数据,但有时确实需要在登录态里携带一些数据。解决方案:JWT 里只放 user_id,然后用 user_id 作为 key 去 Redis 取详细数据。
JWT: { user_id: 123 }
Redis: user.info:123 → { nickname, role, ... }
这样既能享受 JWT 的分布式优势,又能放敏感数据(数据在 Redis 里,前端正经解码 JWT 看不到)。
flowchart TD
Req[请求带 JWT] --> V[验签拿 user_id]
V --> C{需要敏感资料?}
C -- 否 --> Ok[直接返回]
C -- 是 --> R[查 Redis: user.info:user_id]
R --> Ok五、保护系统:限流
5.1 系统漏洞
当前系统的两个明显漏洞:
- 任何人都可以注册(
/users/signup无需登录) - 任何人都可以登录(
/users/login无需登录)
攻击者可以用 shell 脚本狂发请求,数据库负载飙升。
5.2 限流思路
- 限制每秒处理的总请求数(保护系统)
- 限制每个用户/IP 的请求频率(保护公平)
5.3 限流对象:用 IP
登录注册接口用户还没登录,没法用 user_id 限流。用 IP 是 Web 端的较好选择(虽然共享 IP 可能误伤,但阈值设置合理就行)。APP 端可以用设备序列号。
5.4 限流阈值
- 整体阈值:通过压测得到(面试回答这个)。
- 单 IP 阈值:靠经验。正常人手速 1 秒 1 个请求,考虑共享 IP,给 100/秒足够。
5.5 基于 Redis 的 IP 限流
为什么要用 Redis:单机限流在多实例下不准(每个实例都计自己的)。用 Redis 集中计数才能准确限流。
package middleware
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
// IPMaxReqPerSec 每秒每 IP 最大请求数
const IPMaxReqPerSec = 100
func IPRateLimit(client *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
// 步骤 1:拼 key,精确到"秒 + IP"
ip := c.ClientIP()
key := fmt.Sprintf("ip:rate:%s:%d", ip, time.Now().Unix())
// 步骤 2:INCR 原子递增,第一次 INCR 时 key 不存在会创建并设为 1
cnt, err := client.Incr(c, key).Result()
if err != nil {
// 步骤 3:Redis 出错时降级:放行(避免 Redis 故障导致服务不可用)
c.Next()
return
}
// 步骤 4:第一次访问时设置过期时间,让 key 在下一秒自动清理
if cnt == 1 {
client.Expire(c, key, time.Second)
}
// 步骤 5:超阈值则拦截
if cnt > IPMaxReqPerSec {
c.AbortWithStatusJSON(http.StatusTooManyRequests,
gin.H{"msg": "请求过于频繁"})
return
}
c.Next()
}
}
flowchart TD
Req[请求] --> K[INCR ip:rate:ip:秒]
K --> F{计数 > 100?}
F -- 是 --> B[429 拦截]
F -- 否 --> P[放行]⚠️ 新手必踩的坑:Redis 故障要降级放行。上面代码在
Incr出错时直接c.Next()。如果你反过来——Redis 一挂就统统拒绝,那 Redis 抖动会直接打挂全站登录。限流组件本身必须是"高可用且不阻塞主流程"的。
5.6 增强登录安全:User-Agent 校验
光靠 JWT/Session 不够,token 泄露后攻击者就能冒充。增强手段:在登录时记录 User-Agent,登录校验时比对。如果是不同浏览器,就认为有风险。
// 登录时把 User-Agent 写进 JWT(见 3.5)
// 登录校验时比对(见 3.6)
if claims.UserAgent != c.GetHeader("User-Agent") {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
更高级的做法是二次验证(短信、邮件),后面课程会讲。
六、Kubernetes 入门
6.1 为什么需要 K8s
容器编排:管理大量容器的生命周期。比如你有 3 个 webook 实例 + MySQL + Redis,手动管理很麻烦:
- 实例挂了要手动重启
- 流量大了要手动扩容
- 滚动发布要手动操作
Kubernetes(K8s) 是开源的容器编排平台,自动化这些工作。
类比:学生是容器,老师就是 K8s。你是容器,老板就是 K8s。
6.2 核心概念
| 概念 | 类比 | 说明 |
|---|---|---|
| Pod | 一个实例 | K8s 调度的最小单位,里面跑容器 |
| Deployment | 运维 | 管理 Pod,保证副本数符合预期 |
| Service | 业务上的"用户服务"/“订单服务” | 逻辑服务,负载均衡到一组 Pod |
| Ingress | Nginx | 集群入口,按域名/路径转发到 Service |
举例:部署 3 个 webook 实例 = 1 个 Service + 1 个 Deployment(replicas=3)+ 3 个 Pod。
- Deployment 是"运维":你跟它说要 3 个实例,少了它重启一个,多了它删一个。
- Service 是"门面":用户访问 Service,Service 把流量负载均衡到 3 个 Pod。
flowchart TD
subgraph D[Deployment: replicas=3]
P1[Pod webook-1]
P2[Pod webook-2]
P3[Pod webook-3]
end
S[Service: webook
负载均衡] --> P1
S --> P2
S --> P3
I[Ingress
按域名转发] --> S
U[用户] --> I6.3 准备环境
- 安装 Docker
- 在 Docker Desktop 里开启 “Enable Kubernetes”
- 安装 kubectl:
https://kubernetes.io/docs/tasks/tools/ - (可选)Rancher:Docker 启动的 K8s 管理面板
七、用 K8s 部署 Web 服务器
7.1 准备容器镜像
K8s 调度的是容器,容器里跑的是镜像。所以要先把 webook 打包成 Docker 镜像。
第一步:编译 Linux 平台可执行文件
# Mac Apple Silicon 用 arm64,Linux 服务器看具体架构
# 大部分云服务器是 amd64
GOOS=linux GOARCH=amd64 go build -o webook .
第二步:编写 Dockerfile
FROM ubuntu:22.04
# 拷贝可执行文件
COPY webook /app/webook
WORKDIR /app
# 暴露端口(仅声明,实际端口映射由 K8s 控制)
EXPOSE 8080
# 启动命令
ENTRYPOINT ["/app/webook"]
第三步:构建镜像
docker build -t flycash/webook:v0.0.1 .
make docker 一键打包(可选):
docker:
GOOS=linux GOARCH=amd64 go build -o webook .
docker build -t flycash/webook:v0.0.1 .
7.2 编写 Deployment
# k8s-webook-deployment.yaml
apiVersion: apps/v1 # K8s 用 apiVersion 决定如何解读配置
kind: Deployment # 资源类型
metadata:
name: webook # Deployment 名字
labels:
app: webook
spec:
replicas: 3 # 副本数:3 个 Pod
selector:
# 筛选器:Deployment 怎么找到它管理的 Pod
# 通过标签匹配:含 app=webook 标签的 Pod 都归我管
matchLabels:
app: webook
template: # Pod 模板:照着这个创建 Pod
metadata:
labels:
app: webook # Pod 的标签,必须和 selector 对得上
spec:
containers:
- name: webook
image: flycash/webook:v0.0.1
ports:
- containerPort: 8080 # Pod 内部端口
字段解释:
apiVersion:告诉 K8s 用哪个 API 解读这份配置。apps/v1对应 Deployment。spec.replicas:副本数。spec.selector:筛选器,告诉 Deployment 哪些 Pod 是它的。matchLabels按标签匹配。spec.template:Pod 模板。template 里的 labels 必须和 selector 对得上,否则 Deployment 找不到自己创建的 Pod。
⚠️ 新手必踩的坑:template.labels 必须和 selector.matchLabels 完全一致。不一致时 Deployment 会创建出 Pod 却"认不出"是自己的,于是反复创建新 Pod,集群里 Pod 越生越多,经典的"Pod 无限重启/漂移"之一。
7.3 编写 Service
只有 Deployment 还无法从外面访问,需要 Service 把 Pod 包装成逻辑服务。
# k8s-webook-service.yaml
apiVersion: v1
kind: Service
metadata:
name: webook
spec:
type: LoadBalancer # 负载均衡类型,外部可访问
selector:
app: webook # 把流量转发到含 app=webook 标签的 Pod
ports:
- name: http
port: 8080 # Service 对外端口
protocol: TCP
targetPort: 8080 # 转发到 Pod 的哪个端口
端口三兄弟(必须区分清楚):
port:Service 自己监听的端口,集群内访问用webook:8080targetPort:转发到 Pod 的端口(容器内端口)nodePort:集群外访问的端口(type=NodePort 时才有)
7.4 启动
kubectl apply -f k8s-webook-deployment.yaml
kubectl apply -f k8s-webook-service.yaml
apply 是声明式:当前状态和配置不一致就调整到一致。多了删、少了加。
7.5 检查状态
kubectl get pods # 查看 Pod
kubectl get deployments # 查看 Deployment
kubectl get services # 查看 Service
kubectl logs <pod-name> # 查看 Pod 日志
kubectl describe pod <pod-name> # 排查问题
八、用 K8s 部署 MySQL
8.1 持久化存储:PV / PVC
MySQL 要存数据,容器删除后数据不能丢。K8s 用两层抽象:
- PersistentVolume(PV):存储本身,声明"我有什么特性的存储"(大小、访问模式、性能)。
- PersistentVolumeClaim(PVC):使用方声明"我需要什么特性的存储"。
- StorageClass(SC):动态创建 PV 的模板。
比喻:你去旅游,对象负责组织。
- PVC:你跟对象说"我要住靠海的安静酒店"
- PV:酒店在携程上说自己"靠海、安静"
- StorageClass:携程自动按需求匹配酒店
flowchart LR
P[Pod MySQL] --> M[mountPath /var/lib/mysql]
M --> PVC[(PVC: 1Gi RWO)]
PVC --> PV[(PV: 实际磁盘)]
SC[StorageClass] -. 动态创建 .-> PV8.2 accessMode 三种取值
| 模式 | 含义 |
|---|---|
| ReadWriteOnce | 只能挂载到一个 Pod,可读写 |
| ReadOnlyMany | 多个 Pod 可挂载,只读 |
| ReadWriteMany | 多个 Pod 可挂载,都可读写 |
MySQL 用 RWO(一个实例一个卷)。
8.3 MySQL Deployment + Service + PVC
# k8s-mysql.yaml
---
# 持久化卷声明
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql
spec:
replicas: 1
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:8.0.29
env:
- name: MYSQL_ROOT_PASSWORD
value: root
ports:
- containerPort: 3306
volumeMounts:
# 挂载到容器的 /var/lib/mysql
- name: mysql-storage
mountPath: /var/lib/mysql
volumes:
- name: mysql-storage
persistentVolumeClaim:
claimName: mysql-pvc
---
apiVersion: v1
kind: Service
metadata:
name: mysql
spec:
selector:
app: mysql
ports:
- port: 3306
targetPort: 3306
关键点:
volumeMounts:挂载到容器哪个目录。volumes:挂载的东西是什么。这里是引用名为mysql-pvc的 PVC。- Pod 内读写
/var/lib/mysql实际上读写的是 PVC 绑定的 PV。
九、用 K8s 部署 Redis
# k8s-redis.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7
ports:
- containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
type: NodePort # 暴露到集群外
selector:
app: redis
ports:
- port: 6379 # 集群内访问:redis:6379
targetPort: 6379 # 转发到 Pod
nodePort: 31379 # 集群外访问:localhost:31379
端口三兄弟再次区分:
port=6379:集群内 Service 监听端口,业务代码用redis:6379连targetPort=6379:转发到 Pod 的端口(容器内 Redis 监听端口)nodePort=31379:集群外访问端口,用redis-cli -p 31379连
测试:
kubectl apply -f k8s-redis.yaml
redis-cli -p 31379 ping # 应返回 PONG
十、用 K8s 部署 nginx Ingress
10.1 Ingress 是什么
Ingress 是 K8s 中的路由规则:按域名或路径把请求转发到不同 Service。
- Service 强调:流量转发到 Pod
- Ingress 强调:流量分发到不同 Service
类比:Ingress 是 Nginx 配置,Ingress Controller 是 Nginx 本身。
10.2 安装 ingress-nginx
# 安装 helm
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
chmod 700 get_helm.sh
./get_helm.sh
# 用 helm 安装 ingress-nginx
helm upgrade --install ingress-nginx ingress-nginx \
--repo https://kubernetes.github.io/ingress-nginx \
--namespace ingress-nginx --create-namespace
10.3 编写 Ingress
# k8s-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: webook-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx # 指定用 nginx 这个 Ingress Controller
rules:
- host: webook.local # 域名
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: webook # 转发到 webook Service
port:
number: 8080
kubectl apply -f k8s-ingress.yaml
十一、编译标签与启动
11.1 用编译标签管理多环境配置
暂时没引入配置文件模块,可以用编译标签控制:
// internal/config/config.go
package config
type Config struct {
DBDSN string
RedisAddr string
}
var Cfg Config
// internal/config/dev.go
//go:build !k8s
package config
func init() {
Cfg = Config{
DBDSN: "root:root@tcp(localhost:3306)/webook",
RedisAddr: "localhost:6379",
}
}
// internal/config/k8s.go
//go:build k8s
package config
func init() {
Cfg = Config{
// 在 K8s 里用 Service 名 + 端口访问
DBDSN: "root:root@tcp(mysql:3306)/webook",
RedisAddr: "redis:6379",
}
}
关键:K8s 中访问其它服务用 Service 名 + 端口。Service 名是 metadata.name,端口是 spec.ports[].port。
flowchart LR
W[webook Pod] -->|mysql:3306| M[(MySQL Service)]
W -->|redis:6379| R[(Redis Service)]编译:
# 普通开发
go build -o webook .
# K8s 部署
go build -tags=k8s -o webook .
11.2 全部启动
kubectl apply -f k8s-mysql.yaml
kubectl apply -f k8s-redis.yaml
kubectl apply -f k8s-webook-deployment.yaml
kubectl apply -f k8s-webook-service.yaml
kubectl apply -f k8s-ingress.yaml
# 查看启动状态
kubectl get pods
kubectl logs -f <webook-pod-name>
看到 webook 日志输出监听端口即启动成功。
11.3 前端访问
前端只需要把后端地址改为 K8s 暴露的地址(如 Ingress 域名或 NodePort),无需修改其他代码。
十二、工程实践要点
12.1 Session vs JWT 选型
| 场景 | 建议 |
|---|---|
| 单体应用、需要主动注销 | Session(可删) |
| 微服务、对性能敏感 | JWT |
| 需要在登录态放敏感数据 | Session + Redis |
| 既想要 JWT 分布式、又想放敏感数据 | JWT(user_id)+ Redis(user.info:user_id) |
12.2 JWT 安全
- 不放敏感信息:Payload 默认只编码不加密。
- secret 保密:泄露后所有 token 可被伪造。
- 设短过期时间:泄露风险窗口小。
- User-Agent 校验:增强安全性。
- HTTPS 必备:防止 token 被中间人截获。
- 撤销难是 JWT 的硬伤:需要黑名单机制(用 Redis 记录被撤销的 token)。
12.3 限流实践
- 集群限流用 Redis:单机限流在多实例下不准。
- 限流阈值靠压测:整体阈值压测得到,单 IP 阈值靠经验。
- 限流后返回错误:429 Too Many Requests 是标准做法。
- Redis 故障要降级:限流组件故障不能影响主流程,可以临时放行。
- 关键接口都要限流:尤其是免登录接口(注册、登录、短信)。
12.4 K8s 实践
- 生产环境禁止用 AutoMigrate:DDL 走 SQL review 流程。
- 配置走 ConfigMap / Secret:不要硬编码在镜像里。
- 资源限制:每个容器设
resources.limits,防止单个 Pod 吃光资源。 - 健康检查:配
livenessProbe和readinessProbe,K8s 自动重启异常 Pod。 - 多副本:生产至少 2 个副本,避免单点。
- 镜像版本:用具体 tag,不要用
latest,避免不可控变化。
自测题与动手练习
自测题(合上书能答出来,才算懂):
- 单机用 cookie Session 正常,为什么上了多实例用户会被踢下线?Redis Session 是如何解决这个问题的?
- JWT 的三段(Header / Payload / Signature)各自是什么、怎么生成的?为什么说 Payload “只编码不加密”?
- JWT 的 Payload 里能放密码吗?为什么?如果业务真的要在登录态里携带昵称、角色等资料,该怎么设计?
- 基于 Redis 的 IP 限流,为什么不能在 Redis 出错时一律拒绝?正确的降级策略是什么?
- K8s 的
port/targetPort/nodePort三者分别作用于哪一段?Deployment 的template.labels和selector.matchLabels不一致会发生什么?
动手练习(建议真做一遍):
- 拆 JWT:用 jwt.io 粘贴一个你签发的 token,确认能直接看到 Payload 里的
user_id;再故意改一个字符,看验签为何失败,体会签名防篡改。 - 切到 Redis Session:把本地项目的 cookie store 换成 redis store,起两个不同端口的实例 + 一个负载均衡(或简单轮流访问两个端口),验证登录态不再丢失。
- 本地起一套 K8s:在 Docker Desktop 开启 Kubernetes,依次
applyMySQL / Redis / webook 的 YAML,用kubectl get pods确认全 Running,并通过 Ingress 域名访问接口。
本章小结
- Session 多实例问题:用 Redis 作为共享存储解决;面向接口编程让切换 store 不影响业务。
- Session 刷新策略:每次刷新性能差;推荐固定间隔刷新(如 1 分钟内首次访问刷新);最佳是长短 token。
- JWT 三段结构:Header(算法)+ Payload(数据,Base64 不加密)+ Signature(防篡改)。不放敏感数据。
- JWT vs Session:JWT 无需存储、分布式友好、性能好;Session 安全性高、易撤销。可混用:JWT 放 user_id,Redis 放敏感数据。
- 系统保护:限流(基于 IP + Redis 集群计数)、登录安全增强(User-Agent 校验)、限流阈值靠压测、Redis 故障要降级放行。
- K8s 核心概念:Pod(实例)、Deployment(管理 Pod 副本数)、Service(逻辑服务 + 负载均衡)、Ingress(按域名/路径转发)、PV/PVC(持久化存储抽象)。
- K8s 部署流程:编译 Linux 二进制 → 构建 Docker 镜像 → 编写 Deployment + Service + Ingress YAML →
kubectl apply。 - 端口区分:
port= Service 端口,targetPort= Pod 端口,nodePort= 集群外访问端口。 - 多环境配置:用编译标签
-tags=k8s切换;K8s 内访问其它服务用Service名:端口。
至此,我们从 Go 基础语法、Web 框架、ORM、登录认证一路走到容器化部署,已经具备搭建一个完整微服务雏形的能力。后续课程会进入接口抽象、SSO、消息队列等更深入的工程话题(第04章讲接口抽象、短信服务与测试)。