优化日志记录并改用非 root 运行

This commit is contained in:
yml2213
2026-07-29 17:01:36 +08:00
parent e5309cdf23
commit c9d0803b53
10 changed files with 102 additions and 8 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ npm run dev
## 开发态短信与实名 ## 开发态短信与实名
- 后端日志使用单行可读文本。开发环境默认只输出控制台,生产示例只输出 `backend/logs/app-YYYY-MM-DD.log`,避免重复存储;可通过 `LOG_LEVEL=debug|info|warn|error` 调整级别。 - 后端日志使用单行可读文本。开发环境同时输出控制台 `backend/logs/app-YYYY-MM-DD.log`生产示例只输出文件以避免重复存储;可通过 `LOG_LEVEL=debug|info|warn|error` 调整级别。
- 短信验证码默认使用 mock 适配器,本地验证码会以 INFO 日志输出,手机号保持脱敏。生产环境强制使用 `SMS_PROVIDER=aliyun` - 短信验证码默认使用 mock 适配器,本地验证码会以 INFO 日志输出,手机号保持脱敏。生产环境强制使用 `SMS_PROVIDER=aliyun`
- 阿里云短信需要配置 `ALIYUN_ACCESS_KEY_ID``ALIYUN_ACCESS_KEY_SECRET``ALIYUN_SMS_SIGN_NAME``ALIYUN_SMS_LOGIN_TEMPLATE_CODE`,模板变量名默认为 `code` - 阿里云短信需要配置 `ALIYUN_ACCESS_KEY_ID``ALIYUN_ACCESS_KEY_SECRET``ALIYUN_SMS_SIGN_NAME``ALIYUN_SMS_LOGIN_TEMPLATE_CODE`,模板变量名默认为 `code`
- 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。 - 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。
+1 -1
View File
@@ -30,7 +30,7 @@ RATE_LIMIT_REQUESTS_PER_MINUTE=300
LOG_LEVEL=info LOG_LEVEL=info
LOG_DIR=logs LOG_DIR=logs
LOG_ENABLE_CONSOLE=true LOG_ENABLE_CONSOLE=true
LOG_ENABLE_FILE=false LOG_ENABLE_FILE=true
LOG_RETAIN_DAYS=14 LOG_RETAIN_DAYS=14
# MinIO 容器初始化变量,同时供后端对象存储使用。 # MinIO 容器初始化变量,同时供后端对象存储使用。
+4
View File
@@ -38,6 +38,10 @@ ADMIN_BOOTSTRAP_NICKNAME=超级管理员
RATE_LIMIT_ENABLED=true RATE_LIMIT_ENABLED=true
RATE_LIMIT_REQUESTS_PER_MINUTE=300 RATE_LIMIT_REQUESTS_PER_MINUTE=300
# 后端容器使用宿主机业务用户的 UID/GID,服务器上可用 id -u yml、id -g yml 查询。
BACKEND_UID=1000
BACKEND_GID=1000
LOG_LEVEL=info LOG_LEVEL=info
LOG_DIR=/app/logs LOG_DIR=/app/logs
LOG_ENABLE_CONSOLE=false LOG_ENABLE_CONSOLE=false
+6 -1
View File
@@ -24,9 +24,14 @@ ENV TZ=Asia/Shanghai
# 国内服务器访问 dl-cdn.alpinelinux.org 常极慢,改用阿里云镜像 # 国内服务器访问 dl-cdn.alpinelinux.org 常极慢,改用阿里云镜像
RUN sed -i 's#https\?://dl-cdn.alpinelinux.org/alpine#https://mirrors.aliyun.com/alpine#g' \ RUN sed -i 's#https\?://dl-cdn.alpinelinux.org/alpine#https://mirrors.aliyun.com/alpine#g' \
/etc/apk/repositories \ /etc/apk/repositories \
&& apk add --no-cache tzdata && apk add --no-cache tzdata \
&& addgroup -S -g 10001 app \
&& adduser -S -D -H -u 10001 -G app app \
&& mkdir -p /app/logs \
&& chown app:app /app/logs
COPY --from=build /out/hfb-api /app/hfb-api COPY --from=build /out/hfb-api /app/hfb-api
COPY --from=build /go/bin/goose /app/goose COPY --from=build /go/bin/goose /app/goose
COPY --from=build /src/migrations /app/migrations COPY --from=build /src/migrations /app/migrations
USER app:app
EXPOSE 8080 EXPOSE 8080
CMD ["/app/hfb-api"] CMD ["/app/hfb-api"]
+1 -1
View File
@@ -109,7 +109,7 @@ func Load() Config {
Level: getEnv("LOG_LEVEL", "info"), Level: getEnv("LOG_LEVEL", "info"),
Dir: getEnv("LOG_DIR", "logs"), Dir: getEnv("LOG_DIR", "logs"),
EnableConsole: getEnvBool("LOG_ENABLE_CONSOLE", true), EnableConsole: getEnvBool("LOG_ENABLE_CONSOLE", true),
EnableFile: getEnvBool("LOG_ENABLE_FILE", false), EnableFile: getEnvBool("LOG_ENABLE_FILE", true),
RetainDays: getEnvInt("LOG_RETAIN_DAYS", 14), RetainDays: getEnvInt("LOG_RETAIN_DAYS", 14),
}, },
RateLimit: RateLimitConfig{ RateLimit: RateLimitConfig{
+27 -2
View File
@@ -2,6 +2,7 @@ package middleware
import ( import (
"net/http" "net/http"
"strings"
"time" "time"
"hfb_sys/backend/pkg/response" "hfb_sys/backend/pkg/response"
@@ -10,7 +11,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
// 正常请求不记录访问日志;只有慢请求、服务端错误、限流和有诊断价值的认证失败会输出。 // 查询请求默认静默;成功写操作、慢请求、服务端错误、限流和有诊断价值的认证失败会输出。
const slowRequestThresholdMs = 500 const slowRequestThresholdMs = 500
func RequestLogger(logger *zap.Logger) gin.HandlerFunc { func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
@@ -23,8 +24,9 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
path := c.Request.URL.Path path := c.Request.URL.Path
route := c.FullPath() route := c.FullPath()
authFailure := meaningfulAuthFailure(c) authFailure := meaningfulAuthFailure(c)
operation := meaningfulOperation(c.Request.Method, path, status)
if shouldSkipHTTPLog(path, route, status, latencyMs) && !authFailure { if shouldSkipHTTPLog(path, route, status, latencyMs) && !authFailure && !operation {
return return
} }
@@ -78,10 +80,33 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
logger.Warn("后台认证失败", fields...) logger.Warn("后台认证失败", fields...)
case latencyMs >= slowRequestThresholdMs: case latencyMs >= slowRequestThresholdMs:
logger.Warn("HTTP 慢请求", fields...) logger.Warn("HTTP 慢请求", fields...)
case operation:
logger.Info("HTTP 操作完成", fields...)
} }
} }
} }
func meaningfulOperation(method, path string, status int) bool {
if status < 200 || status >= 400 {
return false
}
// 支付回调已有更完整的业务结果日志,不再重复记录一条访问日志。
if isPaymentNotifyPath(path) {
return false
}
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
default:
return false
}
}
func isPaymentNotifyPath(path string) bool {
return (strings.Contains(path, "/payment/") || strings.Contains(path, "/payments/")) &&
strings.HasSuffix(strings.TrimSuffix(path, "/"), "/notify")
}
// shouldSkipHTTPLog 判断是否为无需记录的普通请求。 // shouldSkipHTTPLog 判断是否为无需记录的普通请求。
func shouldSkipHTTPLog(_, _ string, status int, latencyMs float64) bool { func shouldSkipHTTPLog(_, _ string, status int, latencyMs float64) bool {
if status >= 500 || status == http.StatusTooManyRequests { if status >= 500 || status == http.StatusTooManyRequests {
@@ -29,3 +29,28 @@ func TestShouldSkipHTTPLog(t *testing.T) {
}) })
} }
} }
func TestMeaningfulOperation(t *testing.T) {
tests := []struct {
name string
method string
path string
status int
want bool
}{
{name: "创建成功", method: "POST", path: "/api/orders", status: 201, want: true},
{name: "更新成功", method: "PUT", path: "/api/orders/1", status: 200, want: true},
{name: "删除成功", method: "DELETE", path: "/api/orders/1", status: 204, want: true},
{name: "普通查询", method: "GET", path: "/api/orders", status: 200, want: false},
{name: "失败写操作", method: "POST", path: "/api/orders", status: 400, want: false},
{name: "支付回调已有业务日志", method: "POST", path: "/api/payments/lakala/notify", status: 200, want: false},
{name: "支付回调尾部斜杠", method: "POST", path: "/api/payments/leshua/notify/", status: 200, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := meaningfulOperation(tt.method, tt.path, tt.status); got != tt.want {
t.Fatalf("meaningfulOperation() = %v, want %v", got, tt.want)
}
})
}
}
+13 -1
View File
@@ -36,8 +36,19 @@ MINIO_ROOT_USER
MINIO_ROOT_PASSWORD MINIO_ROOT_PASSWORD
STORAGE_ACCESS_KEY_ID STORAGE_ACCESS_KEY_ID
STORAGE_SECRET_ACCESS_KEY STORAGE_SECRET_ACCESS_KEY
BACKEND_UID
BACKEND_GID
``` ```
`BACKEND_UID``BACKEND_GID` 填写服务器业务用户的数值 ID,例如:
```bash
id -u yml
id -g yml
```
后端容器会使用该 UID/GID 运行。部署脚本由 root 执行时会自动把已有日志修正为该属主;非 root 执行且发现旧的 root 日志时,会给出需要执行的 `chown` 命令后停止。
### 同机部署选号网(hfb_show,可选) ### 同机部署选号网(hfb_show,可选)
`backend/.env` 增加(域名不要带 `https://`): `backend/.env` 增加(域名不要带 `https://`):
@@ -76,6 +87,7 @@ HFB_SHOW_DIR=../hfb_show
- 检查 `backend/.env` 是否仍指向 `127.0.0.1``localhost` 或占位值。 - 检查 `backend/.env` 是否仍指向 `127.0.0.1``localhost` 或占位值。
- 使用内置 MinIO 时,检查 `STORAGE_ACCESS_KEY_ID` / `STORAGE_SECRET_ACCESS_KEY` 是否和 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` 一致。 - 使用内置 MinIO 时,检查 `STORAGE_ACCESS_KEY_ID` / `STORAGE_SECRET_ACCESS_KEY` 是否和 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` 一致。
- 构建并启动生产容器。 - 构建并启动生产容器。
- 使用配置的非 root UID/GID 运行后端,并检查日志目录属主。
- 通过 Caddy 自动申请或续签 HTTPS 证书。 - 通过 Caddy 自动申请或续签 HTTPS 证书。
- 等待 MySQL、Redis、MinIO 就绪。 - 等待 MySQL、Redis、MinIO 就绪。
- 按顺序执行尚未应用的数据库迁移。 - 按顺序执行尚未应用的数据库迁移。
@@ -87,7 +99,7 @@ HFB_SHOW_DIR=../hfb_show
./scripts/deploy-prod.sh --logs ./scripts/deploy-prod.sh --logs
``` ```
后端运行日志会同时输出到 Docker 控制台,并写入宿主机: 后端运行日志会写入宿主机:
```text ```text
backend/logs/app-YYYY-MM-DD.log backend/logs/app-YYYY-MM-DD.log
+2
View File
@@ -103,6 +103,8 @@ services:
build: build:
context: ../backend context: ../backend
restart: unless-stopped restart: unless-stopped
# 使用宿主机业务用户的 UID/GID,避免绑定挂载的日志被 root 创建。
user: "${BACKEND_UID:?请在 backend/.env 配置 BACKEND_UID}:${BACKEND_GID:?请在 backend/.env 配置 BACKEND_GID}"
env_file: env_file:
- ../backend/.env - ../backend/.env
environment: environment:
+22 -1
View File
@@ -146,7 +146,7 @@ validate_env() {
exit 1 exit 1
fi fi
local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint backend_uid backend_gid
local minio_root_user minio_root_password storage_access_key_id storage_secret_access_key local minio_root_user minio_root_password storage_access_key_id storage_secret_access_key
app_env="$(require_env APP_ENV)" app_env="$(require_env APP_ENV)"
caddy_domain="$(require_env CADDY_DOMAIN)" caddy_domain="$(require_env CADDY_DOMAIN)"
@@ -163,6 +163,13 @@ validate_env() {
minio_root_password="$(require_env MINIO_ROOT_PASSWORD)" minio_root_password="$(require_env MINIO_ROOT_PASSWORD)"
storage_access_key_id="$(require_env STORAGE_ACCESS_KEY_ID)" storage_access_key_id="$(require_env STORAGE_ACCESS_KEY_ID)"
storage_secret_access_key="$(require_env STORAGE_SECRET_ACCESS_KEY)" storage_secret_access_key="$(require_env STORAGE_SECRET_ACCESS_KEY)"
backend_uid="$(require_env BACKEND_UID)"
backend_gid="$(require_env BACKEND_GID)"
if [[ ! "${backend_uid}" =~ ^[1-9][0-9]*$ || ! "${backend_gid}" =~ ^[1-9][0-9]*$ ]]; then
log_error "BACKEND_UID 和 BACKEND_GID 必须是大于 0 的数字,禁止后端容器使用 root"
exit 1
fi
if [[ "${app_env}" != "production" ]]; then if [[ "${app_env}" != "production" ]]; then
log_warn "APP_ENV 当前是 ${app_env},生产部署建议改为 production" log_warn "APP_ENV 当前是 ${app_env},生产部署建议改为 production"
@@ -353,7 +360,21 @@ health_url() {
} }
prepare_log_dir() { prepare_log_dir() {
local backend_uid backend_gid owner_mismatch
backend_uid="$(require_env BACKEND_UID)"
backend_gid="$(require_env BACKEND_GID)"
mkdir -p "${BACKEND_LOG_DIR}" mkdir -p "${BACKEND_LOG_DIR}"
owner_mismatch="$(find "${BACKEND_LOG_DIR}" \( ! -uid "${backend_uid}" -o ! -gid "${backend_gid}" \) -print -quit)"
if [[ -n "${owner_mismatch}" ]]; then
if [[ "${EUID}" -ne 0 ]]; then
log_error "日志目录存在属主不匹配的文件:${owner_mismatch}"
log_error "请先执行:sudo chown -R ${backend_uid}:${backend_gid} ${BACKEND_LOG_DIR}"
exit 1
fi
log "修正日志目录属主为 ${backend_uid}:${backend_gid}..."
chown -R "${backend_uid}:${backend_gid}" "${BACKEND_LOG_DIR}"
fi
chmod 700 "${BACKEND_LOG_DIR}"
} }
wait_service_healthy() { wait_service_healthy() {