作者: hoywn

  • 个人备案的边界:什么能做,什么别碰

    这个站是个人备案的。办之前以为”备案完就能随便做”,办完之后才明白:备案通过只是起点,它同时划了一条边界。

    下面这些是我在建站过程中逐个确认过的,如果你也在办个人站,可以少走些弯路。

    两个备案是两回事

    很多人只做了第一个:

    • ICP 备案(工信部):向通信管理局提交,拿到”粤ICP备XXXXXXXX号”这样的备案号。这是网站能在中国境内访问的前提。
    • 公安联网备案:拿到 ICP 号之后,还需在 30 日内到全国互联网安全管理服务平台提交,拿到”公网安备”号。

    两个号都要挂在页面底部,ICP 号按工信部要求需要链接到备案管理系统。这个站目前只挂了 ICP 号——公安备案还在办,办下来会补上。

    个人备案 = 非经营性

    这是最核心的一条限制。个人备案的性质是非经营性,也就是说网站不能用于经营活动。

    落到内容层面,就是要避免出现经营性质的表述:商品价格、下单购买、客服咨询、招商加盟、充值付费、会员开通这类内容,都不该出现在个人备案的网站上。

    这不意味着个人站不能有任何价值——技术分享、作品展示、开源项目、工具服务都可以。边界在于有没有交易性质。

    为什么关掉评论

    这个站全站关闭了评论,包括已发布的文章。

    原因是:开放评论意味着网站开始承载用户生成内容(UGC)。一旦有了 UGC,网站的性质就从”个人发布”向”交互式服务”靠拢,监管要求会明显上一个台阶——内容审核、留存、举报机制都得跟上。

    对一个没有运营团队、只想安静写点东西的个人站来说,这个代价和收益完全不成比例。关掉评论之后:

    • 不用处理垃圾评论和机器人灌水。
    • 不用承担内容留存与审核义务。
    • 网站性质保持简单清晰。

    想交流的话留个邮箱就够了。

    生成式 AI:个人基本做不了

    这一点值得单独说,因为很多人(包括我最初)以为”调用个 API 做个 AI 应用”没什么门槛。

    实际情况是:在国内提供具有舆论属性或社会动员能力的生成式 AI 服务,需要完成算法备案;如果涉及自研或微调模型,还需要大模型登记。而这两件事的共同前提是——备案主体必须是境内法人企业,个人主体办不了。

    也就是说,个人身份下做生成式 AI 应用,要么挂靠有资质的平台,要么出海,要么干脆不做。

    还有个容易踩的坑:自研或微调模型会把”登记”升级为”备案”,周期从三四个月拉长到六到八个月。所以即便有公司主体,也应该优先用现成模型做应用层。

    我在这个站上的具体做法

    1. 页脚悬挂 ICP 备案号,链接到工信部备案管理系统,位置在 <footer> 语义标签内。
    2. 全站关闭评论,新文章默认也是关闭。
    3. 内容只涉及技术分享与作品展示,不出现任何经营性质表述。
    4. 联系方式只留邮箱,不放电话、即时通讯等带经营色彩的信息。
    5. 不做生成式 AI 功能,工具箱全部是确定性的图像处理(压缩、转换、裁剪、遮盖)。

    这些不是过度谨慎,而是把”个人备案”这四个字对应的义务想清楚之后的自然结果。

    如果确实要经营

    那就不是”在个人站上加功能”的问题了,而是要换主体:注册公司、办经营性 ICP 许可证、按企业类型重新备案。这是另一条完全不同的路,成本也完全不同。

    我的判断是:先用个人站把事情做出来、验证有没有人用,等真要赚钱了再换主体。顺序反过来的话,成本会压在还没验证的想法上。

  • 为什么我把「去除他人水印」改成了「打码遮盖」

    做在线工具箱时,最初的清单里有一条”去除水印”。做之前重新评估了一遍,最后把它改成了另一个功能:打码遮盖(马赛克 / 模糊 / 纯色块)。

    这个改动不是因为技术难度,而是想清楚了一件事:这个功能的典型用法是什么。

    先说技术:真能”去掉”吗

    要把已经压进画面里的水印去掉,本质上是在做图像修复(inpainting)——让算法根据周围像素去”猜”水印下面原本是什么。

    结果取决于水印的位置和背景:

    • 水印压在纯色或规律背景上:修复效果不错,几乎看不出来。
    • 水印压在复杂纹理或人脸上:结果往往是一块模糊的斑,反而比留着水印更难看。
    • 半透明平铺水印:几乎不可能干净去除。

    所以”去水印”在技术上是个效果不稳定的功能。这一点本身就足以让人犹豫。

    真正的问题:谁在用,用来干什么

    一个诚实的问题是:用户搜索”去水印”,想去掉的是谁的水印?

    答案绝大多数是别人的——下载来的图片素材、别人的摄影作品、付费内容的预览图、视频平台的 logo。

    这就涉及两件事:

    1. 著作权:水印通常是署名和权利声明的载体。去掉它,等于抹掉权利人的身份标识,后续的使用行为就很难被认为是善意的。
    2. 技术措施:水印属于权利人采取的保护措施,提供专门绕过的工具,性质上接近”帮助侵权”。

    即便工具本身是中立的,一个以”去水印”为卖点的产品,实际吸引来的绝大部分流量都指向同一个用途。这时候”我只提供工具、不负责使用”的辩解是很苍白的。

    那自己的水印怎么办

    这里要分开看:如果图是你自己拍的、水印是你自己加的(比如相机自动加的日期水印、自己平台的导出水印),那处理掉完全没有问题——你是在处理自己的作品。

    问题是工具无法区分。用户上传一张图,工具不知道水印是谁加的。

    所以能做的选择只有两个:要么全做(承担上述风险),要么不做。没有”只服务正当用途”的第三条路。

    改成打码遮盖之后

    新功能做的是相反方向的事:往图上加东西,而不是从图上拿走东西。

    它的正当用途非常明确,而且是我自己天天会遇到的:

    • 发快递单据前,把姓名、电话、地址盖掉。
    • 截图分享时,把聊天对象的头像和昵称盖掉。
    • 晒订单、晒票据时,把订单号和金额盖掉。
    • 发证件照片时,把证件号部分遮住。

    这些场景的共同点是:你遮盖的是自己的或他人托付给你的敏感信息,目的是减少泄露,而不是抹掉别人的权利声明。

    功能实现上有三种笔刷——马赛克(不可逆性最强)、高斯模糊(视觉上更自然)、纯色块(最彻底)。强度可调,支持批量处理。

    一个通用的判断方法

    这次评审给我留下一个可复用的检查:

    与其问”这个功能能不能做”,不如问”这个功能被最典型地用来做什么”。

    技术可行性是个客观问题,使用意图是个概率问题。当一个功能的技术门槛很低、而被滥用的概率很高时,不做它通常是对的。

    顺带一提,工具箱里这两个功能都跑在浏览器本地——图片不会上传到任何服务器。这解决不了”用途”的问题,但至少不额外增加”你的图被别人存了一份”的风险。

  • 一张照片能泄露多少信息:EXIF 里存了什么,怎么清

    随便拿一张手机拍的照片,它在记录画面的同时,还悄悄记录了一堆关于”这张照片是怎么产生的”的信息。这些信息叫做 EXIF(Exchangeable Image File Format)。

    EXIF 里到底有什么

    常见的字段可以分成四类:

    1. 时间:拍摄时间,精确到秒,有时还有时区。
    2. 设备:手机型号、相机机身、镜头型号、厂商。
    3. 参数:光圈、快门、ISO、焦距、是否开了闪光灯。
    4. 位置:GPS 经纬度,有时还有海拔和拍摄方向。

    前三类顶多算”透露生活习惯”,第四类是真正的隐私风险——经纬度可以精确到米级。

    为什么这有风险

    关键在于:不同渠道对 EXIF 的处理完全不同。

    • 微信、微博、大部分社交平台:上传时会自动剥离 EXIF(至少剥离 GPS)。这是平台的保护。
    • 原图发送:通过微信”发送原图”、邮件附件、网盘分享、QQ 传文件,EXIF 完整保留。
    • 截图:截图一般不携带原图的 EXIF,但也因此失去了画面质量。

    也就是说,真正危险的不是”发朋友圈”,而是把原图直接发给别人。一张在家门口拍的照片发原图出去,等于告诉对方你的住址。

    还有一类容易被忽略的场景:二手交易时拍的商品照片、租房时拍的房间。画面里的窗外风景加上 GPS,足够定位到具体楼栋。

    怎么查看

    几个不用装软件的方法:

    • Windows:右键文件 → 属性 → 详细信息,往下翻能看到相机型号和 GPS。
    • macOS:预览 → 工具 → 显示检查器 → 信息标签页。
    • 在线查看:用工具箱的 EXIF 查看,直接把图拖进去就能看到全部字段,包括 GPS。

    怎么清除

    分两种需求:

    • 只删位置:保留拍摄参数。适合摄影爱好者分享作品——想让人看到用的什么器材和参数,但不想暴露拍摄地点。
    • 全部清除:所有 EXIF 字段一并抹掉,只留纯像素。适合要发给陌生人的图。

    要注意的一点是:“关掉相机定位”和”清除已有照片的 EXIF”是两件事。前者只对之后拍的生效,已经拍好的老照片,GPS 还在里面。

    清了 EXIF 就等于匿名了吗

    不是。这是最容易产生虚假安全感的地方。

    EXIF 只是元数据。画面本身包含的信息往往更多:门牌号、路牌、校服、工牌、窗外地标、背景里的电脑屏幕。清完 EXIF 之后,如果画面里能看出来你在哪,那清了也没用。

    所以正确的顺序是:先看画面的内容要不要处理,再决定要不要清 EXIF。如果画面里有敏感信息(快递单、证件、聊天记录),那需要的是打码遮盖,不是清 EXIF。

    一个实际的习惯

    我现在的做法是分场景处理:

    1. 要在社交平台发:直接发,平台会处理。
    2. 要发给陌生人或公开上传:先清 EXIF,必要时先打码。
    3. 要长期保存归档:保留原图和完整 EXIF,那是照片的一部分。

    工具箱里的 EXIF 处理同样在浏览器本地完成,GPS 字段默认折叠显示——毕竟查看自己的照片信息,本来就不需要经过任何人的服务器。

  • HEIC 是什么,iPhone 的照片为什么在电脑上打不开

    把 iPhone 拍的照片传到电脑上,双击打不开;想上传到某个系统,提示”不支持的格式”。这不是文件坏了,只是苹果从 2017 年开始换了一种存储格式。

    HEIC 到底是什么

    HEIC 是 HEIF(High Efficiency Image Format)格式的一种扩展名。它是视频编码标准 HEVC/H.265 的静态图像版本——既然 H.265 能用一半体积存下同样质量的视频,那么用同样的压缩技术存照片,自然也能小很多。

    实际效果大概是:同等画质下,HEIC 比 JPEG 小 40%–50%。对一台存了几千张照片、存储又很贵的手机来说,这个收益足够大,所以苹果从 iOS 11 起把它设成了默认。

    HEIC 还有几个 JPEG 做不到的能力:支持透明通道、支持连拍序列(Live Photo 的动态部分)、支持 16 位色深。

    为什么到处都打不开

    两个原因叠加:

    • 专利授权:HEVC 涉及多个专利池,授权体系复杂,很多厂商(尤其开源社区)出于法律风险不愿意内置支持。
    • 惯性:JPEG 诞生于 1992 年,三十多年下来几乎所有软件、所有系统、所有网站都认它。要替换的是一个生态,不只是一个格式。

    目前的情况是:macOS 原生支持,iOS 原生支持,Windows 需要装扩展,Android 新版本支持,而大量网站和内部系统只收 JPG/PNG。

    浏览器里就能解码,不用上传

    过去转换 HEIC 要么装软件,要么传到在线转换网站。后者的风险和压缩工具一样——你的照片要先去别人的服务器上转一圈。

    现在有另一条路:把 HEVC 解码器编译成 WebAssembly,直接在浏览器里跑。页面加载一次解码器(几 MB),之后所有转换都在本地内存完成,文件不出设备。

    技术上大致是这样:

    读取 File 对象 → 交给 WASM 解码器 → 得到原始像素 → 用 Canvas 重新编码为 JPG/PNG → 生成下载链接

    代价是首次加载要下载解码器,之后就很快了。相比”把照片传给陌生服务器”,这个代价完全可以接受。

    转换的代价:这是有损的

    需要先说清楚一件事:HEIC 转 JPG 必然损失质量,因为两者是不同的编码方式,不存在”无损转换”。

    实际影响有多大,取决于原图:

    • 普通照片、风景、人像:转完几乎看不出差别。
    • 高动态范围、暗部细节丰富的图:可能出现色带。
    • 如果要转成 JPG 再打印或放大,建议用高质量参数(85 以上)。

    所以正确的顺序是:先转,再压缩,而不是压完再转。每一步有损操作都该只做一次。

    什么情况下不要转

    • 只是要在 Mac 或 iPhone 之间传看——完全没必要,原生就能打开。
    • 要长期归档——保留 HEIC 原片,需要用时再转。转出来的 JPG 是”一次性成品”。
    • 对方系统明确支持 HEIC——多问一句能省一次转换。

    如果你现在就需要转,工具箱里的 格式转换可以处理 HEIC 输入,同样全程在浏览器本地完成。

  • 图片压缩怎么选:质量、体积与格式的取舍

    每次要传图都会遇到同一个问题:这张图太大了,得压一压。但压完之后要么还是很大,要么糊得没法看。问题通常出在把”压缩”当成一个单一旋钮,实际上它是三个各自独立的维度。

    三个维度,各管一段

    • 质量参数:决定编码器丢弃多少细节。影响的是”画面干不干净”,对体积的影响是渐进的。
    • 尺寸:像素数量。影响的是”能看多大”,对体积的影响是平方级的——长宽各减半,像素数变成四分之一。
    • 格式:编码方式。同样的画面,不同格式的体积可以差好几倍。

    一个常见误判是:图太大就使劲降质量。但如果这张图是 4000×3000 要放在宽 800 的网页里,先降尺寸带来的收益远大于降质量,而且画面反而更锐利(缩小本身会平滑掉噪点)。

    质量参数的甜点区

    对 JPEG 而言,经验值:

    • 85–90:几乎看不出差别,适合摄影作品、需要放大的图。
    • 75–85:肉眼很难分辨,是网页配图最常用的区间。
    • 60–70:开始能在平坦区域(天空、墙面)看到色块,适合缩略图。
    • 低于 60:明显劣化,除非极端场景否则不值得。

    值得注意的是,已经压缩过的图再压一次,收益很小而劣化很快。JPEG 是有损编码,每一次重新编码都会在前一次的损伤上再削一刀。如果这个图来自微信或微博,它已经被压过了,此时再压不如直接降尺寸。

    什么时候该换格式

    格式的选择其实是在问”我要兼容谁”:

    • JPEG:通用性最好,照片类的默认选择。不支持透明。
    • PNG:无损,适合截图、文字、线条图、需要透明的图。不适合照片——一张照片存成 PNG 可能是 JPEG 的五到十倍大。
    • WebP:同等画质下比 JPEG 小 25–35%,支持透明和动图。现代浏览器都支持,是目前网页的稳妥选择。
    • AVIF:比 WebP 再小一截,画质更好,但编码很慢,且部分旧环境不支持。

    一个实用的判断:如果你要发到微信、公众号、或者别人可能用老软件打开,就老实用 JPEG。如果是自己网站上用,WebP 是当下性价比最高的。

    “压到 200KB 以内”是怎么做到的

    很多场景给的是目标体积而不是质量参数——比如报名系统要求附件不超过 500KB。这时候手动试参数很痛苦。

    自动化的做法是二分查找:先按中等质量压一次,看结果比目标大还是小,然后据此收窄区间,重复几轮就能逼近目标。七八次迭代内基本能收敛,而每次编码在本地浏览器里通常只要几十毫秒。

    这个思路比”固定压到 70 质量”聪明的地方在于:一张纯色海报和一张满是细节的照片,达到同样体积所需的质量参数是完全不同的。

    哪些图不该压

    • 已经是 WebP/AVIF 且体积合理的图,再压收益极低。
    • 含文字的截图——压缩会让文字边缘出现振铃伪影,糊掉的代价远高于省下的几十 KB。这种情况应该存 PNG。
    • 需要二次编辑的原图。压缩是不可逆的,先压了再改就改不回来了。

    关于”上传到服务器压缩”

    市面上大多数在线压缩工具要求你把图片传到它们的服务器。对个人隐私照片、证件、合同这类内容,这本身就是个不该承担的风险。

    我的在线工具箱里的压缩功能全部在浏览器本地完成,文件不会离开你的设备——这也是它当初坚持纯前端实现的主要原因。想试试的话在 图片压缩,整个过程断网也能跑。

  • 一个 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) =&gt; (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.cn,beta 对外是 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 的业务,或者数据丢了会出事,那单节点就不该出现在选项里。

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