Gitea源码深度剖析-第2章 启动流程与配置体系
第 1 章从外部视角给出了 Gitea 的全景:单二进制、分层路由、六个横切机制。本章把镜头推近,回答两个最基础的问题------进程是怎么活起来的 (gitea web 从 main() 到监听端口的每一步),以及配置是怎么变成内存变量的 (app.ini、环境变量、命令行参数如何汇入 modules/setting)。这两个问题共用一条主线:setting.InstallLock。它既决定 runWeb 走安装向导还是走已安装服务,也决定配置加载的深度与顺序。1.6.1 承诺过的 config_provider.go、config_env.go、config_option_instance.go 三件套,以及 1.6.2 承诺的 logger 生命周期与级别控制,都会在本章兑现。
阅读本章前需要明确一点:本文所有代码摘录均忠实于本仓库源码(28.x 开发版),文件路径与函数名可直接对照;与某些版本记忆或常见资料不符的地方(例如日志 sink 的种类、环境变量生效时机),一律以本仓库实际代码为准,并在文中注明出处。
2.1 从 main() 到子命令:启动的第一毫秒
一切从仓库根目录的 main.go 开始。它只有 59 行,但每一行都关乎"启动时序":
go
// main.go
func init() {
setting.AppVer = Version
setting.AppBuiltWith = formatBuiltWith()
setting.AppStartTime = time.Now().UTC()
}
func main() {
gitproxy.MaybeTunnel()
cli.OsExiter = func(code int) {
log.GetManager().Close()
os.Exit(code)
}
app := cmd.NewMainApp(cmd.AppVersion{Version: Version, Extra: formatBuiltWith()})
_ = cmd.RunMainApp(app, os.Args...) // all errors should have been handled by the RunMainApp
// flush the queued logs before exiting, this is a MUST, otherwise there will be log loss
log.GetManager().Close()
}
三个设计点值得展开:
其一,init() 里设置 AppVer 是有原因的 。modules/setting/setting.go 自己的 init() 里有一段兜底:
go
// modules/setting/setting.go init()
func init() {
if AppVer == "" {
AppVer = "dev"
}
// FIXME: the logger shouldn't be initialized here, the app entry should initialize the logger
// By default set this logger at Info - we'll change it later, but we need to start with something.
log.SetupStderrLogger(log.DEFAULT, "console-stderr", log.INFO)
}
Go 的 init() 执行顺序是"被导入包先于主包",所以 setting.init() 实际先于 main.init() 执行------若不加 if AppVer == "" 兜底,setting.AppVer 会被主包赋的正式版本号覆盖(顺序恰好安全:主包 init 后跑),而任何直接 import setting 单独编译的测试程序则会拿到空版本。这段 FIXME 还埋着一个伏笔:setting 包在自己被导入时就初始化了一个 stderr console logger。这是"日志必须先于一切"原则的极端体现------配置加载过程本身可能报错(如 deprecation 警告),报错时必须已有 logger 可用。
其二,cli.OsExiter 被替换 。urfave/cli 在遇到错误退出时默认直接调 os.Exit,会丢掉队列里未 flush 的日志。Gitea 把它换成"先 log.GetManager().Close() 再退出"。main() 末尾正常路径也调一次 Close()。这条"所有退出路径都必须 Close 日志"的约定,在 1.4.1 已经提过,它是 modules/log/manager.go 的 Close() 存在的理由------事件写入是异步队列(见 2.9),不 Close 必丢日志。
其三,gitproxy.MaybeTunnel() 排在最前,说明存在"启动即建网络隧道"的场景(代理隧道),这是唯一在 CLI 解析之前运行的业务逻辑。
cmd.NewMainApp(cmd/main.go:87)组装 CLI 树,其核心是子命令的两分组:
go
// these sub-commands need to use a config file
subCmdWithConfig := []*cli.Command{
webCmd, newServCommand(), newHookCommand(), NewKeysCommand(),
newDumpCommand(), newAdminCommand(), newMigrateCommand(),
newDoctorCommand(), newManagerCommand(), newEmbeddedCommand(),
newMigrateStorageCommand(), newDumpRepositoryCommand(),
newRestoreRepositoryCommand(), newActionsCommand(),
}
// these sub-commands do not need the config file, and they do not depend on any path or environment variable.
subCmdStandalone := []*cli.Command{
cmdConfig(), cmdCert(), newGenerateCommand(), newDocsCommand(),
}
分组不是文档注释,而是行为差异 :只有 subCmdWithConfig 中的命令会被 PrepareSubcommandWithGlobalFlags 包装:
go
// cmd/main.go:51
func PrepareSubcommandWithGlobalFlags(originCmd *cli.Command) {
originBefore := originCmd.Before
originCmd.Before = func(ctxOrig context.Context, cmd *cli.Command) (ctx context.Context, err error) {
ctx = ctxOrig
if originBefore != nil {
ctx, err = originBefore(ctx, cmd)
if err != nil {
return ctx, err
}
}
prepareWorkPathAndCustomConf(cmd)
return ctx, nil
}
}
即:web、dump、doctor 这些命令在自己的 Before 跑完后,自动 调 prepareWorkPathAndCustomConf → setting.InitWorkPathAndCommonConfig(os.Getenv, args),完成路径解析、配置文件加载、common settings 加载三部曲(2.5 详述)。而 gitea config、gitea cert 这类 standalone 命令完全不碰配置------所以 gitea config edit-ini 可以在 app.ini 损坏到无法 MustXxx 解析的极端情况下依然工作。
包装顺序有一个容易忽略的细节:PrepareSubcommandWithGlobalFlags 把 prepareWorkPathAndCustomConf 放在原 Before 之后 执行。而 newWebCommand 的 Before 是 PrepareConsoleLoggerLevel(log.INFO)。两者串联后的效果是:先按命令行 flag(quiet/verbose/debug)定 console logger 级别,再加载配置 。PrepareConsoleLoggerLevel(cmd/helper.go:113)内部有一段防御性检查:
go
func PrepareConsoleLoggerLevel(defaultLevel log.Level) func(context.Context, *cli.Command) (context.Context, error) {
return func(ctx context.Context, c *cli.Command) (context.Context, error) {
if setting.InstallLock {
// During config loading, there might also be logs (for example: deprecation warnings).
// It must make sure that console logger is set up before config is loaded.
log.Error("Config is loaded before console logger is setup, it will cause bugs. Please fix it. CustomConf=%s", setting.CustomConf)
return nil, errors.New("console logger must be setup before config is loaded")
}
...
log.SetupStderrLogger(log.DEFAULT, "console-stderr", level)
return ctx, nil
}
}
为什么 setting.InstallLock == true 就是 bug 信号?因为 InstallLock 只可能在 loadCommonSettingsFrom 里被赋值(InstallLock = HasInstallLock(cfg))。如果进入 Before 时它已是 true,说明配置加载发生在 console logger 初始化之前------那么配置加载期间的警告日志就没有归宿。这个检查把"时序错误"从隐性丢日志变成了显性启动失败,是 fail-fast 设计的典型。
除了 Before 钩子,app.Before = PrepareConsoleLoggerLevel(log.INFO) 是 root 级别的兜底,保证任何未显式设置 Before 的路径也有 logger。NewMainApp 末尾还有一行 setting.UnsetUnnecessaryEnvVars():
go
// modules/setting/config_env.go:170
func UnsetUnnecessaryEnvVars() {
// HOME is managed by Gitea, Gitea's git should use "HOME/.gitconfig".
// But git would try "XDG_CONFIG_HOME/git/config" first if "HOME/.gitconfig" does not exist,
// then our git.InitFull would still write to "XDG_CONFIG_HOME/git/config" if XDG_CONFIG_HOME is set.
_ = os.Unsetenv("XDG_CONFIG_HOME")
}
注释解释了动机:Gitea 要完全掌控 git 的配置文件位置,若继承了调用方的 XDG_CONFIG_HOME,git.InitFull 写出的 git 配置会落到用户指定的目录,破坏可复现性。
最后 RunMainApp(cmd/main.go:176)接管信号与错误:
go
func RunMainApp(app *cli.Command, args ...string) error {
ctx, cancel := installSignals()
defer cancel()
setCLIOnUsageError(app)
...
err := app.Run(ctx, args)
...
}
installSignals(cmd/helper.go:66)注册 SIGINT/SIGTERM → cancel(),这是 CLI 层的信号处理;graceful manager 还有自己的一套信号监听(2.4),两套并存、各司其职:前者让 ctx 取消从而让命令优雅返回,后者驱动停机状态机。
另外 cliHelpPrinterNew(cmd/main.go:33)替换全局 cli.HelpPrinter,在任何 -h 输出末尾追加四行 DEFAULT CONFIGURATION(AppPath/WorkPath/CustomPath/ConfigFile)。这四行对排障极有价值------用户报"配置没生效"时,第一句话就能确认 Gitea 到底读了哪个 app.ini。
2.2 gitea web 启动全流程:runWeb 逐段解析
newWebCommand(cmd/web.go:39)声明了 5 个 flag:port/install-port(临时端口覆盖)、pid(PID 文件)、quiet(日志降为 FATAL)、verbose(日志升为 TRACE)。Action: runWeb 是已安装服务与安装向导的总分叉。
2.2.1 runWeb 主干
go
// cmd/web.go:261
func runWeb(ctx context.Context, cmd *cli.Command) error {
if subCmdName, valid := isValidDefaultSubCommand(cmd); !valid {
return fmt.Errorf("unknown command: %s", subCmdName)
}
managerCtx, cancel := context.WithCancel(ctx)
graceful.InitManager(managerCtx)
defer cancel()
if os.Getppid() > 1 && len(os.Getenv("LISTEN_FDS")) > 0 {
log.Info("Restarting Gitea on PID: %d from parent PID: %d", os.Getpid(), os.Getppid())
} else {
log.Info("Starting Gitea on PID: %d", os.Getpid())
}
// Set pid file setting
if cmd.IsSet("pid") {
createPIDFile(cmd.String("pid"))
}
// init the HTML renderer and load templates, if error happens, it will report the error immediately and exit with error log
// in dev mode, it won't exit, but watch the template files for changes
_ = templates.PageRenderer()
if !setting.InstallLock {
if err := serveInstall(cmd); err != nil {
return err
}
} else {
NoInstallListener()
}
if setting.EnablePprof {
go servePprof()
}
return serveInstalled(cmd)
}
逐步拆解:
-
isValidDefaultSubCommand防御 urfave/cli 的默认命令设计 (cmd/helper.go:133注释原话 "Dirty patch for urfave/cli's strange design")。因为app.DefaultCommand = webCmd.Name,./gitea bad-cmd会被当作 web 命令的参数;这里检查cmd.Args()非空就报错,避免"敲错命令却启动了服务器"。 -
graceful.InitManager(managerCtx)紧随其后 。停机管理器必须在任何 server 创建之前存在,因为 4-server 计数契约(2.4)从它开始生效。LISTEN_FDS + ppid>1的日志分支区分"正常启动"与"从父进程 fork 继承监听 fd 的优雅重启"------优雅重启的子进程用这个日志自证身份。 -
_ = templates.PageRenderer()主动触发模板预编译 。PageRenderer是sync.OnceValue(modules/templates/page.go:62),首次调用会:按setting.IsProd决定 "static" 还是 "auto-reloading" 渲染器 → 收集所有.tmpl(排除mail/)→recompileTemplates全量解析 → 失败走processStartupTemplateError。该函数(modules/templates/htmlrenderer.go:68)的行为分模式:
go
func processStartupTemplateError(err error) {
if err == nil {
return
}
if setting.IsProd || setting.IsInTesting {
// in prod mode, Gitea must have correct templates to run
log.Fatal("Gitea can't run with template errors: %v", err)
}
// in dev mode, do not need to really exit, because the template errors could be fixed by developer soon and the templates get reloaded
log.Error("There are template errors but Gitea continues to run in dev mode: %v", err)
}
prod 直接 Fatal、dev 带错继续 ------这是 dev 模式热重载闭环的一半;另一半是 PageRenderer 里的 if !setting.IsProd { go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), ...) },监听到模板变更就重新编译。注意模板预编译发生在 InstallLock 分流之前 :安装页(install.tmpl)也是模板,未安装时同样需要预编译,所以这个顺序不可交换。
-
if !setting.InstallLock { serveInstall } else { NoInstallListener() }是全章最重要的分叉 。setting.InstallLock此时已由PrepareSubcommandWithGlobalFlags→InitWorkPathAndCommonConfig→loadCommonSettingsFrom的第一行赋值。NoInstallListener()的作用在 2.4 揭晓:安装 listener 是 4 个预期 server 之一,已安装状态下它永远不会被创建,必须显式"报名"注销,否则 systemd 的 READY=1 永远等不到。 -
serveInstalled是已安装路径的全部,接着详述。
2.2.2 serveInstall:安装态的最小服务
go
// cmd/web.go:136
func serveInstall(cmd *cli.Command) error {
showWebStartupMessage("Prepare to run install page")
routers.InitWebInstallPage(graceful.GetManager().HammerContext())
// Flag for port number in case first time run conflict
if cmd.IsSet("port") {
if err := setPort(cmd.String("port")); err != nil {
return err
}
}
if cmd.IsSet("install-port") {
if err := setPort(cmd.String("install-port")); err != nil {
return err
}
}
c := install.Routes()
err := listen(c, false)
if err != nil {
log.Error("Unable to open listener for installer. Is Gitea already running?")
graceful.GetManager().DoGracefulShutdown()
}
select {
case <-graceful.GetManager().IsShutdown():
<-graceful.GetManager().Done()
log.Info("PID: %d Gitea Web Finished", os.Getpid())
return err
default:
}
return nil
}
InitWebInstallPage(routers/init.go:108)只有三步,与 InitWebInstalled 形成鲜明对比:
go
func InitWebInstallPage(ctx context.Context) {
translation.InitLocales(ctx)
setting.LoadSettingsForInstall()
mustInit(svg.Init)
}
LoadSettingsForInstall 只加载 DB/Service/Mails 三节------安装页需要预填数据库、邮件、注册选项表单,但不需要队列、索引器、缓存(数据库都还没有,谈何这些)。这是"配置加载深度与安装状态匹配"的体现。
listen(c, false) 的第二参数 handleRedirector=false:安装态没有"另一个端口的 HTTP 重定向器"(那是 HTTPS 双端口场景,2.2.4)。listen 失败(端口被占)时不 Fatal,而是记 Error + 触发 DoGracefulShutdown,让 4-server 计数继续走完、进程干净退出,日志里留下 "Is Gitea already running?" 的提示------这比 log.Fatal 多给了用户一句人话。
注意 serveInstall 返回后 runWeb 并不退出 ,而是继续 serveInstalled(cmd)。安装流程靠 restartServer(2.3.6)在提交安装后 3 秒关掉安装 server,serveInstalled 才接管。安装完成前 setting.InstallLock 已被 saveConfigReinitDB 重载为 true,所以 serveInstalled 开头的 setting.MustInstalled() 能通过。
2.2.3 serveInstalled:已安装态的 21 步
go
// cmd/web.go:168(节选,完整见源码)
func serveInstalled(c *cli.Command) error {
setting.MustInstalled()
showWebStartupMessage("Prepare to run web server")
if setting.AppWorkPathMismatch {
log.Error("WORK_PATH from config %q doesn't match other paths from environment variables or command arguments. ...", setting.CustomConf)
}
rootCfg := setting.CfgProvider
if rootCfg.Section("").Key("WORK_PATH").String() == "" {
saveCfg, err := rootCfg.PrepareSaving()
... // 把 AppWorkPath 回写进 app.ini 的 WORK_PATH 键
}
// legacy public asset 检查:custom/public 下的旧位置文件给出迁移提示
...
routers.InitWebInstalled(graceful.GetManager().HammerContext())
// We check that AppDataPath exists here (it should have been created during installation)
if _, err := os.Stat(setting.AppDataPath); err != nil {
log.Fatal("Can not find APP_DATA_PATH %q", setting.AppDataPath)
}
// the AppDataTempDir is fully managed by us with a safe sub-path
setting.AppDataTempDir("").RemoveOutdated(3 * 24 * time.Hour)
if c.IsSet("port") {
if err := setPort(c.String("port")); err != nil {
return err
}
}
gtprof.EnableBuiltinTracer(util.Iif(setting.IsProd, 2000*time.Millisecond, 100*time.Millisecond))
webRoutes := routers.NormalRoutes()
auditCtx := cliAuditContext(context.Background())
audit.Record(auditCtx, audit_model.SystemStartup, nil, "version", setting.AppVer)
err := listen(webRoutes, true)
audit.Record(auditCtx, audit_model.SystemShutdown, nil)
<-graceful.GetManager().Done()
log.Info("PID: %d Gitea Web Finished", os.Getpid())
return err
}
几个值得停下来的地方:
- WORK_PATH 回写 用的是
PrepareSaving()而不是直接Save()------因为CfgProvider已被DisableSaving(2.6),且内存里的配置被无数次MustXxx污染过。PrepareSaving重新从磁盘读一份干净配置来写,只改WORK_PATH一个键。这正是 INI-MUST-SIDE-EFFECT 问题的绕行方案。 AppDataTempDir("").RemoveOutdated(3 * 24 * time.Hour):启动时清理 3 天前的临时文件。注释强调"fully managed by us with a safe sub-path"------Gitea 只在gitea-tmp/或tmp/子目录内删东西,不碰APP_TEMP_PATH根,回应了PrepareAppDataPath的 FIXME(setting.go:69:老代码滥用MkdirAll可能在未挂载的挂载点上造空目录)。setPort在InitWebInstalled之后 :它会改setting.HTTPPort并把新的LOCAL_ROOT_URL写回 app.ini(cmd/web.go:300)。LOCAL_ROOT_URL是gitea manager等 CLI 访问运行中进程的地址来源(2.11),端口变了它必须跟着变,否则管理命令会打到错误端口。audit.Record(SystemStartup/SystemShutdown)把启停记入审计流,cliAuditContext附加OriginCLI与NewCliUser()(cmd/audit.go)。- 阻塞点是
<-graceful.GetManager().Done():listen返回不代表进程该退(安装路径、关闭流程都要走完),Done()关闭于 terminate 阶段结束,这才是进程真正的"可以退出"信号。
InitWebInstalled(routers/init.go:115)是装配时序的核心,1.6.6 给过总表,这里按依赖关系重新标注:
go
func InitWebInstalled(ctx context.Context) {
mustInit(git.InitFull)
... // git 版本日志
translation.InitLocales(ctx)
setting.LoadSettings()
mustInit(storage.Init)
mailer.NewContext(ctx)
mustInit(cache.Init)
mustInit(feed_service.Init)
mustInit(uinotification.Init)
mustInitCtx(ctx, archiver.Init)
external.RegisterRenderers()
markup.Init(markup_service.FormalRenderHelperFuncs())
mustInitCtx(ctx, common.InitDBEngine)
log.Info("ORM engine initialization successful!")
mustInit(system.Init)
mustInitCtx(ctx, oauth2.Init)
... // oauth2_provider / release / models / authmodel / repo_service / packages_spec
mustInitCtx(ctx, gitproxy_service.Run) // must start before mirror/migration services spawn git
mustInit(indexer_service.Init)
mirror_service.InitSyncMirrors()
mustInit(webhook.Init)
mustInit(pull_service.Init)
mustInitCtx(ctx, automerge.Init)
mustInit(task.Init)
mustInit(websocket_service.Init)
mustInitCtx(ctx, mailer_incoming.Init)
mustInitCtx(ctx, syncAppConfForGit)
mustInit(ssh.Init)
auth.Init()
mustInit(svg.Init)
mustInitCtx(ctx, actions_service.Init)
mustInit(repo_service.InitLicenseClassifier)
// Finally start up the cron
cron.Init(ctx)
}
时序背后的依赖逻辑:
| 顺序 | 步骤 | 为什么必须在此位置 |
|---|---|---|
| 1 | git.InitFull |
后面所有子系统(hook、mirror、archive)都会执行 git 命令 |
| 4 | setting.LoadSettings() |
initAllLoggers 在其中排第一,日志级别/文件配置必须先于任何会报错的初始化 |
| 5 | storage.Init |
阿凡达、附件、LFS、包存储共用存储后端,必须早于业务服务 |
| 9 | common.InitDBEngine |
数据库是一切 ORM 操作的前提;含重试与迁移(1.6.3) |
| 13 | gitproxy_service.Run |
注释明说 "must start before mirror/migration services spawn git" |
| 17 | syncAppConfForGit |
比对 AppState 里记录的 AppPath/CustomConf,变了就重写 git hooks 与 SSH 公钥(routers/init.go:78)------升级换路径后的自愈机制 |
| 21 | cron.Init(ctx) |
注释 "Finally start up the cron":定时任务要等所有服务就绪才调度 |
mustInit/mustInitCtx 的 fail-fast 实现很精巧(routers/init.go:60):
go
func mustInit(fn func() error) {
err := fn()
if err != nil {
ptr := reflect.ValueOf(fn).Pointer()
fi := runtime.FuncForPC(ptr)
log.Fatal("%s failed: %v", fi.Name(), err)
}
}
用反射把函数指针还原成完整函数名(如 gitea.dev/routers.InitWebInstalled),Fatal 日志直接告诉运维是哪一步挂了 ,不需要读 stacktrace。任何子系统初始化失败都选择 log.Fatal 而非带错返回------启动期的半初始化状态比直接退出更危险。
NormalRoutes(routers/init.go:177)装配四组路由加条件挂载:
go
func NormalRoutes() *web.Router {
r := web.NewRouter()
r.BeforeRouting(common.ProtocolMiddlewares()...)
r.AfterRouting(common.MaintenanceModeHandler())
r.Mount("/", web_routers.Routes())
r.Mount("/api/v1", apiv1.Routes())
r.Mount("/api/internal", private.Routes())
r.Post("/-/fetch-redirect", common.FetchRedirectDelegate)
if setting.Packages.Enabled {
r.Mount("/api/packages", packages_router.CommonRoutes())
r.Mount("/v2", packages_router.ContainerRoutes()) // OCI 端点必须在站点根
}
if setting.Actions.Enabled {
... // /api/actions、/api/actions_pipeline、ArtifactV4RouteBase
}
r.NotFound(...)
return r
}
三个结构性观察:/api/internal 是 gitea manager 等 CLI 的目标(2.11),必须在 Packages/Actions 条件挂载之前 固定存在;OCI 的 /v2 必须挂根路径(容器 registry 协议要求),所以反向代理子路径部署时需特殊映射;BeforeRouting/AfterRouting 是 Gitea 自研 router(modules/web)的中间件阶段钩子,MaintenanceModeHandler 放 AfterRouting 意味着维护模式拦截发生在路由匹配之后------这样才能返回 503 而不是误跳 404。
2.2.4 listen:协议分发
listen(cmd/web.go:332)把配置的 setting.Protocol 映射到具体服务函数:
go
func listen(m http.Handler, handleRedirector bool) error {
listenAddr := setting.HTTPAddr
if setting.Protocol != setting.HTTPUnix && setting.Protocol != setting.FCGIUnix {
listenAddr = net.JoinHostPort(listenAddr, setting.HTTPPort)
}
_, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().HammerContext(), "Web: Gitea Server", process.SystemProcessType, true)
defer finished()
log.Info("Listen: %v://%s%s", setting.Protocol, listenAddr, setting.AppSubURL)
log.Info("AppURL(ROOT_URL): %s", setting.AppURL)
...
var err error
switch setting.Protocol {
case setting.HTTP:
if handleRedirector { NoHTTPRedirector() }
err = runHTTP("tcp", listenAddr, "Web", m, setting.UseProxyProtocol)
case setting.HTTPS:
if setting.EnableAcme {
err = runACME(listenAddr, m)
break
}
if handleRedirector {
if setting.RedirectOtherPort {
go runHTTPRedirector()
} else {
NoHTTPRedirector()
}
}
err = runHTTPS("tcp", listenAddr, "Web", setting.CertFile, setting.KeyFile, m, setting.UseProxyProtocol, setting.ProxyProtocolTLSBridging)
case setting.FCGI:
if handleRedirector { NoHTTPRedirector() }
err = runFCGI("tcp", listenAddr, "FCGI Web", m, setting.UseProxyProtocol)
case setting.HTTPUnix:
if handleRedirector { NoHTTPRedirector() }
err = runHTTP("unix", listenAddr, "Web", m, setting.UseProxyProtocol)
case setting.FCGIUnix:
if handleRedirector { NoHTTPRedirector() }
err = runFCGI("unix", listenAddr, "Web", m, setting.UseProxyProtocol)
default:
log.Fatal("Invalid protocol: %s", setting.Protocol)
}
...
}
协议族五种:http、https(可选 ACME)、fcgi、http+unix(Unix domain socket)、fcgi+unix。server.go 的加载还接受 unix 作为 http+unix 的 deprecated 别名。每个分支都调 NoHTTPRedirector() 或 go runHTTPRedirector() ,这呼应 4-server 契约:第 3 个预期 server 是"HTTP 重定向器"(HTTPS 双端口场景下把 80 端口 307 到 443)。不需要它的分支必须调 NoHTTPRedirector()(内部即 InformCleanup())报到。
handleRedirector=false 的安装路径(2.2.2)直接跳过这些------但注意安装态的 listen 也走同一 switch,所以 Protocol=https 时安装页也走 ACME。
AddTypedContext(HammerContext(), "Web: Gitea Server", ...) 把 server 登记进进程管理器,父 context 用 HammerContext:停机进入 hammer 阶段时,这个 context 取消,server 开始强关连接(2.4.4)。
runHTTP/NoHTTPRedirector/NoInstallListener 都在 cmd/web_graceful.go,其中后两个函数体就是一行 graceful.GetManager().InformCleanup()。runHTTP 走 graceful.HTTPListenAndServe(modules/graceful/server_http.go):
go
// newHTTPServer 关键点(modules/graceful/server_http.go)
// - http.Protocols 开启 HTTP1、HTTP2、UnencryptedHTTP2
// - BaseContext 返回 HammerContext(连接在 hammer 时感知取消)
// - ReadHeaderTimeout 10s(防 slowloris)
// - OnShutdown 关闭 keepalive 连接
HTTPS 路径分两种:手工证书(runHTTPS,cmd/web_https.go)与 ACME(runACME,cmd/web_acme.go)。applyTLSSettings 负责 TLS 参数:给 NextProtos 补 h2/http/1.1、按配置映射 MinVersion/MaxVersion、曲线与密码套件用字符串查表 ,查不到的项 lookupAll 跳过并 Warn------配置写错不 Fatal,降级为告警并使用剩余可用套件。runACME 基于 certmagic:ManageSync 失败(如首次签发网络问题)回退 CacheManagedCertificate + ManageAsync,HTTP-01 challenge 监听在 PortToRedirect 端口。这些细节在 2.12 的配置表里对应 ENABLE_ACME、ACME_ACCEPTTOS 等键。
runHTTPRedirector(cmd/web.go:78)实现 307 跳转:
go
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
target := dest + r.URL.Path
if len(r.URL.RawQuery) > 0 {
target += "?" + r.URL.RawQuery
}
http.Redirect(w, r, target, http.StatusTemporaryRedirect)
})
err := runHTTP("tcp", source, "HTTP Redirector", handler, setting.RedirectorUseProxyProtocol)
用 307 而非 301:307 保留请求方法与 body,POST 跳转不被降级成 GET------这是 HTTPS 迁移时表单提交不丢数据的关键。
2.3 安装流程:从表单到 app.ini 落盘
安装不是独立二进制,而是 gitea web 在 InstallLock=false 时的一段路由(2.2.2),安装完成后不重启进程 ,靠"关安装 server → serveInstalled 接管"完成原地切换。
2.3.1 安装路由
go
// routers/install/routes.go:21
func Routes() *web.Router {
base := web.NewRouter()
base.BeforeRouting(common.ProtocolMiddlewares()...)
base.Methods("GET, HEAD", "/assets/*", public.FileHandlerFunc())
r := web.NewRouter()
r.AfterRouting(common.MustInitSessioner(), installContexter())
r.Get("/", Install) // it must be on the root, because the "install.js" use the window.location to replace the "localhost" AppURL
r.Post("/", web.Bind[*forms.InstallForm](), SubmitInstall)
r.Get("/post-install", InstallDone)
r.Get("/-/web-theme/list", misc.WebThemeList)
r.Post("/-/web-theme/apply", misc.WebThemeApply)
r.Get("/api/healthz", healthcheck.Check)
r.NotFound(installNotFound)
base.Mount("", r)
return base
}
注释解释了 GET / 必须挂根:install.js 用 window.location 修正默认 ROOT_URL 里的 localhost。/api/healthz 在安装态也存在,让反向代理/编排系统在安装期间就能探活。
installNotFound 的实现藏着一个 HTTP 语义细节:
go
func installNotFound(w http.ResponseWriter, req *http.Request) {
w.Header().Add("Content-Type", "text/html; charset=utf-8")
w.Header().Add("Refresh", "1; url="+setting.AppSubURL+"/")
// do not use 30x status, because the "post-install" page needs to use 404/200 to detect if Gitea has been installed.
// the fetch API could follow 30x requests to the page with 200 status.
w.WriteHeader(http.StatusNotFound)
...
}
post-install 页面用 fetch 轮询某个路径,以 404 判定"还没装好"、200 判定"装好了" 。若这里返回 30x,fetch 会自动跟随重定向拿到 200,误判为安装完成。所以宁可用 404 + Refresh meta 标签做浏览器端跳转。
2.3.2 表单与校验
表单定义在 services/forms/user_form.go:20 的 InstallForm,binding 标签驱动校验(TrimSpace、Required、Username、MaxSize、MinSize、Include(@))。其中三个 ReinstallConfirmFirst/Second/Third bool 有注释说明为什么是三个独立 bool:绑定框架对单字段的多值校验有 bug,不能用 1/2/3 或 A/B/C 区分,只能三个字段。
Install handler(routers/install/install.go:63)先做防重入:
go
func Install(ctx *context.Context) {
if setting.InstallLock {
InstallDone(ctx)
return
}
...
}
SubmitInstall 同样开头检查------即使配置已锁,直接 POST / 也只会渲染完成页。随后 Install 从 setting 读取现值预填表单(数据库、端口、邮件、注册选项),这样用环境变量或预置 app.ini 提前配置的项会显示在安装页上 ,installContexter 还注入了 EnvConfigKeys(当前所有 GITEA__ 环境变量名)与 CustomConfFile,页面可提示"这些值来自环境变量"。
2.3.3 SubmitInstall 主流程
go
// routers/install/install.go:190(节选)
func SubmitInstall(ctx *context.Context) {
if setting.InstallLock {
InstallDone(ctx)
return
}
form := web.GetForm[*forms.InstallForm](ctx)
... // AppURL 补尾斜杠
if ctx.HasError() { ... return } // binding 错误回渲染
if _, err := exec.LookPath("git"); err != nil { ... return } // git 可执行检查
// ---- Basic checks are passed, now test configuration.
setting.Database.Type = setting.DatabaseType(form.DbType)
... // 把表单数据库字段灌入 setting.Database
setting.Database.LogSQL = !setting.IsProd
form.AppDataPath = strings.ReplaceAll(form.AppDataPath, "\\", "/")
setting.AppDataPath = form.AppDataPath
if err := setting.PrepareAppDataPath(); err != nil { ... return }
if !checkDatabase(ctx, form) { return }
// admin 用户名/邮箱/密码一致性校验 ...
if err := db.InitEngineWithMigration(ctx, versioned_migration.Migrate); err != nil { ... return }
cfg := fillInstallConfig(ctx, os.Environ(), form)
if cfg == nil { return }
if !saveConfigReinitDB(ctx, cfg, form) { return }
if !initAdminUser(ctx, form) { return }
InstallDone(ctx) // render the "install done" page
restartServer(ctx)
}
注意执行顺序的设计:先用表单值初始化引擎跑迁移,成功了才写 app.ini 。若先写配置后发现数据库连不上,就会留下一个 INSTALL_LOCK=true 但数据库坏掉的 app.ini,下次启动直接 Fatal------先验证后落盘避免了这个中间态。
checkDatabase(install.go:117)做四层防线:
- SQLite 的
PATH空检查; db.UnsetDefaultEngine()+db.InitEngine试连 +CheckDatabaseConnection;- 重装检测 :
HasPostInstallationUsers(user 表非空)+GetMigrationVersion,两者都命中说明数据库曾被 Gitea 用过,要求表单里三个ReinstallConfirm全部勾选,否则拒绝; - 逻辑漏洞检查 :若 user 表非空却填了 AdminName,报"不允许重建管理员";若 user 表空、
DisableRegistration=true且没填 AdminName,报"禁止注册又没管理员 = 谁都进不来"。
2.3.4 fillInstallConfig:写配置与环境变量的两次博弈
go
// routers/install/install.go:304(节选)
func fillInstallConfig(ctx *context.Context, envs []string, form *forms.InstallForm) setting.ConfigProvider {
// Some logic also depends on the config values, so EnvironmentToConfig should also be applied first.
// EnvironmentToConfig is applied on each start up, so it also must override the "install form", so it must be applied after (twice).
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
...
setting.EnvironmentToConfig(cfg, envs)
cfg.Section("").Key("RUN_MODE").SetValue("prod")
cfg.Section("security").Key("INSTALL_LOCK").SetValue("true")
... // 表单值逐键写入 database/server/mailer/service/cron.update_checker 等节
if setting.InternalToken == "" {
var internalToken string
if internalToken, err = generate.NewInternalToken(); err != nil { ... return nil }
cfg.Section("security").Key("INTERNAL_TOKEN").SetValue(internalToken)
}
if !cfg.Section("oauth2").HasKey("JWT_SECRET") && !cfg.Section("oauth2").HasKey("JWT_SECRET_URI") {
_, jwtSecretBase64 := generate.NewJwtSecretWithBase64()
cfg.Section("oauth2").Key("JWT_SECRET").SetValue(jwtSecretBase64)
}
// if there is already a SECRET_KEY, we should not overwrite it, otherwise the encrypted data will not be able to be decrypted
if setting.SecretKey == "" {
var secretKey string
if secretKey, err = generate.NewSecretKey(); err != nil { ... return nil }
cfg.Section("security").Key("SECRET_KEY").SetValue(secretKey)
}
setting.EnvironmentToConfig(cfg, envs)
fillInstallConfigCleanUp(cfg)
return cfg
}
EnvironmentToConfig 调了两次 ,注释解释了原因:第一次让"依赖配置的逻辑"先看到环境变量(有些值表单没有);第二次在表单值写完之后再套一层环境变量,保证 GITEA__* 环境变量始终覆盖表单------因为环境变量在每次启动都会覆盖配置文件(2.7),如果安装时把表单值写进文件而环境变量被压在下面,下次启动环境变量生效,文件与内存不一致。两次调用让落盘结果与下次启动的内存结果一致。
保密钥的生成策略 :LFS_JWT_SECRET(带 LFS_JWT_SECRET_URI 存在则跳过)、INTERNAL_TOKEN、oauth2.JWT_SECRET 只在缺失时生成;SECRET_KEY 特别标注"已有则不覆盖,否则加密数据无法解密"------SECRET_KEY 是所有加密存储(如 SMTP 密码)的根密钥,换掉它等于丢数据。
fillInstallConfigCleanUp 用正则 [\x00-\x1F\x7F] 把所有键值中的控制字符替换成空格,注释坦承这是"quick patch":INI 库对控制字符处理有 bug,不清理会写出损坏的 app.ini。
2.3.5 saveConfigReinitDB:落盘并原地重载
go
// routers/install/install.go:420
func saveConfigReinitDB(ctx *context.Context, cfg setting.ConfigProvider, form *forms.InstallForm) bool {
log.Info("Save settings to custom config file %s", setting.CustomConf)
err := os.MkdirAll(filepath.Dir(setting.CustomConf), os.ModePerm)
...
if err := cfg.SaveTo(setting.CustomConf); err != nil { ... return false }
// unset default engine before reload database setting
db.UnsetDefaultEngine()
// Reload settings (and re-initialize database connection)
setting.InitCfgProvider(setting.CustomConf)
setting.LoadCommonSettings()
setting.MustInstalled()
setting.LoadDBSetting()
if err := common.InitDBEngine(ctx); err != nil {
log.Fatal("ORM engine initialization failed: %v", err)
}
setting.ClearEnvConfigKeys()
log.Info("Installation completed! ...")
return true
}
这七步是"安装即重启配置"的原地实现:InitCfgProvider 重新读盘(这次 INSTALL_LOCK=true 已在文件里)→ LoadCommonSettings 全量重载(InstallLock 随之变 true)→ MustInstalled 断言成功 → 重载数据库节 → 重建引擎。最后 ClearEnvConfigKeys() 把所有 GITEA__* 从当前进程环境删除 ------它防止的是:gitea web 若由脚本带着环境变量启动,后续 exec 出的 git hook 子进程会继承这些变量,把 hook 里的配置又覆盖一遍。路径解析处(path.go:179)也有同样的清除:if HasInstallLock(CfgProvider) { ClearEnvConfigKeys() },即已安装实例从不把配置环境变量传给子进程。
2.3.6 管理员创建与安装收尾
initAdminUser(install.go:452)CreateUser 后自动登录 :签发记住登录 token 写 cookie(LogInRememberDays 有效期)并写 session 的 KeyUID。所以安装完成页直接就是管理员已登录状态,注释强调"even if any session error happens, it should still continue"------登录失败不该阻塞安装完成。
restartServer(install.go:494):
go
func restartServer(ctx *context.Context) {
// Now get the http.Server from this request and shut it down
// NB: This is not our hammerable graceful shutdown this is http.Server.Shutdown
srv, _ := ctx.Value(http.ServerContextKey).(*http.Server)
go func() {
// Sleep for a while to make sure the user's browser has loaded the post-install page and its assets (images, css, js)
// What if this duration is not long enough? That's impossible -- if the user can't load the simple page in time, how could they install or use Gitea in the future ....
time.Sleep(3 * time.Second)
if err := srv.Shutdown(graceful.GetManager().HammerContext()); err != nil {
log.Error("Unable to shutdown the install server! Error: %v", err)
}
// After the HTTP server for "install" shuts down, the `runWeb()` will continue to run the "normal" server
}()
}
3 秒延时是为了让浏览器加载完 post-install 页及其静态资源(注释的反问很幽默:连简单页面都加载不完的用户以后也用不了 Gitea)。它用的是标准库 http.Server.Shutdown 而非 Gitea 自己的 hammer 机制------这里关的只是安装 server 这一个实例,不是整个进程,注释 NB: 特意区分。关闭后 serveInstall 里的 listen 返回,runWeb 继续执行 serveInstalled,同一个进程完成从安装态到服务态的切换。
至此安装流程闭环。需要明确一个事实:全仓库没有 GenerateInstallMark 这样的函数 ------判定安装状态的唯一真源是 [security] INSTALL_LOCK 键,读取入口只有 setting.HasInstallLock(rootCfg)(setting.go:206,直接 rootCfg.Section("security").Key("INSTALL_LOCK").MustBool(false))和变量 setting.InstallLock。任何文档/资料提到别的"安装标记",都不适用于本仓库。
2.4 优雅停机:graceful manager 四阶段状态机
Gitea 的优雅停机不是"收到 SIGTERM 就 Shutdown"这么简单。它是一个四层 context 嵌套的状态机 ,配套跨平台信号处理与 systemd 集成。核心文件都在 modules/graceful/。
2.4.1 四个 context 与状态机
Manager 结构(manager_common.go:32)持有四个 context 和两个 WaitGroup:
go
type Manager struct {
ctx context.Context
isChild bool
forked bool
lock sync.RWMutex
state state
shutdownCtx context.Context
hammerCtx context.Context
terminateCtx context.Context
managerCtx context.Context
shutdownCtxCancel context.CancelFunc
hammerCtxCancel context.CancelFunc
terminateCtxCancel context.CancelFunc
managerCtxCancel context.CancelFunc
runningServerWaitGroup sync.WaitGroup
terminateWaitGroup sync.WaitGroup
createServerCond sync.Cond
createdServer int
shutdownRequested chan struct{}
toRunAtShutdown []func()
toRunAtTerminate []func()
}
四个状态(manager.go:19):stateInit → stateRunning → stateShuttingDown → stateTerminate。四个 context 按关停顺序依次取消:shutdown → hammer → terminate → manager 。context.go 的文件头注释就是这份关停协议的说明,三个对外门面函数:
| API | 取消时机 | 语义 |
|---|---|---|
ShutdownContext() |
doShutdown 立即取消 |
"开始停机":新连接不再接受,存量请求继续完成 |
HammerContext() |
shutdown 后 GracefulHammerTime(默认 60s) |
"锤击":强制关掉仍活跃的连接 |
TerminateContext() |
hammer 后 1 秒 | "终止":清理收尾资源 |
Done()(= managerCtx) |
terminate 收尾完成后 | 进程可以退出了 |
doShutdown(manager.go:124)是状态机的心脏:
go
func (g *Manager) doShutdown() {
if !g.setStateTransition(stateRunning, stateShuttingDown) {
g.DoImmediateHammer()
return
}
g.lock.Lock()
g.shutdownCtxCancel()
atShutdownCtx := pprof.WithLabels(g.hammerCtx, pprof.Labels(gtprof.LabelGracefulLifecycle, "post-shutdown"))
pprof.SetGoroutineLabels(atShutdownCtx)
for _, fn := range g.toRunAtShutdown {
go fn()
}
g.lock.Unlock()
if setting.GracefulHammerTime >= 0 {
go g.doHammerTime(setting.GracefulHammerTime)
}
go func() {
g.runningServerWaitGroup.Wait()
// Mop up any remaining unclosed events.
g.doHammerTime(0)
<-time.After(1 * time.Second)
g.doTerminate()
g.terminateWaitGroup.Wait()
g.lock.Lock()
g.managerCtxCancel()
g.lock.Unlock()
}()
}
三条并发线索同时推进:
- 注册的 shutdown 回调并发执行 (
toRunAtShutdown,每个都带 recover 保护,RunAtShutdown包装时加的); - 定时锤击 :
doHammerTime(GracefulHammerTime)到点就hammerCtxCancel(若还没取消),日志Setting Hammer condition; - 主收尾协程 :等
runningServerWaitGroup(所有 server 报告结束)→ 立即doHammerTime(0)兜底取消 hammer → 硬等 1 秒 给回调缓冲 →doTerminate(状态转stateTerminate、terminateCtxCancel、并发跑toRunAtTerminate)→ 等terminateWaitGroup→ 最后managerCtxCancel(),Done()关闭。
setStateTransition 失败(状态已不是 stateRunning)意味着重复 shutdown,直接升级为 DoImmediateHammer()------第二次请求停机的合理语义就是"别等了,马上锤"。
pprof label 的三次迁移(post-shutdown → post-hammer → post-terminate)让 pprof 的 goroutine 分组能直接看出进程处在哪个关停阶段,gtprof.LabelGracefulLifecycle 是专用标签键。
2.4.2 4-server 计数契约
go
// modules/graceful/manager.go:31
// There are some places that could inherit sockets:
//
// * HTTP or HTTPS main listener
// * HTTP or HTTPS install listener
// * HTTP redirection fallback
// * Builtin SSH listener
//
// If you add a new place you must increment this number
// and add a function to call manager.InformCleanup if it's not going to be used
const numberOfServersToCreate = 4
InformCleanup(manager.go:227)在被调时 createdServer++ 并 Signal 条件变量。等待方是 manager_unix.go 的 start():
go
// Wait for all servers to be created
g.createServerCond.L.Lock()
for {
if g.createdServer >= numberOfServersToCreate {
g.createServerCond.L.Unlock()
g.notify(readyMsg) // systemd READY=1
return
}
select {
case <-g.IsShutdown():
g.createServerCond.L.Unlock()
return
default:
}
g.createServerCond.Wait()
}
只有 4 个位置全部报到(无论真创建还是注销),进程才向 systemd 发 READY=1 。四个位置的实现:主 HTTP server 在 graceful/server.go 的 Serve 里 RegisterServer;其余三个在 cmd/web_graceful.go 的 NoHTTPRedirector/NoInstallListener、modules/ssh/init.go 的内建 SSH、以及 net_unix.go/net_windows.go 的 DefaultGetListener 中 defer InformCleanup()。任何新增监听点忘记报到,systemd Type=notify 就会启动超时------这是把"监听点清单"硬编码进常量的代价,也是它的价值:清单不会静默漂移 。manager_common.go 头部还有一条 FIXME:安装页(INSTALL_LOCK=false)状态下 systemd notify 存在疑似 bug,待查。
StartupTimeout(默认 0 = 关闭)是可选的看门狗:超时未 READY 就 doShutdown 并通知 Startup took too long!。
2.4.3 信号处理与跨平台
Unix 下 start() 启动 handleSignals(manager_unix.go:100):
| 信号 | 动作 |
|---|---|
SIGHUP |
DoGracefulRestart():fork 新进程并传递监听 fd(见下) |
SIGUSR1 |
releasereopen.GetManager().ReleaseReopen():释放并重开日志文件(logrotate 场景) |
SIGUSR2 |
DoImmediateHammer():跳过等待直接锤击 |
SIGINT/SIGTERM |
DoGracefulShutdown():正常优雅停机 |
SIGTSTP |
只记录日志,不动作 |
| watchdog tick | 每 WATCHDOG_USEC/2 发 WATCHDOG=1 |
DoGracefulRestart(manager_unix.go:181)在 setting.GracefulRestartable(默认 true)时执行 doFork:
go
func (g *Manager) doFork() error {
g.lock.Lock()
if g.forked { ... return errors.New("another process already forked. Ignoring this one") }
g.forked = true
g.lock.Unlock()
g.notify(reloadingMsg)
// We need to move the file logs to append pids
setting.RestartLogsWithPIDSuffix()
_, err := RestartProcess()
return err
}
RestartProcess(restart_unix.go)把每个监听 listener File() 出 fd,通过 os.StartProcess 启动新进程并用 LISTEN_FDS 环境变量传递数量(sd_listen 风格,fd 从 3 起)。新进程 start() 里 g.isChild = len(os.Getenv(listenFDsEnv)) > 0 && os.Getppid() > 1,DefaultGetListener 优先 getProvidedFDs 拿继承的 fd。父进程 fork 完必须自己退出 (DoGracefulRestart 里 fork 后调 doShutdown),注释说明原因:否则 leveldb 等独占锁还被父进程持有,新进程起不来。RegisterServer 里的 KillParent()(restart_unix.go,sync.Once 保护)是兜底:子进程接手监听后给父进程发 SIGTERM。
systemd 集成通过 NOTIFY_SOCKET:getProvidedFDs 读取 NOTIFY_SOCKET/WATCHDOG_USEC 后立即 Unsetenv(不传给后续子进程),notify() 逐条发送 READY=1/STOPPING=1/RELOADING=1/WATCHDOG=1/STATUS=.../MAINPID=...。
Windows 对应实现是 manager_windows.go:Manager 实现 svc.Handler 的 Execute,状态机为 StartPending → awaitServer(同 4-server 契约)→ Running → StopPending → hammerLoop。SKIP_MINWINSVC=true 可跳过 SVC 检测;非交互会话用 svc.Run,交互会话(双击 exe)用 debug.Run。hammerCode = 128 是自定义控制码,SCM 发 128 即立即锤击。KillParent 在 Windows 是空实现(没有 fork 重启)。
2.4.4 server 侧的关停配合
server.go 的 Server 结构用 connCounter + connEmptyCond 跟踪连接数。Serve 流程:RegisterServer() → 开始 serve → 收到 shutdown 后停止 accept → waitForActiveConnections()(等连接归零或 hammer)→ ServerDone()。wrapConnection 计数、hammer 时 closeAllConnections 强关,仍有活跃连接则 Warn "After graceful shutdown period, %d connections are still active"。
wrappedConn.Write 施加 PerWriteTimeout(默认 30s)与 PerWritePerKbTimeout(默认 10s)滑动 deadline,防慢速写客户端占住连接拖死停机。server_http.go 的 BaseContext 返回 HammerContext------这是连接处理协程感知 hammer 的通道:60 秒宽限到期,所有 handler 的 ctx 取消,长请求(如大文件推送)被中断。
server_hooks.go 的 awaitShutdown 区分两级:收到 shutdown → doShutdown(关 listener + OnShutdown 关 keepalive);收到 hammer → 额外 doHammer(关连接)。
整体时序可以用一张图概括:
2.5 配置体系之基:路径解析与优先级
modules/setting 有 90+ 文件,按 app.ini 节拆包。理解它要从路径开始------因为配置文件的位置本身也是一条有优先级的解析链。
2.5.1 InitWorkPathAndCfgProvider 的三级优先
cmd/main.go 的 prepareWorkPathAndCustomConf 把三个 flag(--work-path/--custom-path/--config)打包成 ArgWorkPathAndCustomConf 传入(path.go:102):
go
func InitWorkPathAndCfgProvider(getEnvFn func(name string) string, args ArgWorkPathAndCustomConf) {
tryAbsPath := func(paths ...string) string {
s := paths[len(paths)-1]
for i := len(paths) - 2; i >= 0; i-- {
if filepath.IsAbs(s) { break }
s = filepath.Join(paths[i], s)
}
return s
}
var err error
tmpWorkPath := stringWithDefault{Value: appWorkPathBuiltin}
... // builtin 默认值:work=AppPath 目录, custom="custom", conf="conf/app.ini"
readFromEnv := func() {
envWorkPath := getEnvFn("GITEA_WORK_DIR")
if envWorkPath != "" {
tmpWorkPath.Set(envWorkPath)
if !filepath.IsAbs(tmpWorkPath.Value) {
log.Fatal("GITEA_WORK_DIR (work path) must be absolute path")
}
}
envCustomPath := getEnvFn("GITEA_CUSTOM")
... // 同样必须绝对路径
}
readFromArgs := func() {
if args.WorkPath != "" { ... log.Fatal("--work-path must be absolute path") }
if args.CustomPath != "" { ... }
if args.CustomConf != "" {
tmpCustomConf.Set(args.CustomConf)
if !filepath.IsAbs(tmpCustomConf.Value) {
if tmpCustomConf.Value, err = filepath.Abs(tmpCustomConf.Value); err != nil { ... }
}
}
}
readFromEnv()
readFromArgs()
if !tmpCustomConf.IsSet {
tmpCustomConf.Set(tryAbsPath(tmpWorkPath.Value, tmpCustomPath.Value, tmpCustomConf.Value))
}
InitCfgProvider(tmpCustomConf.Value)
if HasInstallLock(CfgProvider) {
ClearEnvConfigKeys() // if the instance has been installed, do not pass the environment variables to sub-processes
}
configWorkPath := ConfigSectionKeyString(CfgProvider.Section(""), "WORK_PATH")
if configWorkPath != "" {
... // 必须绝对路径、Clean
if tmpWorkPath.Value != "" && (getEnvFn("GITEA_WORK_DIR") != "" || args.WorkPath != "") {
fi1, err1 := os.Stat(tmpWorkPath.Value)
fi2, err2 := os.Stat(configWorkPath)
if err1 != nil || err2 != nil || !os.SameFile(fi1, fi2) {
AppWorkPathMismatch = true
}
}
tmpWorkPath.Set(configWorkPath)
}
tmpCustomPath.Set(tryAbsPath(tmpWorkPath.Value, tmpCustomPath.Value))
AppWorkPath = tmpWorkPath.Value
CustomPath = tmpCustomPath.Value
CustomConf = tmpCustomConf.Value
}
梳理出的优先级规则:
| 项 | 优先级(高 → 低) | 特殊规则 |
|---|---|---|
| WorkPath | builtin(链接器注入或 AppPath 目录)→ GITEA_WORK_DIR → --work-path → app.ini WORK_PATH(最终覆盖一切) |
env/args 必须绝对路径;与配置不一致时置 AppWorkPathMismatch |
| CustomPath | builtin → GITEA_CUSTOM → --custom-path → 相对则拼 WorkPath |
|
| CustomConf | builtin → --config → 由 WorkPath+CustomPath+conf/app.ini 拼接 |
相对路径按真实 cwd 取绝对 |
两个反直觉之处:
- app.ini 里的
WORK_PATH最后覆盖 env 和命令行 。按常理命令行最高,但这里配置文件赢------因为 Gitea 认为"工作路径决定一切相对路径的基准,它必须自洽"。env/args 与它冲突时不是静默采用谁,而是设AppWorkPathMismatch,由serveInstalled打出长警告(2.2.3),提示"只该在配置里设 WORK_PATH"。 ClearEnvConfigKeys在配置刚读完就执行 (仅已安装时)。这意味着原生二进制运行时,GITEA__*环境变量不会被自动应用 ------InitCfgProvider之后没有任何EnvironmentToConfig调用。环境变量真正写入配置的时机只有三处:安装流程(fillInstallConfig两处,2.3.4)、gitea config edit-ini --apply-env(2.11)、Docker 镜像的启动脚本environment-to-ini(docker/root/usr/local/bin/environment-to-ini,内容是exec /app/gitea/gitea config edit-ini --in-place --apply-env "$@",由 s6 的setup在 gitea 启动前 执行写盘)。这个事实与"GITEA__ 环境变量运行时覆盖配置"的常见印象相反,务必按本仓库行为理解:环境变量覆盖是一次性落盘动作,不是每次读取的动态层。
2.5.2 builtin 路径与 init() 时序
path.go:64 的 init() 负责 AppPath(exec.LookPath(os.Args[0]),Windows 绝对路径特判,最后统一转 / 分隔------注释说明是 legacy,兼容 //psf/Home/... 双斜杠开头)与 AppWorkPath 默认值。appWorkPathBuiltin 等三个 builtin 变量在 init() 里被快照,MockBuiltinPaths 供测试替换。这些 builtin 值可由构建时链接器注入(-X 参数),是发行版打包的钩子。
2.6 配置体系之柱:config_provider.go 与 INI 副作用防护
这是 1.6.1 承诺要讲透的第一个文件。config_provider.go 用三个接口封装 gopkg.in/ini.v1:
go
type ConfigKey interface {
internal()
Name() string
Value() string
SetValue(v string)
String() string
Strings(delim string) []string
Bool() (bool, error)
// FIXME: INI-MUST-SIDE-EFFECT: ini package's In/MustXxx functions have bad side-effects:
// they will change the origin config content and write the key with default value if the key didn't exist.
// Need to completely refactor the ini package to remove this side-effect.
In(defaultVal string, candidates []string) string
MustString(defaultVal string) string
MustBool(defaultVal ...bool) bool
MustInt(defaultVal ...int) int
MustInt64(defaultVal ...int64) int64
MustDuration(defaultVal ...time.Duration) time.Duration
}
type ConfigSection interface {
Name() string
MapTo(any) error
HasKey(key string) bool
NewKey(name, value string) (ConfigKey, error)
Key(key string) ConfigKey
DeleteKey(key string)
Keys() []ConfigKey
ChildSections() []ConfigSection
}
type ConfigProvider interface {
Section(section string) ConfigSection
Sections() []ConfigSection
NewSection(name string) (ConfigSection, error)
GetSection(name string) (ConfigSection, error)
DeleteSection(name string)
Save() error
SaveTo(filename string) error
DisableSaving()
PrepareSaving() (ConfigProvider, error)
IsLoadedFromEmpty() bool
}
2.6.1 INI-MUST-SIDE-EFFECT 问题
FIXME 注释点破了 ini.v1 的坑:MustXxx 系列在键不存在时会把默认值写回配置对象 。例如代码里 sec.Key("LEVEL").MustString("info"),若 app.ini 没有 LEVEL,内存配置就多出一条 LEVEL=info------此时若把内存配置 Save 回文件,用户会看到一堆他们从没写过的键,且默认值被"固化",未来版本改默认值时老用户的文件里是旧默认值。
Gitea 用三层防御解决:
第一层:DisableSaving + PrepareSaving。
go
func InitCfgProvider(file string) {
var err error
if CfgProvider, err = NewConfigProviderFromFile(file); err != nil {
log.Fatal("Unable to init config provider from %q: %v", file, err)
}
CfgProvider.DisableSaving() // do not allow saving the CfgProvider into file, it will be polluted by the "MustXxx" calls
}
全局 CfgProvider 加载后立刻禁写,Save()/SaveTo() 返回 errDisableSaving。需要写配置时(如 setPort 回写 LOCAL_ROOT_URL、serveInstalled 回写 WORK_PATH)用 PrepareSaving() 重新从磁盘读一份干净副本 ,只改目标键再 Save。PrepareSaving 的注释直说这是 workaround:"After the INI package gets refactored, no 'MustXxx' pollution, this workaround can be dropped."
Save() 还有一个安全动作:保存后若权限大于 0o600 就 chmod 0o600(失败则 Warn 提示手动收紧)------app.ini 里有 SECRET_KEY、INTERNAL_TOKEN 等密钥。
第二层:ConfigSectionKey 与 ConfigInheritedKey 的语义分野。 这是本文件最精妙的设计,两个函数注释写得极清楚:
go
// ConfigSectionKey only searches the keys in the given section, but it is O(n).
// ini package has a special behavior: with "[sec] a=1" and an empty "[sec.sub]",
// then in "[sec.sub]", Key()/HasKey() can always see "a=1" because it always tries parent sections.
// It returns nil if the key doesn't exist.
func ConfigSectionKey(sec ConfigSection, key string) ConfigKey { ... } // 只查本节, 不存在返回 nil
// ConfigInheritedKey works like ini.Section.Key(), but it always returns a new key instance, it is O(n) because NewKey is O(n)
// and the returned key is safe to be used with "MustXxx", it doesn't change the parent's values.
// Otherwise, ini.Section.Key().MustXxx would pollute the parent section's keys.
// It never returns nil.
func ConfigInheritedKey(sec ConfigSection, key string) ConfigKey { ... }
ini.v1 的节继承行为:[sec.sub] 空节里 Key("a") 会穿透到父节 [sec] 找 a。于是有两种需求:
- 只想知道本节有没有这个键 (如
HasKey检查旧配置、EnvironmentToConfig判断是否新增)→ 用ConfigSectionKey,线性扫本节键列表,绕过继承,不存在返回nil; - 想要继承语义但要用
MustXxx读默认值 → 用ConfigInheritedKey:先sec.Key(key)拿继承值,再sec.NewKey(...)复制成本节自己的新实例 ,之后MustXxx写默认值只写本节,不污染父节。ConfigSectionKeyString/Bool与ConfigInheritedKeyString是常用便捷封装。
判断"用户是否配置过某键"的正确姿势因此是 ConfigSectionKey(...) != nil 或 sec.HasKey(HasKey 走 ini 原生,注意它也有继承穿透------所以严格本节判断还是 ConfigSectionKey)。
第三层:loadLogModeByName 等处显式容忍副作用 ,setting/log.go 里多处 // FIXME: INI-MUST-SIDE-EFFECT 标注了"知道这里会写键,故意的"。
2.6.2 加载选项与杂项
configProviderLoadOptions:
go
func configProviderLoadOptions() ini.LoadOptions {
return ini.LoadOptions{
KeyValueDelimiterOnWrite: " = ",
IgnoreContinuation: true,
}
}
写盘用 = 分隔(可读性),IgnoreContinuation 禁用行尾 \ 续行(避免路径末尾反斜杠被当续行符)。init() 里 ini.PrettyFormat = false 关闭 ini 库的"美化格式"(保留原排版)。
NewConfigProviderFromFile:文件不存在时不报错 ,返回 loadedFromEmpty=true 的空配置(首次安装场景),Save 时会 MkdirAll 创建目录。cfg.NameMapper = ini.SnackCase 让 MapTo 结构体时 HTTP_PORT ↔ HttpPort 这类下划线到驼峰的映射生效。
mustMapSetting 是批量映射的 fail-fast 封装(失败 log.Fatal("Failed to map %s settings"))。
StartupProblems 与 LogStartupProblem(config_provider.go:387)构成启动问题累积器 :deprecation 警告、路径重叠警告都往里记,管理后台的 self_check.tmpl 直接展示这个列表------运维不用翻日志就能看到"哪些配置项过时了/冲突了"。配套两个 helper:deprecatedSetting(旧键存在则提示迁移到新键,带移除版本号)与 deprecatedSettingDB(键已迁到 admin panel 数据库,app.ini 里留着也不生效)。
2.6.3 动态配置选项(config.go + config_option_instance.go)
1.6.1 提到的第三个文件。initDefaultConfig 用 config.NewOption[T](...).WithFileConfig(CfgSecKey{...}) 声明选项,setting.Config()(config.go:88,sync.Once 初始化)返回 *ConfigStruct(含 Picture/Repository/Instance 三组)。与静态 MustXxx 读取的区别在于惰性求值 :cfgSecKeyGetter.GetValue 每次都走 CfgProvider 现取,且可挂数据库动态来源(routers/common/db.go 的 InitDBEngine 成功后 config.SetDynGetter(system_model.NewDatabaseDynKeyGetter())------数据库成为动态配置 getter,admin 面板改的配置即刻生效,无需重启)。WebBannerType/MaintenanceModeType 还带 ShouldDisplay/IsActive 时间窗方法。这条"文件配置 + 数据库动态覆盖"的双源结构是 Gitea 配置演进的方向,第 13 章管理后台会再遇它。
2.7 环境变量覆盖:config_env.go 与 GITEA__ 的真相
config_env.go 全文 179 行,是 1.6.1 承诺的第二个文件。常量与转义规则:
go
const (
EnvConfigKeyPrefixGitea = "GITEA__"
EnvConfigKeySuffixFile = "__FILE"
)
const escapeRegexpString = "_0[xX](([0-9a-fA-F][0-9a-fA-F])+)_"
环境变量名格式 GITEA__SECTION__KEY,__ 分割 section 与 key;键名里的特殊字符用字节十六进制转义:_0x2E_ = .。所以 [git.config] 节的 foo.bar 键对应 GITEA__git_0x2E_config__foo_0x2E_bar(cmd/config.go 的 edit-ini 帮助文本里有这个例子)。decodeEnvSectionKey 用正则找所有转义段,逐段解码,__ 首次出现处分界(所以 section 和 key 本身都能含转义),section 最后 ToLower,key 为空则整体判非法。config_env_test.go 覆盖了 GITEA____KEY(空 section)、GITEA__SEC__FILE 等边界。
__FILE 后缀的语义是值来自文件内容 :GITEA__security__SECRET_KEY__FILE=/run/secrets/key 表示读取该文件内容(去掉尾部 \r\n/\n)作为键值------容器 secrets 的标准接法。
EnvironmentToConfig 是唯一的应用函数:
go
func EnvironmentToConfig(cfg ConfigProvider, envs []string) (changed bool) {
for _, kv := range envs {
before, after, ok := strings.Cut(kv, "=")
if !ok { continue }
envKey := before
envValue := after
ok, sectionName, keyName, useFileValue := decodeEnvironmentKey(EnvConfigKeyPrefixGitea, EnvConfigKeySuffixFile, envKey)
if !ok { continue }
keyValue := envValue
if useFileValue {
fileContent, err := os.ReadFile(envValue)
if err != nil {
log.Error("Error reading file for %s : %v", envKey, envValue, err)
continue
}
... // 去尾换行
keyValue = string(fileContent)
}
section, err := cfg.GetSection(sectionName)
if err != nil {
section, err = cfg.NewSection(sectionName)
...
}
key := ConfigSectionKey(section, keyName) // 只查本节, 绕过继承
if key == nil {
changed = true
key, err = section.NewKey(keyName, keyValue)
...
}
oldValue := key.Value()
if !changed && oldValue != keyValue {
changed = true
}
key.SetValue(keyValue)
}
return changed
}
设计要点:读 envs []string 而非直接 os.Environ()------调用方(安装流程)可传快照;GetSection 失败自动 NewSection(环境变量可以引入 app.ini 里不存在的节,如 [queue.myqueue]);用 ConfigSectionKey(2.6.1)判断是否已存在键,避免 sec.Key() 的继承穿透把父节键误判为本节键;返回 changed 供 gitea config edit-ini 决定是否需要写盘。
配套 CollectEnvConfigKeys(列出当前 GITEA__* 变量,安装页据此提示用户)与 ClearEnvConfigKeys(全部 Unsetenv,见 2.3.5/2.5.1 的两处调用时机)。
再强调一遍 2.5.1 的结论并给出完整证据链:原生二进制的普通启动路径(PrepareSubcommandWithGlobalFlags → InitWorkPathAndCommonConfig)只调 InitCfgProvider + LoadCommonSettings,全程没有 EnvironmentToConfig 。环境变量生效的三个真实场景:安装流程(fillInstallConfig)、gitea config edit-ini --apply-env(Docker 启动脚本在进程启动前调用它把 env 写进 app.ini)。此外路径类环境变量 GITEA_WORK_DIR/GITEA_CUSTOM/GITEA_RUN_MODE 是例外,它们由 path.go/loadRunModeFrom 直接读 os.Getenv,属于"运行时真·环境变量",与 GITEA__ 机制是两套系统。
2.8 配置加载时序:两段式与顺序依赖
2.8.1 第一段:loadCommonSettingsFrom
LoadCommonSettings(setting.go:112)→ loadCommonSettingsFrom(setting.go:119),在每个 subCmdWithConfig 命令的 Before 里执行:
go
func loadCommonSettingsFrom(cfg ConfigProvider) error {
// a lot of logic depends on InstallLock value, so it must be loaded before any other settings
InstallLock = HasInstallLock(cfg)
// WARNING: don't change the sequence except you know what you are doing.
loadRunModeFrom(cfg)
loadLogGlobalFrom(cfg)
loadAuditFrom(cfg)
loadServerFrom(cfg)
loadSSHFrom(cfg)
mustCurrentRunUserMatch(cfg) // it depends on the SSH config, only non-builtin SSH server requires this check
loadOAuth2From(cfg)
loadSecurityFrom(cfg)
if err := loadAttachmentFrom(cfg); err != nil { return err }
if err := loadLFSFrom(cfg); err != nil { return err }
loadTimeFrom(cfg)
loadRepositoryFrom(cfg)
if err := loadAvatarsFrom(cfg); err != nil { return err }
if err := loadRepoAvatarFrom(cfg); err != nil { return err }
if err := loadPackagesFrom(cfg); err != nil { return err }
if err := loadActionsFrom(cfg); err != nil { return err }
loadUIFrom(cfg)
loadAdminFrom(cfg)
loadAPIFrom(cfg)
loadMetricsFrom(cfg)
loadCamoFrom(cfg)
loadI18nFrom(cfg)
loadGitFrom(cfg)
loadMirrorFrom(cfg)
loadMarkupFrom(cfg)
loadRedisFrom(cfg)
loadGlobalLockFrom(cfg)
loadOtherFrom(cfg)
return nil
}
注释 "WARNING: don't change the sequence" 不是客套,三处硬依赖清晰可见:
InstallLock第一行 :后续mustCurrentRunUserMatch(仅 InstallLock 时检查运行用户)、loadSSHFrom、loadSecurityFrom的InternalToken处理(security.go:207:InstallLock 时空 token 的行为不同)都读它。loadLogGlobalFrom第二 (RunMode之后):[log]全局段(LEVEL/MODE/BUFFER_LEN/ROOT_PATH)必须先于一切可能报错的加载------虽然initAllLoggers在第二段,但全局日志参数属于 common 段。loadServerFrom在loadSSHFrom之前 ,mustCurrentRunUserMatch在loadSSHFrom之后:运行用户检查依赖SSH.StartBuiltinServer(IsRunUserMatchCurrentUser里if IsWindows || SSH.StartBuiltinServer { return "", true }跳过)------内建 SSH server 模式下不要求 RUN_USER 匹配,因为 ssh 登录走的是 Gitea 自己的认证。loadSSHFrom在loadSecurityFrom之前 :security.go的SecretKey/InternalToken走loadSecret(支持xxx_URI文件引用),与 SSH 无关但顺序被固定在中部。loadRepositoryFrom在loadPackagesFrom/loadActionsFrom之前 :后者的存储路径默认值依赖AppDataPath与 repository 基准路径。
loadRunModeFrom(setting.go:169)里两个细节:
go
runModeValue := os.Getenv("GITEA_RUN_MODE")
runModeValue = util.IfZero(runModeValue, rootSec.Key("RUN_MODE").String())
// non-dev mode is treated as prod mode, to protect users from accidentally running in dev mode if there is a typo in this value.
IsProd = !strings.EqualFold(runModeValue, "dev") // TODO: can use case-sensitive comparing in the future
RunMode = util.Iif(IsProd, "prod", "dev")
环境变量 GITEA_RUN_MODE 优先于 RUN_MODE 键 (这是少数运行时生效的 env)。IsProd 的判定故意宽松:只有精确等于 dev(不分大小写)才是 dev,prodction 这种拼写错误也落进 prod------注释明说这是向安全侧倒(dev 模式有热重载、宽松错误等行为,误入 dev 危险更大)。同函数里 mustNotRunAsRoot 检查 uid=0:SNAP 环境豁免,否则要求 I_AM_BEING_UNSAFE_RUNNING_AS_ROOT 配置或 env,报错信息致敬 VLC 的措辞,并提示用 setcap cap_net_bind_service 代替 root。
mustCurrentRunUserMatch 同样只在 HasInstallLock 时检查------未安装时不打断安装流程。
2.8.2 第二段:LoadSettings
LoadSettings(setting.go:221)由 InitWebInstalled 调用:
go
func LoadSettings() {
initAllLoggers()
loadDBSetting(CfgProvider)
loadServiceFrom(CfgProvider)
loadOAuth2ClientFrom(CfgProvider)
loadCacheFrom(CfgProvider)
loadWebsocketFrom(CfgProvider)
loadSessionFrom(CfgProvider)
loadCorsFrom(CfgProvider)
loadMailsFrom(CfgProvider)
loadProxyFrom(CfgProvider)
loadWebhookFrom(CfgProvider)
loadMigrationsFrom(CfgProvider)
loadIndexerFrom(CfgProvider)
loadTaskFrom(CfgProvider)
LoadQueueSettings()
loadProjectFrom(CfgProvider)
loadMimeTypeMapFrom(CfgProvider)
loadFederationFrom(CfgProvider)
}
initAllLoggers() 排第一 ------1.6.1 已标注:日志必须在任何可能报错的加载前就绪。这 18 个 load 覆盖的都是"重配置"(数据库连接、缓存后端、队列、索引器、邮件),它们与第一段的"轻配置"(路径、协议、端口)分家的原因:第一段在 Before 钩子跑,任何命令都需要;第二段只在真正要跑服务/数据库时才加载,gitea doctor、gitea dump 这类命令按需选择(2.11)。LoadSettingsForInstall 只取三节(DB/Service/Mails),是第二段的安装态子集。
两段式之外还有一个 LoadDBSetting(database.go 的 loadDBSetting 单独暴露),供 CLI 子命令"只要数据库配置"的场景(initDB helper)。
2.8.3 配置校验与 Fatal 行为
配置错误的处理策略可以归纳为三档:
| 档位 | 行为 | 例子 |
|---|---|---|
| Fatal(启动必炸) | 协议非法、ACME 未接受 TOS、WORK_PATH 非绝对、root 未豁免、MapTo 失败、APP_TEMP_PATH 不存在 |
log.Fatal("Invalid protocol: %s") |
| LogStartupProblem(带病启动) | 路径重叠、deprecated 键、权限收紧失败 | checkOverlappedPath:"Configured path %q is used by %q and %q at the same time" |
| Warn/Error + 降级 | TLS 套件查表未命中、模板错误(dev 模式)、legacy public asset 残留 | log.Warn 后使用剩余可用配置 |
判断标准是错误是否让实例必然不工作或必然丢数据 :协议名拼错则完全无法服务 → Fatal;两个存储节指向同一路径会静默丢数据 → 记 StartupProblem 高调警告但不拦(因为也可能是有意共享 data 根)。PanicInDevOrTesting(setting.go:260)则把 dev/test 下的可疑状态直接 panic 出来,让开发者第一时间看到。
checkOverlappedPath 的实现值得注意:configuredPaths 是全局 map,PPROF_DATA_PATH 等键在各自 load 时登记,同路径被两个名字使用即记 ERROR。这是"配置冲突静态检测"的最小实现。
2.8.4 custom/ 目录覆盖规则
用户最常问的"改了模板为什么没生效",答案在 assetfs.LayeredFS。modules/templates/base.go 构造:
go
Layered(CustomAssets(), BuiltinAssets())
LayeredFS(modules/assetfs/layered.go)的语义:custom 层在前优先命中,builtin 层兜底 。Open 逐层查找,返回第一个非 NotExist 的结果;ReadDir 合并各层目录项(同名 custom 覆盖 builtin)。CustomAssets() 是 os.DirFS(CustomPath) 的本地文件系统层(Local()),BuiltinAssets() 是编译进二进制的 embed 层。三处使用:templates(页面模板)、options(custom/option)、public(静态资源)。
因此 custom/ 的覆盖是按文件路径逐文件覆盖 ,不是目录替换:想改 templates/repo/header.tmpl,把文件放到 custom/templates/repo/header.tmpl 即可,未覆盖的模板仍用内置版本。这与 app.ini 的机制完全不同------app.ini 不做分层合并 ,只有"单文件 + (特定时机的)环境变量写入",custom/conf/app.ini 本身就是唯一配置文件(CustomConf 默认指向它),不存在 conf/app.ini 与 custom/conf/app.ini 合并这件事。
2.9 日志体系:从 [log] 配置到 LoggerManager 扇出
1.6.2 的兑现章节。先纠正一个常见误解:本仓库的 modules/log 只注册了三种 EventWriter:console、file、conn (分别在 event_writer_console.go、event_writer_file.go、event_writer_conn.go 的 init() 里 RegisterEventWriter)。没有 smtp、slack、discard 这类 sink------某些版本或竞品的资料里有,但不适用本仓库。配置里写未知 writer 类型会在 loadLogModeByName 的 default 分支被 log.HasEventWriter 拦下,报 "invalid log writer type (mode)"。
2.9.1 三层结构:Logger → EventWriter → 共享 writer
logger 是逻辑分组,EventWriter 是物理输出 ,多对多:initLoggerByName 里 manager.GetSharedWriter(writerName) 让"default 和 router 都用 console writer"时共享同一个 writer 实例(manager.go 的 NewSharedWriter/GetSharedWriter,注释 "a shared writer won't be closed if a logger is closed"------关闭一个 logger 不会误关别的 logger 还在用的 writer)。
事件流动(logger_impl.go 的 SendLogEvent)有两个关键决策:
- 在调用线程预格式化 :注释说明因为 writer goroutine 不能碰
Stringer(可能有并发副作用),所以格式化在投递前完成; - 队列满即丢弃 :
EventWriterBaseImpl的Queue chan *EventFormatted满时,json.Marshal后走FallbackErrorf输出到 stderr 并丢弃该条。日志系统宁可丢日志也不能阻塞业务线程------这是"可观测性不拖垮被观测系统"的取舍。
logger_impl.go 的 Log 还做两件事:runtime.Caller 补 Caller/Filename/Line;protectSensitiveInfo 把 URL query 参数值全部替换成 _ ------防日志泄漏 token。IsEnabled 要求 level < FATAL 且有 writer。
级别模型分两级:writer 级别 (WriterMode.Level,来自 [log.<mode>].LEVEL,缺省继承 [log].LEVEL)与 logger 级别 (syncLevelInternal:取所有 writer 的最低者------logger 只有在至少一个 writer 接受该级别时才值得格式化)。Level 常量(level.go):UNDEFINED/TRACE/DEBUG/INFO/WARN/ERROR/FATAL/NONE,CRITICAL=ERROR 别名,LevelFromString 未知值回落 INFO。
2.9.2 [log] 全局段与旧键迁移
loadLogGlobalFrom(log.go:35):LEVEL 默认 info、STACKTRACE_LEVEL 默认 none、BUFFER_LEN 默认 10000、MODE 默认 console、ROOT_PATH 默认 {AppWorkPath}/log(相对则拼 WorkPath)、ENABLE_SSH_LOG 默认 false、ACCESS_LOG_TEMPLATE 默认 CLF 风格模板(accessLogTemplateDefault,含 RemoteHost/Method/Status/UA 等字段)、REQUEST_ID_HEADERS 逗号分割。
prepareLoggerConfig(log.go:55)是旧配置键迁移层,把 1.21 之前的键翻译成新格式:
| 旧键 | 新键 | 迁移逻辑 |
|---|---|---|
[log] ACCESS / ENABLE_ACCESS_LOG |
logger.access.MODE |
旧值非空则设为新键;ENABLE_ACCESS_LOG=false 则置空(禁用) |
[log] ROUTER / DISABLE_ROUTER_LOG |
logger.router.MODE |
同上;不存在新键时默认 ,(跟随 MODE) |
[log] XORM / ENABLE_XORM_LOG |
logger.xorm.MODE |
同上 |
每处都伴随 deprecatedSetting(rootCfg, "log", "ACCESS", "log", "logger.access.MODE", "1.21")------旧键存在就在 StartupProblems 记一条 "Deprecation: ... this fallback will be/has been removed in 1.21"。默认值 , 是精心设计的哨兵:initLoggerByName 里 if modeVal == "," { modeVal = Log.Mode },即"未单独配置的 logger 跟随 [log].MODE",而显式空字符串 "" 表示禁用 该 logger(disabled := sec.HasKey(...) && String() == "")。这三种状态------未配置(跟随)、,(显式跟随)、""(禁用)------是理解 logger.xxx.MODE 的钥匙。
2.9.3 按 mode 构造 writer:loadLogModeByName
initLoggerByName 把 logger.<name>.MODE 按逗号 SplitSeq 拆成多个 mode,逐个构造 writer------这就是"一个 logger 多 sink"的实现 (如 logger.default.MODE = console,file)。每个 mode 对应一节 [log.<mode>]:
go
func loadLogModeByName(rootCfg ConfigProvider, loggerName, modeName string) (writerName, writerType string, writerMode log.WriterMode, err error) {
sec := rootCfg.Section("log." + modeName)
writerMode = log.WriterMode{}
writerType = ConfigSectionKeyString(sec, "MODE")
if writerType == "" {
writerType = modeName // 节名即类型: [log.file] → file writer
}
writerName = modeName
defaultFlags := "stdflags"
defaultFilaName := "gitea.log"
if loggerName == "access" {
// "access" logger is special, by default it doesn't have output flags, so it also needs a new writer name to avoid conflicting with other writers.
// so "access" logger's writer name is usually "file.access" or "console.access"
writerName += ".access"
defaultFlags = "none"
defaultFilaName = "access.log"
}
writerMode.Level = log.LevelFromString(ConfigInheritedKeyString(sec, "LEVEL", Log.Level.String()))
writerMode.StacktraceLevel = log.LevelFromString(ConfigInheritedKeyString(sec, "STACKTRACE_LEVEL", Log.StacktraceLogLevel.String()))
writerMode.Prefix = ConfigInheritedKeyString(sec, "PREFIX")
writerMode.Expression = ConfigInheritedKeyString(sec, "EXPRESSION")
writerMode.Flags = log.FlagsFromString(ConfigInheritedKeyString(sec, "FLAGS", defaultFlags))
switch writerType {
case "console":
useStderr := ConfigInheritedKey(sec, "STDERR").MustBool(false)
...
writerMode.Colorize = ConfigInheritedKey(sec, "COLORIZE").MustBool(defaultCanColor)
writerMode.WriterOption = log.WriterConsoleOption{Stderr: useStderr}
case "file":
fileName := LogPrepareFilenameForWriter(ConfigInheritedKey(sec, "FILE_NAME").String(), defaultFilaName)
writerOption := log.WriterFileOption{}
writerOption.FileName = fileName + filenameSuffix
writerOption.LogRotate = ConfigInheritedKey(sec, "LOG_ROTATE").MustBool(true)
writerOption.MaxSize = 1 << uint(ConfigInheritedKey(sec, "MAX_SIZE_SHIFT").MustInt(28))
writerOption.DailyRotate = ConfigInheritedKey(sec, "DAILY_ROTATE").MustBool(true)
writerOption.MaxDays = ConfigInheritedKey(sec, "MAX_DAYS").MustInt(7)
writerOption.Compress = ConfigInheritedKey(sec, "COMPRESS").MustBool(true)
writerOption.CompressionLevel = ConfigInheritedKey(sec, "COMPRESSION_LEVEL").MustInt(-1)
writerMode.WriterOption = writerOption
case "conn":
writerOption := log.WriterConnOption{}
writerOption.ReconnectOnMsg = ConfigInheritedKey(sec, "RECONNECT_ON_MSG").MustBool()
writerOption.Reconnect = ConfigInheritedKey(sec, "RECONNECT").MustBool()
writerOption.Protocol = ConfigInheritedKey(sec, "PROTOCOL").In("tcp", []string{"tcp", "unix", "udp"})
writerOption.Addr = ConfigInheritedKey(sec, "ADDR").MustString(":7020")
writerMode.WriterOption = writerOption
default:
if !log.HasEventWriter(writerType) {
return "", "", writerMode, fmt.Errorf("invalid log writer type (mode): %s, maybe it needs something like 'MODE=file' in [log.%s] section", writerType, modeName)
}
}
return writerName, writerType, writerMode, nil
}
要点清单:
writerType默认等于 mode 名 :[log.file]节没写MODE=就是 file writer;要让[log.xxx]指向别的类型,显式MODE=file。错误提示里直接给出修法。- access logger 的三个特殊性 :writer 名加
.access后缀(避免与 default 的同名 writer 冲突,共享图里的file.access/console.access)、默认 flags 为none(访问日志不需要时间戳前缀,CLF 模板自带时间)、默认文件名access.log。 - 相对文件名拼
Log.RootPath并MkdirAll,失败直接 panic (LogPrepareFilenameForWriter)------日志目录建不出来是部署级错误。 - 所有键走
ConfigInheritedKey*:[log.file]没写LEVEL就继承[log].LEVEL,且不污染父节(2.6.1 的第二层防御在这里被大量使用)。 MAX_SIZE_SHIFT默认 28,实际1 << 28= 256MB------键名带 shift,值是位数。filenameSuffix:优雅重启 fork 前RestartLogsWithPIDSuffix()把后缀设为.<pid>并重初始化全部 logger,父子进程日志文件分离。源码 FIXME 坦承"这看起来不对,会破坏日志轮转或日志收集器"------已知权衡。
initAllLoggers(log.go:192)除初始化 4 个 logger 外,还把标准库 log 重定向 到 default logger(golog.SetOutput(log.LoggerToWriter(log.GetLogger(log.DEFAULT).Info)))------第三方库直接用 log.Printf 的输出也不会丢。DisableLoggerInit()(cmd/dump.go 用)可整体跳过,防止 dump 命令用文件配置覆盖了 CLI 已设的控制台日志。
InitSQLLoggersForCli(level)(log.go:260)是 CLI 专用:log.SetupStderrLogger("xorm", "console-stderr", level),绕过文件配置直接给 SQL logger 装 stderr writer------gitea doctor recreate-table --debug 这类命令要的是"终端里看到 SQL",不是写进 gitea.log。
2.9.4 writer 基类与生命周期
event_writer_base.go 的 EventWriterBaseImpl:Queue chan *EventFormatted(容量 BufferLen,NewEventWriterBase 兜底 1000)+ Run() 消费循环。Run 内:Expression 正则过滤(匹配 file:line:caller 或 MsgSimpleText,实现"只记录某模块日志")、handlePaused(manager 暂停时阻塞)、格式化写入 OutputWriteCloser,写失败 → FallbackErrorf。eventWriterStopWait 关闭 Queue 并给 2 秒超时,超时 FallbackErrorf 报弃------停机时日志 flush 有硬上限,不无限等慢磁盘。
event_writer_file.go 的 NewEventWriterFile 打开失败时的行为有段著名注释:讨论了 panic / exit / ignore / fallback 四种选择,最终选回退到 stderr writer------日志文件写不了(磁盘满、权限错)时进程继续跑并把日志打到 stderr,比直接崩掉更可用。
logger_global.go 的 SetupStderrLogger 注释解释了为什么 CLI 用 stderr、web 可以用 stdout :gitea dump --file - 要把归档写 stdout,日志混进去会污染归档;而 web 从配置初始化的 console logger 默认 stdout 是历史行为,fail2ban 等工具依赖 stderr 里的登录日志 ,所以暂不改。Fatal = Log(FATAL) + GetManager().Close() + OsExiter(1)------保证致命错误必然落盘。
manager.go 的 PauseAll/ResumeAll(gitea manager logging pause 的后端)通过 pauseMu/pauseChan 让所有 writer 的 Run 阻塞在 handlePaused;DumpLoggers 输出内部状态;Close() 顺序:关全部 logger → eventWriterStopWait 等全部 writer → ctxCancel。
最后是 PrepareConsoleLoggerLevel 的完整语义回顾(2.1 已见):它在配置加载前 按 quiet→FATAL、debug/verbose→TRACE、默认 INFO 设置 stderr console writer,cmd/serv.go 的 Before 用 log.FATAL------git 协议(push/fetch)的 stdout 绝不能混入日志,否则客户端永久挂起,这是 serv/hook 子命令的设计红线。
2.10 gitea web 之外:子命令的启动差异
subCmdWithConfig 的 14 个命令共享 Before 里的路径+配置加载,但加载深度差异很大。核心分野是:谁需要数据库、谁需要完整 settings、谁只是运行中进程的"遥控器"。
2.10.1 initDB helper:最小数据库路径
cmd/helper.go:50:
go
func initDB(ctx context.Context) error {
setting.MustInstalled()
setting.LoadDBSetting()
setting.InitSQLLoggersForCli(log.INFO)
if setting.Database.Type == "" {
log.Fatal(`Database settings are missing from the configuration file: %q.
Ensure you are running in the correct environment or set the correct configuration file with -c.`, setting.CustomConf)
}
if err := db.InitEngine(ctx); err != nil {
return fmt.Errorf("unable to initialize the database using the configuration in %q. Error: %w", setting.CustomConf, err)
}
return nil
}
四步:断言已安装 → 只加载 [database] 节 (不跑 LoadSettings 的 18 个 load)→ SQL 日志设 stderr → 连库。gitea migrate 直接用它,然后 db.InitEngineWithMigration(versioned_migration.Migrate)。不加载完整 settings 的原因:迁移只需要数据库连接;加载队列/缓存/索引器反而可能引入不必要的失败面(比如缓存 Redis 不可达会挡住本可进行的迁移)。
cmd/migrate.go 的 runMigrate 即 initDB + 打印路径 + 迁移。cmd/doctor.go 的 runRecreateTable 用同一路径(外加 EnsureUpToDate 断言迁移版本再重建表);runDoctorCheck 则先 setupDoctorDefaultLogger------默认静默到 FATAL (setupConsoleLogger(log.FATAL, ..., os.Stderr)),--log-file 指定则换成 TRACE 级 file writer,--log-file=- 用 stdout。诊断工具的输出哲学:报告结果是主输出,日志是噪音,默认静默。
gitea dump(cmd/dump.go)的特殊处理:
go
setting.DisableLoggerInit() + LoadSettings()
// 注释: cannot access session settings otherwise
dump 需要 session、存储等完整 settings 来定位要打包的目录,但禁用 logger 初始化 ------避免 dump 进程按 app.ini 把日志写进正在被打包的 log/ 目录(自引用),日志保持 CLI 启动时的 stderr 状态。
2.10.2 gitea manager:HTTP 管理客户端,不是 daemon
又一处需要按源码纠偏的认知:gitea manager 不是后台进程管理 daemon ,它是一个对运行中进程发 HTTP 请求的客户端 。cmd/manager.go 的 6 个子命令(shutdown/restart/reload-templates/flush-queues/logging/processes)结构完全一致:
go
func runShutdown(ctx context.Context, c *cli.Command) error {
setup(ctx, c.Bool("debug"))
extra := private.Shutdown(ctx)
return handleCliResponseExtra(extra)
}
modules/private/manager.go 揭示传输方式:
go
reqURL := setting.LocalURL + "api/internal/manager/shutdown" // :20
reqURL := setting.LocalURL + "api/internal/manager/restart" // :27
reqURL := setting.LocalURL + "api/internal/manager/reload-templates" // :34
reqURL := setting.LocalURL + "api/internal/manager/flush-queues" // :47
reqURL := setting.LocalURL + "api/internal/manager/pause-logging" // :57
... // resume / release-and-reopen / set-log-sql / processes
即:CLI 读本地配置拿到 LOCAL_ROOT_URL(2.2.3 的 setPort 维护它),POST 到运行中 gitea web 的 /api/internal/manager/*(NormalRoutes 里的 r.Mount("/api/internal", private.Routes()))。服务端持锁、客户端只是遥控 ------这也解释了为什么这些命令属于 subCmdWithConfig:它需要配置来知道 LocalURL,但不需要数据库连接。
每个 runXxx 开头的 setup(ctx, debug) 内部做 MustInstalled 与调试日志设置;--debug 只影响本次请求的错误输出。reload-templates 对应服务端 templates.ReloadAllTemplates()(2.2.1 的 dev 热重载的运维版,生产环境不重启改模板的正规手段);logging 子命令的 pause/resume 对应 LoggerManager.PauseAll/ResumeAll(2.9.4),release-and-reopen 对应 releasereopen(配合 logrotate + SIGUSR1)。
Windows 上 restart 的 usage 直接标注 "(not implemented for windows servers)"------因为优雅重启依赖 fork + fd 传递,Windows 没有(manager_windows.go 的 KillParent 是空实现)。
2.10.3 gitea admin 与 gitea actions
cmd/admin.go 的 5 个子命令(user/repo-sync-releases/regenerate/auth/sendMail)。其中 user 系列需要数据库(走 initDB 或等价路径),auth 子命令的 Before 挂 cliAuditContext(cmd/audit.go:audit.WithOrigin(OriginCLI) + audit.WithDoer(NewCliUser())),让 CLI 造的用户变更也带审计身份。
cmd/actions.go 的 generate-runner-token 是纯 HTTP 客户端模式的又一例:
go
func runGenerateActionsRunnerToken(ctx context.Context, c *cli.Command) error {
setting.MustInstalled()
scope := c.String("scope")
respText, extra := private.GenerateActionsRunnerToken(ctx, scope)
if extra.HasError() {
return handleCliResponseExtra(extra)
}
cprintln(c, respText.Text)
return nil
}
只 MustInstalled(需要配置里的 LocalURL),token 由运行中进程生成------保证 token 与进程内密钥一致。
2.10.4 gitea serv/hook/keys:git 协议子进程
cmd/serv.go 是隐藏命令(Hidden: true),由内建 SSH server 或 git hook 拉起。它的 Before 是 PrepareConsoleLoggerLevel(log.FATAL)------日志级别压到 FATAL 以下全无 ,因为 fail() 函数把错误写 stdout 给 git 客户端、日志写 stderr,任何多余 stdout 输出都会破坏 git/ssh 协议(2.9.4 的红线)。setup 做 MustInstalled + RepoRootPath 检查 + git.Init,比 initDB 还轻------serv 只转发 git 命令,多数路径不需要数据库。
2.10.5 子命令启动方式对比总表
| 命令 | 路径+common 配置 | LoadSettings 完整段 |
数据库 | 信号/停机 | 特殊点 |
|---|---|---|---|---|---|
web |
✅(Before 包装) | ✅(InitWebInstalled) |
✅ 完整引擎+迁移 | graceful manager | 4-server 契约、模板预编译 |
serv/hook/keys |
✅ | ❌ | 视路径 | 基础 SIGINT | 日志 FATAL、stdout 协议红线 |
migrate |
✅ | ❌(LoadDBSetting) |
✅ 迁移专用 | 基础信号 | initDB 四步 |
doctor |
✅ | ❌ | ✅(recreate-table) | 基础信号 | 默认日志静默 FATAL |
dump |
✅ | ✅(需 session 等) | ✅ | 基础信号 | DisableLoggerInit 防自引用 |
manager |
✅ | ❌ | ❌ | 基础信号 | HTTP 客户端 → LocalURL |
admin |
✅ | 按子命令 | 多数需要 | 基础信号 | CLI 审计身份 |
actions generate-runner-token |
✅ | ❌ | ❌ | 基础信号 | HTTP 客户端 |
config/cert/generate/docs |
❌ standalone | ❌ | ❌ | 基础信号 | 不读配置,损坏也能用 |
这张表也回答了"为什么 gitea config edit-ini 能修复配置"------它是 standalone 组,唯一读配置的路径是 runConfigEditIni 里显式 --config 指定的文件,config-keep-keys 模板只保留指定键、apply-env 用 EnvironmentToConfig 注入环境变量、最后 SaveTo 落盘。整个过程绕过 MustXxx 加载链,天然免疫 2.6.1 的污染问题。
2.11 关键配置项速查表
以下按节整理本章涉及的高频配置项(键名不区分大小写;"默认值"取源码中 MustXxx/结构体默认值的实义默认,即文件缺键时的行为)。
2.11.1 [server](modules/setting/server.go)
| 键 | 默认值 | 作用 | 源码位置 |
|---|---|---|---|
HTTP_ADDR |
0.0.0.0 |
监听地址(相对路径会拼 AppWorkPath) | loadServerFrom |
HTTP_PORT |
3000 |
监听端口 | 同上 |
PROTOCOL |
http |
http/https/fcgi/http+unix/fcgi+unix(unix 为 deprecated 别名),非法 Fatal |
同上 |
ROOT_URL |
按协议推导 | 对外 URL,推导 AppSubURL/AppDomain |
loadServerDomainAndURL |
CERT_FILE / KEY_FILE |
空 | TLS 证书(相对路径拼 CustomPath) | runHTTPS |
ENABLE_ACME |
false |
ACME 自动证书(兼容旧键 ENABLE_LETSENCRYPT) |
loadServerFrom |
ACME_ACCEPTTOS |
false |
必须接受 ACME TOS,否则 Fatal | 同上 |
ENABLE_PPROF |
false |
localhost:6060 pprof/fgprof |
servePprof |
LOCAL_ROOT_URL |
按协议推导 | CLI → 运行进程的内网地址(manager/admin 用) | setPort 回写 |
APP_DATA_PATH |
data |
数据根目录 | loadServerDomainAndURL |
APP_TEMP_PATH |
空 | 临时目录(须已存在,否则 Fatal);AppDataTempDir 据此选 /tmp/gitea-tmp 或 data/tmp |
同上 / path.go |
UNIX_SOCKET_PERMISSION |
666 |
Unix socket 权限(八进制解析) | loadServerFrom |
REDIRECT_OTHER_PORT |
false |
HTTPS 双端口 307 重定向器 | runHTTPRedirector |
GRACEFUL_RESTARTABLE |
true |
允许 SIGHUP fork 优雅重启 | DoGracefulRestart |
GRACEFUL_HAMMER_TIME |
60s |
停机宽限期,超时强关连接 | doShutdown |
STARTUP_TIMEOUT |
0(关闭) |
未 READY 超时自杀 | manager_unix.go |
PER_WRITE_TIMEOUT / PER_WRITE_PER_KB_TIMEOUT |
30s / 10s |
每次写/每 KB 写超时(防慢客户端) | wrappedConn.Write |
PROTOCOL_HEALTH_CHECK 等 4 键 |
false |
proxy protocol 支持 | loadServerFrom |
LANDING_PAGE |
0 |
登录后落地页映射 | 同上 |
2.11.2 [security](modules/setting/security.go)
| 键 | 默认值 | 作用 |
|---|---|---|
INSTALL_LOCK |
false |
安装状态唯一真源 ,HasInstallLock/MustInstalled 读它 |
SECRET_KEY |
无(loadSecret,支持 _URI) |
加密存储根密钥,安装时生成,之后不可覆盖 |
INTERNAL_TOKEN |
无(支持 _URI) |
内部 API 认证,安装时生成 |
LOGIN_REMEMBER_DAYS |
31 |
记住登录天数 |
COOKIE_REMEMBER_NAME |
gitea_inremember |
记住登录 cookie 名 |
I_AM_BEING_UNSAFE_RUNNING_AS_ROOT |
false |
豁免 root 运行检查(另有 env 同名) |
2.11.3 [database](modules/setting/database.go)
| 键 | 默认值 | 作用 |
|---|---|---|
DB_TYPE |
空(缺失时 CLI Fatal 提示) | mysql/postgres/mssql/sqlite3 |
PATH |
{AppDataPath}/gitea.db |
SQLite 文件路径 |
SQLITE_TIMEOUT |
<5000 强制回 20000 |
SQLite 锁超时保护性钳制 |
CONN_MAX_LIFETIME |
MySQL 3s,其余 0 |
连接回收 |
DB_RETRIES / DB_RETRY_BACKOFF |
10 / 3s |
连库重试(InitDBEngine) |
AUTO_MIGRATION |
true |
自动表结构同步 vs 版本化迁移 |
ITERATE_BUFFER_SIZE |
50 |
批量迭代缓冲 |
SLOW_QUERY_THRESHOLD |
5s |
慢查询告警阈值 |
2.11.4 [log] 与 [log.<mode>](modules/setting/log.go)
| 键 | 默认值 | 作用 |
|---|---|---|
[log] MODE |
console |
默认 logger 的 sink 列表(逗号分隔) |
[log] LEVEL |
info |
全局级别(TRACE..FATAL/NONE) |
[log] STACKTRACE_LEVEL |
none |
带堆栈的级别 |
[log] BUFFER_LEN |
10000 |
每个 writer 队列容量,满则丢弃 |
[log] ROOT_PATH |
{AppWorkPath}/log |
日志根目录(相对拼 WorkPath) |
[log] ENABLE_SSH_LOG |
false |
SSH 日志 |
[log] ACCESS_LOG_TEMPLATE |
CLF 模板 | 访问日志格式 |
[log] logger.<name>.MODE |
,(跟随 MODE) |
4 个 logger:default/access/router/xorm;"" 禁用 |
[log.<mode>] MODE |
= 节名 | writer 类型覆盖(console/file/conn) |
[log.<mode>] LEVEL |
继承 [log].LEVEL |
该 sink 级别 |
[log.file] FILE_NAME |
gitea.log(access 为 access.log) |
相对则拼 RootPath |
[log.file] MAX_SIZE_SHIFT |
28(=256MB) |
轮转大小 = 1 << shift |
[log.file] LOG_ROTATE/DAILY_ROTATE/MAX_DAYS/COMPRESS/COMPRESSION_LEVEL |
true/true/7/true/-1 |
轮转策略 |
[log.console] STDERR/COLORIZE |
false/按流推导 |
输出到 stderr、颜色 |
[log.conn] PROTOCOL/ADDR/RECONNECT/RECONNECT_ON_MSG |
tcp/:7020/false/false |
网络日志 sink |
2.11.5 [service](modules/setting/service.go,节选 64 键中的高频项)
| 键 | 默认值 | 作用 |
|---|---|---|
DISABLE_REGISTRATION |
true |
默认关闭注册(安装页可改) |
ACTIVE_CODE_LIVE_MINUTES |
180 |
激活码有效期 |
RESET_PASSWD_CODE_LIVE_MINUTES |
180 |
重置密码码有效期 |
REQUIRE_SIGNIN_VIEW |
false |
全站登录可见(Bool 特殊处理) |
ENABLE_CAPTCHA |
false |
验证码开关,CAPTCHA_TYPE 默认 image |
ENABLE_BASIC_AUTHENTICATION/ENABLE_PASSWORD_SIGNIN_FORM/ENABLE_PASSKEY_AUTHENTICATION |
true/true/true |
三种认证方式开关 |
NO_REPLY_ADDRESS |
noreply.{AppDomain} |
noreply 邮箱域名 |
QoS.ENABLED/MAX_INFLIGHT/MAX_WAITING/TARGET_WAIT_TIME |
false/4×NumCPU/100/250ms |
限流 |
2.11.6 其他高频节
| 节 | 代表键与默认值 | 源文件 |
|---|---|---|
[cache] |
ADAPTER=memory、ITEM_TTL=60s、LAST_COMMIT=8760h、MemcacheMaxTTL=30天 |
cache.go |
[queue] / [queue.<name>] |
TYPE=level、DATADIR=queues/common、LENGTH=100000、BATCH_LENGTH=20、MAX_WORKERS=NumCPU/2(钳 1..10);redis CONN_STR 回退链 [queue.name]→[redis]→redis://127.0.0.1:6379/0;8 个旧键存在即 log.Fatal "Please update your app.ini" |
queue.go |
[repository] |
ROOT=data/gitea-repositories、DISABLE_HTTP_GIT=false、MAX_CREATION_LIMIT=-1、DEFAULT_BRANCH=main |
repository.go |
[indexer] |
ISSUE_INDEXER_TYPE=bleve、ISSUE_PATH=data/indexers/issues.bleve、REPO_INDEXER_ENABLED=false、STARTUP_TIMEOUT=30s |
indexer.go |
[packages] |
ENABLED=true |
packages.go |
[actions] |
ENABLED=true、DEFAULT_ACTIONS_URL=github(校验回落)、三个超时默认 |
actions.go |
[oauth2] |
JWT_SECRET=loadSecret(缺失生成并回写) |
oauth2.go |
[ssh] |
SSH_PORT=22、SSH_LISTEN_PORT、内建 server 开关 |
ssh.go |
[mailer] |
ENABLED=false、旧键 HOST→SMTP_ADDR 兼容、大量 FIXME INI-MUST-SIDE-EFFECT |
mailer.go |
[session]/[cors]/[websocket] |
由 LoadSettings 第 6-8 步加载 |
session.go 等 |
| 根节 | RUN_MODE(env GITEA_RUN_MODE 优先)、RUN_USER、WORK_PATH、APP_NAME |
setting.go/path.go |
[other] |
ENABLED=true 等全 bool,sec.MapTo(&Other) 失败 Fatal |
other.go |
2.12 源码级问答
Q1:为什么 setting.InstallLock 必须在 loadCommonSettingsFrom 第一行加载?
注释原话 "a lot of logic depends on InstallLock value"。具体依赖链:mustCurrentRunUserMatch 只在已安装时检查(未安装时不打断安装);loadSecurityFrom 的 InternalToken 在 InstallLock 时空值有专门处理(security.go:207);serveInstall/serveInstalled 的分叉;MustInstalled 的 Fatal 判定;path.go 的 ClearEnvConfigKeys 时机。它若后置,这些逻辑读到的都是零值 false,已安装实例会被误判成未安装。
Q2:PrepareConsoleLoggerLevel 为什么检测到 InstallLock==true 就报错?
因为 InstallLock 只会由配置加载赋值。进入 Before 时它已为 true,意味着配置先于 console logger 加载------配置加载期的日志(deprecation 警告等)没有 logger 可用。这是用"状态反推时序"做的自检,把隐性丢日志变成显性启动失败(2.1)。
Q3:改了 custom/templates/xxx.tmpl 为什么不生效?
三个可能:(1) 模式是 prod------PageRenderer 只在 !setting.IsProd 时启动 WatchLocalChanges 热重载,prod 下需 gitea manager reload-templates 或重启;(2) 文件路径不对------LayeredFS 按相对路径 逐文件覆盖,必须精确复刻 templates/... 下的相对位置;(3) 放到了 custom/public/ 旧位置------serveInstalled 会对此打 "legacy public asset" 错误,新位置是 custom/public/assets/。
Q4:环境变量 GITEA__server__HTTP_PORT=8080 为什么没生效?
因为原生二进制普通启动路径不调用 EnvironmentToConfig(2.5.1/2.7 的证据链)。它生效的场景:安装流程(两处)、gitea config edit-ini --apply-env(Docker 启动脚本在 gitea 启动前执行写盘)。若用 Docker 镜像则该变量会被 environment-to-ini 写进 app.ini 后再生效。路径类 env(GITEA_WORK_DIR/GITEA_CUSTOM/GITEA_RUN_MODE)例外,运行时直接读。
Q5:为什么停机宽限默认 60 秒,超过就 hammer?
GRACEFUL_HAMMER_TIME=60s 后 hammerCtxCancel,BaseContext 返回的 HammerContext 取消让 handler ctx 失效,closeAllConnections 强关。设计取舍:无限等待会被僵尸连接(如不读数据的下载客户端)拖住,运维发 SIGTERM 后进程永不退出比中断个别长请求更糟;PER_WRITE_TIMEOUT=30s 已保证单次写不会无限慢。想延长可用 GRACEFUL_HAMMER_TIME;想立即强杀用 SIGUSR2。
Q6:numberOfServersToCreate=4 改成动态计数不行吗?
可以但会丢掉一个保障:systemd Type=notify 需要知道"所有 监听点都就绪了"才 READY=1。若动态计数,某个监听点因配置错误没创建,进程会静默少一个服务但对外显示 ready。硬编码常量 + InformCleanup 报到清单,把"漏建监听"变成"启动永不 ready"的显性故障。代价是新增监听点必须同步改常量,故注释用大写 MUST 强调。
Q7:fillInstallConfig 为什么调两次 EnvironmentToConfig?
第一次让依赖配置的逻辑看到环境变量;第二次在表单值写入后再套一层,保证环境变量覆盖表单------与下次启动时"环境变量覆盖文件"的语义一致,避免"安装时表单赢、下次启动 env 赢"的配置漂移(2.3.4 注释原话)。
Q8:4 个 logger 为什么默认都用 , 跟随 [log].MODE,而不是直接写死?
, 是哨兵值:未配置 → 跟随全局;显式 , → 显式跟随;"" → 禁用。三态需要区分"用户没管"与"用户明确要关"。若默认直接等于 Log.Mode 的拷贝,就无法区分前者,prepareLoggerConfig 也没法用 HasKey 判断是否需要迁移旧键(2.9.2)。
Q9:gitea manager shutdown 和直接 kill 有什么区别?
前者 HTTP POST 到 /api/internal/manager/shutdown,服务端触发 DoGracefulShutdown------走完整四阶段状态机(2.4):停止 accept、等待存量请求、hammer 宽限、terminate 收尾、systemd STOPPING=1、Done() 后才退出。SIGTERM 也走同一状态机(handleSignals),但 gitea manager 的价值在于:无需找到 PID、可远程(只要有 LocalURL 可达)、返回结构化错误(handleCliResponseExtra)。
Q10:app.ini、环境变量、命令行参数三者优先级到底如何?
要分"路径类"与"配置键类":
| 项 | 优先级 |
|---|---|
| WorkPath | --work-path/GITEA_WORK_DIR(互相覆盖,args 赢 env)→ 但最终 app.ini WORK_PATH 覆盖一切 (冲突置 AppWorkPathMismatch) |
| CustomConf | --config > builtin 默认(相对路径按 cwd 取绝对) |
| RunMode | GITEA_RUN_MODE > app.ini RUN_MODE > 非 dev 即 prod |
| 普通配置键 | 安装时/config edit-ini --apply-env 把 GITEA__* 写进 app.ini (写入时 env 覆盖表单/原值);运行时只有 app.ini 生效,MustXxx 缺键时用代码默认值 |
即不存在三层同时叠加的动态优先级------env 是"写盘时的覆盖者",运行时读取只看文件。
Q11:为什么 fillInstallConfigCleanUp 要清洗控制字符?
INI 库对 \x00-\x1F 控制字符处理有缺陷,写入后产生的 app.ini 可能无法被重新解析(键值含换行会截断后续行)。源码注释直说是 "quick patch to avoid generating a corrupted ini file",并承认"如果自己写 ini 包就不需要这个补丁"。安装表单来自用户输入,含控制字符是现实可能(复制粘贴),清洗是最后一道防线。
Q12:dump 为什么要 DisableLoggerInit()?
gitea dump 会打包 log/ 目录。若按 LoadSettings → initAllLoggers 初始化文件 logger,dump 进程会往正被打包的 gitea.log 写内容------一边读一边写导致归档损坏或自引用死锁。注释 "cannot access session settings otherwise" 解释了为什么还要跑 LoadSettings(需要 session/存储配置定位文件),禁用 logger 是让这两件事解耦(2.10.1)。
2.13 本章小结
本章沿两条线走完了 Gitea 的"出生"过程:
启动线 :main() → NewMainApp(子命令两分组,Before 自动注入路径+配置加载)→ runWeb → InstallLock 分流 → InitWebInstalled 的 21 步装配 → NormalRoutes 四组挂载 → listen 协议分发 → graceful manager 的 4-server 契约与四阶段停机状态机。安装流程作为分流的"另一支",用"先验证数据库、后落盘配置、再原地重载"的顺序保证了没有半安装状态,最后 3 秒延时关闭安装 server,同一进程无缝切到服务态。
配置线 :路径三级优先(builtin → env → args,WORK_PATH 反向覆盖)→ ConfigProvider 三接口封装 ini 与 INI-MUST-SIDE-EFFECT 的三层防御(DisableSaving、ConfigSectionKey/ConfigInheritedKey 分野、PrepareSaving 干净副本)→ 两段式加载(InstallLock 最先、initAllLoggers 第一)→ GITEA__ 环境变量的真实生效边界(写盘时覆盖,运行时不自动应用)→ custom/ 的 LayeredFS 逐文件覆盖 → 日志三层结构(4 个 logger → console/file/conn 共享 writer 扇出、队列满丢弃、旧键迁移、三态 MODE 哨兵)。
贯穿两线的三个原则值得记住:日志先于一切 (setting.init 就建 stderr logger、Before 先定级别再加载配置)、fail-fast 装配 (mustInit 反射报函数名、协议非法 Fatal)、停机是状态机不是函数调用 (shutdown/hammer/terminate/manager 四阶段,任何 server 都要为 4-server 契约报到或注销)。下一章进入请求的世界:HTTP 中间件链与 modules/web 自研路由的匹配算法。