优化日志记录并改用非 root 运行
This commit is contained in:
@@ -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`。
|
||||
- 阿里云短信需要配置 `ALIYUN_ACCESS_KEY_ID`、`ALIYUN_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME` 和 `ALIYUN_SMS_LOGIN_TEMPLATE_CODE`,模板变量名默认为 `code`。
|
||||
- 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。
|
||||
|
||||
@@ -30,7 +30,7 @@ RATE_LIMIT_REQUESTS_PER_MINUTE=300
|
||||
LOG_LEVEL=info
|
||||
LOG_DIR=logs
|
||||
LOG_ENABLE_CONSOLE=true
|
||||
LOG_ENABLE_FILE=false
|
||||
LOG_ENABLE_FILE=true
|
||||
LOG_RETAIN_DAYS=14
|
||||
|
||||
# MinIO 容器初始化变量,同时供后端对象存储使用。
|
||||
|
||||
@@ -38,6 +38,10 @@ ADMIN_BOOTSTRAP_NICKNAME=超级管理员
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_REQUESTS_PER_MINUTE=300
|
||||
|
||||
# 后端容器使用宿主机业务用户的 UID/GID,服务器上可用 id -u yml、id -g yml 查询。
|
||||
BACKEND_UID=1000
|
||||
BACKEND_GID=1000
|
||||
|
||||
LOG_LEVEL=info
|
||||
LOG_DIR=/app/logs
|
||||
LOG_ENABLE_CONSOLE=false
|
||||
|
||||
+6
-1
@@ -24,9 +24,14 @@ ENV TZ=Asia/Shanghai
|
||||
# 国内服务器访问 dl-cdn.alpinelinux.org 常极慢,改用阿里云镜像
|
||||
RUN sed -i 's#https\?://dl-cdn.alpinelinux.org/alpine#https://mirrors.aliyun.com/alpine#g' \
|
||||
/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 /go/bin/goose /app/goose
|
||||
COPY --from=build /src/migrations /app/migrations
|
||||
USER app:app
|
||||
EXPOSE 8080
|
||||
CMD ["/app/hfb-api"]
|
||||
|
||||
@@ -109,7 +109,7 @@ func Load() Config {
|
||||
Level: getEnv("LOG_LEVEL", "info"),
|
||||
Dir: getEnv("LOG_DIR", "logs"),
|
||||
EnableConsole: getEnvBool("LOG_ENABLE_CONSOLE", true),
|
||||
EnableFile: getEnvBool("LOG_ENABLE_FILE", false),
|
||||
EnableFile: getEnvBool("LOG_ENABLE_FILE", true),
|
||||
RetainDays: getEnvInt("LOG_RETAIN_DAYS", 14),
|
||||
},
|
||||
RateLimit: RateLimitConfig{
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/pkg/response"
|
||||
@@ -10,7 +11,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 正常请求不记录访问日志;只有慢请求、服务端错误、限流和有诊断价值的认证失败会输出。
|
||||
// 查询请求默认静默;成功写操作、慢请求、服务端错误、限流和有诊断价值的认证失败会输出。
|
||||
const slowRequestThresholdMs = 500
|
||||
|
||||
func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
|
||||
@@ -23,8 +24,9 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
|
||||
path := c.Request.URL.Path
|
||||
route := c.FullPath()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -78,10 +80,33 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
|
||||
logger.Warn("后台认证失败", fields...)
|
||||
case latencyMs >= slowRequestThresholdMs:
|
||||
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 判断是否为无需记录的普通请求。
|
||||
func shouldSkipHTTPLog(_, _ string, status int, latencyMs float64) bool {
|
||||
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
@@ -36,8 +36,19 @@ MINIO_ROOT_USER
|
||||
MINIO_ROOT_PASSWORD
|
||||
STORAGE_ACCESS_KEY_ID
|
||||
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,可选)
|
||||
|
||||
在 `backend/.env` 增加(域名不要带 `https://`):
|
||||
@@ -76,6 +87,7 @@ HFB_SHOW_DIR=../hfb_show
|
||||
- 检查 `backend/.env` 是否仍指向 `127.0.0.1`、`localhost` 或占位值。
|
||||
- 使用内置 MinIO 时,检查 `STORAGE_ACCESS_KEY_ID` / `STORAGE_SECRET_ACCESS_KEY` 是否和 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` 一致。
|
||||
- 构建并启动生产容器。
|
||||
- 使用配置的非 root UID/GID 运行后端,并检查日志目录属主。
|
||||
- 通过 Caddy 自动申请或续签 HTTPS 证书。
|
||||
- 等待 MySQL、Redis、MinIO 就绪。
|
||||
- 按顺序执行尚未应用的数据库迁移。
|
||||
@@ -87,7 +99,7 @@ HFB_SHOW_DIR=../hfb_show
|
||||
./scripts/deploy-prod.sh --logs
|
||||
```
|
||||
|
||||
后端运行日志会同时输出到 Docker 控制台,并写入宿主机:
|
||||
后端运行日志会写入宿主机:
|
||||
|
||||
```text
|
||||
backend/logs/app-YYYY-MM-DD.log
|
||||
|
||||
@@ -103,6 +103,8 @@ services:
|
||||
build:
|
||||
context: ../backend
|
||||
restart: unless-stopped
|
||||
# 使用宿主机业务用户的 UID/GID,避免绑定挂载的日志被 root 创建。
|
||||
user: "${BACKEND_UID:?请在 backend/.env 配置 BACKEND_UID}:${BACKEND_GID:?请在 backend/.env 配置 BACKEND_GID}"
|
||||
env_file:
|
||||
- ../backend/.env
|
||||
environment:
|
||||
|
||||
+22
-1
@@ -146,7 +146,7 @@ validate_env() {
|
||||
exit 1
|
||||
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
|
||||
app_env="$(require_env APP_ENV)"
|
||||
caddy_domain="$(require_env CADDY_DOMAIN)"
|
||||
@@ -163,6 +163,13 @@ validate_env() {
|
||||
minio_root_password="$(require_env MINIO_ROOT_PASSWORD)"
|
||||
storage_access_key_id="$(require_env STORAGE_ACCESS_KEY_ID)"
|
||||
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
|
||||
log_warn "APP_ENV 当前是 ${app_env},生产部署建议改为 production"
|
||||
@@ -353,7 +360,21 @@ health_url() {
|
||||
}
|
||||
|
||||
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}"
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user