- Docker:新增只读、生命周期、清理、Compose 共 32 条命令,Linux 自动适配 podman - 危险命令 clearall/prunevolumes 未带 --yes 时仅提示并以退出码 2 结束 - 需要容器/镜像名的命令做必填参数校验,-- 之后参数计入位置参数 - diagnose:自动识别 Docker/Podman 容器或 K8s Pod,输出状态、根因、依据、建议并附日志与事件 - diagnose 中 -n 表示 namespace,其余命令 -n 仍为 --dry-run - 新增内置动作桥接与平台变体 helper,补充单元/集成/CLI 测试与 README
249 lines
7.4 KiB
Go
249 lines
7.4 KiB
Go
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] + "…"
|
||
}
|