Go 里要调起一个外部程序,os/exec 包是公认的唯一正解,也是标准库给咱们铺好的路。别自己费劲去折腾 os.StartProcess,那玩意儿太底层了,绕开了 Cmd 提供的一整套封装,环境变量继承、I/O 重定向、信号管理这些坑,一不小心就踩一遍,得不偿失。
参数必须一个个拆开传,别图省事拼字符串
新手最容易犯的错误,就是图方便把整个命令行当字符串往里塞,像 exec.Command("ls -l /tmp") 这么写。这其实是在告诉 Go:“嘿,去给我找个名叫 ‘ls -l /tmp’ 的可执行文件”,结果大概率是给你甩回一句 "executable file not found"。
exec.Command的调用规则很明确:第一个参数是程序路径(比如"ls"或者"/bin/ls"),后面的每一个都是独立的参数字符串。所以,正确姿势是exec.Command("ls", "-l", "/tmp")。- 遇到路径里带空格的,比如文件名叫
"my file.txt",直接把它当成一个参数传进去就行,不用手动加引号或者转义。因为 Go 绕过了 shell 解析,它不会被错误地拆成两个词。 - 如果真的要用到 shell 的一些花活,比如管道(
|)、通配符(*)、变量展开($HOME),那就得显式地召唤 shell 了:exec.Command("sh", "-c", "ls *.go | head -1")。这是个方便的捷径,但前提是,你传给-c的字符串里如果有用户输入,一定得做严格的过滤和校验,不然就是给系统注入攻击留了个后门。
命令找不到?LookPath 比硬编码路径靠谱,但也不是万能的
你本机 exec.Command("curl") 跑得好好的,代码一部署到 Alpine 镜像的容器里就翻车。这不一定是 PATH 环境变量没设对,实际情况很可能是容器里压根儿就没装 curl 这个包。
- 所以,推荐用
exec.LookPath("curl")来先摸个底。它会按照当前进程的PATH环境变量去搜索,行为和你在 shell 里输入命令是完全一致的。避免在代码里写死"/usr/bin/curl"这种硬编码路径,不同 Linux 发行版、macOS 或者 Docker 镜像,文件位置可能都不一样。 - 但注意了,
LookPath只检查文件存不存在、有没有执行权限。它不验证这个二进制文件运行时需要哪些动态库。比如ffmpeg,它可能依赖libx264,如果这个库缺失,LookPath会告诉你 “没问题”,但等你真正去Run()的时候还是会报错。 - 在生产环境里,想图个安心,就得做完整的探针检查:调用一次
LookPath,再试着调一次cmd.Run(),并且捕获*exec.ExitError错误,只有这样才能确认这个二进制文件真的能跑起来。
Output() 和 CombinedOutput() 都会卡住,而且 stdout 和 stderr 混在一起
想拿命令的输出结果,最直接的办法是调 Output()。但别指望它能帮你区分哪行是标准输出、哪行是错误日志——它把 stdout 和 stderr 一股脑儿都扔到一个 buffer 里返回给你。如果命令出错了,你还得手动从返回的 error 类型里去解析那个包含 stderr 内容的字符串,这感觉有点原始。
Output()内部其实就是帮你调了Run(),并且偷偷把Stdout和Stderr都指向了同一个 buffer。对于date、git rev-parse这类输出很短的小命令够用了,但不适合那种会吐一大堆日志的长命令,不然很容易就把你的内存吃光。- 如果你确实需要把标准输出和标准错误分开来捕获,就得自己动手:比如
cmd.Stdout = &stdoutBuf、cmd.Stderr = &stderrBuf,然后再调cmd.Run()去控制整个过程。 - 如果你需要“实时”读取输出,比如在 tail 一个日志文件,那就得用
cmd.StdoutPipe()拿到一个管道,然后开一个 goroutine 去不停地读这个流。记得在Start()之后、Wait()之前立刻把那个 goroutine 开起来,否则管道会因为没人读而堵住,你的Wait()也就永远等不回来了。
超时、取消、僵尸进程——这才是 Start()+Wait() 组合拳的真正意义
Run() 用起来确实简单,一行代码搞定启动和等待。但坏消息是,它把这俩过程绑死了。万一你调起的那个命令自己卡住不响应了,整个 goroutine 也就跟着卡死了。你既没法 kill 它,没法设置超时,也完全回收不了任何资源。
- 真正可控的操作流程应该是:先
cmd.Start()启动进程,然后用context.WithTimeout来包裹cmd.Wait(),一旦超时,手动调用cmd.Process.Kill()强制结束。 - 如果只
Start()但忘了调Wait()(特别是在循环里反复Start()新进程的时候),那些跑完的子进程就会变成僵尸进程,占用着进程表。时间长了,你的系统 PID 资源会被耗尽,再也启动不了新程序。 - 如果你需要向子进程发送特定信号(比如优雅退出的
SIGTERM),直接用cmd.Process.Signal()就完事了。千万别图省事儿又包装一层sh -c,那样你发的信号只会发给 shell 进程,shell 下面的真正进程根本收不到,你就没法精确控制了。
最后,有个细节最容易被忽略:子进程默认并不会继承父进程的 PATH 环境变量和工作目录。在容器环境或者 systemd 服务启动的场景下,这点尤其坑爹。因此,每次创建一个 Cmd 对象之前,最好都显式地设置 cmd.Dir 和 cmd.Env。设置 Env 一个稳妥的做法是先调 os.Environ() 拿到父进程的所有环境变量,然后再往里面追加你需要的。千万别想当然地以为它“应该”跟你的 shell 环境是一样的。