也许你会觉得这种情况很熟悉:你的生产环境中的容器在凌晨3点崩溃了。等到你醒来的时候,它已经连续出现了同样的错误两个小时了。你需要通过SSH登录到容器中,提取日志文件,解读那些晦涩难懂的错误信息,在网上搜索相关解决方案,最后才重新启动容器。这样,你早上本来可以用来做其他事情的20分钟就被浪费掉了。而最糟糕的是,下周这种情况还会再次发生。
我受够了这种循环。当时我在一台Linode服务器上运行了5个基于容器的服务:一个Flask API、一个Postgres数据库、一个Nginx反向代理服务器、一个Redis缓存系统,以及一个后台工作进程。每隔一周,其中就会有一个服务出现故障。日志文件杂乱无章,错误信息也不明显,而我却要浪费时间去调试那些本可以在几秒钟内就被自动检测并解决的问题。
因此,我开发了一个更好的解决方案:一个Python脚本,它可以实时监控这些容器,一旦发现错误,就会利用Claude算法分析出问题所在,并在不需要打扰你的情况下自动修复这些问题。我将这个工具称为“Container Doctor”。这并不是什么神奇的东西,它只不过是Docker API、大语言模型以及一些自动化脚本的结合而已。下面我会详细说明我是如何开发这个工具的,在开发过程中遇到了哪些问题,以及如果再做一次的话,我会做出哪些不同的调整。
目录
为什么不直接使用Prometheus呢?
这确实是个好问题。Prometheus、Grafana、DataDog这些工具都非常优秀,但对于我的需求来说,它们反而有些过度了。我在一台每月只需20美元的Linode服务器上运行了5个容器,而要使用Prometheus的话,就需要部署一个指标收集服务器,为每个服务配置相应的数据导出机制,搭建Grafana监控界面,还要编写警报规则。仅仅为了监控这5个容器,就要耗费这么多时间和资源,实在有些得不偿失。
即便如此,这些工具也只能告诉你“发生了什么”。它们会显示内存使用量的突然增加或500错误率之类的数据,但却无法说明“为什么会发生这种情况”。你仍然需要人类来查看日志,找出根本原因,然后决定该采取什么措施。
这就是我想填补的空白。我并不需要另一个控制面板,而是需要一种能够解读堆栈跟踪信息、理解具体情境,并且要么自动解决问题,要么明确告诉我该做什么的工具。事实证明,Claude在这方面表现得非常出色。它能够快速解读Python堆栈跟踪信息,从而找出问题所在——其效率甚至超过了大多数初级开发人员(说实话,有些资深开发者也不及它的水平)。
架构设计
各部分之间的连接关系如下:
┌─────────────────────────────────────────────┐
│ Docker主机 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Web服务 │ │ API服务 │ │ 数据库 │ │
│ │ (使用nginx) │ │ (使用flask) │ │(使用postgres)│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └──────────────┼──────────────┘ │
│ │ │
│ Docker套接字 │
│ │ │
│ ┌─────────┴─────────┐ │
│ │ Container Doctor │ │
│ │ (Python代理) │ │
│ └─────────┬─────────┘ │
│ │ │
└──────────────────────┼─────────────────────────┘
│
┌────────┴────────┐
│ Claude API │
│ (用于诊断) │
└────────┬────────┘
│
┌────────┴────────┐
│ Slack通知机制 │
│ (用于发送警报) │
└─────────────────┘
整个流程的工作原理如下:
-
Container Doctor会在自己的容器中运行,并连接Docker套接字。
-
每隔10秒,它会从每个目标容器中获取最后50行的日志信息。
-
它会扫描这些日志中的错误线索(如“error”、“exception”等关键词)。
-
一旦发现异常,它就会将相关日志连同结构化的提示信息一起发送给Claude。
-
Claude会返回一份JSON格式的诊断报告,其中包含根本原因、问题严重程度、建议的解决方法以及是否可以自动重启容器。
-
如果问题严重程度较高且自动重启是安全的,脚本就会重新启动相应的容器。
-
无论结果如何,系统都会通过Slack发送包含完整诊断信息的通知。
另外,还有一个简单的健康检查接口,可以用来查看Container Doctor本身的运行状态。
关键在于:这个脚本本身并不尝试进行复杂的诊断分析,而是将所有工作都交给了Claude来处理。脚本的任务仅仅是收集日志、将其转发给Claude,并执行相应的响应操作而已。
项目设置
创建您的项目目录:
mkdir container-doctor && cd container-doctor
这是您的requirements.txt文件内容:
docker==7.0.0
anthropic>>=0.28.0
python-dotenv==1.0.0
flask==3.0.0
requests==2.31.0
为测试目的在本地安装所需依赖项:pip install -r requirements.txt
创建一个.env文件:
ANTHROPIC_API_KEY=sk-ant-...
TARGET_contAINERS=web,api,db
CHECK_INTERVAL=10
LOG_lines=50
AUTO_fix=true
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
POSTGRES_USER=user
POSTGRES_PASSWORD=changeme
POSTGRES_DB=mydb
MAX_DIAGNOSES_PER_HOUR=20
关于CHECK_INTERVAL的说明:10秒的间隔时间已经相当快了。在生产环境中,我会将其调整为30到60秒。在开发阶段,我保持这个较短的间隔时间以便能更快地看到测试结果,说实话,后来我都忘了调整它……直到我的API费用账单提醒了我才想起来。
监控脚本——逐行解析
以下是完整的container_doctor.py文件。之后我会逐一解释其中的重要部分:
import docker
import json
import time
import logging
import os
import requests
from datetime import datetime, timedelta
from collections import defaultdict
from threading import Thread
from flask import Flask, jsonify
from anthropic import Anthropic
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
client = Anthropic()
docker_client = None
# --- 配置参数 ---
TARGET_contAINERS = os.getenv("TARGET_CONTAINERS", "").split(",")
CHECK_INTERVAL = int(os.getenv("CHECK INTERVAL", "10"))
LOG_lines = int(os.getenv("LOG_LINES", "50"))
AUTO_fix = os.getenv("AUTO_FIX", "true").lower() == "true"
SLACK_WEBHOOK = os.getenv("SLACK/WebHOOK_URL", "")
MAX_DIAGNOSES = int(os.getenv("MAX_DIAGNOSES_PER_HOUR", "20"))
# --- 状态跟踪 ---
diagnosis_history = []
fix_history = defaultdict(list)
last_error_seen = {}
rate_limit_counter = defaultdict(int)
rate_limit_reset = datetime.now() + timedelta(hours=1)
app = Flask(__name__)
def get_docker_client():
"""延迟初始化Docker客户端。"""
global docker_client
if docker_client is None:
docker_client = docker.from_env()
return docker_client
def get_container_logs(container_name):
"""获取容器的最后N行日志。"""
try:
container = get_docker_client().containers.get(container_name)
logs = container.logs(
tail=LOG_lines,
timestamps=True
).decode("utf-8")
return logs
except docker.errors.NotFound:
logger.warning(f"未找到容器{container_name}。跳过此操作。")
return None
except docker.errors.APIError as e:
logger.error(f"获取容器{container_name}的日志时出现Docker API错误:{e}")
return None
except Exception as e:
logger.error(f"在获取容器{container_name}的日志时发生意外错误:{e}")
return None
def detect_errors(logs):
"""检查日志中是否包含错误信息。"""
error_patterns = [
"error", "exception", "traceback", "failed", "crash",
"fatal", "panic", "segmentation fault", "out of memory",
"killed", "oomkiller", "connection refused", "timeout",
"permission denied", "no such file", "errno"
]
logs_lower = logs.lower()
found = []
for pattern in error_patterns:
if pattern in logs_lower:
found.append(pattern)
return found
def is_new_error(container_name, logs):
"""判断这是否是一个新的错误,还是我们之前已经诊断过的错误。"""
log_hash = hash(logs[-200:]) # 计算最后200个字符的哈希值
if last_error_seen.get(container_name) == log_hash:
return False
last_error_seen[container_name] = log_hash
return True
def check_rate_limit():
"""确保不会因为发送过多请求而干扰Claude的工作。"""
global rate_limit_counter, rate_limit_reset
now = datetime.now()
if now > rate_limit_reset:
rate_limit_counter.clear()
rate_limit_reset = now + timedelta(hours=1)
total = sum(rate_limit_counter.values())
if total >= MAX_DIAGNOSES:
logger.warning(f"已达到每小时{total}/{MAX_DIAGNOSES}次的请求限制。跳过此次诊断操作。")
return False
return True
def diagnose_with_claude(container_name, logs, error_patterns):
"""将日志发送给Claude进行诊断。"""
if not check_rate_limit():
return None
rate_limit_counter[container_name] += 1
prompt = f"""您是一名DevOps专家,正在分析容器的日志。
容器:{container_name}
时间戳:{datetime.now().isoformat()}
检测到的错误模式:{', '.join(error_patterns)}
最近的日志:
---
{logs}
---
请分析这些日志,并仅回复有效的JSON格式内容(不要使用markdown或解释性文字):
{{
"root_cause": "用一句话准确说明出了问题所在",
"severity": "低|中|高",
"suggested_fix": "操作员应按照以下步骤进行修复",
"auto_restart_safe": true或false,
"config_suggestions": ["ENV_VAR=value", "..."],
"likely_recurring": true或false,
"estimated_impact": "如果不进行修复,将会产生什么影响"
}}
"""
try:
message = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=600,
messages=[
{"role": "user", "content": prompt}
]
)
return message.content[0].text
except Exception as e:
logger.error(f"Claude API出现错误:{e}")
return None
def parse_diagnosis(diagnosis_text):
"""从Claude的回复中提取JSON数据。"""
if not diagnosis_text:
return None
try:
start = diagnosis_text.find("{")
end = diagnosis_text.rfind("}") + 1
if start >= 0 and end > start:
json_str = diagnosis_text[start:end]
return json.loads(json_str)
except json.JSONDecodeError as e:
logger.error(f"JSON解析失败:{e}")
logger.debug(f"原始回复内容:{diagnosis_text}")
except Exception as e:
logger.error(f"无法解析诊断结果:{e}")
return None
def apply_fix(container_name, diagnosis):
"""在安全的情况下应用自动修复措施。"""
if not AUTO_FIX:
logger.info(f"全局禁用了自动修复功能。跳过容器{container_name}的检测操作。")
return False
if not diagnosis.get("auto_restart_safe"):
logger.info(f"Claude提示:重新启动容器{container_name}存在安全风险。跳过此操作。")
return False
# 确保每小时不会重复重启同一个容器超过3次
recent_fixes = [
t for t in fix_history[container_name]
if t > datetime.now() - timedelta(hours=1)
]
if len(recent_fixes) >= 3:
logger.warning(
f"容器{container_name}在今天已经重新启动了{len(recent_fixes)}次。可能存在更严重的问题。跳过此次检测操作。"
)
send_slack_alert(
container_name, diagnosis,
extra="连续失败:该容器在过去1小时内已被重启3次以上。需要人工干预。"
)
return False
try:
container = get_docker_client().containers.get(container_name)
logger.info(f"正在重新启动容器{container_name}...")
container.restart(timeout=30)
fix_history[container_name].append(datetime.now())
logger.info(f"容器{container_name}已成功重启。")
# 确认容器确实已经重新启动并运行正常
time.sleep(5)
container.reload()
if container.status != "running":
logger.error(f"容器{container_name}重启后仍然无法正常运行。")
return False
return True
except Exception as e:
logger.error(f"重新启动容器{container_name}时发生错误:{e}")
return False
def send_slack_alert(container_name, diagnosis, extra=""):
"""将诊断结果发送到Slack。"""
if not SLACK_WEBHOOK:
return
severity_emoji = {
"low": "🟡",
"medium": "🟠",
"high": "🔴"
}
severity = diagnosis.get("severity", "未知")
emoji = severity_emoji.get(severity, "⚪")
blocks = [
{
"type": "header",
"text": {
"type": "plain_text",
"text": f"{emoji} 容器诊断警报:{container_name}"
}
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": f"*严重程度:* {severity}"},
{"type": "mrkdwn", "text": f"*容器名称:* `{container_name}`"},
{"type": "mrkdwn", "text": f"*根本原因:* {diagnosis.get('root_cause', '未知')}"},
{"type": "mrkdwn", "text": f"*修复建议:* {diagnosis.get('suggested_fix', 'N/A')}"
]
}
]
if diagnosis.get("config_suggestions"):
suggestions = "\n".join(
f"• `{s}`" for s in diagnosis["config_suggestions"]
)
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*配置建议:*\n{suggestions}"
}
})
if extra:
blocks.append({
"type": "section",
"text": {"type": "mrkdwn", "text": f"*⚠️ {extra}*"}
})
try:
requests.post(SLACK_WEBHOOK, json={"blocks": blocks}, timeout=10)
except Exception as e:
logger.error(f"Slack通知发送失败:{e}")
# --- 健康检查端点 ---
@app.route("/health")
def health():
"""用于检查容器诊断系统的健康状态。"""
try:
get_docker_client().ping()
docker_ok = True
except:
docker_ok = False
return jsonify({
"status": "正常" if docker_ok else "异常",
"docker_connected": docker_ok,
"monitoring": TARGET_contAINERS,
"total_diagnoses": len(diagnosis_history),
"fixes_applied": {k: len(v) for k, v in fix_history.items()),
"rate_limit_remaining": MAX_DIAGNOSES - sum(rate_limit_counter.values()),
"uptime_check": datetime.now().isoformat()
}
@app.route("/history")
def history():
"""返回最近的诊断记录。"""
return jsonify(diagnosis_history[-50:]))
def monitor_containers():
"""主要的监控循环函数。"""
logger.info(f"容器诊断系统正在启动...")
logger.info(f"当前正在监控的容器数量:{TARGET_CONTAINERS}")
logger.info(f"检查间隔时间为:{CHECK_INTERVAL}秒")
logger.info(f"自动修复功能是否启用:{AUTO_fix}")
logger.info(f"当前的请求限制设置为:{MAX_DIAGNOSES}次/小时")
while True:
for container_name in TARGET_containers:
container_name = container_name.strip()
if not container_name:
continue
logs = get_container_logs(container_name)
if not logs:
continue
error_patterns = detect_errors(logs)
if not errorpatterns:
continue
# 如果已经诊断过这个错误,就跳过本次检查
if not is_new_error(container_name, logs):
continue
logger.warning(
f"在容器{container_name}中检测到了以下错误:{error_patterns}"
)
diagnosis_text = diagnose_with_claude(
container_name, logs, errorpatterns
)
if not diagnosis_text:
continue
diagnosis = parse_diagnosis(diagnosis_text)
if not diagnosis:
logger.error("无法解析Claude的诊断结果。跳过本次检查。")
continue
# 将诊断结果记录下来
diagnosis_history.append({
"container": container_name,
"timestamp": datetime.now().isoformat(),
"diagnosis": diagnosis,
"patterns": error_patterns
})
logger.info(
f"容器{container_name}的诊断结果为:"
f"严重程度:{diagnosis.get('severity')},
"根本原因:{diagnosis.get('root_cause')}"
)
# 仅对严重程度为“高”的错误应用自动修复功能
fixed = False
if diagnosis.get("severity") == "high":
fixed = apply_fix(container_name, diagnosis)
# 必须向Slack发送通知
send_slack_alert(
container_name, diagnosis,
extra="已自动修复" if fixed else ""
)
time.sleep(CHECK_INTERVAL)
if __name__ == "__main__":
# 在后台运行Flask的健康检查端点
flask_thread = Thread(
target=lambda: app.run(host="0.0.0.0", port=8080, debug=False),
daemon=True
)
flask_thread.start()
logger.info("健康检查端点正在8080端口运行..."
try:
monitor_containers()
except KeyboardInterrupt:
logger.info("容器诊断系统正在关闭...")
这段代码确实很长,因此让我来重点讲解其中重要的部分吧。
错误去重功能(is_new_error):这个功能是我通过惨痛的经验才学会的。如果不使用这个机制,脚本会每隔10秒就检测到相同的错误,并向Claude发送重复的请求。我会对日志输出的最后200个字符进行哈希处理,如果这些字符与我们之前记录过的错误信息相匹配,就会跳过这次检测。这个方法很简单,但却让我的API使用成本降低了大约80%。
速率限制功能(check_rate_limit):这是一个必不可少的措施。即使采用了去重机制,我仍然将每小时的诊断请求次数上限设定为20次。如果某个系统出现了严重故障,导致每小时产生20条以上的错误信息,那么无论如何还是需要人工介入进行处理。
重启控制功能(在apply_fix函数内部实现):如果同一个容器在1小时内被重新启动了3次,那就说明存在更严重的问题。重启循环是无法解决配置错误或磁盘丢失这类问题的,因此脚本会停止尝试重启,而是发送更醒目的Slack警报。
重启后的验证步骤:在容器重启后,脚本会等待5秒钟,然后检查该容器是否真的处于运行状态。我曾经遇到过这样的情况:某个容器刚被重启,马上又崩溃了。如果没有这个验证步骤,脚本就会错误地报告“操作成功”,而实际上容器仍然无法正常运行。
Claude诊断提示语的设计原则(以及结构的重要性)
为了让Claude能够返回格式规范的JSON数据,我进行了多次尝试。最初的版本使用的是一种较为随意的提示语,结果得到的响应中包含了很多解释性文字,而JSON数据则被嵌在那些解释内容之中。有时这些JSON数据会用markdown标记来格式化,有时则不会。
prompt = f"""您是一位DevOps专家,正在分析容器日志。
容器名称:{container_name}
时间戳:{datetime.now().isoformat()}
检测到的错误信息:{', '.join(error_patterns)}
最近的日志记录:
---
{logs}
---
请仅使用有效的JSON格式进行回复(不要使用markdown或解释性文字):
{{
"root_cause": "能够准确说明问题根源的简短描述",
"severity": "低|中|高",
"suggested_fix": "操作人员应按照这些步骤进行修复",
"auto_restart_safe": true或false,
"config_suggestions": ["ENV_VAR=value", "..."],
"likely_recurring": true或false,
"estimated_impact": "如果不进行修复,将会产生什么后果"
}}
"""
通过这些尝试,我总结出了以下经验:
必须包含检测到的错误信息。告诉Claude“我检测到了‘timeout’和‘connection refused’这两个错误”,可以帮助它更准确地定位问题。如果不包含这些信息,它有时会忽略日志中那些无关紧要的警告信息。
一定要询问estimated_impact这个字段的内容。在Slack警报中,这个字段的作用最为显著。当团队看到“如果不及时处理,数据库连接数会在15分钟内激增,导致API崩溃”这样的警告时,他们会比看到“连接池已耗尽”这样的信息时反应更快、行动也更迅速。likely_recurring这一指标确实非常有用。如果Claude指出某个问题很可能会再次发生,我就知道仅仅重启系统只是治标不治本,我必须真正找到问题的根本原因。因此,我会在Slack中特别标注这些问题。
Claude会返回如下这样的信息:
{
"root_cause": "连接池已耗尽。默认的连接池大小为5,但该应用程序同时有8个以上的任务在运行。",
"severity": "high",
"suggested_fix": "1. 在环境配置中将POOL_SIZE设置为20。2> 设置30秒的连接超时时间。3. 考虑使用像PgBouncer这样的连接池管理工具。",
"auto_restart_safe": true,
"config_suggestions": ["POOL_SIZE=20", "CONNECTION_TIMEOUT=30"],
"likely_recurring": true,
"estimated_impact": "API请求将会排队,导致超时。用户在2到3分钟内会看到503错误提示。"
}
我只会在问题严重性为“high”时自动重启容器。对于中度或轻度的问题,我会将它们记录下来并发送到Slack,在工作时间再进行处理。这种区分非常重要:你肯定不希望脚本因为每一个短暂的警告就重新启动容器。
自动修复逻辑——有意采取保守措施
自动修复功能是被刻意限制的。目前,它仅仅负责重启容器,并不会修改环境变量、配置文件或调整服务规模。原因如下:
重新启动容器是安全且可逆的操作。如果重启后情况变得更糟,容器只会再次崩溃,而我也会收到另一条警报。但如果脚本开始修改环境变量或docker-compose文件,错误的操作可能会影响到其他服务。
在任何容器被重启之前,系统会进行三项安全检查:
-
全局开关:在.env文件中设置
-
Claude的评估结果:
auto_restart_safe必须设置为true。如果Claude提示“不要重启这个容器,否则数据库会受损”,脚本就会听从这一建议。 -
重启频率限制
: 每个容器每小时最多只能被重启3次。超过这个限度,就需要由人工来处理了。
如果我为一个团队开发这样的系统,我会添加审批流程。例如,可以通过Slack发送一条“是否要重启?”的消息,并提供两个选项供他人选择。这样虽然会增加一些延迟,但可以避免自动化操作带来的混乱。
添加Slack通知功能
无论容器是否被重启,所有的诊断结果都会被发送到Slack。通知中会包含问题的严重程度、根本原因、建议的修复方法以及配置调整建议,并且这些信息会以颜色编码的形式呈现。
Slack的区块格式使得这些警报信息更加易于阅读:红色表示问题严重性高,橙色表示中度问题,黄色表示轻度问题。你的团队可以快速查看这些通知,从而判断是否需要立即采取行动,还是可以稍后再处理。
要设置这一功能,你可以在api.slack.com/apps创建一个Slack应用,添加一个接收Webhook的配置,然后将生成的URL添加到你的。.env文件中即可。
健康检查端点
医生也需要其他医生的帮助。我添加了一个简单的Flask端点,以便能够监控相关的检测脚本:
curl http://localhost:8080/health
返回结果如下:
{
"status": "healthy",
"docker_connected": true,
"monitoring": ["web", "api", "db"],
"total_diagnoses": 14,
"fixes_applied": {"api": 2, "web": 1},
"rate_limit_remaining": 6,
"uptime_check": "2026-03-15T14:30:00"
}
而路径/history可以查看最近50次的检测结果:
curl http://localhost:8080/history
我使用UptimeRobot(免费版本)来监控/health端点的运行状态。如果Container Doctor本身出现故障,我会收到一封电子邮件。这个监控系统能够覆盖所有情况。
限制对Claude服务的调用频率
在开发过程中,我在这方面花费了不少成本。如果不设置调用限制,当容器进入崩溃循环时,该脚本每小时会发送100多次请求。按照每次请求只需几美分计算,每小时就会产生几美元的费用。虽然不算灾难性损失,但确实很烦人。
我的限流机制很简单:使用一个每小时重置的计数器。默认限制是每小时最多进行20次检测。如果超过了这个限制,脚本会记录一条警告信息,并暂时停止检测,直到计时窗口重新开始。错误仍然会被检测到,只是不会被发送给Claude服务。
结合错误去重机制(相同的错误不会被重复检测),即使同时监控5个容器,我的Claude服务费用也保持在每月5美元以下。
Docker Compose——完整配置方案
以下是包含Container Doctor以及示例Web服务器、API和数据库的完整docker-compose.yml文件:
version: '3.8'
services:
container_doctor:
build:
context: .
dockerfile: Dockerfile
container_name: container_doctor
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- TARGET_contAINERS=web,api,db
- CHECK_INTERVAL=10
- LOG_lines=50
- AUTO_fix=true
- SLACK_WEBHOOK_URL=${SLACK/WebHOOK_URL}
- MAX_DIAGNOSES_PER_HOUR=20
ports:
- "8080:8080"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
web:
image: nginx:latest
container_name: web
ports:
- "80:80"
restart: unless-stopped
api:
build: ./api
container_name: api
environment:
- DATABASE_URL=postgres://\({POSTGRES_USER}:\){POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
- POOL_SIZE=20
depends_on:
- db
restart: unless-stopped
db:
image: postgres:15
container_name: db
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
volumes:
- db_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db_data:
而Dockerfile的内容如下:
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY container_doctor.py .
EXPOSE 8080
CMD ["python", "-u", "container_doctor.py"]
要启动所有服务,只需执行docker compose up -d即可。
重要提示:通过将/var/run/docker.sock挂载到容器中,Container Doctor能够完全访问Docker守护进程。同时,请不要将.env文件复制到Docker镜像中——否则API密钥会被嵌入到镜像中。环境变量应通过docker-compose.yml文件或在运行时进行设置。
在生产环境中遇到的实际问题
我已经使用这个系统运行了大约3周,以下是它所检测到的一些问题:
事件1:内存不足导致的程序终止(第1周)
日志中只显示了一个词:Killed。这是Linux系统的OOM Killer在起作用。
Claude的分析如下:
{
"root_cause": "进程因内存不足被OOM Killer终止。该容器在负载较高时请求的内存超出了256MB的限制。",
"severity": "high",
"suggested_fix": "在docker-compose.yml文件中将内存限制增加到512MB,并继续监控在更高限制下是否还会出现内存泄漏问题。",
"auto_restart_safe": true,
"config_suggestions": ["mem_limit: 512m", "memswap_limit: 1g"],
"likely_recurring": true,
"estimated_impact": "API服务会完全中断,所有请求都会收到来自nginx的502错误响应。"
}
该脚本在3秒钟内重新启动了容器。第二天早上我更新了docker-compose.yml文件。在没有使用Container Doctor之前,这种问题可能会导致整个系统停运2个小时。
事件2:连接池耗尽(第2周)
ERROR: 数据库连接池已耗尽
ERROR: 无法创建新的连接池条目
ERROR: 连接池的最大容量为5,当前已达到上限
Claude发现,我的连接池大小对于同时运行的8个Gunicorn进程来说太小了:
{
"root_cause": "SQLAlchemy连接的连接池容量仅为5个,无法满足8个并发进程的需求。每个进程在处理请求时都会占用一个连接。",
"severity": "high",
"suggested_fix": "将POOL_SIZE设置为20,并增加POOL_TIMEOUT为30秒。长期解决方案是使用PgBouncer作为连接池管理工具。",
"auto_restart_safe": true,
"config_suggestions": ["POOL_SIZE=20", "POOL_TIMEOUT=30", "POOL_RECYCLE=3600"],
"likely_recurring": true,
"estimated_impact": "新的API请求会等待30秒后超时;现有的请求可能会完成,但速度会很慢。"
}
事件3:短暂性超时(第2周)
WARN: 连接上游服务时发生超时
WARN: 正在重试请求(第2次尝试)
INFO: 重试后请求成功完成
Claude正确地将这个问题判断为无关紧要的:
{
"root_cause": "在DNS解析出现故障时,发生了短暂的网络超时现象。重试后问题得到了解决。",
"severity": "低",
"suggested_fix": "无需采取任何措施。这种短暂的网络中断是正常现象,只有当这种情况频繁发生时才需要调查。",
"auto_restart_safe": false,
"config_suggestions": [],
"likely_recurring": false,
"estimated_impact": "影响很小。个别请求的响应时间会延迟约2秒,但所有请求最终都能完成。"
}
不需要重启系统,也不需要发出警报(我会过滤掉Slack中那些提示严重性较低的警报)。这样的处理方式是正确的:每次遇到短暂的网络超时问题就重启系统,反而会导致更多的停机时间。
事件4:磁盘空间不足(第3周)
错误:无法写入临时文件:设备上没有剩余空间
严重错误:数据目录已经没有可用空间了
{
"root_cause": "Postgres的数据存储空间已满。WAL文件和临时排序文件占用了所有可用的存储空间。",
"severity": "高",
"suggested_fix": "1. 清理WAL文件:执行SELECT pg_switch_wal()命令。2. 增加数据存储空间的大小。3. 启用日志轮换机制。4. 将max_wal_size设置为1GB。",
"auto_restart_safe": false,
"config_suggestions": ["max_wal_size=1GB", "log_rotation_age=1d"],
"likely_recurring": true,
"estimated_impact": "数据库将变为只读状态,所有写入操作都会失败。任何数据修改尝试都会导致API返回500错误代码。"
}
注意,Claude在这里明确指出了“auto_restart_safe: false”这一设置。当磁盘空间不足时重启Postgres可能会导致数据丢失。该脚本并没有自动触发重启操作,而是在凌晨4点向我发送了详细的警报信息。我第二天早上就清理了WAL文件。Claude的处理方式非常明智。
成本明细——实际花费是多少
在5个容器上运行这个系统3周后,所产生的费用如下:
-
Claude API:每月约3.80美元(已应用限流和去重机制)
-
Linode计算资源:无需额外付费(Container Doctor仅消耗大约50MB的RAM内存)
-
Slack:使用免费账户
-
我节省下来的时间
:每月可避免在凌晨3点进行调试工作,节省约2到3小时的时间
如果没有应用限流机制,第一周使用Claude API所产生的费用为8美元。但通过启用去重功能和限流机制后,这一成本大幅下降。我的大多数容器运行都非常正常,只有当系统真正出现故障时,该脚本才会向Claude发送请求。
如果你需要监控更多的容器,或者日志数据量较大,那么所需费用可能会增加。你可以通过调整“MAX_DIAGNOSES_PER_HOUR”这个参数来控制成本开支。
安全注意事项
让我们来谈谈一个非常重要的问题:Docker socket的安全性。
/var/run/docker.sock这个文件被挂载后,Container Doctor就会获得对你的Docker守护进程的“root级别访问权限”。它能够启动、停止或删除任何容器,也能够拉取镜像文件,甚至可以进入正在运行的容器内部执行操作。如果有人攻击了Container Doctor,那么他们就完全控制了你的整个Docker服务器。
以下是我采取的缓解措施:
-
网络隔离:Container Doctor的健康检查接口仅在本地主机上开放。在生产环境中,应将其置于带有身份验证功能的反向代理之后。
-
仅读访问权限:该脚本仅用于读取日志文件和重启容器,从不执行任何进入容器的操作,也不会拉取镜像或修改存储卷。
-
禁止外部输入:该脚本不接受来自Slack或其他外部来源的命令,仅用于发送日志信息或警报。
-
API密钥定期轮换:我会每月更换Anthropic API密钥。这样一来,即使容器被入侵,受影响的范围也会受到限制。
为了进一步提高安全性,可以考虑在Docker的socket挂载选项中使用`–read-only`参数,并使用像docker-socket-proxy这样的工具来限制Container Doctor能够执行的API调用。
我会采取哪些不同的做法?
在系统投入生产运行三周后,我进行了如下反思:
从一开始就应该使用结构化日志记录格式。目前我使用的基于正则表达式的错误检测机制会产生太多误报,而采用包含严重性等级的JSON日志格式会使故障检测的准确性大大提高。
应为每个容器设置不同的配置规则。现在所有容器都使用相同的处理方式,但实际上数据库服务器和Web服务器可能需要不同的管理策略——例如,数据库服务器不应被自动重启,而无状态Web服务器则可以自动重启。
应该开发一个简单的Web用户界面。
虽然`/history`接口返回的是JSON数据,但若能使用React框架创建一个展示故障发生时间、修复成功率及成本信息的仪表板,会更加实用。
应先尝试使用本地模型进行诊断。
对于一些简单的错误(如内存溢出或连接失败),可以在Ollama上运行本地模型来进行诊断,这样就不会产生任何API调用费用。只有在对那些需要复杂分析的复杂故障情况下,才应该使用Claude模型。
应添加“学习模式”。
可以先让Container Doctor以仅观察模式运行一周,让它自行诊断所有问题但不进行修复,然后人工审核这些诊断结果。一旦确认其判断能力可靠,再开启自动修复功能。这样就能在真正赋予它执行修复操作的权利之前,建立起对它的信任。
下一步计划是什么?
如果你觉得这篇文章有帮助,我每周都会撰写关于Docker、AI工具以及开发者工作流程的文章。我是Balajee Asish——Docker领域的专家,也是freeCodeCamp的贡献者,目前正通过一个接一个的项目来探索AI工具领域。
如果有任何疑问,或者你开发了类似的功能,欢迎在下方留言,也可以在GitHub或LinkedIn上找到我。
祝你在技术探索的道路上一切顺利!


