From f2e0c4308359bcf01d2b2a81c10686e38f2d3ea1 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 29 Oct 2026 16:28:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=E5=81=A5=E5=BA=B7=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E7=AB=AF=E7=82=B9=EF=BC=8C=E5=B9=B6=E6=8A=8A=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E8=B6=85=E6=97=B6=E4=B8=8E=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E8=AF=BB=E8=B6=85=E6=97=B6=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) /healthz 与 /readyz 改动前站内唯一的「探活」手段是 curl /,而首页只渲染模板、不查数据库: DB 挂了首页照样 200(漏报),Redis 挂了首页又可能 500(误报)。 监控、CDN 源站健康检查、滚动重启的摘流判定都没有可靠依据。 → /healthz 永远 200,刻意不碰任何依赖:进程活着 ≠ 能对外服务, 把 DB 检查塞进存活探针会导致数据库抖动时进程被反复重启,对恢复毫无帮助; → /readyz 查 DB(2s 超时),失败 503;响应体只回组件状态, 绝不回错误详情 —— 这是公开端点,DB 报错常带库名/主机名/账号。 实测:停掉 PostgreSQL 后 /healthz 仍 200、/readyz 503,恢复后 200。 2) ReadTimeout 30s → 120s 站内允许单图 8MB、请求体上限 12MB。30s 传完 8MB 需要 ≥273 KB/s 上行, 而手机 4G 上行常在 1–2 Mbps(125–250 KB/s)—— 一张 8MB 图必然超时, 且用户已写好的正文会一起丢(POST 失败不回填)。120s 对应约 68 KB/s, 偏远地区 3G 也能过。 放宽它不会让 slowloris 重新可行:那类攻击卡在「发不完 header」, 由 10s 的 ReadHeaderTimeout 拦住,ReadTimeout 只影响读完 header 之后传 body 的时长。 --- internal/cmd/web.go | 13 +++++++-- internal/db/db.go | 20 ++++++++++++++ internal/route/route.go | 7 +++++ route/health.go | 61 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 route/health.go diff --git a/internal/cmd/web.go b/internal/cmd/web.go index 127c6e6..d7cf3fa 100644 --- a/internal/cmd/web.go +++ b/internal/cmd/web.go @@ -149,11 +149,20 @@ func runWeb(ctx *cli.Context) error { }() // 最外层挂载 gzip 压缩,并在 http.Server 上配置超时: - // - ReadHeaderTimeout 防止慢速客户端占用连接 + // - ReadHeaderTimeout 防止慢速客户端占用连接(slowloris 的防线在这里) // - ReadTimeout / WriteTimeout 防止读写挂死 // - IdleTimeout 复用 keep-alive 连接 // listen 留空时回退到 ":port"(监听所有网卡);Cloudflare Tunnel 部署建议设为 // "127.0.0.1:",使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连。 + // + // ReadTimeout 从 30s 提到 120s:站内允许单图 8MB(storage.MaxUploadBytes), + // 请求体上限 12MB(form.MaxRequestBodySize)。30s 传完 8MB 需要 ≥273 KB/s 上行, + // 而手机 4G 上行常在 1–2 Mbps(125–250 KB/s)—— 一张 8MB 图必然超时,且用户 + // 已经写好的提问正文会一起丢掉(POST 失败不回填)。120s 对应约 68 KB/s, + // 偏远地区 3G 也能过。 + // + // 放宽 ReadTimeout 不会让 slowloris 重新变得可行:那类攻击卡在「发不完 header」, + // 由 10s 的 ReadHeaderTimeout 拦住;ReadTimeout 只影响读完 header 之后传 body 的时长。 addr := conf.Server.Listen if addr == "" { addr = fmt.Sprintf(":%d", conf.Server.Port) @@ -162,7 +171,7 @@ func runWeb(ctx *cli.Context) error { Addr: addr, Handler: route.GzipHandler(r), ReadHeaderTimeout: 10 * time.Second, - ReadTimeout: 30 * time.Second, + ReadTimeout: 120 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second, } diff --git a/internal/db/db.go b/internal/db/db.go index 6dd647b..9db47a9 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -15,6 +15,25 @@ import ( "github.com/tamakyi/TamaBox/internal/conf" ) +// handle 保存 Init 建立的连接,供「不需要具体 store」的场景使用(如就绪探针)。 +// 与下面那些业务 store 的包级变量区分开:那些是接口,这个是连接本身。 +var handle *gorm.DB + +// Ping 检查数据库连接是否可用。供 /readyz 就绪探针调用。 +// +// 为什么需要它:原来的「探活」只能 curl /,而首页只渲染模板、不碰数据库 —— +// DB 挂了首页照样 200,反过来 Redis 挂了首页又可能 500。于是探针既会漏报也会误报。 +func Ping(ctx context.Context) error { + if handle == nil { + return errors.New("database not initialized") + } + sqlDB, err := handle.DB() + if err != nil { + return errors.Wrap(err, "get sql.DB") + } + return sqlDB.PingContext(ctx) +} + var AllTables = []interface{}{ &User{}, &Question{}, &FollowUp{}, &CensorLog{}, &UploadImage{}, &UploadImageQuestion{}, &Conversation{}, &Message{}, &MessageImage{}, &UserBlock{}, @@ -48,6 +67,7 @@ func Init(typ, dsn string) (*gorm.DB, error) { if err != nil { return nil, errors.Wrap(err, "connect to database") } + handle = db sqlDB, err := db.DB() if err != nil { diff --git a/internal/route/route.go b/internal/route/route.go index eed8192..7ca57f7 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -319,6 +319,13 @@ func New() *flamego.Flame { reqUserSignIn := context.Toggle(&context.ToggleOptions{UserSignInRequired: true}) f.Group("", func() { + // 健康检查:存活(永远 200)与就绪(DB 可用才 200,否则 503)。 + // 免鉴权、不限流、不查业务数据;响应体不含任何内部错误详情。 + // 注意:这两个端点仍会经过全局中间件(安全头、IP 黑名单、自动封禁), + // 这是有意保留的 —— 异常来源不该因为「探针路径」就绕过封禁。 + f.Get("/healthz", route.Healthz) + f.Get("/readyz", route.Readyz) + f.Get("/", route.Home) f.Get("/pixel", reqUserSignIn, pixel.Index) f.Get("/sponsor", route.Sponsor) diff --git a/route/health.go b/route/health.go new file mode 100644 index 0000000..dced0e3 --- /dev/null +++ b/route/health.go @@ -0,0 +1,61 @@ +// Copyright 2026 tamakyi. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package route + +import ( + stdctx "context" + "net/http" + "time" + + "github.com/sirupsen/logrus" + + "github.com/tamakyi/TamaBox/internal/context" + "github.com/tamakyi/TamaBox/internal/db" +) + +// readyzTimeout 就绪探针等待数据库的最长时间。 +// 刻意短:探针本身不该成为慢请求,超时就视为「未就绪」并由上层重试。 +const readyzTimeout = 2 * time.Second + +// Healthz 存活探针:进程还能处理请求就返回 200。 +// +// 刻意不碰任何外部依赖。「进程还活着」和「能对外服务」是两个问题: +// 前者挂了才该重启,后者挂了该摘流 —— 后者看 Readyz。 +// 把 DB 检查塞进存活探针是常见反模式:数据库抖动会导致进程被反复重启, +// 而重启对恢复数据库毫无帮助,只会让在途请求全部失败。 +func Healthz(ctx context.Context) { + w := ctx.ResponseWriter() + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} + +// Readyz 就绪探针:数据库可用才返回 200,否则 503。 +// +// 为什么需要这个端点:改动前站内唯一的「探活」手段是 curl /,而首页只渲染模板、 +// 不查数据库 —— DB 挂了首页照样 200(漏报),Redis 挂了首页又可能 500(误报)。 +// 于是监控、CDN 源站健康检查、滚动重启的摘流判定都没有可靠依据。 +// +// 响应体只回组件状态,绝不回错误详情:这是公开端点,数据库报错里常带库名、 +// 主机名甚至账号信息。错误详情只写日志。 +func Readyz(ctx context.Context) { + reqCtx, cancel := stdctx.WithTimeout(ctx.Request().Context(), readyzTimeout) + defer cancel() + + w := ctx.ResponseWriter() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + + if err := db.Ping(reqCtx); err != nil { + logrus.WithContext(reqCtx).WithError(err).Warn("就绪探针:数据库不可用") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"status":"unavailable","database":"down"}`)) + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok","database":"up"}`)) +}