JWT 认证与 Kubernetes 部署

2022-12-16T14:11:02+08:00 | 18分钟阅读 | 更新于 2025-12-16T14:11:02+08:00

@

学习目标

学完本章,你应该能够:

  1. 理解多实例部署下 Session 的痛点,能用 Redis 作为 Session Store 解决分布式登录态问题。
  2. 掌握 Session 过期刷新的几种策略(每次刷新 / 快过期刷新 / 固定间隔刷新 / 长短 token),能在 middleware 中实现刷新逻辑。
  3. 彻底搞懂 JWT(Header / Payload / Signature)的原理与使用,能完成 JWT 登录、校验、跨域改造。
  4. 能从工程角度对比 Session 和 JWT 的优缺点,知道何时混用。
  5. 掌握基本的系统保护手段:限流(基于 IP、基于 Redis 集群限流)、登录安全增强(User-Agent 校验)。
  6. 理解 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 --> N

2.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_iduser_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 优缺点对比

维度SessionJWT
服务端存储需要(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 步骤总结

  1. 在 Login 接口登录成功后,生成 JWT token
  2. 通过响应头 x-jwt-token 返回给前端
  3. 改造 CORS:AllowHeadersAuthorizationExposeHeadersx-jwt-token
  4. 接入 JWT 登录校验 middleware:读取 Authorization: Bearer xxx、验签、校验 User-Agent
  5. 在 middleware 中刷新 token(快过期时重新签发并通过 x-jwt-token 返回)
  6. 前端在每次请求的 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
IngressNginx集群入口,按域名/路径转发到 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[用户] --> I

6.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:8080
  • targetPort:转发到 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] -. 动态创建 .-> PV

8.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 安全

  1. 不放敏感信息:Payload 默认只编码不加密。
  2. secret 保密:泄露后所有 token 可被伪造。
  3. 设短过期时间:泄露风险窗口小。
  4. User-Agent 校验:增强安全性。
  5. HTTPS 必备:防止 token 被中间人截获。
  6. 撤销难是 JWT 的硬伤:需要黑名单机制(用 Redis 记录被撤销的 token)。

12.3 限流实践

  1. 集群限流用 Redis:单机限流在多实例下不准。
  2. 限流阈值靠压测:整体阈值压测得到,单 IP 阈值靠经验。
  3. 限流后返回错误:429 Too Many Requests 是标准做法。
  4. Redis 故障要降级:限流组件故障不能影响主流程,可以临时放行。
  5. 关键接口都要限流:尤其是免登录接口(注册、登录、短信)。

12.4 K8s 实践

  1. 生产环境禁止用 AutoMigrate:DDL 走 SQL review 流程。
  2. 配置走 ConfigMap / Secret:不要硬编码在镜像里。
  3. 资源限制:每个容器设 resources.limits,防止单个 Pod 吃光资源。
  4. 健康检查:配 livenessProbereadinessProbe,K8s 自动重启异常 Pod。
  5. 多副本:生产至少 2 个副本,避免单点。
  6. 镜像版本:用具体 tag,不要用 latest,避免不可控变化。

自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 单机用 cookie Session 正常,为什么上了多实例用户会被踢下线?Redis Session 是如何解决这个问题的?
  2. JWT 的三段(Header / Payload / Signature)各自是什么、怎么生成的?为什么说 Payload “只编码不加密”?
  3. JWT 的 Payload 里能放密码吗?为什么?如果业务真的要在登录态里携带昵称、角色等资料,该怎么设计?
  4. 基于 Redis 的 IP 限流,为什么不能在 Redis 出错时一律拒绝?正确的降级策略是什么?
  5. K8s 的 port / targetPort / nodePort 三者分别作用于哪一段?Deployment 的 template.labelsselector.matchLabels 不一致会发生什么?

动手练习(建议真做一遍)

  1. 拆 JWT:用 jwt.io 粘贴一个你签发的 token,确认能直接看到 Payload 里的 user_id;再故意改一个字符,看验签为何失败,体会签名防篡改。
  2. 切到 Redis Session:把本地项目的 cookie store 换成 redis store,起两个不同端口的实例 + 一个负载均衡(或简单轮流访问两个端口),验证登录态不再丢失。
  3. 本地起一套 K8s:在 Docker Desktop 开启 Kubernetes,依次 apply MySQL / 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章讲接口抽象、短信服务与测试)。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!