feat: 扩充 Docker 命令并新增 diagnose 状态诊断

- Docker:新增只读、生命周期、清理、Compose 共 32 条命令,Linux 自动适配 podman
- 危险命令 clearall/prunevolumes 未带 --yes 时仅提示并以退出码 2 结束
- 需要容器/镜像名的命令做必填参数校验,-- 之后参数计入位置参数
- diagnose:自动识别 Docker/Podman 容器或 K8s Pod,输出状态、根因、依据、建议并附日志与事件
- diagnose 中 -n 表示 namespace,其余命令 -n 仍为 --dry-run
- 新增内置动作桥接与平台变体 helper,补充单元/集成/CLI 测试与 README
This commit is contained in:
2026-10-31 14:09:38 +08:00
parent 84f7361396
commit e02f3010cf
19 changed files with 2465 additions and 69 deletions
+248
View File
@@ -0,0 +1,248 @@
package diagnose
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
type dockerHealth struct {
Status string `json:"Status"`
}
type dockerContainer struct {
ID string `json:"Id"`
Name string `json:"Name"`
RestartCount int `json:"RestartCount"`
Config struct {
Image string `json:"Image"`
} `json:"Config"`
State struct {
Status string `json:"Status"`
Running bool `json:"Running"`
Paused bool `json:"Paused"`
Restarting bool `json:"Restarting"`
OOMKilled bool `json:"OOMKilled"`
Dead bool `json:"Dead"`
ExitCode int `json:"ExitCode"`
Error string `json:"Error"`
StartedAt string `json:"StartedAt"`
FinishedAt string `json:"FinishedAt"`
Health *dockerHealth `json:"Health"`
} `json:"State"`
}
func (s *Service) diagnoseDocker(ctx context.Context, src source, target string, opts Options) error {
res, err := s.run(ctx, src.program, "inspect", "--type", "container", target)
if err != nil {
return err
}
var containers []dockerContainer
if err := json.Unmarshal(res.Stdout, &containers); err != nil {
return fmt.Errorf("解析 %s inspect 输出失败: %w", src.program, err)
}
if len(containers) == 0 {
return fmt.Errorf("%s 中没有名为 %q 的容器", src.program, target)
}
c := containers[0]
name := strings.TrimPrefix(c.Name, "/")
if name == "" {
name = target
}
fmt.Fprintf(s.out, "目标: %s\n", name)
fmt.Fprintf(s.out, "来源: Docker (%s)\n", src.program)
fmt.Fprintf(s.out, "状态: %s ExitCode=%d OOMKilled=%t RestartCount=%d\n",
c.State.Status, c.State.ExitCode, c.State.OOMKilled, c.RestartCount)
if c.Config.Image != "" {
fmt.Fprintf(s.out, "镜像: %s\n", c.Config.Image)
}
if c.State.Health != nil && c.State.Health.Status != "" {
fmt.Fprintf(s.out, "健康: %s\n", c.State.Health.Status)
}
if c.State.StartedAt != "" && !strings.HasPrefix(c.State.StartedAt, "0001-") {
fmt.Fprintf(s.out, "时间: %s", c.State.StartedAt)
if c.State.FinishedAt != "" && !strings.HasPrefix(c.State.FinishedAt, "0001-") && c.State.FinishedAt != c.State.StartedAt {
fmt.Fprintf(s.out, " → %s", c.State.FinishedAt)
}
fmt.Fprintln(s.out)
}
s.renderFinding("", judgeDocker(c))
if !opts.NoLogs {
logRes, err := s.run(ctx, src.program, "logs", "--tail", strconv.Itoa(opts.Lines), "--timestamps", target)
if err != nil {
fmt.Fprintf(s.errOut, "警告: 获取日志失败: %v\n", err)
} else {
s.writeSection(fmt.Sprintf("最近日志 (%d 行)", opts.Lines), string(logRes.Stdout))
}
}
if !opts.NoEvents {
now := s.now()
since := now.Add(-time.Hour).UTC().Format(time.RFC3339)
until := now.UTC().Format(time.RFC3339)
eventRes, err := s.run(ctx, src.program, "events",
"--since", since, "--until", until, "--filter", "container="+name)
if err != nil {
fmt.Fprintf(s.errOut, "警告: 获取事件失败: %v\n", err)
} else {
s.writeSection("最近事件 (1h)", tailLines(string(eventRes.Stdout), 20))
}
}
return nil
}
// judgeDocker maps container state to a human-readable root cause.
func judgeDocker(c dockerContainer) Finding {
st := c.State
evidence := "State.Status=" + st.Status
if st.Status == "exited" || st.ExitCode != 0 {
evidence += fmt.Sprintf(", ExitCode=%d", st.ExitCode)
}
if st.OOMKilled {
evidence += ", OOMKilled=true"
}
if c.RestartCount > 0 {
evidence += fmt.Sprintf(", RestartCount=%d", c.RestartCount)
}
if st.Error != "" {
evidence += ", Error=" + truncate(st.Error, 200)
}
switch {
case st.Restarting || st.Status == "restarting":
return Finding{
Severity: "error",
Verdict: "容器处于重启循环,正在反复启动",
Evidence: evidence,
Advice: "查看最近日志与上一次退出码,确认应用是否启动即崩溃或依赖不可用",
}
case st.Status == "exited":
switch {
case st.OOMKilled:
return Finding{
Severity: "error",
Verdict: "容器因内存超限被 OOM kill",
Evidence: evidence,
Advice: "提高容器的内存限制,或降低应用峰值内存;可用 kopsc docker stats 观察",
}
case st.ExitCode == 137:
return Finding{
Severity: "error",
Verdict: "容器收到 SIGKILL(137),可能被手动 kill 或 stop 超时后强杀",
Evidence: evidence,
Advice: "若并非人为操作,检查宿主机 OOM 日志与 stop 超时设置",
}
case st.ExitCode == 143:
return Finding{
Severity: "warn",
Verdict: "容器收到 SIGTERM(143)后退出,通常是正常停止",
Evidence: evidence,
Advice: "若预期继续运行,检查是谁发送了停止信号或重启策略",
}
case st.ExitCode == 126:
return Finding{
Severity: "error",
Verdict: "容器内命令不可执行(126):权限不足或可执行格式错误",
Evidence: evidence,
Advice: "检查 entrypoint/cmd 是否可执行、是否缺少可执行权限",
}
case st.ExitCode == 127:
return Finding{
Severity: "error",
Verdict: "容器内命令不存在(127)",
Evidence: evidence,
Advice: "检查 entrypoint/cmd 与镜像内实际的二进制路径",
}
case st.ExitCode == 0:
if c.RestartCount > 0 {
return Finding{
Severity: "warn",
Verdict: "容器正常退出,但已多次重启,重启策略可能导致循环",
Evidence: evidence,
Advice: "若是任务型容器,确认重启策略应为 no/on-failure 而不是 always",
}
}
return Finding{
Severity: "ok",
Verdict: "容器已正常退出(ExitCode=0)",
Evidence: evidence,
}
default:
return Finding{
Severity: "error",
Verdict: "容器以非零状态退出,应用自身报错",
Evidence: evidence,
Advice: "查看下方日志的末尾,定位应用错误;确认命令与配置参数",
}
}
case st.Status == "running":
if st.Paused {
return Finding{
Severity: "warn",
Verdict: "容器处于暂停(paused)状态",
Evidence: evidence,
Advice: "确认是否有人执行了 docker pause;唤醒使用 docker unpause",
}
}
if st.Health != nil && strings.EqualFold(st.Health.Status, "unhealthy") {
return Finding{
Severity: "error",
Verdict: "容器健康检查失败(unhealthy)",
Evidence: evidence + ", State.Health.Status=unhealthy",
Advice: "查看日志与 healthcheck 命令;确认依赖服务可用、检查间隔与超时合理",
}
}
return Finding{
Severity: "ok",
Verdict: "容器正在运行",
Evidence: evidence,
}
case st.Status == "created":
return Finding{
Severity: "warn",
Verdict: "容器已创建但尚未启动",
Evidence: evidence,
Advice: "使用 kopsc docker start <容器> 启动;若启动失败查看 docker events",
}
case st.Status == "paused":
return Finding{
Severity: "warn",
Verdict: "容器已暂停",
Evidence: evidence,
}
case st.Status == "dead":
return Finding{
Severity: "error",
Verdict: "容器处于 dead 状态,运行时未能正常清理",
Evidence: evidence,
Advice: "通常需要删除后重新创建容器;检查存储驱动与宿主资源",
}
}
return Finding{
Severity: "warn",
Verdict: "容器状态未识别:" + st.Status,
Evidence: evidence,
}
}
func truncate(s string, max int) string {
s = strings.ReplaceAll(strings.TrimSpace(s), "\n", " ")
if len(s) <= max {
return s
}
return s[:max] + "…"
}