Go os/exec 入门:调用外部命令时如何处理超时和输出

用图片转换命令示例讲 os/exec 的基本用法,包括 CommandContext、stdout/stderr、参数传递和安全边界。

Go 程序有时需要调用外部命令:图片转换、压缩文件、调用已有脚本、执行系统工具。标准库的 os/exec 可以做到,但边界要清楚。最重要的是:不要把用户输入拼成 shell 命令;要设置超时;要处理 stdout 和 stderr。

本文用图片转换做例子,讲 exec.CommandContext 的基本用法。

最小命令

func ConvertImage(ctx context.Context, input string, output string) error {
	cmd := exec.CommandContext(ctx, "convert", input, "-resize", "800x800", output)
	out, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("convert image: %w: %s", err, string(out))
	}
	return nil
}

CommandContext 会在 context 取消时杀掉进程。CombinedOutput 会收集 stdout 和 stderr,适合输出不大的命令。图片转换错误时,stderr 往往包含有用信息。

不要通过 shell 拼接

危险写法:

cmd := exec.Command("sh", "-c", "convert "+input+" "+output)

如果 input 来自用户,可能注入额外命令。正确做法是把每个参数作为独立字符串传给 exec.Command

exec.CommandContext(ctx, "convert", input, output)

这样参数不会被 shell 再解析。除非你确实需要 shell 特性,否则不要用 sh -c

设置超时

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

err := ConvertImage(ctx, "in.png", "out.png")

外部命令可能卡住。没有超时,后台 worker 就可能一直占着资源。超时时间要结合任务大小和业务要求设置,不能所有命令都无限等。

分开 stdout 和 stderr

输出较大时,可以用 buffer:

func RunTool(ctx context.Context, name string, args ...string) error {
	cmd := exec.CommandContext(ctx, name, args...)
	var stdout bytes.Buffer
	var stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr

	if err := cmd.Run(); err != nil {
		return fmt.Errorf("%s failed: %w: %s", name, err, stderr.String())
	}
	log.Printf("tool output: %s", stdout.String())
	return nil
}

不要把无限输出都放进内存。长时间运行且输出很多的命令,可以把输出接到日志 writer 或临时文件。

工作目录和环境变量

cmd := exec.CommandContext(ctx, "go", "test", "./...")
cmd.Dir = "/path/to/project"
cmd.Env = append(os.Environ(), "GOFLAGS=-count=1")

Dir 控制命令在哪个目录运行,Env 控制环境变量。默认继承当前进程环境。为了可重复,关键环境最好显式设置。不要假设服务进程的工作目录和你终端一样。

检查命令是否存在

启动时可以检查:

func CheckConvertAvailable() error {
	_, err := exec.LookPath("convert")
	return err
}

如果某个外部工具是核心依赖,服务启动时就应该验证,而不是等用户上传图片时才发现命令不存在。

测试调用逻辑

不要在单元测试里真的依赖系统安装了某个工具。可以把执行器抽象成接口:

type Runner interface {
	Run(ctx context.Context, name string, args ...string) error
}

生产实现调用 os/exec,测试实现记录参数:

type fakeRunner struct {
	name string
	args []string
}

func (r *fakeRunner) Run(ctx context.Context, name string, args ...string) error {
	r.name = name
	r.args = append([]string(nil), args...)
	return nil
}

这样业务逻辑可以测试“是否调用了正确命令和参数”,集成测试再验证真实工具。

避免命令输出撑爆内存

CombinedOutput 很方便,但它会把所有输出读进内存。如果外部命令可能输出很多日志,就应该把输出流式处理:

cmd := exec.CommandContext(ctx, "long-tool")
stdout, err := cmd.StdoutPipe()
if err != nil {
	return err
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
	return err
}
go func() {
	scanner := bufio.NewScanner(stdout)
	for scanner.Scan() {
		log.Printf("tool: %s", scanner.Text())
	}
}()
return cmd.Wait()

Scanner 默认单行有大小限制,长行要调整 buffer。外部命令边界总是比普通函数复杂,输出量、退出码、超时都要考虑。

退出码和错误信息

命令返回非零时,Go 会返回 *exec.ExitError。可以识别它:

var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
	log.Printf("exit code=%d", exitErr.ExitCode())
}

这能帮助区分“命令不存在”“被 context 杀掉”“命令自己返回失败”。错误分类清楚,重试和告警策略才好写。

路径和权限

服务进程的 PATH 可能和你的终端不同。生产中可以使用绝对路径,或启动时用 LookPath 检查并记录实际路径。还要确认运行用户有权限执行命令和读写相关目录。很多部署问题不是 Go 代码错,而是进程用户和环境不同。

临时文件和清理

外部命令经常需要输入输出文件。建议给每次任务创建临时目录:

dir, err := os.MkdirTemp("", "convert-*")
if err != nil {
	return err
}
defer os.RemoveAll(dir)

input := filepath.Join(dir, "input.png")
output := filepath.Join(dir, "output.webp")

这样命令产生的中间文件不会散落在系统目录里,失败路径也容易清理。不要让用户文件名直接进入命令参数里的输出路径,服务端应该生成自己的路径。

区分业务错误和系统错误

如果命令处理用户上传的坏文件失败,可能是用户输入问题;如果命令不存在或超时,可能是系统问题。错误映射要区分:

if errors.Is(ctx.Err(), context.DeadlineExceeded) {
	return ErrConvertTimeout
}

HTTP 层可以把用户文件格式错误返回 400,把系统执行失败返回 500。外部命令只是实现细节,用户不需要看到底层 stderr 的全部内容。

小结

os/exec 能让 Go 调用外部命令。使用时优先 CommandContext,把参数作为独立字符串传入,设置超时,处理 stdout/stderr,并明确工作目录和环境变量。不要把用户输入拼进 shell 命令。

外部命令是进程边界,失败模式比普通函数更多。把超时、输出、安全和测试替身设计好,调用外部工具才会可靠。

继续阅读

探索更多技术文章

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

全部文章 返回首页