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 命令。
外部命令是进程边界,失败模式比普通函数更多。把超时、输出、安全和测试替身设计好,调用外部工具才会可靠。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。