Go 工作目录入门:为什么本地能读到文件,部署后却找不到

讲 Go 程序中的当前工作目录、相对路径、os.Getwd、配置路径、embed 和测试临时目录,帮助初学者避免路径问题。

“本地能跑,部署后找不到文件”是 Go 初学者常见问题。代码里写了 templates/index.html,本地从项目根目录运行没事;部署成 systemd 服务后,工作目录变了,程序就报 open templates/index.html: no such file or directory。问题不在 Go 编译,而在当前工作目录。

本文讲工作目录、相对路径和几种更稳的处理方式。

当前工作目录是什么

wd, err := os.Getwd()
if err != nil {
	log.Fatal(err)
}
log.Println("working directory:", wd)

相对路径都是相对当前工作目录,不是相对源码文件,也不是相对二进制文件。你在项目根目录运行:

go run ./cmd/app

工作目录通常是项目根。部署后从 //var/lib/app 启动,结果就不同。

不要假设从项目根启动

脆弱写法:

tmpl := template.Must(template.ParseFiles("templates/index.html"))

更稳的是通过配置传入模板目录:

type Config struct {
	TemplateDir string
}

func LoadTemplates(cfg Config) (*template.Template, error) {
	return template.ParseGlob(filepath.Join(cfg.TemplateDir, "*.html"))
}

部署时明确配置:

TEMPLATE_DIR=/opt/myapp/templates

这样工作目录变化不会影响模板路径。

使用 embed

如果模板随程序版本发布,可以嵌入:

//go:embed templates/*.html
var templateFS embed.FS

func LoadEmbeddedTemplates() (*template.Template, error) {
	return template.ParseFS(templateFS, "templates/*.html")
}

embed 的路径相对包含 //go:embed 的源码文件所在目录,在编译时处理。运行时不依赖工作目录。小型 Web 工具和邮件模板很适合这种方式。

不适合 embed 的是运行时需要修改的文件,比如用户上传、外部配置、热更新模板。

二进制所在目录

有时你想以二进制位置为基准:

exe, err := os.Executable()
if err != nil {
	return err
}
base := filepath.Dir(exe)
path := filepath.Join(base, "config.json")

这比工作目录稳定,但也不是万能。容器、符号链接、打包方式都可能影响路径。配置文件通常还是显式路径更清楚。

测试里控制工作目录

Go 测试的工作目录通常是当前包目录。不要让测试依赖外部启动位置。可以用 t.TempDir() 创建文件:

func TestLoadTemplates(t *testing.T) {
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(`hello`), 0644); err != nil {
		t.Fatal(err)
	}
	_, err := template.ParseGlob(filepath.Join(dir, "*.html"))
	if err != nil {
		t.Fatal(err)
	}
}

如果确实要测试工作目录行为,可以用 t.Chdir(dir)。它会在测试结束后恢复目录,比手动 os.Chdir 安全。

路径日志要有帮助

文件打不开时,错误里要包含实际路径:

func readConfig(path string) ([]byte, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read config %s: %w", path, err)
	}
	return data, nil
}

线上看到 no such file 但不知道读的是哪个路径,会很难排查。错误上下文要具体。

systemd 和容器里的工作目录

systemd 服务可以配置 WorkingDirectory,容器镜像也可以配置 WORKDIR。但应用不应该完全依赖这些外部约定。更稳的做法是:启动日志打印关键路径,配置里使用绝对路径,缺失时直接启动失败。

log.Printf("template_dir=%s config_path=%s", cfg.TemplateDir, cfg.ConfigPath)

这类日志不需要打印密钥,但路径信息很有帮助。部署时看到服务读的是 /templates 还是 /opt/app/templates,问题会快很多。

统一路径解析

不要在代码各处各自解析路径。可以在配置加载后统一转成绝对路径:

func absPath(path string) (string, error) {
	if filepath.IsAbs(path) {
		return path, nil
	}
	return filepath.Abs(path)
}

然后组件只接收已经规范化的路径。这样错误信息、日志和测试都会更一致。路径规则集中在一个地方,比散在每个 handler 或 service 里更好维护。

相对路径适合测试,不适合生产约定

测试里相对路径很方便,因为包目录可控;生产里相对路径依赖启动方式。你可以允许本地开发使用相对路径,但在生产配置检查里要求绝对路径。这个规则简单直接,也能避免很多部署差异。

不要用源码路径做运行时依赖

有些人会用 runtime.Caller 找当前源码文件,再推导项目根目录:

_, file, _, _ := runtime.Caller(0)
root := filepath.Dir(filepath.Dir(file))

这在测试工具里偶尔有用,但不适合作为生产程序读取资源的方式。编译后的二进制不应该依赖源码目录还存在。部署环境里通常只有二进制、配置和静态资源,没有完整源码树。

生产程序要么通过配置拿路径,要么把资源 embed 进二进制。源码路径属于开发期信息,不是运行时契约。

把路径问题写进启动检查

启动时可以检查关键目录:

func checkDir(path string) error {
	info, err := os.Stat(path)
	if err != nil {
		return err
	}
	if !info.IsDir() {
		return fmt.Errorf("%s is not a directory", path)
	}
	return nil
}

服务启动失败比运行到第一个请求才失败更容易排查。路径依赖越关键,越应该在启动阶段验证。

CLI 工具要尊重用户所在目录

如果你写的是命令行工具,当前工作目录反而是用户语义的一部分。比如格式化当前目录下的配置、扫描相对路径文件,都应该以用户运行命令的位置为基准。这和服务端程序不同。服务端追求稳定路径,CLI 则要尊重调用者上下文。

可以在 CLI 里把用户传入路径转成绝对路径后再处理:

func normalizeInputPath(p string) (string, error) {
	if p == "" {
		p = "."
	}
	return filepath.Abs(p)
}

这样后续日志和错误信息会更清楚。路径策略要跟程序形态匹配:服务用配置固定,CLI 用用户输入表达意图。

小结

Go 相对路径基于当前工作目录,不基于源码文件或二进制文件。本地能读到文件,部署后找不到,通常是工作目录不同导致的。

稳定做法是:配置里传绝对路径,随版本发布的资源用 embed,测试里用 t.TempDirt.Chdir 控制环境。路径问题不要靠约定“必须从项目根启动”解决,越早显式化越稳。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页