ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Go os/exec 执行外部命令实战:超时杀进程、捕获输出与命令注入防护

Go os/exec 执行外部命令实战:超时杀进程、捕获输出与命令注入防护 Go os/exec 执行外部命令实战:超时杀进程、捕获输出与命令注入防护用 Go 调用外部命令(ffmpeg、git、ping……)看着简单,exec.Command一把梭。但线上总出幺蛾子:命令卡死拖垮整个服务、报错了却拿不到 stderr 只看到一句exit status 1、更严重的是把用户输入拼进命令里被人注入。这篇把os/exec最容易踩的四个坑一次讲清:超时控制、输出捕获、错误诊断和注入防护。起手式:别用 Run() 丢掉输出最朴素的写法丢了太多信息:cmd:exec.Command(git,status)err:cmd.Run()// 只知道成没成功,输出全丢了Run()不返回任何输出,命令打印的内容直接进了黑洞。想拿到标准输出,最简单是Output():out,err:exec.Command(git,rev-parse,HEAD).Output()iferr!nil{log.Fatal(err)}fmt.Printf(当前 commit: %s,out)// out 是 []byte,是命令的 stdout但Output()有个隐藏坑:它只捕获 stdout,命令失败时的错误信息通常打在 stderr,你还是看不到。下面专门说这个。坑一:报错只看到 exit status 1,拿不到真正原因命令失败时,Output()返回的 err 往往只是exit status 1,真正的错误描述在 stderr 里被丢了。诊断起来抓瞎。两种正确做法。做法 A:用CombinedOutput把 stdoutstderr 一起收:out,err:exec.Command(git,clone,bad-url).CombinedOutput()iferr!nil{// out 里现在包含了 git 打到 stderr 的真实错误log.Printf(命令失败: %v\n输出: %s,err,out)}做法 B:分开捕获,并从ExitError里取 stderr:cmd:exec.Command(git,clone,bad-url)varstdout,stderr bytes.Buffer cmd.Stdoutstdout// 把 stdout 引到自己的 buffercmd.Stderrstderr// stderr 单独引出来err:cmd.Run()iferr!nil{varee*exec.ExitErroriferrors.As(err,ee){// 命令跑起来了但退出码非 0,真实原因在 stderrlog.Printf(退出码%d, 错误%s,ee.ExitCode(),stderr.String())}else{// 命令根本没启动(比如可执行文件不存在)log.Printf(启动失败: %v,err)}}注意这里区分了两类错误:exec.ExitError表示命令启动成功但退出码非 0;其他 err(如exec.ErrNotFound)表示命令压根没跑起来。用errors.As区分它俩,排查方向完全不同。坑二:命令卡死,拖垮整个服务这是最致命的问题。Run()/Output()会一直阻塞等命令结束,如果外部命令卡住(网络挂了、进程 hang),你的 goroutine 就永远堵在那。生产环境必须给外部命令设超时。正确姿势是用exec.CommandContext配合context.WithTimeout:ctx,cancel:context.WithTimeout(context.Background(),5*time.Second)defercancel()// CommandContext:ctx 超时/取消时,会自动 kill 掉这个进程cmd:exec.CommandContext(ctx,ping,-c,100,example.com)out,err:cmd.CombinedOutput()ifctx.Err()context.DeadlineExceeded{log.Println(命令超时,已被强制终止)}elseiferr!nil{log.Printf(命令出错: %v\n%s,err,out)}else{fmt.Printf(%s,out)}CommandContext的关键价值:context 超时时,Go 会自动向子进程发送 kill 信号,把它干掉。别用exec.Command然后自己开 goroutine 数秒——那样又要处理进程句柄、又容易泄漏,CommandContext已经帮你封装好了。一个进阶点:默认 kill 用的是SIGKILL(直接砍),某些程序需要先收SIGTERM优雅退出。Go 1.20 可以设cmd.Cancel和cmd.WaitDelay来先发 SIGTERM、给一个宽限期再 SIGKILL。坑三:把用户输入拼进命令 命令注入这是安全红线。Go 的exec.Command默认不经过 shell,这其实是它比字符串拼接 sh -c安全的地方。看两种写法:// 危险:走 shell,用户输入能注入userInput:example.com; rm -rf /// 恶意输入exec.Command(sh,-c,ping userInput)// sh 会把 ; 后面当新命令执行!// 安全:参数逐个传,不经过 shell,userInput 只会被当成一个普通参数exec.Command(ping,-c,4,userInput)// ; rm -rf / 只是个畸形域名,不会被执行exec.Command(name, arg...)把每个参数原样交给程序,不会有 shell 去解析;、|、$()这些元字符。所以铁律是:永远不要用sh -c去拼用户输入;把参数拆成独立的 arg 传给 Command。如果确实需要 shell 特性(管道、通配符),那就必须对用户输入做严格白名单校验,而不是转义——转义几乎总有漏网的边界情况。坑四:处理大量输出别用 Output() 一次性读进内存Output()/CombinedOutput()会把全部输出读进内存。如果命令产出几个 GB(比如导出大文件、长时间日志),内存直接爆。这时用StdoutPipe流式读取:cmd:exec.CommandContext(ctx,some-tool,--export)stdout,_:cmd.StdoutPipe()// 拿到一个 io.ReadCloseriferr:cmd.Start();err!nil{// Start 立即返回,不阻塞log.Fatal(err)}// 边产出边处理,内存里只留一行scanner:bufio.NewScanner(stdout)forscanner.Scan(){process(scanner.Text())// 逐行处理,不囤积}iferr:cmd.Wait();err!nil{// 必须 Wait 回收子进程,否则僵尸进程log.Printf(命令结束异常: %v,err)}流式读取的固定套路:StdoutPipe→Start(不阻塞)→ 边读边处理 →Wait(回收资源)。切记Start之后一定要配对Wait,否则子进程变僵尸、管道也不释放。小结别用Run()丢输出;要 stdout 用Output(),要连 stderr 一起看用CombinedOutput()。报错只看到exit status 1时,用errors.As取*exec.ExitError,真实原因在 stderr。生产环境外部命令必须设超时:用exec.CommandContextcontext.WithTimeout,超时自动杀进程。安全红线:绝不用sh -c拼用户输入;exec.Command逐参数传递、不过 shell,天然防注入。输出可能很大时用StdoutPipe流式读,套路是Start → 边读 → Wait,别一次性读进内存。一句话记忆:给命令设超时、把 stderr 收好、参数别过 shell——这三条守住,os/exec 基本不出事。
返回列表