分类: 自建集群

单节点 k3s 集群的搭建、运维与故障排查记录:容器编排、证书签发、路由配置、存储与数据安全。

  • 一个 MySQL 实例喂五个应用:库隔离与两个连接池

    这台机器上只有一个 MySQL 实例,却被 WordPress、二维码平台、在线工具箱同时用着。下面的取舍和踩坑记录,对任何”一个数据库服务多个应用”的场景都适用。

    按库隔离,不按表前缀

    每个应用一套独立的库,按环境再分:

    wordpress_prod   / wordpress_beta
    qrcode_prod      / qrcode_beta
    tools_prod       / tools_beta
    users_prod       / users_beta   ← 注意这个

    用独立库而不是”一个库 + 表名前缀”,好处是备份、迁移、授权都能按库粒度做,某天要把某个应用挪走也不用在一堆表里挑挑拣拣。

    为什么用户表要单独一个库

    这是我改动最大的一个设计。最初每个应用各自建用户表,结果就是:在二维码平台注册的用户,到工具箱还得再注册一次。

    后来把用户表抽出来放进独立的 users_* 库,两个应用连同一个用户表,配合同一把 JWT 密钥,登录态就打通了。

    代价是应用侧要维护两个连接池

    getDB()      → 连业务库(qrcode_* / tools_*)
    getAuthDB()  → 连共享用户库(users_*)

    多一个连接池不算复杂,但它换来的是”一次注册,全站通用”。对用户来说是天壤之别。

    坑一:mysql2 不接受 undefined

    这个坑的表现非常迷惑:接口返回 500,但代码看起来毫无问题。

    // 报错
    await db.query('INSERT INTO t (a, b) VALUES (?, ?)', [foo, bar]);
    // bar 是 undefined 时 → 500

    原因是 mysql2 的绑定参数只接受 null,传 undefined 会直接抛错。而 JavaScript 里”对象上不存在的属性”返回的就是 undefined,这在拼装请求体时太常见了。

    解决办法是在数据库访问层统一做一次转换,而不是在每个调用点小心翼翼:

    function toParams(arr) {
      return arr.map((v) => (v === undefined ? null : v));
    }

    在数据访问层收口,比靠人记得写 ?? null 可靠得多。

    坑二:连接的时区必须等于实例的时区

    这个坑更隐蔽——它不报错,只是读出来的时间整体偏移几个小时

    MySQL 连接如果不显式指定时区,驱动会按自己的默认值解释 TIMESTAMP。容器里默认是 UTC,而你可能下意识按东八区理解,于是所有时间都差 8 小时。

    // 线上实例是 UTC,就明确写 Z
    createPool({
      timezone: 'Z',
      // ...
    });

    判断实例实际时区:

    SELECT @@global.time_zone, @@session_time_zone, NOW();

    然后让连接池的 timezone 和它一致。不要靠猜。

    这个方案的真实风险

    必须说清楚:单实例 MySQL 就是单点。它挂了,所有应用一起挂。

    对个人项目来说这个风险可以接受,但前提是要做到两件事——定期备份,并且真的演练过一次恢复。没有演练过的备份等于没有备份。

  • cert-manager 自动签发 HTTPS 证书:配置与三个常见失败

    手动申请证书、手动配置、到期前手动续期——这件事在只有一两个域名时还能忍,一旦有 beta 和 prod 两套环境就很容易忘。cert-manager 把整个流程变成了一段声明式配置。

    集群里跑的是 cert-manager v1.21.2。

    ClusterIssuer 与两个环境

    Issuer 是命名空间级的,ClusterIssuer 是集群级的。因为 beta 和 prod 都要用,建了 ClusterIssuer。

    关键是建两个:staging 和 prod。它们的区别不在安全性,而在速率限制

    • staging:Let’s Encrypt 的测试环境,签发频率限制很宽松,但证书不被浏览器信任(会报红色警告)。用来验证流程通不通。
    • prod:正式环境,证书被信任,但对”同一域名在一段时间内的签发次数”有严格限制(目前是每周 50 张)。

    正确顺序是:先用 staging 把流程跑通,确认 HTTP-01 校验能通过,再切到 prod。反过来做的话,很容易因为配置错误反复重试,几小时内就把一周的额度用光,然后只能干等。

    HTTP-01 校验为什么失败

    cert-manager 会在你的站点上临时放一个校验文件,然后让 Let’s Encrypt 从公网访问 http://<你的域名>/.well-known/acme-challenge/<token> 来确认这个域名确实归你。

    失败通常就三个原因:

    • 80 端口不通:云厂商安全组、服务器防火墙没放行。HTTP-01 只能用 80 端口,没有例外。
    • 被强制跳转 HTTPS:如果 Ingress 配了”所有 HTTP 请求 301 到 HTTPS”,校验请求会被跳走,Let’s Encrypt 拿不到文件内容。需要给 challenge 路径单独放行。
    • DNS 还没生效:域名解析没指向这台服务器,或者刚改过解析还在传播中。

    排查命令固定三板斧:

    kubectl get certificate -A
    kubectl describe certificate &lt;name&gt; -n &lt;ns&gt;
    kubectl get challenge -A      # 看 challenge 的状态和原因

    describe certificate 输出的 Events 基本会直接告诉你卡在哪一步。

    证书复用

    同一台主机下的所有 Ingress 共用一张证书。我的 WordPress、/qrcode/tools 都在 www.mazter.cn 下,所以只有一张 wp-tls

    新增子路径应用时不需要动证书配置,这一点在 Traefik 那篇里也提过——共用域名的隐性收益。

    续期

    Let’s Encrypt 证书有效期 90 天。cert-manager 会在到期前自动续期,默认是到期前 30 天开始尝试。你什么都不用做。

    值得做的一件事是加个监控:证书状态不是 Ready 时能收到通知。它平时不出问题,但一旦出问题(比如 DNS 改了、80 端口被封),表现是”整站突然打不开”,而且往往在你最不想处理事情的时候发生。

  • Traefik 子路径路由:一个域名挂多个应用

    这个域名下挂着三套完全独立的东西:根路径是 WordPress,/qrcode 是一个 Nuxt 应用(二维码与文件分享),/tools 是另一个 Nuxt 应用(在线图片工具)。它们没有共用任何代码,只是恰好共用一个域名。

    让这件事成立的是 Traefik 的 Ingress 路径匹配。

    路径规则

    Ingress 里按前缀分流,一个 host 下三条规则:

    rules:
      - host: www.mazter.cn
        http:
          paths:
            - path: /qrcode
              pathType: Prefix
              backend:
                service:
                  name: svc-qrcode-app
                  port:
                    number: 80
            - path: /tools
              pathType: Prefix
              backend:
                service:
                  name: svc-tools-app
                  port:
                    number: 80
            - path: /
              pathType: Prefix
              backend:
                service:
                  name: svc-wordpress
                  port:
                    number: 80

    匹配规则是最长前缀优先/tools/compress 先匹配 /tools 而不是 /。所以顺序写在前面后面其实不影响结果,但把具体的写在前面更符合直觉。

    要不要 stripPrefix

    这是最容易踩的一个决策点。所谓 stripPrefix,是指 Ingress 把 /qrcode 这段前缀剥掉再转给后端,后端收到的就是 /

    我没有用它,理由是:现代前端框架(Nuxt、Next、Vite)都要求在构建期就知道自己的 base 路径。如果让 Ingress 偷偷把前缀剥掉,前端生成的资源链接和实际访问路径就会对不上,出现”页面能打开但 JS/CSS 全 404″。

    做法是让应用自己知道前缀,构建时注入:

    # Nuxt 构建期指定 baseURL
    nuxt.config.ts 中:
      app: {
        baseURL: '/qrcode/'
      }

    于是后端收到的请求路径仍然是 /qrcode/xxx,应用自己也按 /qrcode 生成链接,两边对得上。

    Middleware 的 API 组陷阱

    Traefik v2 和 v3 的 CRD 分属不同的 API 组,写错的话资源能创建但完全不生效,而且不报错:

    # v2(当前集群是 v2.11.18)
    apiVersion: traefik.containo.us/v1alpha1
    
    # v3
    apiVersion: traefik.io/v1alpha1

    判断方法很简单,看集群里实际装了哪些 CRD:

    kubectl get crd | grep traefik

    哪个组存在就用哪个。这个坑的表现是”我明明配了跳转 HTTPS,为什么不生效”,而 YAML 看起来毫无问题。

    证书:一个 host 只有一张

    三个应用共用一个 host,所以也只能有一张 TLS 证书。它们复用同一个 Secret(这里是 wp-tls),不需要为每个应用单独签发。cert-manager 按 host 签发,不是按路径。

    这意味着新增子路径应用时,证书部分什么都不用做——这是共用域名的另一个便利。

    什么时候该换子域名

    子路径方案的前提是所有应用”彼此信任、共用一套 Cookie 域”。我的两个应用确实需要共享登录态(同一个 mazter_token Cookie 挂在根路径上),所以子路径是加分项。

    如果某个应用需要完全独立的 Cookie、独立的安全策略,或者要交给别人维护,那就该拆成子域名。判断标准不是技术难度,是信任边界

  • 一个 HTTP 请求头引发的 72 小时崩溃循环

    有一阵子 beta.mazter.cn 一直打不开,返回 404。当时没太在意,以为是 WordPress 初始化慢。三天后去看 Pod,才发现它已经重启了 5 次,一直卡在崩溃循环里。

    这篇文章记录完整的排查链条和根因——它有个挺有意思的特点:每一个组件都在按设计工作,组合起来却产生了死循环

    现象

    $ kubectl get pods -n beta
    NAME                         READY   STATUS    RESTARTS   AGE
    wordpress-5c79d8dc94-xxxxx   0/1     Running   5          3d22h

    同一套配置在 prod 命名空间一切正常(0 次重启),所以第一反应不是配置错,而是”beta 环境有什么特殊的”。这个判断后来被证明是错的——prod 只是运气好。

    看 Events

    kubectl describe pod 是这类问题唯一的正确答案来源:

    Events:
      Warning  Unhealthy  liveness probe failed: HTTP probe failed with statuscode: 503
      Warning  Unhealthy  Readiness probe failed: HTTP probe failed with statuscode: 503
      Normal   Killing    Container failed liveness probe, will be restarted

    还有一条很关键的:Probe terminated redirects。探针跟随重定向,然后被终止了。

    还原死循环

    把整条链路串起来是这样:

    • 探针按配置去请求 http://<PodIP>:80/wp-login.php
    • WordPress 收到的是明文 HTTP,而站点地址配置的是 HTTPS,于是它回一个 302,让客户端跳到 https://beta.mazter.cn/wp-login.php
    • 这个 HTTPS 请求出去之后,经过 Traefik Ingress 又被路由回来——目标是同一个 WordPress Service。
    • 而此刻这个 Pod 还没通过 readiness 检查,后端池里没有可用端点,Traefik 返回 503
    • 探针拿到 503,判定存活失败,kubelet 杀掉容器重启。
    • 重启后一切重演。

    为什么 WordPress 坚持要跳 HTTPS?因为官方镜像的 wp-config.php 里有一段条件判断,只有在检测到代理头 X-Forwarded-Proto: https 时,才会把 $_SERVER['HTTPS'] 置为 on。探针直连 Pod,不带这个头,于是 WordPress 认为”你现在是 HTTP,不安全”,坚持把你推向 HTTPS。

    修复

    给探针补上这个头即可,readiness 和 liveness 都要改:

    readinessProbe:
      httpGet:
        path: /wp-login.php
        port: 80
        httpHeaders:
          - name: X-Forwarded-Proto
            value: https
      initialDelaySeconds: 30
      periodSeconds: 10
    livenessProbe:
      httpGet:
        path: /wp-login.php
        port: 80
        httpHeaders:
          - name: X-Forwarded-Proto
            value: https
      initialDelaySeconds: 60
      periodSeconds: 15

    改完 apply 之后 Pod 一次就 Ready 了,beta.mazter.cn/wp-login.php 恢复 200。

    为什么 prod 没炸

    这是最值得警惕的部分。prod 之所以正常,是因为它的 Pod 已经跑了很久、早就处于 Ready 状态,重定向绕回来时能命中一个健康的后端,拿到 200 而不是 503。

    换句话说:prod 不是没有问题,只是还没被触发。任何一次重启(节点重启、镜像更新、资源不足被驱逐)都可能让 prod 走进同一个循环。所以修复同时打到了两个环境。

    更根本的解法

    补请求头是把 WordPress 的行为”哄”对了。更稳妥的做法是让探针打一个不会重定向的端点,比如在站点根放一个空的 healthz.html,探它即可。这样探针不依赖应用层的 HTTPS 判断逻辑,也不受代理头约定变化的影响。

    两种方案都行,重要的是别让健康检查去请求一个”可能会重定向”的地址——重定向会让探针的失败原因变得极难解释。

  • 一台云服务器跑起整个站:单节点 k3s 落地记录

    这个站(包括二维码平台和在线工具箱)跑在一台云服务器上。不是买不起第二台,而是想清楚了一件事:对个人项目来说,多副本带来的可用性提升,远不如把单机的持久化、备份和恢复做扎实来得实在。

    下面记录这套环境的选型过程、落地结构,以及踩过的坑。

    为什么是 k3s 而不是 k8s 或 docker compose

    三个选项摆在一起比较:

    • docker compose:最简单,但没有健康检查自动恢复、没有声明式的滚动更新,进程挂了得自己发现。
    • 标准 Kubernetes:能力最全,但对单节点来说是杀鸡用牛刀,光控制面就要吃掉 1–2 GB 内存。
    • k3s:单个二进制、内置 Traefik 做 Ingress、默认用 SQLite 也能换外部存储,控制面开销小,而且是 CNCF 认证的发行版,命令和 kubectl 完全一致。

    最后选了 k3s。它把”我想用 kubectl 和 YAML 管一切”和”我只有一台 4C8G 的机器”这两件事调和了。

    落地结构

    节点是 vm-20-8-ubuntu,跑 k3s v1.32.1+k3s1。环境隔离用命名空间而不是用两台机器:

    • prod 命名空间对外是 www.mazter.cnbeta 对外是 beta.mazter.cn
    • 两个命名空间跑同一套镜像,差异只在 ConfigMap 和 Ingress host。
    • 数据库、Redis 这种有状态服务也各部署一份,靠库名和 db 编号隔离(wordpress_prod / wordpress_beta,Redis db0 / db1)。

    这样改配置不会污染生产,发布前先在 beta 验一遍,确认没问题再推 prod。

    存储的硬边界

    单节点上所有卷都落在本地磁盘(hostPath / 本地 PVC)。这带来一个必须记住的限制:

    单节点卷没法被另一个节点挂载,所以任何 Deployment 的副本数都只能是 1。

    这不是配置疏忽,是物理限制。想要多副本,就得先解决分布式存储(NFS、Longhorn、云盘),而那会把事情复杂好几倍。对个人项目来说,接受”单点”这个事实,然后把备份和恢复做扎实,是更划算的选择。

    两个值得记的命令行坑

    第一个是 KUBECONFIG 必须用绝对路径。用相对路径且路径写错时,kubectl 不会报”找不到 kubeconfig”,而是静默回落到默认的 http://localhost:8080,于是你看到的是:

    Unable to connect to the server: dial tcp 127.0.0.1:8080: connect: connection refused

    这看起来像”集群挂了”,实际上是”配置文件没读到”。排查方向会完全跑偏。

    第二个是本机可能同时配了多个集群的 context,默认 context 未必是你想操作的那个。每次执行前显式指定能省掉很多麻烦:

    export KUBECONFIG=/path/to/key-house/k3s-master-cn.yaml
    kubectl config current-context   # 确认一下再动手

    什么时候这套方案不合适

    这套结构的前提是”能接受短暂不可用”。如果要做的是需要 99.9% SLA 的业务,或者数据丢了会出事,那单节点就不该出现在选项里。

    对个人作品站、工具类站点来说,它不是妥协,是清醒。