乱码三千 – 分享实用IT技术

乱码三千 – 码出一个新世界


  • 首页

  • 归档

  • 搜索

OpenClaw+CC Switch工具实现国内外模型任意切换

发表于 2026-09-02

前言

目前OpenClaw官方已经出了桌面应用, 再也不用像之前那般苦苦折腾命令行了

image-20260902190345482

CC Switch

以前我们切换模型, 需要手动修改配置文件, 假如有十来个模型, 管理上也是个大问题, CC Switch这个工具的出现就是解决这些痛点的, 它不仅可以自动帮我们修改配置文件切换模型,、可视化管理模型key, 还可以实时监控token额度, 很是方便

最主要的是开源免费, 不用担心隐私泄露

image-20260902185734424

image-20260902190158869

官网: 点击进入

免费大模型API

如下:

平台 免费额度 模型 特点
魔搭 API-Inference 每日 2000 次 Qwen/DeepSeek/GLM/MiniMax 等 6万+ 按次不计token,长上下文不额外烧
硅基流动 SiliconFlow 新用户 2000万 Tokens(永久) DeepSeek/Qwen/Llama,7B以下永久免费 注册即送,OpenAI 兼容
火山方舟(字节) 文本 50万 tokens;视频 Seedance 200万 tokens Doubao 全家 注册送,含视频

以上只是其中一小部分, 关于第三方的模型API, 大家可以自行B站或者抖音搜索, 有一些羊毛可以薅

本地部署大模型

如果是本地自己机器部署的话可以使用qwen2.5开源大模型:

模型 ollama 名称 显存需求 特点(对你的剧本解析)
Qwen2.5‑3B qwen2.5:3b 4‑5G ⭐优先小模型,原生强中文;输出 yaml 稳定性远好 llama3.2‑3B;适合显卡配置不高机器,做测试原型
Qwen2.5‑7B qwen2.5:7b 7‑9G ⭐⭐⭐最推荐正式生产;中文剧本理解、结构化 yaml 输出能力强,Apache2.0 完全开源,无厂商附加约束
Qwen2.5‑14B qwen2.5:14b 12‑14G 长剧本、多角色大文本解析,效果最好,吃显存

Qwen(通义千问开源版),阿里开源,权重 Apache‑2.0 真正开源,没有 Llama 那一套社区协议约束,没有月活上限,个人、商用、二次分发都可以自由使用

可以使用Ollama或者vLLM本地部署, 两种方式对比如下:

对比项 vLLM(当前方案) Ollama(备选方案)
标准 modelscope 模型 ✅直接加载,无需转换 ❌必须转为 GGUF 格式
长文本分镜生成速度 ⭐⭐⭐⭐⭐(PagedAttention 长上下文加速) ⭐⭐⭐
空闲自动释放显存 原生无,需要外部脚本 kill 进程 ✅自带 keep‑alive N 秒自动卸载模型
环境部署复杂度 高(CUDA、vLLM 版本、显存参数) 极低
量化方案 FP16、AWQ、FP8,工业级 GGUF(Q4_K_M/Q5_K_M)
ComfyUI 节点 HTTP 调用 支持(OpenAI 接口 http://127.0.0.1:8000/v1)

ollama 一键拉取模型命令:

1
2
3
4
5
# 3B小模型,测试原型,Apache‑2.0真正开源
ollama pull qwen2.5:3b

# 7B正式生产,强烈推荐
ollama pull qwen2.5:7b

如果使用vLLM部署, 那么推荐国内的modelscope源速度快, 一键下载指令如下:

1
modelscope download --model qwen/Qwen2.5-7B-Instruct --local_dir /models/Qwen2.5-7B-Instruct

vLLM部署+启动脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
vLLM 一条龙后端服务:自动下载Qwen2.5‑7B‑Instruct + 启动OpenAI兼容API
启动后地址:http://127.0.0.1:8000/v1
"""
import os
import sys
import subprocess
import time
import requests

# ======================== 配置区,按需修改 ========================
MODEL_ID = "qwen/Qwen2.5-7B-Instruct"
LOCAL_MODEL_ROOT = "./models"
MODEL_FOLDER_NAME = "Qwen2.5-7B-Instruct"
# 拼接本地完整路径
LOCAL_MODEL_PATH = os.path.abspath(os.path.join(LOCAL_MODEL_ROOT, MODEL_FOLDER_NAME))
API_HOST = "127.0.0.1"
API_PORT = 8000
SERVED_MODEL_NAME = "qwen2.5-7b"
GPU_MEM_UTIL = 0.85
WAIT_SERVICE_TIMEOUT = 180
# ================================================================

def check_model_ready(model_dir: str) -> bool:
"""校验分片模型完整性:检查索引文件是否存在"""
index_file = os.path.join(model_dir, "model.safetensors.index.json")
config_file = os.path.join(model_dir, "config.json")
if os.path.isfile(index_file) and os.path.isfile(config_file):
return True
return False

def download_model():
"""调用 modelscope cli 下载模型"""
print(f"\n📥 模型不存在,开始下载 {MODEL_ID}")
print(f"目标目录: {LOCAL_MODEL_PATH}")
cmd = [
"modelscope", "download",
"--model", MODEL_ID,
"--local_dir", LOCAL_MODEL_PATH
]
proc = subprocess.run(cmd)
if proc.returncode != 0:
print("❌ modelscope 下载失败!")
sys.exit(1)
print("✅ 模型下载完成")

def start_vllm_server():
cmd = [
sys.executable, "-m", "vllm.entrypoints.openai.api_server",
"--model", LOCAL_MODEL_PATH,
"--served-model-name", SERVED_MODEL_NAME,
"--host", API_HOST,
"--port", str(API_PORT),
"--gpu-memory-utilization", str(GPU_MEM_UTIL)
]
print("\n🚀 正在启动 vLLM API 后端,加载模型,请耐心等待...")
print(f"命令: {' '.join(cmd)}")
# 后台拉起服务进程
subprocess.Popen(cmd)

def wait_for_service_ready():
base_url = f"http://{API_HOST}:{API_PORT}/v1/models"
print(f"\n⏳ 等待vLLM服务就绪,最长等待 {WAIT_SERVICE_TIMEOUT}s ...")
for t in range(1, WAIT_SERVICE_TIMEOUT + 1):
time.sleep(1)
try:
resp = requests.get(base_url, timeout=3)
if resp.status_code == 200:
print(f"✅ LLM后端服务启动成功!耗时 {t}s")
print(f"API Endpoint: http://{API_HOST}:{API_PORT}/v1")
return True
except Exception:
pass
print("❌ 等待超时,vLLM启动失败,请手动查看控制台报错")
return False

def main():
print("===== vLLM‑Qwen2.5 一条龙服务启动器 =====")
# 1.检测模型
if not check_model_ready(LOCAL_MODEL_PATH):
download_model()
else:
print(f"✅ 本地模型已就绪: {LOCAL_MODEL_PATH}")

# 2.启动vllm后台
start_vllm_server()
# 3.轮询等待端口就绪
ok = wait_for_service_ready()
if not ok:
sys.exit(1)

# 简单连通性测试
test_payload = {
"model": SERVED_MODEL_NAME,
"messages": [{"role": "user", "content": "只用两个字回复OK"}],
"temperature": 0.1,
"max_tokens": 16,
"stream": False
}
resp = requests.post(f"http://{API_HOST}:{API_PORT}/v1/chat/completions", json=test_payload, timeout=120)
if resp.status_code == 200:
res_json = resp.json()
print("🧪 推理测试返回:", res_json["choices"][0]["message"]["content"])
print("\n🎉 LLM后端完全就绪,可以开始运行你的视频流水线脚本!")
else:
print("⚠️ 连通测试失败,HTTP", resp.status_code)

if __name__ == "__main__":
main()

ollama一键部署脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Ollama + Qwen2.5 一键部署脚本(工程优化版)
支持 Windows / Linux;Linux国内镜像加速;交互式选择模型;显存释放配置;环境校验
"""
import os
import sys
import time
import platform
import subprocess
import requests
from typing import Optional, Dict, List

# ======================== 配置常量区 ========================
OLLAMA_HOST = "http://127.0.0.1:11434"
SERVICE_WAIT_SECONDS = 40
KEEP_ALIVE = "30s" # 模型空闲多久释放显存,串行视频流水线建议30s

MODEL_OPTIONS: List[Dict] = [
{
"id": 1,
"tag": "qwen2.5:3b",
"vram": "4‑5GB",
"desc": "轻量版,适合测试、显存紧张机器"
},
{
"id": 2,
"tag": "qwen2.5:7b",
"vram": "7‑9GB",
"desc": "⭐推荐,短剧剧本解析生产首选,综合平衡"
},
{
"id": 3,
"tag": "qwen2.5:14b",
"vram": "12‑14GB",
"desc": "高性能版,长剧本、多角色复杂剧本解析"
}
]

# Linux国内镜像安装地址
INSTALL_SCRIPT_CN = "https://ollama.ac.cn/install.sh"
# ==========================================================


def run_subprocess(args: List[str], shell: bool = False) -> subprocess.CompletedProcess:
"""封装subprocess,统一捕获输出"""
return subprocess.run(
args,
shell=shell,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)


def get_ollama_version() -> Optional[str]:
"""
多层校验:判断ollama是否可用
返回版本字符串;二进制损坏/残留文件则返回None
"""
try:
proc = run_subprocess(["ollama", "--version"])
if proc.returncode == 0 and proc.stdout.strip():
return proc.stdout.strip()
return None
except (FileNotFoundError, OSError):
return None


def install_ollama_linux():
"""Linux平台:国内镜像一键安装Ollama"""
print("\n📦 Linux 使用国内社区镜像安装 Ollama")
print(f"镜像源: {INSTALL_SCRIPT_CN}")
cmd = f'curl -fsSL {INSTALL_SCRIPT_CN} | sudo sh'
ret = run_subprocess(cmd, shell=True)
if ret.returncode != 0:
print("❌ Ollama国内镜像安装失败!")
print("stderr:", ret.stderr)
print("备选方案:手动下载tar包进行安装")
sys.exit(1)
print("✅ Ollama 安装脚本执行完毕")


def install_ollama_windows():
"""Windows平台:winget安装"""
print("\n📦 Windows 通过 winget 安装 Ollama")
ret = run_subprocess(["winget", "install", "Ollama.Ollama"], shell=True)
if ret.returncode != 0:
print("❌ winget安装失败,请手动官网下载安装包")
print("官网地址:https://ollama.com/download/windows")
sys.exit(1)
print("✅ Windows Ollama安装完成,请**重启终端**之后重新运行本脚本!")
sys.exit(0)


def install_flow():
os_name = platform.system()
if os_name == "Linux":
install_ollama_linux()
elif os_name == "Windows":
install_ollama_windows()
elif os_name == "Darwin":
print("\n⚠️ macOS暂不支持自动安装,请手动前往官网下载DMG安装包")
print("https://ollama.com/download/mac")
sys.exit(0)
else:
print(f"❌ 当前操作系统 {os_name} 暂不支持自动安装")
sys.exit(1)


def select_model() -> str:
"""交互式菜单选择模型"""
print("\n====== Qwen2.5模型选择(Apache‑2.0完全开源) ======")
for item in MODEL_OPTIONS:
print(f"[{item['id']}] {item['tag']:12} | 显存:{item['vram']:8} | {item['desc']}")
while True:
user_input = input("\n请输入模型序号(1/2/3): ").strip()
if user_input.isdigit():
sel = int(user_input)
for m in MODEL_OPTIONS:
if m["id"] == sel:
return m["tag"]
print("输入无效!请输入数字:1、2或者3")


def set_ollama_env_linux():
"""Linux设置环境变量OLLAMA_KEEP_ALIVE,控制显存释放"""
# systemd方式:修改ollama服务环境变量(永久生效)
service_path = "/etc/systemd/system/ollama.service"
if os.path.exists(service_path):
print(f"\n⚙️ 配置 ollama.service 显存释放策略 keep‑alive = {KEEP_ALIVE}")
try:
# 简单方案:环境变量写入/etc/environment(ollama进程可以读取)
env_line = f'OLLAMA_KEEP_ALIVE={KEEP_ALIVE}'
with open("/tmp/ollama_env.conf", "w", encoding="utf‑8") as f:
f.write(env_line + "\n")
subprocess.run(["sudo", "tee", "-a", "/etc/environment"],
input=env_line, text=True, capture_output=True)
subprocess.run(["sudo", "systemctl", "daemon‑reload"], capture_output=True)
subprocess.run(["sudo", "systemctl", "restart", "ollama"], capture_output=True)
print("✅ systemd Ollama服务已重启,显存释放策略永久生效")
except Exception as e:
print(f"⚠️ 无法修改systemd服务配置:{e},本次会话临时设置环境变量")
# 当前进程环境变量(临时生效,用于当前启动ollama serve)
os.environ["OLLAMA_KEEP_ALIVE"] = KEEP_ALIVE


def start_ollama_service() -> bool:
"""启动ollama服务,兼容systemd(Linux) / nohup(手动) / Windows后台进程"""
# 先检测端口是否已经就绪
try:
resp = requests.get(f"{OLLAMA_HOST}/api/tags", timeout=2)
if resp.status_code == 200:
print("✅ Ollama服务已经正在运行,跳过启动")
return True
except Exception:
pass

os_name = platform.system()
print("\n🚀 尝试启动 Ollama 后台服务")

if os_name == "Linux":
# 优先尝试systemd启动(安装版自带systemd)
res = run_subprocess(["systemctl", "is‑active", "--quiet", "ollama"], shell=False)
if res.returncode == 0:
subprocess.run(["sudo", "systemctl", "start", "ollama"], capture_output=True)
else:
# systemd不存在,手动nohup后台拉起
set_ollama_env_linux()
subprocess.Popen(
["ollama", "serve"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
elif os_name == "Windows":
os.environ["OLLAMA_KEEP_ALIVE"] = KEEP_ALIVE
subprocess.Popen(
["ollama", "serve"],
creationflags=subprocess.CREATE_NO_WINDOW
)

# 轮询等待端口就绪
print(f"⏳ 等待Ollama服务启动(最大等待 {SERVICE_WAIT_SECONDS}s)")
for sec in range(1, SERVICE_WAIT_SECONDS + 1):
time.sleep(1)
try:
r = requests.get(f"{OLLAMA_HOST}/api/tags", timeout=2)
if r.status_code == 200:
print(f"✅ Ollama服务启动成功,耗时 {sec}s")
return True
except Exception:
continue

print(f"❌ 等待 {SERVICE_WAIT_SECONDS}s,服务仍然未就绪,启动失败")
print("排查建议:终端手动执行 ollama serve 查看报错日志")
return False


def is_model_installed(model_tag: str) -> bool:
"""结构化检测模型是否已经本地存在,避免grep脆弱字符串匹配"""
try:
resp = requests.get(f"{OLLAMA_HOST}/api/tags", timeout=10)
data = resp.json()
models = data.get("models", [])
for m in models:
if m.get("name") == model_tag:
return True
return False
except Exception as e:
print(f"模型列表接口查询异常:{e}")
return False


def pull_model(model_tag: str):
if is_model_installed(model_tag):
print(f"\n✅ 模型 {model_tag} 本地已存在,跳过下载")
return

print(f"\n📥 开始拉取模型:{model_tag}")
print("提示:ollama原生支持断点续传,下载中断之后重新运行脚本即可恢复")
proc = run_subprocess(["ollama", "pull", model_tag])
if proc.returncode != 0:
raise RuntimeError(f"模型下载失败,返回码:{proc.returncode}, stderr:{proc.stderr}")
print(f"✅ {model_tag} 下载完成")


def model_inference_test(model_tag: str) -> bool:
"""模型推理连通性验证测试"""
print("\n🔍 模型推理功能验证")
payload = {
"model": model_tag,
"prompt": "只用两个字回复:OK",
"stream": False
}
try:
resp = requests.post(f"{OLLAMA_HOST}/api/generate", json=payload, timeout=120)
if resp.status_code != 200:
print(f"接口异常,HTTP状态码:{resp.status_code}")
return False
result_json = resp.json()
reply = result_json.get("response", "")
print(f"模型返回:{reply.strip()}")
if "ok" in reply.lower():
print("✅ 模型推理验证通过!")
return True
else:
print(f"⚠️ 验证返回结果不符合预期,返回内容:{reply}")
return False
except Exception as err:
print(f"推理测试失败,异常信息:{err}")
return False


def print_summary(ollama_version: str, selected_model: str):
print("\n" + "=" * 70)
print("📋 Ollama + Qwen2.5 部署环境汇总")
print(f"操作系统 : {platform.system()}")
print(f"Ollama版本 : {ollama_version}")
print(f"当前选中模型 : {selected_model}")
print(f"API服务地址 : {OLLAMA_HOST}")
print(f"显存释放策略 : keep‑alive = {KEEP_ALIVE}")
print("-" * 70)
print("Python调用示例(集成进你的script_parser.py):")
code_snippet = f'''
import requests

url = "http://127.0.0.1:11434/api/generate"
body = {{
"model": "{selected_model}",
"prompt": "你的短剧分镜解析提示词",
"stream": False
}}
resp = requests.post(url, json=body, timeout=180)
data = resp.json()
result_text = data["response"]
'''
print(code_snippet)
print("=" * 70)


def main():
print("==== Ollama & Qwen2.5 一键部署工具(工程优化版) ====")
ollama_ver = get_ollama_version()
if ollama_ver is not None:
print(f"✅ Ollama已检测到,版本信息:{ollama_ver}")
else:
print("⚠️ Ollama未安装或者二进制文件损坏,进入安装流程")
install_flow()
# 安装完成后二次校验版本
ollama_ver = get_ollama_version()
if ollama_ver is None:
print("❌ 安装完成,但是当前环境依然无法找到ollama命令!")
print("Linux:新开终端,刷新环境变量;Windows:必须重启终端!")
sys.exit(1)

# 启动服务
service_ok = start_ollama_service()
if not service_ok:
sys.exit(1)

# 模型选择
selected_model = select_model()

# 下载模型
pull_model(selected_model)

# 推理验证
test_ok = model_inference_test(selected_model)
if not test_ok:
print("⚠️ 警告:模型下载成功,但是推理验证失败,建议检查显卡显存大小!")

# 打印环境汇总
print_summary(ollama_ver, selected_model)


if __name__ == "__main__":
main()

本文为作者原创 转载时请注明出处 谢谢

乱码三千 – 点滴积累 ,欢迎来到乱码三千技术博客站

现场即兴编曲演唱直播技术的实现

发表于 2026-08-31

前言

闲来无事, 打算开现场编曲直播, 这不, 记录生活的同时顺带完成了编曲写歌的活儿, 一举多得, 至于会不会翻车, 管他呢, 谁规定唱歌不能跑调了😁

主要还是因为比较懒, 不太喜欢挨个乐器录制剪辑, 这种一步到位的方式比较适合我😂

设备清单

  • 运行环境:macOS 10.15.4
  • 推流工具: OBS 29.01版本/抖音直播伴侣
  • 音频宿主:Reaper
  • 声卡: M-Audio M-Track Duo
  • 画面采集: 手机摄像头
  • 平台:抖音直播

关于虚拟声卡

由于M-Audio M-Track Duo声卡不带lookback通道, 也就是说OBS只能录制声卡输入的声音, 但无法录制乐器和人声混合的声音, 所以需要下载一个虚拟声卡做一个中转桥

当然了, 你如果使用高端声卡, 内置lookback, 可以跳过安装虚拟声卡这一步骤

Mac平台能用的免费虚拟声卡有:

  • VB-CABLE macOS 版
  • BlackHole 2ch: 直接使用brew install blackhole-2ch安装 或 GitHub下载 release pkg安装包
  • NNA Virtual Audio:支持 10.15 Catalina+,免费、可配通道数,Intel 通用
  • LitLink: 要 macOS 14 Sonoma+
  • OBS 自带「macOS Audio Capture」: 要求OBS 30+ 版本且 macOS 13 Ventura+
  • Soundflower:: 10.15.4 基本加载不了

我的Mac是Intel的芯片, 系统也比较老, 能适配的只有VB-CABLE、BlackHole、 NNA Virtual Audio这三个, 秉着哪个能用用哪个的原则, 最后安装了VB-CABLE:

image-20260902205352039

VB-CABLE: 点击进入下载页面

声卡配置

虚拟声卡安装完毕后, 要对Mac系统、OBS和Reaper进行相应的设置

  1. OBS音频设置

    OBS → 来源 → 添加 音频输入采集→ 设备选 VB-CABLE → 高级音频属性里把它设推流音轨 1

    image-20260902214202583

    image-20260902214439596

    image-20260902214455445

    image-20260902214659015

    统一采样率为44.1KHZ:

    image-20260902213843926

    image-20260902215507113

  2. Mac系统音频设置

    前往→实用工具→音频MIDI设置→创建多输出设备:

    image-20260902215615268

    image-20260902215644568

    勾选使用VB-Cable和USB AUDIO CODEC(这个是M-Audio声卡名称), 然后将USB AUDIO CODEC设为主设备, 并开启VB-Cable的漂移校正功能, 让其跟随主设备同步, 同时采样率也需要统一成44.1KHZ:

    image-20260902215846140

    我将该多输出设备重命名为LiveStrem, 以便于区分

    同时声音偏好设置中, 将输入设为选择为USB AUDIO CODEC, 输出设备选择为刚创建的LiveStrem:

    image-20260902223218571

    image-20260902223238281

  3. Reaper宿主音频设置

    进入Preferences→Audio→Device页面, 将输入设备选择为USB AUDIO CODEC, 输出设备选为LiveStrem, 并统一采样率为44.1kHz:

    image-20260902222334469

注意: 采样率要确保三端统一, 要么统一48kHz, 要么44.1kHz, 如果混用, 可能会出现音画不同步或者声音音高偏移的问题

声卡信号原理

为什么要做上面这些复杂的设置, 我们需要明白音频的信号流程, 如下:

1
2
3
4
5
6
7
8
9
10
11
12
吉他 / 话筒 / MIDI键盘
│
▼
M-Track Duo 输入 (Input 1/2, MIDI走USB不算声卡输入)
│
▼
Reaper(各轨 → STREAM BUS → Master)
│
▼
多输出设备 LiveStream-Mix(Reaper 输出指向它)
├─→ M-Track Duo Out → 你耳机(物理监听,零延迟)
└─→ VB-CABLE Out →(驱动内环回)→ VB-CABLE In → OBS 音频输入捕获 → 推流给观众

虽然OBS音频采集设置中可以选择USB AUDIO CODEC, 也就是外置的M-Audio声卡, 但实际捕获的是输入的声音, 假如你是单纯的直播聊天, 那完全没问题, 只需要将麦克风接到声卡, 观众就能听到你讲话.

但是我们的需求是: 人声不直接通过OBS出去, 而是先作为输入内容进入到Reaper宿主, 经过一系列的效果插件与乐器声音混合后在作为输出内容传到OBS, 进而传递给观众. 问题就出在这一步, OBS采集不到USB AUDIO CODEC的输出内容, 只能采集输入.

因此, 我们需要借助虚拟声卡, 将USB AUDIO CODEC的输出内容复制一份给VB-CABLE虚拟声卡, 然后以输入端的身份进入到OBS, 以此来骗过系统, 而多输出设备的目的就是保证输出信号复制的同时不影响原有USB AUDIO CODEC声卡的输入输出, 堪称完美👍

关于OBS智能采集输入信号的问题, 主要是Mac系统权限导致的, Windows平台不存在这种问题.

直播架构分析

整个直播从技术层面分为三个端。所有音频最终汇聚到声卡,由声卡统一送入 OBS;视频则由多个独立视频源在 OBS 内与音频对齐合成一路后推流。

端口 角色 在本方案中的具体载体
推流端 采集、编码、上传音视频 本地台式电脑:OBS(编码推流)+ Reaper(编曲混音)
中转端 接收、转码、复制、CDN 分发 抖音流媒体服务器 / 直播伴侣接入节点
播放端 拉流、解码、渲染画面 观众手机抖音 App(含拉流模块 + 播放器)

链路:吉他/贝斯/话筒/MIDI → 声卡 → Reaper(STREAM BUS) → 声卡 Loopback → OBS → 抖音服务器(中转) → CDN → 观众手机(播放)。


画面采集

关于人脸和背景画面的采集, 我直接使用手机自带的摄像头, 通过Wifi的形式与电脑OBS相连, 要确保两个设备在同一个网络下

这里需要借助一个桥接工具, 那就是Droidcam, 电脑和手机需要同时安装该应用

电脑端的话直接下载OBS专属的Droidcam插件即可, 由于我使用的是OBS29.01的版本, 所以我选择Droidcam2.02的适配版本:

  • Droidcam-OBS-Plugin: 点击进入GitHub下载页
  • Droidcam官网: 点击进入

image-20260904082824000

iShot_2026-09-03_22.25.53

而手机端, 直接去APP Store应用市场搜索下载安装即可

两端的Droidcam下载安装完毕后, 打开手机端的Droidcam获取IP地址和端口号:

feem_66886_IMG_0505

然后启动电脑端OBS, 添加DroidCam OBS采集:

image-20260904083900783

image-20260904083933584

在IP和端口一栏内填入手机端显示的IP和端口地址:

image-20260904084114652

image-20260904084147669

配置好后点击Activate激活按钮开启连接:

image-20260904084358707

此时我们就能在OBS上实时看到手机拍摄的画面了:

image-20260904084525005

关于音画同步的问题

由于音频和画面走的是两条不同的通道, 加上WIFI网络的不稳定性, 势必会存在延迟问题, 画面比声音晚到, 流程图如下:

1
2
3
音频:Reaper 混音 → VB-CABLE → OBS,延迟 ≈ 0ms

视频:手机 → USB/Wi-Fi → OBS,延迟 ≈ 80–250ms

假如视频比声音先到, 那么我们可以直接在OBS中给视频添加滤镜延迟, 让其晚一些播出去:

image-20260904085951505

image-20260904090043208

但问题是视频滤镜延迟时间只能填正数而填不了负数, 所以视频比音频晚的问题, 无法通过该方式解决

我们只能考虑将音频推迟, 好在OBS也支持音频时间的偏移设置, 因此, 只需要在高级音频设置中对音频同步偏移数值进行调整即可:

image-20260904092227902

image-20260904092307710

该属性支持正负数设置, 具体设置为多少, 根据自己的实际情况而定,在直播测试过程中进行调整观察从而获取自己满意的点

由于WIFI信号的波动性, 如果换成USB有线连接延迟会更低更稳定, 不过有条件的话最好使用电脑专用摄像头, 体验会更好, 手机毕竟多了一层Droidcam的中转, 这部分的延迟少不了

手机DroidCam USB 和电脑专用摄像头信号链路对比:

1
2
3
4
5
6
手机摄像头传感器
→ 手机 SoC 编码(H.264/MJPEG)
→ USB 隧道协议(ADB/usbmuxd)
→ Mac 端 USB 驱动
→ DroidCam OBS 插件解码
→ OBS 视频源

每一层都吃时间:编码约20–40ms,USB 传输约 10–20ms,插件解码约10–20ms,合计 60–100ms起步。

1
2
3
4
摄像头传感器
→ UVC 标准协议(USB 视频类)
→ Mac 系统原生驱动(免驱)
→ OBS 视频源

UVC 设备把原始 YUV 帧直接通过 USB 推给系统,没有编码、没有隧道、没有插件解码,系统拿到就是能直接用的帧,合计 30–50ms。

硬件接线

  • MIDI 键盘 → 电脑 USB(直接进主机,用于触发鼓、和弦音源)
  • 吉他、贝斯 → 声卡高阻输入(INST / HI-Z)
  • 话筒 → 声卡 XLR 输入(如为电容麦,打开 48V 幻象电源)
  • 耳机监听 → 声卡耳机孔(关键:必须硬件直出,绕过系统与 OBS,实现零延迟监听)
  • 手机(临时摄像头)→ 与电脑同一 WiFi,装 DroidCam / Iriun App;后期购入 USB 摄像头直接插电脑
  • 电脑 → 路由器 → 互联网(上传带宽建议 ≥ 10 Mbps,直播全程有线连接)
  • Mac 系统权限:首次使用需到「系统设置 → 隐私与安全性」中,给 OBS 开启「麦克风」「屏幕录制」「辅助功能」三项权限,否则 OBS 抓不到设备和桌面

信号流向

所有声音信号进入声卡后,由 Reaper 统一处理并混成一路干净的直播混音(STREAM BUS,再通过声卡Loopback或虚拟声卡交给 OBS。原则是:监听走声卡硬件,直播走 OBS,两者互不干扰

Reaper 编曲模板

需要给直播创建专用的工程模板, 每次开播直接调用

创建轨道

首先需要创建的基础轨道有:

在 Reaper中建立专用工程模板(File → Project Templates → Save Project as Template)并保存,每次开播直接调用。核心是单独建立一条「STREAM BUS(直播母线)」,只把要送给观众的声音送进去。

轨道路径 承载信号 是否进 STREAM BUS
乐器轨(MIDI) MIDI 键盘 → 鼓音源 / 和弦音源 是
吉他轨(Audio) 声卡输入(吉他)→ 软效果器 是
贝斯轨(Audio) 声卡输入(贝斯)→ 效果器 是
话筒轨(Audio) 声卡输入(话筒)→ EQ / 压缩 是
节拍器 / 单听轨 排练、对点用 否(务必排除)
STREAM BUS(立体声) 上述四轨混合后的总和 只此一路送 OBS

开启人声监听

点亮音轨中的小喇叭即可开启实时监听, 演唱的时候能实时听到自己的声音:

image-20260904100811237

开启监听后, 即便在走带停止不播放的时候你说话也能听到, 观众也能听到, 适合中场休息聊天, 不需要额外设置, 监听喇叭打开就行, 实时录唱和停录说话无缝过渡

另外, 如果想给监听音频添加效果器, 可以点击右上方Monitor Fx入口:

image-20260909105235703

比如添加混响等等, 观众也能听到效果:

image-20260909105321201

不过这种监听上添加效果器是作用于所有音轨的, 不建议大家直接在这里加效果器, 建议在各自的音轨上添加各自的效果器更好:

image-20260909112442536

循环LOOP设置

一共有四个地方需要设置:

  1. 循环录制完后自动保存MIDI文件并加入工程, 而不需要手动停止录音才保存

    Preference→Audio →Loop Recording→勾选At each loop:

    image-20260909085318167

    如果不开启该选项, 那么录制完一遍进入新循环后之前录制的完全听不到, 必须停止录音后音符才会显示出来

  2. 循环录制时创建新的media item

    Options→New Recording that overlaps existing media item →Creates new media items:

    image-20260909085534219

    Reaper默认选项是splits, 循环录制时会分多个take, 同一时间下只有其中处于活动状态的take才出声, 无法多个take同时出声, 如下:

    image-20260909103938013

    直播时可能随时要对同一个钢琴音色追加补充音符, 或者分开录制鼓组的底鼓军鼓等音色, 那么选择Creates new media items, 可以让多个循环take重叠共同发声

  3. 给每个音轨开启已录制音频的监听

    右键录音按钮→勾选monitort rack media when recording:

    image-20260909104629677

    如果不勾选该选项, 那么在循环录制的时候无法实时听到上一段的循环录制, 只能听到当前正在弹的音符

  4. 给每个音轨开启预录制功能

    右键录音按钮→勾选Automatic record-arm when track selected:

    image-20260909110752450

    这样一来, 我们只需要点击其中一轨, 就可以随时切换不同音轨录音

    其中人声轨道比较特殊, 需要根据自身情况勾选, 如果你演唱一遍过, 那就勾选, 如果一个loop来回唱就不要勾选, 不然多条人声信号叠加, 会比较混乱

关于画中画布局

假设我们需要实现人脸(手机摄像头)作为主画面,Reaper 录屏小窗叠在人脸上方的画中画效果, OBS很容易能实现这一点, 只需要让Reaper窗口采集源和DroidCam OBS采集源同时存在即可:

image-20260904095250909

这两个都是独立视频源,谁在上层谁就盖住下层,直接在预览区拖动/缩放即可,无需额外软件。

层(从下到上) 来源 布局做法
底层(全屏) 手机摄像头(视频捕获设备) 铺满整个 1920×1080 画布,作为主画面
上层(画中画) 显示器捕获 / 窗口捕获(Reaper 界面) 拖到画面上方或下方,缩放到约 1/4–1/3 大小,作为编曲操作的小窗
最上层(可选) 文字 / 歌词 / 标题 放在画中画上方或下方,不遮挡操作区

image-20260904095533361

OBS 推流设置

项目 推荐值
画布分辨率 1920×1080;帧率 30 fps(编曲直播足够,60 fps 徒增显卡负担)
编码器 NVIDIA NVENC H.264(N 卡)/ AMD AMF(A 卡)
码率 700–8000 kbps(根据实际上传带宽而定)
关键帧间隔 2 秒
音频编码器 AAC,44.1kHz,192 kbps,CBR

开始直播

目前抖音和B站推流码有粉丝门槛, 既然如此, 那就先不播了, 等攒够粉丝再说, 嘿嘿罒ω罒

如果你有推流码, 那直接在OBS中设置页面的直播选项中填入即可, 服务器填直播地址, 推流码填直播密钥:

image-20260904102351975

image-20260904102247923

设置完后点击开始直播:

image-20260904103545236

右下方能看到绿色块同时有码率显示, 就说明推流成功:

image-20260904103702539

注意事项

如果你拿到的推流码是rtmp://push.example.com/live/abc123?txSecret=xxxx&txTime=yyyy这种格式的, 需要以最后一个斜杠作为分界线拆成两部分

带协议的rtmp://push.example.com/live/这部分填入服务器一栏, 后边的abc123?txSecret=xxxx&txTime=yyyy部分填入推流码一栏

OBS在推流时会自动将服务器和串流密钥内容拼接起来发出去,等价于原完整地址

分阶段落地路线表

阶段 动作 目标
阶段 1:通链 接好声卡/吉他/话筒/MIDI,Reaper 出声并听到监听(监听开关设 USB) 能弹奏演唱,自己听着不延迟
阶段 2:路由 建 STREAM BUS + 多输出设备(VB-CABLE),OBS 捕获到干净混音 OBS 音频混音器只有直播声、无系统杂音
阶段 3:视频 加入手机摄像头 + 录屏(窗口捕获) 人脸、编曲界面两个画面源齐备
阶段 4:画中画 录屏小窗叠在人脸上方,源排序、缩放到位 布局成型,可随时换 USB 摄像头
阶段 5:同步 拍手录屏测延迟,给各视频源分别加补偿滤镜 音画对齐
阶段 6:试推 先本地录制核验,再填 RTMP 试推小范围 无爆音、无漂移
阶段 7:上线 正式开播,关注电平、延迟、网络 稳定直播(如需可常驻)

常见问题与红线清单

  • 节拍器进了直播间 → STREAM BUS 路由排查,排练信号不要送观众。
  • 桌面音频没关,观众听到微信提示 → 禁用 OBS 桌面音频捕获。
  • 嘴型对不上 → 给摄像头加视频延迟滤镜,只延视频。
  • 越播越漂移 → 检查四处采样率是否统一为 48kHz。
  • 中途切场景导致断流 → 不要动音频源,只切换画面层。
  • 合规提醒:抖音对纯录播/无人挂机治理严格;本方案为真人实时编曲弹唱,属合规边内,仍建议有人值守、及时互动。

本文为作者原创 转载时请注明出处 谢谢

乱码三千 – 点滴积累 ,欢迎来到乱码三千技术博客站

使用 Docker-compose 搭建SRS直播中转服务端

发表于 2026-08-31

前言

我们知道, 直播的完整闭环核心链条是由推流端→中转端→播放端三个部分组成的:

  • 推流端(采集+编码+上传):负责把现场音视频或者录制好的文件“打包装箱”,推到服务器。工具如 OBS、Kplayer、直播编码器。
  • 中转端(接收+复制+分发/CDN):负责接收推流端的“货物”,然后复制成成千上万份,通过CDN广播给所有观众。比如抖音服务器、阿里云等流媒体平台。
  • 播放端(拉流+解码+渲染):观众手里的设备,负责向中转端“请求拿货”,拿到后自动拆包装箱并播放。比如手机抖音App、电脑浏览器、电视大屏。

关于推流端的部署, 之前有写过相应文章介绍, 有兴趣可以参考:

  • 《自建直播推流服务器》
  • 《使用kplayer实现无人直播》
  • 《使用Docker安装kplayer实现无人直播》
  • 《使用ffmpeg实现无人直播》
  • 《使用Ant Media实现无人直播》

今天我们来介绍如何使用Docker-compose来部署SRS中转端应用, 用于个人直播的测试

具体部署操作如下:

部署流程

假设你的机器已经安装了Docker, 关于Docker的安装, 有需要可以查看之前的文章:《ubuntu安装docker详细步骤》或者《Mac平台安装Docker》

1. 创建自定义网络

1
docker network create c_bridge

创建自定义网络,方便后期多容器交互。

2. 创建工作目录和数据映射目录

1
2
3
4
5
6
#创建持久化数据映射目录
mkdir -p /root/srs
#创建工作目录
mkdir srs-docker
#进入工作目录
cd srs-docker

3. 创建 docker-compose.yml

由于我们直接使用现成的镜像, 所以无需Dockerfile,只需在工作目录中执行以下指令创建 docker-compose.yml文件即可:

1
vim docker-compose.yml

并在文件中写入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
version: '3.8'  

services:
srs:
image: ossrs/srs:latest
container_name: c_srs
hostname: c_srs # 设置容器主机名
restart: always # 设置自动重启策略
ports:
- "1935:1935" # RTMP协议推流/拉流端口
- "8088:8080" # HTTP协议拉流播放端口
- "8085:1985" # api运维管理端口 容器1985映射到宿主8085
volumes:
- /root/srs/config:/usr/local/srs/conf
- /root/srs/data:/usr/local/srs/objs/nginx/html
command: ["./objs/srs", "-c", "conf/srs.conf"]
networks:
- c_bridge

networks:
c_bridge:
external: true

三个端口详细介绍:

  • 1935(TCP)— RTMP 推流/拉流端口
    主播端(OBS、FFmpeg、摄像头、手机 SDK)把流推上来用的就是它,地址形如 rtmp://你的IP:1935/live/流名。观众如果直接用 RTMP播放器拉流也走这个口。也就是说在RTMP协议下, 它既充当推流口也充当拉流口
  • 1985(TCP)— HTTP API 管理端口
    给程序/运维用的,不是给普通观众用的。比如查版本 curl http://IP:1985/api/v1/versions、查在线流、查客户端列表、做 WebRTC 信令(WHIP/WHEP)、接监控和自动化脚本都走这。浏览器直接开 :1985 能看到 API 返回 JSON,不是漂亮后台页面。
  • 8080(TCP)— HTTP 服务端口(播放 + 控制台)干两件事:
    1. 发 HTTP 协议直播流:http://IP:8080/live/流名.flv(HTTP-FLV)、http://IP:8080/live/流名.m3u8(HLS)
    2. 内置 Web 控制台/测试播放页:浏览器开 http://IP:8080/ 或 /console/ 能看到 SRS 自带界面、在线测试播放器。

4. 启动服务

执行以下指令会自动拉取镜像, 并按照docker-compose.yml中的配置启动容器:

1
docker compose up -d
  • -d:后台运行

执行过程中, 如果提示 docker compose 命令不存在,则需要执行以下指令安装compose插件:

1
2
sudo apt-get update
sudo apt-get install docker-compose-plugin

使用compose启动容器的目的无它, 就是为了方便维护, 免得时间一长忘了🤣

5. 测试服务

  1. 第一步. 在电脑端打开OBS, 使用rtmp://srs所在IP:1935/live/test地址进行推流

    或者使用ffmpeg命令行的方式, 推送桌面屏幕:

    1
    2
    3
    4
    5
    6
    7
    ffmpeg -f avfoundation -framerate 25 -pix_fmt nv12 -i "0:none" \
    -vf "scale=854:480,fps=25,format=yuv420p" \
    -c:v libx264 -preset veryfast -tune zerolatency \
    -profile:v main -level 3.1 \
    -b:v 700k -maxrate 700k -bufsize 1400k \
    -g 50 -keyint_min 50 -sc_threshold 0 -bf 0 \
    -f flv rtmp://SRS_IP:1935/live/test

    或者循环推送本地视频:

    1
    2
    3
    4
    5
    ffmpeg -re -stream_loop -1 -i ./11.mp4 \
    -c:v libx264 -preset veryfast -tune zerolatency -pix_fmt yuv420p \
    -b:v 700k -maxrate 700k -bufsize 1400k -g 50 \
    -c:a aac -b:a 64k -ac 1 -ar 44100 \
    -f flv rtmp://SRS_IP:1935/live/test
  1. 第二步. 在电脑端或者手机打开支持直播源的播放器, 填入rtmp://srs所在IP:1935/live/test地址看是否可以正常播放

如果正常播放, 说明部署成功, 至于播放卡顿之类的, 需要根据你服务器的带宽调整相应的码率,详见下文

关于播放卡顿

由于SRS默认不转码, 推流和播流的码率是一致的, 因此想要流畅播放, 需要确保推流码率不超过带宽, 比如你服务器是1M的出网带宽, 那么码率应设置在800-900kbps甚至500kbps, 当然了码率越低, 清晰度越差, 都是有所牺牲的

有几个概念需要理清楚:

  • 上行带宽: 从电脑传出的方向, 比如我们日常上传文件到网盘, 走的是上行带宽
  • 下行带宽: 传入电脑的方向, 比如我们从网上下载电影
  • 出网带宽: 从服务器传出去的方向, 比如从服务器上传文件到网盘或者用户从网站下载资源, 它们走的是出网带宽
  • 入网带宽: 传入服务器的方向, 比如下载docker镜像到服务器或者用户往服务器上传文件, 或往服务器推流

那么很多人会把下行带宽和出网带宽混为一谈, 同样是下载文件, 但是动作视角不同, 将电脑/服务器当成第一人称视角, 你会发现, 下行带宽和入网带宽是一个意思, 上行带宽和出网带宽是一个意思.

以阿里云1M带宽服务器为例, 这个1M指的是出网带宽, 而不是入网带宽,入网带宽一般会比出网带宽高, 通常在10M左右乃至更高, 方便用户在服务器下载软件部署服务

由于我们在直播闭环中涉及到推流端→中转端→播放端三个端的协作, 所以我们在推流的时候, 需要考虑三个端的带宽, 假设使用本地的OBS推流, 那么需要考虑本地网络的上行带宽以及中转服务器的入网带宽, 而观众在手机上播放直播时需要考虑到本地网络的下行带宽以及中转服务器的出网带宽.

所以, 视频播放卡顿, 有可能出在推流或者服务器那边, 也有可能出在观众那边, 观众的带宽层次不齐我们没法控制, 我们只需要调整推流端和中转端的传输码率, 以确保流畅度即可

因此, 在中转端不进行转码的情况下, 码率设置如下:

  • OBS推流码率: 本地网络的上行带宽和中转服务器的入网带宽以及中转服务器的出网带宽, 三者取最小值设置

如果中转端会进行转码, 码率设置如下:

  • OBS推流码率: 本地网络的上行带宽和中转服务器的入网带宽, 两者取最小值设置
  • 转码输出码率: 不超过中转服务器的出网带宽

如果想要开启SRS的转码功能, 需要在SRS的conf配置文件中的加入以下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
transcode {
enabled on; # 开启转码
ffmpeg ./objs/ffmpeg/bin/ffmpeg;

# 只开一路低码率,专门适配 1M 出网
engine ld {
enabled on;
vcodec libx264;
vbitrate 700; # 转码输出码率 700kbps,出网占用就这数
vfps 25; # 降帧到 25,省带宽
vwidth 854; # 480p 宽(16:9)
vheight 480; # 480p 高
vprofile baseline; # 兼容老播放器
vpreset veryfast; # 省 CPU
vthreads 1;
acodec aac;
abitrate 64; # 音频 64kbps
asample_rate 44100;
achannels 1; # 单声道再省一点
output rtmp://127.0.0.1:[port]/[app]/[stream]_ld;
}
}

关于浏览器播放

如果你不想到处安装RTMP播放器, 希望能任意浏览器直接播放, 那么我们可以专门写一个前端页面放置在SRS的html目录下

在/root/srs/data目录下创建index.html文件, 并将以下内容写入到该文件中(记得要替换掉代码中的主机地址和端口号):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>直播观看</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
width: 100%; height: 100%;
background: #000;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #fff;
overflow: hidden;
}
#app { display: flex; flex-direction: column; width: 100%; height: 100%; }

/* 视频区域:移动端铺满,PC 居中限宽 */
.stage {
position: relative;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #000;
width: 100%;
}
video {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
/* PC 端限制视频最大宽度,居中显示 */
@media (min-width: 820px) {
.stage { padding: 16px; }
video { max-width: 960px; max-height: 540px; border-radius: 12px; }
}

/* 加载/提示遮罩 */
.overlay {
position: absolute; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 14px;
background: rgba(0,0,0,.55);
z-index: 10;
padding: 24px;
text-align: center;
}
.overlay.hidden { display: none; }
.spinner {
width: 40px; height: 40px;
border: 4px solid rgba(255,255,255,.25);
border-top-color: #4facfe;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.msg { font-size: 15px; line-height: 1.6; opacity: .92; }
.btn {
margin-top: 6px;
padding: 10px 26px;
font-size: 15px;
color: #fff;
background: linear-gradient(135deg, #4facfe, #00f2fe);
border: none; border-radius: 24px;
cursor: pointer;
}

/* 顶部状态栏 */
.bar {
position: absolute; top: 0; left: 0; right: 0;
display: flex; align-items: center; gap: 8px;
padding: 12px 14px;
padding-top: calc(12px + env(safe-area-inset-top));
background: linear-gradient(180deg, rgba(0,0,0,.6), transparent);
font-size: 13px;
z-index: 5;
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: #ff4d4f; }
.dot.on { background: #52c41a; box-shadow: 0 0 8px #52c41a; }
</style>
</head>
<body>
<div id="app">
<div class="stage">
<video id="player" playsinline webkit-playsinline x5-playsinline controls autoplay></video>

<div class="bar">
<span id="dot" class="dot"></span>
<span id="status">连接中…</span>
</div>

<div id="overlay" class="overlay">
<div class="spinner" id="spinner"></div>
<div class="msg" id="msg">正在加载直播流…</div>
<button class="btn hidden" id="retryBtn">点击重试</button>
</div>
</div>
</div>

<!-- 关键:全部走公共 CDN,本地无需任何文件 -->
<script src="https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js"></script>

<script>
(function () {
'use strict';

// ===== 配置区:改成你自己的 =====
var HOST = window.location.hostname || '你的服务器IP'; // 若与本页同域可留空自动取
var API_PORT = '8085';
var HTTP_PORT = '8088';
// 流名优先级:地址栏 ?stream=xxx > API 当前第一条流 > 默认 'abc'
var params = new URLSearchParams(window.location.search);
var streamParam = params.get('stream');
// ============================

var video = document.getElementById('player');
var overlay = document.getElementById('overlay');
var spinner = document.getElementById('spinner');
var msgEl = document.getElementById('msg');
var retryBtn = document.getElementById('retryBtn');
var dotEl = document.getElementById('dot');
var statusEl = document.getElementById('status');

function showOverlay(text, showRetry) {
msgEl.textContent = text;
overlay.classList.remove('hidden');
spinner.style.display = showRetry ? 'none' : 'block';
retryBtn.classList.toggle('hidden', !showRetry);
}
function hideOverlay() {
overlay.classList.add('hidden');
}
function setStatus(text, on) {
statusEl.textContent = text;
dotEl.classList.toggle('on', !!on);
}

// 由流名拼出两种地址
function buildUrls(name) {
return {
flv: 'http://' + HOST + ':' + HTTP_PORT + '/live/' + name + '.flv',
hls: 'http://' + HOST + ':' + HTTP_PORT + '/live/' + name + '.m3u8'
};
}

// 判断平台:iOS 走 HLS,其余走 FLV
function isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
}

var currentPlayer = null;

function playFLV(url) {
if (window.flvjs && flvjs.isSupported()) {
var player = flvjs.createPlayer({
type: 'flv',
isLive: true,
url: url
}, { enableStashBuffer: false, stashInitialSize: 128 });
player.attachMediaElement(video);
player.load();
player.play();
currentPlayer = player;
bindEvents(player, 'flv');
} else {
showOverlay('当前浏览器不支持 FLV,请使用 Chrome / Edge / Firefox,或换用 HLS 地址。'+url, true);
}
}

function playHLS(url) {
if (window.flvjs &&Hls.isSupported()) {
var hls = new Hls({ liveSyncDurationCount: 3 });
hls.loadSource(url);
hls.attachMedia(video);
currentPlayer = hls;
hls.on(Hls.Events.MANIFEST_PARSED, function () { video.play(); });
bindHLSEvents(hls);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// iOS Safari 原生
video.src = url;
video.play();
} else {
showOverlay('当前浏览器不支持 HLS 播放。', true);
}
}

function bindEvents(player, tag) {
player.on('statistics_info', function () { setStatus('直播中 · ' + tag.toUpperCase(), true); });
player.on('media_info', function () { hideOverlay(); });
player.on('error', function (e) {
console.error(tag + ' error', e);
setStatus('播放异常', false);
showOverlay('播放出错,可能是流已中断。', true);
});
player.on('loading_complete', function () { /* live 一般不到这 */ });
}
function bindHLSEvents(hls) {
hls.on(Hls.Events.MEDIA_ATTACHED, function () { setStatus('缓冲中…', false); });
hls.on(Hls.Events.FRAG_LOADED, function () { hideOverlay(); setStatus('直播中 · HLS', true); });
hls.on(Hls.Events.ERROR, function (_, data) {
if (data && data.fatal) {
setStatus('播放异常', false);
showOverlay('播放出错:' + (data.details || '未知错误'), true);
}
});
}

function start(name) {
var urls = buildUrls(name);
hideOverlay();
setStatus('缓冲中…', false);
if (isIOS()) {
playHLS(urls.hls);
} else {
playFLV(urls.flv);
}
}

retryBtn.addEventListener('click', function () {
if (streamParam) { start(streamParam); return; }
// 无指定流名时重新拉取 API
fetchStreamAndStart();
});

// 优先用地址栏指定流;否则查 SRS API 取当前第一条在线流
function fetchStreamAndStart() {
var apiUrl = 'http://' + HOST + ':' + API_PORT + '/api/v1/streams/';
fetch(apiUrl)
.then(function (r) { return r.json(); })
.then(function (data) {
var list = (data && data.streams) || [];
if (list.length === 0) {
setStatus('无直播', false);
showOverlay('当前没有正在直播的流,请先推流后再刷新。', true);
return;
}
// 取第一条的 name(rtmp app/stream 中的 stream 部分)
var name = list[0].name;
start(name);
})
.catch(function (e) {
console.error(e);
setStatus('连接失败', false);
showOverlay('无法连接直播服务(' + apiUrl + '),请检查服务器地址、端口与安全组。', true);
});
}

// 入口
if (streamParam) {
start(streamParam);
} else {
fetchStreamAndStart();
}

// 页面隐藏时暂停,恢复时重连(省流量、避黑屏)
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
try { video.pause(); } catch (e) {}
}
});
})();
</script>
</body>
</html>

此时在浏览器中输入http://中转服务器IP:8088/即可实现浏览器观看直播

image-20260831135506370

如果使用ffmpeg推流时浏览器可以正常播放, 但是换成OBS推流却播放不了, 那问题大概率出在OBS直播配置上, 需要在直播设置→输出→高级输出模式页面中, 在x264选项栏中填入以下内容, 用于兼容浏览器设备:

1
keyint=50:min-keyint=50:scenecut=0:bframes=0:profile=main:pix_fmt=yuv420p

image-20260831171330174

本文为作者原创 转载时请注明出处 谢谢

乱码三千 – 点滴积累 ,欢迎来到乱码三千技术博客站

Open Knowledge Framework 一套让智能体越用越好用的成长框架

发表于 2026-05-11

前言

知识不等于行为,行为不能靠自觉,要靠机制。

一个真实的问题

我用 Coze 平台搭了一堆智能体帮我干活——写代码、混音乐、写网文、做视频、运营社媒……一开始很顺畅,但很快发现了一个致命问题:

智能体会失忆。

今天踩的坑,明天换个会话又踩一遍。上周验证过的方案,这周重新摸索。一个角色积累的经验,另一个角色完全不知道。

更头疼的是多角色协作:系统开发者改了个配置,ECS 运维不知道,部署时崩了;运营发现平台规则变了,没通知到创作者,内容被打回。每个角色都在”独立聪明”,但没有”一起变聪明”。

我需要的不是一个更聪明的 Prompt,而是一套机制——让智能体的知识能沉淀、能流通、能自动触发行为。

这就是 Open Knowledge Framework 的起点。

它是什么

Open Knowledge Framework 是一套面向 AI 智能体的成长操作系统(开源、MIT 协议)。

它不是工具箱,不是模板库,而是一套让智能体从”能用”→”好用”→”自己变更好用”的成长机制。

核心理念用一句话说:知识不等于行为,行为不能靠自觉,要靠机制。机制用步骤来实现,步骤要读写闭环。

五个设计支柱

支柱 含义 为什么重要
无限扩展 新功能 = 注册扩展,架构本身永远不需要改 今天 3 个角色,明天 30 个,体系不能重写
高效少错 机制替代自觉,步骤替代记忆 智能体会失忆、会偷懒、会自作主张
省 Token 每句话都花钱,少废话多干活 不做无用搜索、不重复操作、不空转等待
平台无关 抽象掉平台差异,核心逻辑可迁移 扣子→飞书→Hermes→机器人,体系代码不用改
实用耐用 规则写在文件里,不靠人记住 踩过的坑自动升级成规则,重犯 = 体系失职

它解决什么问题

问题 1:智能体会失忆

现象:每次对话都像第一次见面,之前踩过的坑、积累的经验全部归零。

解法:三层记忆体系

  • 即时层:自动加载到上下文——身份定义、用户画像、当前状态、工具经验、敏感凭证
  • 近中期层:按需加载——项目进度快照、重要决策记录、待办事项
  • 长期层:语义检索——历史对话和文件内容的 RAG 搜索

关键设计:记忆有分层上限,不是所有东西都往一个文件里塞。超过上限的内容自动沉淀到详细文件,索引层只保留指针。

问题 2:知识不等于行为

现象:规则写在文档里 ≠ 智能体真的会遵守。踩坑记录在知识库里 ≠ 下次不会重犯。

解法:执行门机制

每个角色都有专属的”执行门”——强制性的步骤化流程:

1
① 查后定方案 → ② 执行 → ③ 验证 → ④ 反哺 → ⑤ 交付

每一步都有明确的检查项,跳步 = 不合格。关键的是第 ④ 步”反哺”——做完任务必须把经验写回知识体系,否则任务不算完成。

问题 3:踩过的坑反复踩

现象:同一个错误,换个会话、换个角色,又犯一遍。

解法:知识自动升级机制

1
2
3
踩 1 次 → 记入 knowledge(被动防御,搜索才能找到)
踩 2 次 → 升级为 hot-rules(每次加载必读,主动防御)
系统性风险 → 升级为铁律或脚本保护(物理不可绕过)

典型案例:图片生成任务传了 count=5,一次烧了 5 万积分。这个教训从 knowledge 升级为铁律后,所有相关任务都自动继承这条规则,再也没犯过。

问题 4:多角色协作断层

现象:各角色各自为战,A 改了配置 B 不知道,C 的需求 D 没收到。

解法:三层单向关联 + 交接台

1
项目 → 角色 → 技能(单向关联,消费方找提供方)
  • 项目 INDEX.md 声明关联了哪些角色
  • 角色 RULES.md 声明需要哪些技能
  • 交接台是所有角色启动时的第一站,防止遗漏和断链

体系架构

文件即框架

整个框架完全由 Markdown 文件和 Shell 脚本构成,不依赖任何特定平台或数据库。知识在文件中,平台只是运行时。

1
2
3
4
5
6
7
宪法层(改一次全局生效)    共享知识/项目规范/
蓝图层(愿景 + 哲学) 共享知识/蓝图.md
角色层(执行者) 角色/[角色名]/
技能层(可复用能力) 技能/[技能名]/
项目层(产出物) 项目/[项目名]/
运行时(工单流转) 交接台/
自动化(引擎脚本) scripts/

Extension Registry 模式

这是架构的核心模式:新功能 = 注册扩展,架构本身永远不需要改。

1
2
3
4
添加新角色 → 不改框架,只创建 角色/新角色/
添加新技能 → 不改框架,只创建 技能/新技能/
添加新项目 → 不改框架,只创建 项目/新项目/
添加新工具 → 不改框架,只注册新扩展

这不是一句口号,是设计约束。无论体系从 3 个角色扩展到 30 个还是 300 个,目录结构、协作规范、执行流程都不需要改。

核心机制详解

1. 五步门——所有任务的标准流程

每个角色执行任务时必须走完五步门,跳步 = 不合格:

步骤 动作 防什么
① 查后定方案 先搜索现有知识和踩坑记录,再定方案 防盲干、防重复造轮子
② 执行 按方案执行,改前备份 防发散、防覆盖
③ 验证 跑测试/自检/扫描 防”自以为写对了”
④ 反哺 写回 INDEX / knowledge / hot-rules 防白干、防失忆
⑤ 交付 推送代码,闭环确认 防忘推、防半成品

2. 知识分层——经验跟着人走

做完任务后,经验不是随便一存,而是先判断归属:

归属 写到哪里 判断标准
项目特有 项目 docs/knowledge/ 换个项目没用了
角色通用 角色 knowledge/ 换个项目还有用
全局共享 共享知识/ 换个角色还能用

3. 自动化脚本——让机器干机器的活

框架内置 40+ 个自动化脚本,覆盖初始化、推送、同步、归档、索引重建、协作引擎等:

脚本 用途
init.sh 一键配置 git 用户/token/remote
push.sh 安全推送(commit + pull + push + 检查)
act.sh 意图检索 + 前科必显(1 秒索引加速)
rebuild-index.sh 重建搜索索引
archive.sh 文件归档到回收站
create-repo.sh 创建仓库 + GitHub/Gitee/Gitea 三推
auto-collab.sh 自动化协作引擎(匀速/加速/并行/聚焦)

4. 多平台容灾——单平台故障不影响安全

所有仓库配置 GitHub + Gitee + Gitea 三推送,一条 git push 推三个平台,git pull 只走 GitHub。

Gitee 和 Gitea 全部私有,绝不在公开仓库暴露任何内部信息。

我的实际使用数据

这套框架不是理论设计,是我在日常使用中一步步迭代出来的:

维度 数据
角色数量 25 个(系统开发者、ECS 运维、混音母带工程师、网文写手、动画导演……)
项目数量 22 个(OpenDAW、OpenLink、动漫制作、网文创作……)
技能数量 34 个
Markdown 文件 1867 个
自动化脚本 40+ 个
迭代版本 v30(从 v1 到 v30,每个版本都是踩坑换来的)

快速上手

3 分钟启动

1
2
3
4
5
6
7
8
9
10
# 1. Fork 框架
# GitHub 上 Fork https://github.com/youbanzhishi/open-knowledge-framework
git clone https://github.com/<你的用户名>/open-knowledge-framework.git
cd open-knowledge-framework

# 2. 一键初始化
bash scripts/init.sh <平台名>

# 3. 让你的智能体读 入口.md
# 智能体会自动走流程、建目录、开始工作

定制你的体系

1
2
3
4
5
6
7
8
9
10
11
# 定义智能体身份
vim 基础设定/SOUL.md

# 添加角色
cp -r 角色/模板 角色/你的角色名

# 添加项目
cp -r 项目/模板 项目/你的项目名

# 添加技能
cp -r 技能/模板 技能/你的技能名

适合谁

✅ 适合:

  • 用多个智能体协作开发项目的团队或个人
  • 想让智能体”越用越聪明”而不是”每次从零开始”的人
  • 需要跨项目、跨平台知识流通的场景
  • 希望用一套统一框架管理所有智能体的知识工作者

❌ 不适合:

  • 只用一个智能体做一个简单任务
  • 不需要知识积累的一次性工作

开源与社区

  • 仓库地址:github.com/youbanzhishi/open-knowledge-framework
  • 许可证:MIT(自由使用、修改、分发)
  • 协作模式:Fork → 增强 → PR 回公共仓库

欢迎 PR 回来的内容

  • ✅ 通用角色/技能/项目模板改进
  • ✅ 脚本 bug 修复或功能增强
  • ✅ 新的通用设计模式
  • ✅ 规范文档改进

留在你私有仓库的内容

  • ❌ 你的私有项目内容和个人信息
  • ❌ 你的凭据和密钥
  • ❌ 与具体业务相关的定制

写在最后

这套框架的起点不是什么宏大的技术愿景,而是一个朴素的诉求——我不想再重复踩同一个坑了。

从最初的几条规则,到五步门,到执行门分层,到知识自动升级机制,到多角色协作网络……每一步迭代都是被真实的坑逼出来的。

今天它还在持续进化。每一次协作中的失误,都会变成体系的一部分。每一次成功的经验,都会流通到更多角色和项目。

知识不等于行为,行为不能靠自觉,要靠机制。

如果你也在用智能体干活,如果你也觉得”每次从零开始”很痛苦,试试这套框架。


Open Knowledge Framework — 让智能体从”能用”到”好用”到”自己变更好用”。

本文为作者原创 转载时请注明出处 谢谢

乱码三千 – 点滴积累 ,欢迎来到乱码三千技术博客站

开发一套AI智能体协作知识体系

发表于 2026-05-10

前言

最开始使用智能体也就是从OpenClaw开始, 通过技能的封装来实现经验的复用, 面对简单的流程性问题, 技能完全可以足够应付我们的需求, 同时也方便用户的交流.

但是遇到复杂且庞大的项目, 技能就显得有些鸡肋了

另外就是, 智能体经常不守规矩, 乱删文件, 喜欢自作主张

还有就是智能体协作的问题, 目前主流的方式是通过多agent实现, 每个agent对接一个独立的飞书机器人, 然后在同一个群中进行协作, 挺好的一个方案, 然而直到hermes的出现, 我突然意识到一个问题, 未来会有更多新的智能体平台诞生, 追着新鲜事物跑并不是一个明智的选择, 我们需要跳出这个体系

为了解决自身的一些需求, 于是决定借助AI自己手搓一个智能体知识体系

该体系的目的不是让智能体变聪明, 而是让他们更守规矩, 按照我们预设的流程和规范来做事, 同时可以实现自我成长, 进入越用越好用的良性循环状态

架构设计

模拟真实世界的做事规则, 一个项目可以由多个角色共同参与完成, 一个角色在职责范围内具备多个技能, 当我们在做一个项目的时候, 会不断积累项目经验, 有一些经验归属于角色, 有一些经验独属于项目, 可复用的项目经验提炼到公共知识库, 可以提升下一个项目的执行效率, 所以知识体系架构如下:

  • 公共知识库
  • 项目
  • 角色
  • 技能

项目可以关联多个角色, 角色可以关联多个技能, 其角色是最为核心的部分, 整个体系可以没有项目没有技能, 但不能缺失角色, 也就是现实中的人

知识体系详解

由于该体系是通过逐步迭代完善而来, 优点比较多, 我一时半会儿也很难说的全面, 索性直接让智能体来帮我生成了一般详细文档, 并单开一篇独立博文介绍, 详情可参见:《Open Knowledge Framework 一套让智能体越用越好用的成长框架》

知识体系的使用

我自己用了一段时间, 觉得很好用, 因此把核心部分抽出来分享给大家使用, 大家可以基于该框架自行拓展

项目已开源:

1
https://github.com/youbanzhishi/open-knowledge-framework

当然了, 我还开发一个短链系统, 如果你嫌麻烦, 想要直接上手使用, 那么只需要一条链接即可, 将下面这条链接发送给任意智能体, 无论是元宝还是豆包扣子, 都可以自动识别:

1
https://y6e.cn/we-ai

总结

今天OpenClaw, 明天Hermes, 后天可能是别的, 无论怎么变我们的知识体系都可以无缝过渡使用, 足以应对未来AI变化

本文为作者原创 转载时请注明出处 谢谢

乱码三千 – 点滴积累 ,欢迎来到乱码三千技术博客站

如何配置 OpenClaw 使用自定义模型

发表于 2026-04-22

前言

网上撸了很多免费的大模型,比如元宝、阿里云、飞书、天翼云等等,由于都是限量供应,比如元宝的和阿里云的 Api 每天会提供一定的额度,所以不得不在多个模型中来回切换。
有些厂商的定版制openclaw 除了飞书外,其他的比如阿里云的 JVS Claw在模型配置面板中只支持部分知名模型供应商,无法随意配置自定义模型,而元宝压根就不支持第三方模型的配置,所以如果我们想要自定义,那就只能通过指令配置的形式了。
指令的执行需要依靠终端窗口,但是没关系,只能你能和龙虾对上话,哪怕厂商不暴露终端给你也一样能配置,让龙虾帮你配就好了, 这还不好简单😉

那么接下来,就跟大家分享一下如何配置自定义模型

第一步:增加自定义模型配置

首先,您需要在 OpenClaw 的配置中注册您的模型提供商和模型信息。

1. 清理旧配置(如存在)

如果之前配置过同名模型,建议先清理旧配置以确保环境干净。

1
openclaw config unset models.providers.<your_provider_name>

提示:请将上述命令中的 <your_provider_name> 替换为您的自定义标识,例如 yuanbao。

2. 激活模型配置模式

确保 OpenClaw 的模型模式设置为 merge,以允许合并新的模型配置。

1
openclaw config set models.mode merge

3. 初始化模型提供商配置结构

确保 models.providers 配置项存在且为一个空对象,为后续注入配置做好准备。

1
openclaw config set models.providers '{}'

4. 注入自定义模型配置

使用 --json 参数,以正确的 JSON 格式注入您的模型详细信息。这是最关键的一步。

1
2
3
4
5
6
7
8
9
10
11
openclaw config set 'models.providers.<your_provider_name>' --json '{
"baseUrl": "这里填你模型的 baseUrl",
"apiKey": "这里填你模型的apiKey",
"api": "openai-completions",
"models": [
{
"id": "这里填模型id",
"name": "这里填模型名称"
}
]
}'

重要参数说明:

  • baseUrl:模型服务实际的 API 端点地址。
  • apiKey: 模型 API 密钥。
  • api:指定适配的 API 协议,openai-completions 表示兼容 OpenAI 的补全接口。
  • models.id:模型在服务端的唯一标识符。
  • models.name:为您模型设置的展示名称。

执行效果:成功执行后,您的配置文件openclaw.json 中会新增类似以下的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"models": {
"providers": {
"<your_provider_name>": {
"baseUrl": "<your_model_api_base_url>",
"apiKey": "<your_api_key>",
"api": "openai-completions",
"models": [
{
"id": "<your_model_id>",
"name": "<your_model_display_name>"
}
]
}
}
}
}

第二步:设置默认模型

注册模型后,需要将其指定为 OpenClaw 智能体(Agents)使用的默认主模型。

在容器内执行以下命令,将您的模型设置为全局默认模型:

1
openclaw config set agents.defaults.model.primary <your_provider_name>/<your_model_id>

关键点解析:

  • 配置路径:agents.defaults.model.primary 是 OpenClaw 框架中用于设置主模型的标准配置路径。
  • 格式规范:值必须采用 provider/modelId 的格式。

执行效果:此命令将在配置文件中新增如下结构,使系统默认调用您指定的模型:

1
2
3
4
5
6
7
8
9
{
"agents": {
"defaults": {
"model": {
"primary": "<your_provider_name>/<your_model_id>"
}
}
}
}

第三步:重启网关并验证

配置修改完成后,需要重启相关服务以使新配置生效。

1. 重启 OpenClaw 网关

执行以下命令重启网关服务,加载最新的模型配置。

1
openclaw gateway restart

2. 验证配置状态

重启完成后,使用以下命令查看当前系统中已就绪且生效的默认模型,确认配置是否成功。

1
openclaw models status

当命令输出中显示您配置的模型(如 <your_provider_name>/<your_model_id>)为可用状态时,即表示自定义模型配置并设置默认成功。

总结

总共分为 注册模型、设为默认、重启生效 三个步骤,简单即可完成 OpenClaw 对自定义模型的集成,赶紧去尝试一下吧😁

分部配置方案

有时候我们可能只需要修改某个模型的密钥或者名称,那么可以使用单独的指令会更加方便

具体模板可以参考如下:

第一步:清理配置

1
openclaw config unset models.模型别名

第二步:设置运行模式

1
openclaw config set models.mode "merge"

第三步:配置基础信息

1
2
3
4
5
6
7
8
# 设置 API 端点地址
openclaw config set models.providers.模型别名.baseUrl "API基础URL"

# 设置 API 密钥
openclaw config set models.providers.模型别名.apiKey "API密钥"

# 设置 API 类型
openclaw config set models.providers.模型别名.api "API类型"

第四步:配置模型列表

1
2
3
4
5
6
7
8
9
10
# 1. 先清空 models 配置
openclaw config unset models.providers.模型别名.models

# 2. 添加第一个模型
openclaw config set models.providers.模型别名.models[0].id "第一个模型ID"
openclaw config set models.providers.模型别名.models[0].name "第一个模型显示名称"

# 3. 如需添加更多模型(可选)
openclaw config set models.providers.模型别名.models[1].id "第二个模型ID"
openclaw config set models.providers.模型别名.models[1].name "第二个模型显示名称"

元宝配置示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1. 清理配置
openclaw config unset models.yuanbao

# 2. 设置运行模式
openclaw config set models.mode "merge"

# 3. 配置基础信息
openclaw config set models.providers.yuanbao.baseUrl "https://bot.yuanbao.tencent.com/api/bot"
openclaw config set models.providers.yuanbao.apiKey "675628accfc4963a4a9f9c42fe98014cd29d6522717f5a79976165630c55d1bd2a8cfe4bcf821451c0455ed302eeb8d97d5fb52e8c0dd3ba07519b08d90c17845c710b4ffda5fbe7e4383d51fb64"
openclaw config set models.providers.yuanbao.api "openai-completions"

# 4. 配置模型
openclaw config unset models.providers.yuanbao.models
openclaw config set models.providers.yuanbao.models[0].id "openclaw_yuanbao_robot_model"
openclaw config set models.providers.yuanbao.models[0].name "腾讯元宝"

多模型配置示例

1
2
3
4
5
6
7
8
9
10
# 清空后重新设置多个模型
openclaw config unset models.providers.yuanbao.models

# 添加第一个模型
openclaw config set models.providers.yuanbao.models[0].id "openclaw_yuanbao_robot_model"
openclaw config set models.providers.yuanbao.models[0].name "腾讯元宝"

# 添加第二个模型
openclaw config set models.providers.yuanbao.models[1].id "yuanbao-pro"
openclaw config set models.providers.yuanbao.models[1].name "元宝Pro版"

本文为作者原创 转载时请注明出处 谢谢

乱码三千 – 点滴积累 ,欢迎来到乱码三千技术博客站

从零散Docker容器一键生成 docker-compose 编排配置的自动化方案

发表于 2026-04-22

前言

许多开发者在早期使用Docker时,习惯通过手动执行docker run命令启动容器。随着项目复杂度增加,这种方式的问题逐渐暴露:配置难以复现、迁移成本高、团队协作困难。本文将重点介绍如何通过自动化工具,从现有容器中一键生成规范的docker-compose.yml配置,实现快速迁移和标准化部署。

核心自动化工具

1. docker-autocompose:命令行利器

docker-autocompose是一个专为逆向工程设计的命令行工具,能够从现有容器自动生成docker-compose.yml配置。

基本用法

1
2
3
4
# 分析指定容器并生成配置
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
ghcr.io/red5d/docker-autocompose:latest \
wp-site1 wp-site2 mysql-db

保存到文件

1
2
3
4
# 将输出重定向到文件
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
ghcr.io/red5d/docker-autocompose:latest \
wp-site1 wp-site2 mysql-db > docker-compose.yml

高级特性

  • 支持已停止的容器:工具通过读取Docker引擎的元数据工作,不要求容器处于运行状态
  • 保留所有配置:自动提取端口映射、环境变量、数据卷挂载、网络设置等
  • 多容器分析:可一次性分析多个相互关联的容器

2. 交互式工具:compose-generator

对于偏好图形界面的用户,compose-generator提供了交互式逆向生成体验。

通过Docker运行

1
2
3
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock \
-v $(pwd):/cg/out \
ghcr.io/compose-generator/compose-generator:latest

工作流程

  1. 启动后选择”From running project”
  2. 在列表中选择要分析的容器
  3. 跟随向导完成配置生成
  4. 生成的docker-compose.yml将输出到当前目录

实战案例:WordPress站点自动化迁移

场景描述

现有三个运行中的容器:两个WordPress站点(wp-site1、wp-site2)共用一个MySQL数据库(mysql-db)。

自动化步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 第一步:一键生成编排配置
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
ghcr.io/red5d/docker-autocompose:latest \
wp-site1 wp-site2 mysql-db > docker-compose.yml

# 第二步:自动生成环境变量模板
cat > .env << 'EOF'
# 自动生成的配置模板
# 请替换为实际的安全密码
DB_PASSWORD=your_secure_password_here
MYSQL_ROOT_PASSWORD=your_root_password_here
EOF

# 第三步:验证生成的配置
docker-compose config

生成结果示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 自动生成的docker-compose.yml
version: '3.8'
services:
wp-site1:
image: wordpress:latest
ports:
- "8081:80"
environment:
WORDPRESS_DB_HOST: mysql-db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
WORDPRESS_DB_NAME: wordpress_site1
volumes:
- wp_site1_data:/var/www/html
networks:
- wp_network
depends_on:
- mysql-db

wp-site2:
image: wordpress:latest
# ... 类似配置
mysql-db:
image: mysql:latest
# ... 数据库配置

进阶:自动化版本号检测与替换

自动化工具生成的配置中,镜像标签通常是latest。为了确保环境一致性,建议固化版本号。

自动化版本检测脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash
# 自动检测并替换latest标签为具体版本
for container in wp-site1 wp-site2 mysql-db; do
image_name=$(docker inspect -f '{{.Config.Image}}' $container)
if [[ "$image_name" == *:latest ]]; then
actual_version=$(docker image inspect $image_name | grep -i version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+|[0-9]+\.[0-9]+' | head -1)
if [ -n "$actual_version" ]; then
# 在docker-compose.yml中替换版本
base_image=${image_name%:*}
sed -i "s|image: $base_image:latest|image: $base_image:$actual_version|g" docker-compose.yml
fi
fi
done

Dockerfile的自动化逆向生成

对于需要重构镜像构建过程的场景,也可以自动化生成Dockerfile。

使用dfimage工具

1
2
3
4
5
6
7
# 一键生成近似的Dockerfile
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
alpine/dfimage -sV=1.36 nginx:latest

# 或使用whaler
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
pegleg/whaler nginx:latest > Dockerfile.approximate

一键式集成脚本

结合上述工具,可以创建一个完整的自动化迁移脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#!/bin/bash
# migrate-docker-to-compose.sh
# 一键从现有容器迁移到编排配置

set -e

echo "🔍 开始Docker容器逆向迁移"

# 1. 使用docker-autocompose生成基础配置
echo "📦 生成docker-compose.yml..."
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
ghcr.io/red5d/docker-autocompose:latest $(docker ps -a --format "{{.Names}}") \
> docker-compose.yml

# 2. 生成环境变量模板
echo "🔐 创建环境变量模板..."
cat > .env.example << 'EOF'
# 自动生成的环境变量配置
# 请根据实际情况修改以下值
EOF

# 3. 生成各服务的Dockerfile(如需要)
echo "🐳 生成服务Dockerfile..."
for service in $(grep -E '^ [a-zA-Z]' docker-compose.yml | sed 's/://'); do
image=$(grep -A5 "^ $service:" docker-compose.yml | grep "image:" | cut -d: -f2- | tr -d ' ')
if [ -n "$image" ]; then
echo "# 为 $service 生成Dockerfile参考"
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
alpine/dfimage -sV=1.36 $image > "Dockerfile.$service" 2>/dev/null || true
fi
done

echo "✅ 迁移完成!"
echo "📁 生成的文件:"
echo " - docker-compose.yml (主编排文件)"
echo " - .env.example (环境变量模板)"
echo " - Dockerfile.* (各服务构建参考)"
echo ""
echo "🚀 使用方式:"
echo " 1. 编辑 .env 文件,设置实际值"
echo " 2. 运行: docker-compose up -d"
echo " 3. 验证: docker-compose ps"

验证与测试

生成配置后,务必进行验证:

1
2
3
4
5
6
7
8
9
10
# 语法验证
docker-compose config

# 测试启动(不实际运行)
docker-compose up --dry-run

# 实际测试运行
docker-compose up -d
docker-compose ps
docker-compose logs

总结

通过自动化工具,原本需要数小时的手动配置工作可以在几分钟内完成。关键工具包括:

  1. docker-autocompose:命令行工具,适合批量处理和脚本集成
  2. compose-generator:交互式工具,适合新手和复杂场景
  3. dfimage/whaler:Dockerfile逆向生成,用于镜像重构

自动化逆向工程不仅提高了迁移效率,更重要的是建立了标准的、可版本控制的部署配置。无论是应对服务器迁移、环境标准化,还是团队协作,这套自动化方案都能显著降低运维成本,提升部署的可靠性

附加

我借助AI生成了一套交互式逆推脚本,它可以将本机所有已创建的容器进行逆推,或者指定具体某几个容器,脚本代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
 #!/bin/bash
# docker-compose-archaeologist-final-fixed.sh
# 最终修复版:修复序号选择和版本检测
# 版本:v2.4-final-fixed

set -e

# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m'
BOLD='\033[1m'
UNDERLINE='\033[4m'

# 输出函数
error() { echo -e "${RED}✗ $1${NC}" >&2; }
success() { echo -e "${GREEN}✓ $1${NC}"; }
info() { echo -e "${BLUE}ℹ $1${NC}"; }
warning() { echo -e "${YELLOW}⚠ $1${NC}"; }
step() { echo -e "\n${CYAN}▶ $1${NC}"; }
header() { echo -e "\n${MAGENTA}▬▬▬▬▬▬▬▬▬▬ $1 ▬▬▬▬▬▬▬▬▬▬${NC}"; }

# 配置
AUTOCOMPOSE_IMAGE="ghcr.io/red5d/docker-autocompose:latest"
OUTPUT_FILE="docker-compose.yml"
TEMP_FILE="docker-compose.tmp.yml"
BACKUP_TIMESTAMP=$(date +%Y%m%d_%H%M%S)

# 打印横幅
print_banner() {
clear
echo -e "${BOLD}${CYAN}"
cat << "EOF"
╔══════════════════════════════════════════════════════════════╗
║ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ║
║ ▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌ ║
║ ▐░█▀▀▀▀▀▀▀▀▀ ▐░█▀▀▀▀▀▀▀█░▌▐░█▀▀▀▀▀▀▀█░▌ ▀▀▀▀█░█▀▀▀▀ ║
║ ▐░▌ ▐░▌ ▐░▌▐░▌ ▐░▌ ▐░▌ ║
║ ▐░█▄▄▄▄▄▄▄▄▄ ▐░█▄▄▄▄▄▄▄█░▌▐░█▄▄▄▄▄▄▄█░▌ ▐░▌ ║
║ ▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌ ▐░▌ ║
║ ▀▀▀▀▀▀▀▀▀█░▌▐░█▀▀▀▀█░█▀▀ ▐░█▀▀▀▀▀▀▀█░▌ ▐░▌ ║
║ ▐░▌▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ║
║ ▄▄▄▄▄▄▄▄▄█░▌▐░▌ ▐░▌ ▐░▌ ▐░▌ ▄▄▄▄█░█▄▄▄▄ ║
║ ▐░░░░░░░░░░░▌▐░▌ ▐░▌▐░▌ ▐░▌▐░░░░░░░░░░░▌ ║
║ ▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀▀▀▀▀▀▀▀▀ ║
║ ║
║ Docker Compose 考古学家 v2.4 ║
║ 自动逆向生成版本化编排配置 ║
╚══════════════════════════════════════════════════════════════╝
EOF
echo -e "${NC}"

echo -e "${YELLOW}📦 自动发现并迁移 Docker 容器到编排配置${NC}"
echo -e "${YELLOW}🔍 支持版本号自动检测和替换${NC}"
echo ""
}

# 检查是否安装jq
check_jq() {
if command -v jq &> /dev/null; then
HAS_JQ=true
JQ_VERSION=$(jq --version | grep -oE '[0-9]+\.[0-9]+')
success "jq $JQ_VERSION (JSON处理已启用)"
else
HAS_JQ=false
warning "未检测到 jq,使用基础功能 (建议安装: sudo apt install jq)"
fi
echo ""
}

# 获取所有容器
get_all_containers() {
step "扫描 Docker 容器"

if [ "$HAS_JQ" = true ]; then
CONTAINER_DATA=$(docker ps -a --format '{"Names":"{{.Names}}","Image":"{{.Image}}","Status":"{{.Status}}","State":"{{.State}}"}' | jq -s '.')
CONTAINER_COUNT=$(echo "$CONTAINER_DATA" | jq length)
echo "使用jq模式,找到 $CONTAINER_COUNT 个容器"
else
CONTAINER_LIST=$(docker ps -a --format "{{.Names}}\t{{.Image}}\t{{.Status}}")
CONTAINER_COUNT=$(echo "$CONTAINER_LIST" | wc -l | tr -d ' ')
echo "使用基础模式,找到 $CONTAINER_COUNT 个容器"
fi

if [ "$CONTAINER_COUNT" -eq 0 ] || [ -z "$CONTAINER_COUNT" ]; then
error "未发现任何Docker容器"
exit 1
fi

success "发现 $CONTAINER_COUNT 个容器"
}

# 显示容器列表
display_containers() {
header "容器列表"

echo "┌─────┬──────────────────────┬──────────────────────────┬─────────────────┐"
echo "│ 序号 │ 容器名称 │ 镜像 │ 状态 │"
echo "├─────┼──────────────────────┼──────────────────────────┼─────────────────┤"

if [ "$HAS_JQ" = true ]; then
for i in $(seq 0 $((CONTAINER_COUNT-1))); do
name=$(echo "$CONTAINER_DATA" | jq -r ".[$i].Names")
image=$(echo "$CONTAINER_DATA" | jq -r ".[$i].Image")
status=$(echo "$CONTAINER_DATA" | jq -r ".[$i].Status")

name_display=$(printf "%-20s" "$name" | cut -c1-20)
image_display=$(printf "%-24s" "$image" | cut -c1-24)
status_display=$(printf "%-15s" "$status" | cut -c1-15)

printf "│ %3d │ %-20s │ %-24s │ %-15s │\n" \
$((i+1)) "$name_display" "$image_display" "$status_display"
done
else
i=1
while IFS=$'\t' read -r name image status; do
[ -z "$name" ] && continue

name_display=$(printf "%-20s" "$name" | cut -c1-20)
image_display=$(printf "%-24s" "$image" | cut -c1-24)
status_display=$(printf "%-15s" "$status" | cut -c1-15)

printf "│ %3d │ %-20s │ %-24s │ %-15s │\n" \
"$i" "$name_display" "$image_display" "$status_display"
((i++))
done <<< "$CONTAINER_LIST"
fi

echo "└─────┴──────────────────────┴──────────────────────────┴─────────────────┘"
echo ""
}

# 选择模式
select_mode() {
header "选择模式"

echo "请选择操作方式:"
echo ""
echo " 1) 📋 选择所有容器 ($CONTAINER_COUNT 个)"
echo " 2) 🔢 选择特定容器 (支持单个、多个、范围选择)"
echo " 3) 🎯 按状态筛选 (运行中/已停止)"
echo " 4) 🔍 搜索容器"
echo " 5) ❌ 退出"
echo ""

read -p "请输入选择 [1-5]: " mode

case $mode in
1)
select_all_containers
;;
2)
select_specific_containers
;;
3)
select_by_status
;;
4)
search_containers
;;
5)
info "退出程序"
exit 0
;;
*)
error "无效选择,使用默认模式"
select_all_containers
;;
esac
}

# 选择所有容器
select_all_containers() {
info "选择所有容器"

if [ "$HAS_JQ" = true ]; then
SELECTED_CONTAINERS=($(echo "$CONTAINER_DATA" | jq -r '.[].Names'))
else
SELECTED_CONTAINERS=()
while IFS=$'\t' read -r name _; do
[ -n "$name" ] && SELECTED_CONTAINERS+=("$name")
done <<< "$CONTAINER_LIST"
fi

echo "已选择 ${#SELECTED_CONTAINERS[@]} 个容器"
}

# 解析范围选择
parse_range_selection() {
local selection="$1"
local result=()

IFS=',' read -ra parts <<< "$selection"
for part in "${parts[@]}"; do
part=$(echo "$part" | tr -d ' ')

if [[ "$part" =~ ^([0-9]+)-([0-9]+)$ ]]; then
start=${BASH_REMATCH[1]}
end=${BASH_REMATCH[2]}

if [ "$start" -le "$end" ] && [ "$start" -ge 1 ] && [ "$end" -le "$CONTAINER_COUNT" ]; then
for ((i=start; i<=end; i++)); do
result+=("$i")
done
else
warning "范围 $part 无效,跳过"
fi
elif [[ "$part" =~ ^[0-9]+$ ]]; then
result+=("$part")
else
warning "无效输入: $part,跳过"
fi
done

echo "${result[@]}" | tr ' ' '\n' | sort -nu | tr '\n' ' '
}

# 修复:选择特定容器 - 确保不会直接退出
select_specific_containers() {
while true; do
info "选择特定容器"

display_containers

echo ""
echo "📝 选择方式:"
echo " - 单个容器: 1"
echo " - 多个容器: 1 3 5"
echo " - 范围选择: 1-5"
echo " - 混合选择: 1,3-5,7"
echo " - 全部选择: all"
echo " - 返回菜单: back"
echo ""

read -p "请输入选择: " selection

if [ "$selection" = "back" ]; then
info "返回主菜单"
select_mode
return
fi

if [ "$selection" = "all" ]; then
select_all_containers
return
fi

if [ -z "$selection" ]; then
error "输入为空,请重新输入"
continue
fi

parsed_selection=$(parse_range_selection "$selection")

if [ -z "$parsed_selection" ]; then
error "无效的选择,请重新输入"
continue
fi

SELECTED_CONTAINERS=()
selected_count=0

for num in $parsed_selection; do
idx=$((num-1))

if [ "$HAS_JQ" = true ]; then
if [ $idx -ge 0 ] && [ $idx -lt $CONTAINER_COUNT ]; then
container=$(echo "$CONTAINER_DATA" | jq -r ".[$idx].Names")
SELECTED_CONTAINERS+=("$container")
((selected_count++))
success "✓ 已选择: $container"
else
warning "⚠ 序号 $num 无效,跳过"
fi
else
container=$(echo "$CONTAINER_LIST" | sed -n "${num}p" | cut -f1)
if [ -n "$container" ]; then
SELECTED_CONTAINERS+=("$container")
((selected_count++))
success "✓ 已选择: $container"
else
warning "⚠ 序号 $num 无效,跳过"
fi
fi
done

if [ ${#SELECTED_CONTAINERS[@]} -eq 0 ]; then
error "未选择任何有效容器,请重新输入"
continue
fi

success "已选择 $selected_count 个容器"

# 显示选择结果
echo ""
echo "📋 已选择的容器:"
for container in "${SELECTED_CONTAINERS[@]}"; do
echo " - $container"
done
echo ""

# 修复:添加确认步骤,但确保不会因为确认而退出
read -p "确认选择? [Y/重新选择(r)/返回菜单(b)]: " confirm

case $confirm in
[rR])
info "重新选择"
continue
;;
[bB])
info "返回主菜单"
select_mode
return
;;
*)
# 用户确认,继续执行
return
;;
esac
done
}

# 按状态筛选
select_by_status() {
while true; do
info "按状态筛选"

echo "请选择状态:"
echo " 1) 🟢 运行中 (running)"
echo " 2) 🔴 已停止 (exited)"
echo " 3) 🟡 所有状态"
echo " 4) ↩ 返回菜单"
echo ""

read -p "请输入 [1-4]: " status_choice

case $status_choice in
1)
status_filter="Up"
status_display="运行中"
;;
2)
status_filter="Exited"
status_display="已停止"
;;
3)
status_filter="all"
status_display="所有状态"
;;
4)
info "返回主菜单"
select_mode
return
;;
*)
error "无效选择"
continue
;;
esac

SELECTED_CONTAINERS=()

if [ "$status_filter" = "all" ]; then
select_all_containers
return
fi

if [ "$HAS_JQ" = true ]; then
if [ "$status_filter" = "Up" ]; then
SELECTED_CONTAINERS=($(echo "$CONTAINER_DATA" | jq -r ".[] | select(.Status | startswith(\"Up\")) | .Names"))
elif [ "$status_filter" = "Exited" ]; then
SELECTED_CONTAINERS=($(echo "$CONTAINER_DATA" | jq -r ".[] | select(.Status | startswith(\"Exited\")) | .Names"))
fi
else
while IFS=$'\t' read -r name image status; do
[ -z "$name" ] && continue

if [ "$status_filter" = "Up" ] && [[ "$status" == Up* ]]; then
SELECTED_CONTAINERS+=("$name")
elif [ "$status_filter" = "Exited" ] && [[ "$status" == Exited* ]]; then
SELECTED_CONTAINERS+=("$name")
fi
done <<< "$CONTAINER_LIST"
fi

if [ ${#SELECTED_CONTAINERS[@]} -eq 0 ]; then
warning "未找到状态为 '$status_display' 的容器"
echo ""
read -p "是否重新选择? [Y/n] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
continue
else
select_mode
return
fi
else
success "找到 ${#SELECTED_CONTAINERS[@]} 个 '$status_display' 状态的容器"

echo ""
echo "📋 找到的容器:"
for container in "${SELECTED_CONTAINERS[@]}"; do
echo " - $container"
done
echo ""

read -p "确认选择? [Y/n/重新选择(r)]: " confirm
case $confirm in
[nN]|[rR])
continue
;;
*)
return
;;
esac
fi
done
}

# 搜索容器
search_containers() {
while true; do
info "搜索容器"

echo "搜索选项:"
echo " 1) 搜索容器名称"
echo " 2) 搜索镜像名称"
echo " 3) 搜索全部字段"
echo " 4) ↩ 返回菜单"
echo ""

read -p "请选择搜索模式 [1-4]: " search_mode

case $search_mode in
1) search_type="name" ;;
2) search_type="image" ;;
3) search_type="all" ;;
4)
info "返回主菜单"
select_mode
return
;;
*)
error "无效选择"
continue
;;
esac

read -p "请输入搜索关键词: " keyword

if [ -z "$keyword" ]; then
warning "搜索词为空,返回所有容器"
select_all_containers
return
fi

SELECTED_CONTAINERS=()

if [ "$HAS_JQ" = true ]; then
case $search_type in
"name")
SELECTED_CONTAINERS=($(echo "$CONTAINER_DATA" | jq -r ".[] | select(.Names | contains(\"$keyword\")) | .Names"))
;;
"image")
SELECTED_CONTAINERS=($(echo "$CONTAINER_DATA" | jq -r ".[] | select(.Image | contains(\"$keyword\")) | .Names"))
;;
"all")
SELECTED_CONTAINERS=($(echo "$CONTAINER_DATA" | jq -r ".[] | select(.Names | contains(\"$keyword\")) or select(.Image | contains(\"$keyword\")) | .Names"))
;;
esac
else
while IFS=$'\t' read -r name image status; do
[ -z "$name" ] && continue

local match=false
case $search_type in
"name")
[[ "$name" == *"$keyword"* ]] && match=true
;;
"image")
[[ "$image" == *"$keyword"* ]] && match=true
;;
"all")
[[ "$name" == *"$keyword"* ]] || [[ "$image" == *"$keyword"* ]] && match=true
;;
esac

[ "$match" = true ] && SELECTED_CONTAINERS+=("$name")
done <<< "$CONTAINER_LIST"
fi

if [ ${#SELECTED_CONTAINERS[@]} -eq 0 ]; then
warning "未找到包含 '$keyword' 的容器"
echo ""
read -p "是否重新搜索? [Y/n] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
continue
else
select_mode
return
fi
else
success "找到 ${#SELECTED_CONTAINERS[@]} 个匹配的容器"

echo ""
echo "🔍 搜索结果:"
for container in "${SELECTED_CONTAINERS[@]}"; do
echo " - $container"
done
echo ""

read -p "确认选择? [Y/n/重新搜索(r)]: " confirm
case $confirm in
[nN]|[rR])
continue
;;
*)
return
;;
esac
fi
done
}

# 备份现有文件
backup_existing() {
if [ -f "$OUTPUT_FILE" ]; then
backup_file="${OUTPUT_FILE}.backup.$BACKUP_TIMESTAMP"
cp "$OUTPUT_FILE" "$backup_file"
info "已备份现有文件: $backup_file"
fi
}

# 优化:使用简单的docker inspect获取版本号
get_image_version_simple() {
local image_name="$1"
local image_tag="$2"
local full_image="${image_name}:${image_tag}"

if ! docker image inspect "$full_image" &> /dev/null; then
echo ""
return
fi

# 使用简单的 docker inspect | grep -i version 方法
local version_info=$(docker image inspect "$full_image" 2>/dev/null | \
grep -i "version" | \
grep -oE '[0-9]+\.[0-9]+\.[0-9]+|[0-9]+\.[0-9]+' | \
head -1)

if [ -n "$version_info" ]; then
echo "$version_info"
return
fi

# 尝试从常见的环境变量中获取
local env_version=$(docker image inspect "$full_image" 2>/dev/null | \
grep -oE '"VERSION="[^"]*|"NGINX_VERSION="[^"]*|"POSTGRES_VERSION="[^"]*|"MYSQL_VERSION="[^"]*|"REDIS_VERSION="[^"]*' | \
cut -d'"' -f4 | \
head -1)

if [ -n "$env_version" ]; then
echo "$env_version"
return
fi

# 从创建时间推断
local created_date=$(docker image inspect "$full_image" 2>/dev/null | \
grep -o '"Created"[^,]*' | \
cut -d '"' -f4 | \
head -1 | \
cut -d'T' -f1)

if [ -n "$created_date" ]; then
echo "$created_date" | grep -oE '^[0-9]{4}-[0-9]{2}' | tr '-' '.'
return
fi

echo ""
}

# 替换版本号
replace_versions() {
step "版本号考古与替换"

mapfile -t IMAGE_LINES < <(grep -n -E '^\s+image:' "$TEMP_FILE" 2>/dev/null || true)

if [ ${#IMAGE_LINES[@]} -eq 0 ]; then
warning "未找到镜像定义"
return
fi

info "发现 ${#IMAGE_LINES[@]} 个镜像"

cp "$TEMP_FILE" "$OUTPUT_FILE"

local replaced_count=0

for line_info in "${IMAGE_LINES[@]}"; do
line_num=$(echo "$line_info" | cut -d: -f1)
line_content=$(echo "$line_info" | cut -d: -f2-)

image_spec=$(echo "$line_content" | sed 's/^[[:space:]]*//;s/image://;s/[[:space:]]*$//;s/#.*$//')

[ -z "$image_spec" ] && continue

if [[ "$image_spec" == *:* ]]; then
image_name="${image_spec%:*}"
current_tag="${image_spec#*:}"
else
image_name="$image_spec"
current_tag="latest"
fi

image_name=$(echo "$image_name" | sed "s/^['\"]//;s/['\"]$//")
current_tag=$(echo "$current_tag" | sed "s/^['\"]//;s/['\"]$//")

if [[ "$current_tag" == "latest" ]]; then
# 使用优化后的版本检测方法
version=$(get_image_version_simple "$image_name" "$current_tag")

if [ -n "$version" ] && [ "$version" != "latest" ]; then
# 显示调试信息
echo -e " ${BLUE}ℹ${NC} 检测镜像: ${image_name}:${current_tag}"
echo -e " ${GREEN}✓${NC} 找到版本: $version"

# 替换latest为具体版本
sed -i "s|^\(\s*image:\s*\)${image_name}:latest\b|\1${image_name}:${version}|g" "$OUTPUT_FILE"
sed -i "s|^\(\s*image:\s*\)['\"]\?${image_name}:latest['\"]\?\b|\1\"${image_name}:${version}\"|g" "$OUTPUT_FILE"

echo -e " ${GREEN}✓${NC} 替换: ${image_name}:latest → ${image_name}:${version}"
((replaced_count++))
else
echo -e " ${YELLOW}⚠${NC} 无法确定版本: ${image_name}:latest (保持原样)"
fi
else
echo -e " ${BLUE}ℹ${NC} 已有具体版本: ${image_name}:${current_tag}"
fi
done

if [ $replaced_count -gt 0 ]; then
success "版本替换完成 ($replaced_count 个镜像已更新)"
else
info "没有需要替换的 latest 标签"
fi
}

# 生成Compose配置
generate_compose() {
local containers=("$@")

header "生成配置"

step "验证容器"
local valid_containers=()

for container in "${containers[@]}"; do
if docker inspect "$container" &> /dev/null; then
valid_containers+=("$container")
success " ✓ $container"
else
error " ✗ $container (不存在)"
fi
done

if [ ${#valid_containers[@]} -eq 0 ]; then
error "没有有效的容器可分析"
exit 1
fi

backup_existing

step "调用 AutoCompose"
echo "正在分析: ${valid_containers[*]}"

if ! docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
"$AUTOCOMPOSE_IMAGE" "${valid_containers[@]}" > "$TEMP_FILE" 2>&1; then
error "AutoCompose执行失败"
echo "错误信息:"
cat "$TEMP_FILE"
exit 1
fi

if [ ! -s "$TEMP_FILE" ]; then
error "生成的配置为空"
exit 1
fi

success "原始配置生成完成 ($(wc -l < "$TEMP_FILE") 行)"

replace_versions

header "完成"

echo -e "${GREEN}📁 输出文件: $OUTPUT_FILE${NC}"
echo -e "${BLUE}📊 文件大小: $(du -h "$OUTPUT_FILE" | cut -f1)${NC}"
echo -e "${BLUE}📈 总行数: $(wc -l < "$OUTPUT_FILE")${NC}"
echo -e "${BLUE}📦 容器数量: ${#valid_containers[@]}${NC}"
echo ""

echo -e "${YELLOW}🔍 生成的镜像配置:${NC}"
grep -E '^\s+image:' "$OUTPUT_FILE" 2>/dev/null | head -20 | sed 's/^/ /'

echo ""
echo -e "${CYAN}💡 使用建议:${NC}"
echo " 1. 🔍 检查配置: vim $OUTPUT_FILE"
echo " 2. ✅ 验证语法: docker-compose -f $OUTPUT_FILE config"
echo " 3. 🚀 测试启动: docker-compose -f $OUTPUT_FILE up -d"
echo " 4. 📊 查看状态: docker-compose -f $OUTPUT_FILE ps"
echo " 5. 🛑 停止清理: docker-compose -f $OUTPUT_FILE down"
echo ""

rm -f "$TEMP_FILE"

echo -e "${MAGENTA}┌──────────────────────────────────────────────────────┐${NC}"
echo -e "${MAGENTA}│ 📁 文件位置: $(pwd)/$OUTPUT_FILE${NC}"
echo -e "${MAGENTA}└──────────────────────────────────────────────────────┘${NC}"
}

# 修复:主函数 - 确保选择后不会直接退出
main() {
print_banner

check_jq

get_all_containers

display_containers

select_mode

# 修复:检查SELECTED_CONTAINERS是否为空
if [ ${#SELECTED_CONTAINERS[@]} -eq 0 ]; then
error "未选择任何容器,退出程序"
exit 1
fi

echo ""
echo -e "${CYAN}已选择以下 ${#SELECTED_CONTAINERS[@]} 个容器:${NC}"
for container in "${SELECTED_CONTAINERS[@]}"; do
echo " - $container"
done
echo ""

read -p "确认开始生成配置? [Y/n] " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Nn]$ ]]; then
info "操作取消"
exit 0
fi

generate_compose "${SELECTED_CONTAINERS[@]}"
}

# 异常处理
trap 'echo ""; error "脚本被中断"; exit 1' INT TERM

# 运行主函数
main "$@"

将上面代码拷贝到xxx.sh 文件中,然后./xxx.sh 运行即可,运行前记得给予它执行权限:

1
chmod +x xxx.sh

本文为作者原创 转载时请注明出处 谢谢

乱码三千 – 点滴积累 ,欢迎来到乱码三千技术博客站

Docker Compose 常用命令汇总

发表于 2026-04-22

核心生命周期管理

1
2
# 创建并启动所有服务
docker compose up -d

行为说明:容器不存在则创建,配置变更则重建,否则直接启动

1
2
# 重新构建镜像并启动
docker compose up -d --build

行为说明:强制重新构建镜像并用新镜像创建容器

1
2
# 停止并删除所有容器、网络
docker compose down

行为说明:停止删除容器和网络,保留卷和镜像

1
2
# 停止容器不删除
docker compose stop

行为说明:仅停止容器,保留容器和数据

1
2
# 启动已停止的服务
docker compose start
1
2
# 重启所有服务
docker compose restart

容器操作

1
2
# 进入容器 Shell
docker compose exec 服务名 sh
1
2
# 查看服务日志
docker compose logs -f 服务名

行为说明:-f 参数持续跟踪日志输出

1
2
# 查看容器状态
docker compose ps

行为说明:默认只显示运行中的容器,-a 查看所有

镜像构建与管理

1
2
# 仅构建镜像
docker compose build
1
2
# 强制重建镜像
docker compose build --no-cache

行为说明:忽略所有构建缓存,从头开始

1
2
# 拉取服务镜像
docker compose pull

监控与日志

1
2
# 追踪所有日志
docker compose logs -f
1
2
# 查看容器进程
docker compose top

状态与信息查询

1
2
# 验证配置语法
docker compose config

行为说明:解析显示最终配置,验证语法

1
2
# 查看端口映射
docker compose port 服务名 端口

清理与维护

1
2
# 彻底清理(包括卷)
docker compose down -v

行为说明:删除容器、网络、卷,数据会丢失

1
2
# 清理构建缓存
docker builder prune

调试与开发

1
2
# 前台运行调试
docker compose up

行为说明:前台运行,容器退出则停止

1
2
# 执行一次性命令
docker compose run --rm 服务名 命令

行为说明:创建临时容器执行命令,完成后自动删除

项目与上下文管理

1
2
# 使用自定义 Compose 文件
docker compose -f docker-compose-dev.yml up
1
2
# 指定项目名称
docker compose -p 项目名 up

常用场景组合

开发调试

1
2
# 代码变更后重建
docker compose up -d --build
1
2
# 查看最近日志
docker compose logs --tail=20

生产更新

1
2
# 拉取最新镜像
docker compose pull
1
2
# 滚动更新
docker compose up -d

行为说明:用新镜像替换旧容器,实现无停机更新

完全重置

1
2
# 彻底清理
docker compose down -v
1
2
# 从头开始
docker compose up -d --build

一键脚本示例

开发环境重启脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash
# dev-restart.sh
echo "停止服务..."
docker compose stop

echo "清理旧容器..."
docker compose rm -f

echo "清理未使用的镜像..."
docker image prune -f

echo "重新构建并启动..."
docker compose up -d --build

echo "查看日志..."
docker compose logs -f

生产部署脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#!/bin/bash
# deploy.sh
set -e # 出错时退出

echo "1. 拉取最新镜像..."
docker compose pull

echo "2. 备份数据库..."
docker compose exec -T db pg_dumpall -U postgres > backup_$(date +%Y%m%d_%H%M%S).sql

echo "3. 执行数据库迁移..."
docker compose run --rm web python manage.py migrate

echo "4. 重启服务..."
docker compose up -d

echo "5. 健康检查..."
sleep 30
if docker compose ps | grep -q "unhealthy"; then
echo "❌ 服务健康检查失败"
docker compose logs --tail=50
exit 1
fi

echo "✅ 部署成功"

监控脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash
# monitor.sh
echo "=== Docker Compose 服务状态 ==="
docker compose ps

echo -e "\n=== 资源使用情况 ==="
docker compose stats --no-stream

echo -e "\n=== 最近错误日志 ==="
docker compose logs --tail=100 2>/dev/null | grep -E "(ERROR|ERR|Error)" | tail -20

echo -e "\n=== 服务健康状态 ==="
for service in $(docker compose ps --services); do
status=$(docker compose ps $service | tail -1 | awk '{print $4}')
echo "$service: $status"
done

最佳实践

黄金法则

  • up -d --build - 开发时用,确保代码变更生效
  • pull + up -d - 生产更新用,实现滚动更新
  • logs -f - 调试时用,实时查看日志
  • down -v - 清理时用,彻底重置环境

重要提示

  • 容器内修改会在重建后丢失
  • 重要数据要使用卷持久化
  • 配置文件变更会导致容器重建
  • 镜像标签不变时不会自动拉取新版本

本文为作者原创 转载时请注明出处 谢谢

乱码三千 – 点滴积累 ,欢迎来到乱码三千技术博客站

Docker 环境下 MySQL 数据库备份与查看操作指南

发表于 2026-04-21

前言

在使用 Docker 部署 MySQL 时,很多开发者习惯进入容器内部进行操作,然而这常常带来以下痛点:

  • 备份文件默认保存在容器内部,容器一旦停止或删除,备份随之丢失
  • 操作步骤繁琐,需要在容器内外反复切换终端
  • 不利于自动化脚本编写,手动操作易出错
  • 使用磁盘目录映射,要更加方便一些,但依然不够优雅

本文将系统介绍如何在 Docker 环境中安全、高效地备份和查看 MySQL 数据库,并提供”不进容器”的最佳实践方案,同时提供完整的自动化备份和恢复脚本。


一、备份操作

1.1 容器内部备份(不推荐但需了解)

如果你已经进入 MySQL 容器(docker exec -it mysql bash),备份流程如下:

1
2
3
4
5
6
7
8
# 备份单个数据库到容器内临时目录
mysqldump -u root -p密码 数据库名 > /tmp/backup.sql

# 备份所有数据库
mysqldump -u root -p密码 --all-databases > /tmp/all_backup.sql

# 备份并压缩
mysqldump -u root -p密码 数据库名 | gzip > /tmp/backup.sql.gz

关键问题:备份文件在容器内部,容器删除则文件丢失。

解决方法:将文件从容器复制到宿主机

1
2
# 在宿主机执行(另一个终端窗口)
docker cp 容器名或ID:/tmp/backup.sql /宿主机/目标路径/

完整工作流示例:

1
2
3
4
5
6
7
8
# 1. 容器内备份
root@容器ID:/# mysqldump -u root -p123456 mydb > /tmp/mydb_backup.sql

# 2. 宿主机复制(新终端窗口)
$ docker cp mysql-container:/tmp/mydb_backup.sql ~/backups/

# 3. 清理容器临时文件
root@容器ID:/# rm /tmp/mydb_backup.sql

1.2 推荐方案:不进容器直接备份

直接在宿主机执行,一步到位:

1
2
3
4
5
6
7
8
# 备份单个数据库到宿主机
docker exec mysql-container mysqldump -u root -p密码 数据库名 > ~/backup.sql

# 备份所有数据库
docker exec mysql-container mysqldump -u root -p密码 --all-databases > ~/all_backup.sql

# 备份并压缩
docker exec mysql-container mysqldump -u root -p密码 数据库名 | gzip > ~/backup.sql.gz

为什么这是最佳实践?

  • ✅ 简单:单条命令完成备份
  • ✅ 安全:备份直接保存在宿主机
  • ✅ 自动化:易于集成到脚本和定时任务
  • ✅ 资源友好:不占用容器内磁盘空间

1.3 自动化备份脚本

对于生产环境,建议使用自动化备份脚本。以下是一个功能完整的双重备份脚本,可保存为 backup-mysql.sh:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
#!/bin/bash
# MySQL 双重备份脚本
# 1. 完整备份所有数据库
# 2. 每个数据库单独备份
# 3. 保留最近3份备份

# ============ 配置区域 ============
CONTAINER_NAME="c_mysql" # MySQL容器名
MYSQL_USER="root" # MySQL用户名
MYSQL_PASSWORD="songjian" # MySQL密码
BACKUP_BASE_DIR="/root/songjian/mysql/backups" # 备份根目录
KEEP_BACKUPS=3 # 保留的备份份数
LOG_FILE="/root/songjian/mysql/backup-mysql.log" # 日志文件
# ==================================

# 创建必要的目录
mkdir -p $BACKUP_BASE_DIR
mkdir -p $(dirname $LOG_FILE)

# 日志函数
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a $LOG_FILE
}

# 错误处理函数
error_exit() {
log "❌ 错误: $1"
exit 1
}

# 获取当前时间戳
BACKUP_TIME=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="$BACKUP_BASE_DIR/$BACKUP_TIME"

log "=========================================="
log "开始 MySQL 双重备份"
log "备份时间: $BACKUP_TIME"
log "备份目录: $BACKUP_DIR"
log "容器名称: $CONTAINER_NAME"
log "备份路径: $BACKUP_BASE_DIR"

# 检查容器是否运行
CONTAINER_STATUS=$(docker ps --filter "name=$CONTAINER_NAME" --format "{{.Names}}")

if [ -z "$CONTAINER_STATUS" ]; then
error_exit "容器 ${CONTAINER_NAME} 未运行!"
else
log "✅ 容器状态: 运行中 ($CONTAINER_NAME)"
fi

# 创建本次备份目录
mkdir -p $BACKUP_DIR/all_databases
mkdir -p $BACKUP_DIR/separate_databases
mkdir -p $BACKUP_DIR/logs

# 1. 备份所有数据库(完整备份)
log "1. 开始完整备份所有数据库..."
ALL_BACKUP_FILE="$BACKUP_DIR/all_databases/full_backup.sql.gz"

# 测试 MySQL 连接
log "测试 MySQL 连接..."
if ! docker exec $CONTAINER_NAME mysql -u$MYSQL_USER -p$MYSQL_PASSWORD -e "SELECT 1" >/dev/null 2>&1; then
error_exit "无法连接到 MySQL 数据库,请检查用户名和密码"
else
log "✅ MySQL 连接测试成功"
fi

# 执行完整备份
docker exec $CONTAINER_NAME mysqldump -u$MYSQL_USER -p$MYSQL_PASSWORD \
--all-databases \
--single-transaction \
--routines \
--events \
--triggers \
--set-gtid-purged=OFF 2>> "$BACKUP_DIR/logs/full_backup.log" | gzip > $ALL_BACKUP_FILE

if [ $? -eq 0 ] && [ -s $ALL_BACKUP_FILE ]; then
SIZE=$(du -h $ALL_BACKUP_FILE | cut -f1)
log " ✅ 完整备份成功: $(basename $ALL_BACKUP_FILE) ($SIZE)"
else
# 尝试不使用 --set-gtid-purged 参数
log "⚠️ 第一次备份失败,尝试不使用 GTID 参数..."
docker exec $CONTAINER_NAME mysqldump -u$MYSQL_USER -p$MYSQL_PASSWORD \
--all-databases \
--single-transaction \
--routines \
--events \
--triggers 2>> "$BACKUP_DIR/logs/full_backup_alt.log" | gzip > $ALL_BACKUP_FILE

if [ $? -eq 0 ] && [ -s $ALL_BACKUP_FILE ]; then
SIZE=$(du -h $ALL_BACKUP_FILE | cut -f1)
log " ✅ 完整备份成功(备选方案): $(basename $ALL_BACKUP_FILE) ($SIZE)"
else
error_exit "完整备份失败!"
fi
fi

# 2. 获取所有数据库列表
log "2. 获取数据库列表..."
DATABASES=$(docker exec $CONTAINER_NAME mysql -u$MYSQL_USER -p$MYSQL_PASSWORD \
--skip-column-names \
-e "SHOW DATABASES;" 2>> "$BACKUP_DIR/logs/db_list.log")

if [ $? -ne 0 ]; then
error_exit "获取数据库列表失败!"
fi

# 过滤掉系统数据库
FILTERED_DATABASES=""
for DB in $DATABASES; do
case $DB in
information_schema|performance_schema|sys|mysql)
log " 跳过系统数据库: $DB"
;;
*)
FILTERED_DATABASES="$FILTERED_DATABASES $DB"
;;
esac
done

# 重新赋值
DATABASES=$FILTERED_DATABASES
DB_COUNT=$(echo "$DATABASES" | wc -w)

log " 找到 $DB_COUNT 个用户数据库需要单独备份"

# 如果没有数据库,只备份完整备份
if [ $DB_COUNT -eq 0 ]; then
log "⚠️ 没有找到用户数据库,只保留完整备份"

# 创建备份摘要
cat > "$BACKUP_DIR/backup_summary.txt" << EOF
备份摘要
========
备份时间: $(date)
备份编号: $BACKUP_TIME
备份目录: $BACKUP_DIR
容器名称: $CONTAINER_NAME

备份内容:
1. 完整备份: $ALL_BACKUP_FILE
大小: $(du -h $ALL_BACKUP_FILE | cut -f1)

2. 单独数据库备份: 0 个 (没有找到用户数据库)

备份统计:
数据库总数: 0
完整备份: 成功
单独备份: 跳过
EOF

# 清理旧备份
cleanup_old_backups
exit 0
fi

log " 需要备份的数据库: $DATABASES"

# 3. 逐个备份每个数据库
SUCCESS_COUNT=0
FAIL_COUNT=0
FAILED_DBS=""
log "3. 开始逐个数据库备份..."

for DB in $DATABASES; do
log " 备份数据库: $DB"
DB_BACKUP_FILE="$BACKUP_DIR/separate_databases/${DB}.sql.gz"

docker exec $CONTAINER_NAME mysqldump -u$MYSQL_USER -p$MYSQL_PASSWORD \
--single-transaction \
--routines \
--events \
--triggers \
$DB 2>> "$BACKUP_DIR/logs/${DB}.log" | gzip > $DB_BACKUP_FILE

if [ $? -eq 0 ] && [ -s $DB_BACKUP_FILE ]; then
SIZE=$(du -h $DB_BACKUP_FILE | cut -f1)
log " ✅ 成功 ($SIZE)"
((SUCCESS_COUNT++))
else
log " ❌ 失败,尝试简单备份..."
# 尝试简化备份
docker exec $CONTAINER_NAME mysqldump -u$MYSQL_USER -p$MYSQL_PASSWORD \
$DB 2>> "$BACKUP_DIR/logs/${DB}_simple.log" | gzip > $DB_BACKUP_FILE

if [ $? -eq 0 ] && [ -s $DB_BACKUP_FILE ]; then
SIZE=$(du -h $DB_BACKUP_FILE | cut -f1)
log " ✅ 成功(简单模式) ($SIZE)"
((SUCCESS_COUNT++))
else
log " ❌❌ 备份失败"
rm -f $DB_BACKUP_FILE
FAILED_DBS="$FAILED_DBS $DB"
((FAIL_COUNT++))
fi
fi
done

log " 数据库单独备份完成: 成功 $SUCCESS_COUNT 个, 失败 $FAIL_COUNT 个"
if [ $FAIL_COUNT -gt 0 ]; then
log " 失败的数据库: $FAILED_DBS"
fi

# 4. 创建备份摘要
log "4. 生成备份摘要..."
SUMMARY_FILE="$BACKUP_DIR/backup_summary.txt"
cat > $SUMMARY_FILE << EOF
备份摘要
========
备份时间: $(date)
备份编号: $BACKUP_TIME
备份目录: $BACKUP_DIR
容器名称: $CONTAINER_NAME
MySQL 用户: $MYSQL_USER

备份内容:
1. 完整备份: $ALL_BACKUP_FILE
大小: $(du -h $ALL_BACKUP_FILE | cut -f1)

2. 单独数据库备份 ($SUCCESS_COUNT 个):
EOF

# 添加每个数据库的备份信息
for DB in $DATABASES; do
FILE="$BACKUP_DIR/separate_databases/${DB}.sql.gz"
if [ -f "$FILE" ]; then
SIZE=$(du -h "$FILE" 2>/dev/null | cut -f1 || echo "未知")
echo " - $DB: $SIZE" >> $SUMMARY_FILE
fi
done

cat >> $SUMMARY_FILE << EOF

备份统计:
数据库总数: $DB_COUNT
成功备份: $SUCCESS_COUNT
备份失败: $FAIL_COUNT
EOF

if [ -n "$FAILED_DBS" ]; then
echo " 失败列表: $FAILED_DBS" >> $SUMMARY_FILE
fi

cat >> $SUMMARY_FILE << EOF
日志文件: $BACKUP_DIR/logs/

生成时间: $(date)
EOF

# 5. 清理旧备份函数
cleanup_old_backups() {
log "5. 清理旧备份(保留最近 $KEEP_BACKUPS 份)..."
BACKUP_LIST=($(ls -dt $BACKUP_BASE_DIR/20* 2>/dev/null))
BACKUP_COUNT=${#BACKUP_LIST[@]}

if [ $BACKUP_COUNT -gt $KEEP_BACKUPS ]; then
TO_DELETE_COUNT=$((BACKUP_COUNT - KEEP_BACKUPS))
log " 发现 $BACKUP_COUNT 份备份,需要删除 $TO_DELETE_COUNT 份旧备份"

for ((i=KEEP_BACKUPS; i<BACKUP_COUNT; i++)); do
OLD_BACKUP=${BACKUP_LIST[$i]}
OLD_BACKUP_NAME=$(basename "$OLD_BACKUP")
log " 删除旧备份: $OLD_BACKUP_NAME"
rm -rf "$OLD_BACKUP"

# 记录删除操作
echo "删除备份: $OLD_BACKUP_NAME ($(date))" >> "$BACKUP_DIR/logs/cleanup.log"
done
else
log " 当前有 $BACKUP_COUNT 份备份,无需清理(最多保留 $KEEP_BACKUPS 份)"
fi
}

# 执行清理
cleanup_old_backups

# 6. 计算总大小
TOTAL_SIZE=$(du -sh $BACKUP_DIR 2>/dev/null | cut -f1 || echo "0B")
REMAINING_SIZE=$(du -sh $BACKUP_BASE_DIR 2>/dev/null | cut -f1 || echo "0B")
BACKUP_LIST_CURRENT=($(ls -dt $BACKUP_BASE_DIR/20* 2>/dev/null))
CURRENT_BACKUP_COUNT=${#BACKUP_LIST_CURRENT[@]}

log "6. 备份完成统计"
log " 本次备份大小: $TOTAL_SIZE"
log " 总备份占用: $REMAINING_SIZE"
log " 当前保留备份: $CURRENT_BACKUP_COUNT 份"
log " 备份目录: $BACKUP_BASE_DIR"
log " 备份日志: $LOG_FILE"

# 生成最终报告
log "=========================================="
log "✅ MySQL 双重备份完成!"
log " 备份编号: $BACKUP_TIME"
log " 完整备份: $(basename $ALL_BACKUP_FILE)"
log " 单独备份: $SUCCESS_COUNT 个数据库"
log " 保留备份: $CURRENT_BACKUP_COUNT/$KEEP_BACKUPS 份"

# 输出摘要
echo ""
echo "===== 备份摘要 ====="
cat $SUMMARY_FILE

设置定时任务:

1
2
3
4
5
# 编辑 crontab
crontab -e

# 添加以下行(每隔2天的凌晨2点执行)
0 2 */2 * * /bin/bash /root/songjian/mysql/backup-mysql.sh

二、查看操作(不进容器)

无需进入容器,直接在宿主机通过 docker exec 调用 MySQL 客户端。

2.1 查看数据库信息

1
2
3
4
5
# 查看所有数据库
docker exec mysql-container mysql -u root -p123456 -e "SHOW DATABASES;"

# 查看当前使用的数据库
docker exec mysql-container mysql -u root -p123456 -e "SELECT DATABASE();"

2.2 查看表结构

1
2
3
4
5
6
7
8
# 查看指定数据库的所有表
docker exec mysql-container mysql -u root -p123456 mydb -e "SHOW TABLES;"

# 查看表结构
docker exec mysql-container mysql -u root -p123456 mydb -e "DESC 表名;"

# 查看建表语句
docker exec mysql-container mysql -u root -p123456 mydb -e "SHOW CREATE TABLE 表名;"

2.3 数据查询

1
2
3
4
5
6
7
8
# 查询数据(限制5条)
docker exec mysql-container mysql -u root -p123456 mydb -e "SELECT * FROM users LIMIT 5;"

# 查询记录数
docker exec mysql-container mysql -u root -p123456 mydb -e "SELECT COUNT(*) FROM users;"

# 带条件的查询
docker exec mysql-container mysql -u root -p123456 mydb -e "SELECT id, name FROM users WHERE status=1;"

2.4 交互式模式

如需连续执行多条 SQL 命令,使用交互式模式:

1
2
# 进入 MySQL 交互命令行
docker exec -it mysql-container mysql -u root -p

输入密码后,进入 mysql> 提示符,可执行任何 SQL 命令:

1
2
3
4
USE mydb;
SHOW TABLES;
SELECT * FROM users;
EXIT; -- 退出

三、恢复操作

3.1 手动恢复

1
2
3
4
5
# 恢复完整备份
zcat backup.sql.gz | docker exec -i mysql-container mysql -u root -p密码

# 恢复单个数据库
zcat database.sql.gz | docker exec -i mysql-container mysql -u root -p密码 数据库名

3.2 交互式恢复脚本

对于复杂的恢复需求,可以使用以下交互式恢复脚本,保存为 interactive-restore.sh:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
#!/bin/bash
# 交互式 MySQL 恢复脚本

CONTAINER_NAME="c_mysql"
MYSQL_USER="root"
MYSQL_PASSWORD="songjian"
BACKUP_BASE_DIR="/root/songjian/mysql/backups"

# 声明数组变量
declare -a BACKUP_PATHS

# 显示菜单
show_menu() {
clear
echo "========================================"
echo " MySQL 数据库恢复工具"
echo "========================================"
echo ""

# 列出备份
echo "可用的备份:"
echo "序号 | 备份目录 | 大小"
echo "----|---------|-----"

local backups=($(ls -dt $BACKUP_BASE_DIR/20* 2>/dev/null))
local count=1

for backup in "${backups[@]}"; do
if [ -d "$backup" ]; then
local backup_name=$(basename "$backup")
local backup_size=$(du -sh "$backup" 2>/dev/null | cut -f1 || echo "未知")
printf "%-4s | %-15s | %s\n" "$count" "$backup_name" "$backup_size"

# 存储备份路径
BACKUP_PATHS[$count]="$backup"
((count++))
fi
done

if [ $count -eq 1 ]; then
echo "未找到任何备份"
fi

echo ""
echo "========================================"
echo "0. 退出"
echo "========================================"
echo ""
}

# 显示备份摘要
show_backup_summary() {
local backup_path="$1"
if [ -f "$backup_path/backup_summary.txt" ]; then
cat "$backup_path/backup_summary.txt"
fi
}

# 恢复完整备份
restore_full_backup() {
local backup_path="$1"
echo ""
echo "⚠️ 警告: 这将覆盖所有现有数据库!"
read -p "确认恢复完整备份? (y/N): " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
echo "开始恢复完整备份..."
zcat "$backup_path/all_databases/full_backup.sql.gz" | \
docker exec -i $CONTAINER_NAME mysql -u$MYSQL_USER -p$MYSQL_PASSWORD
if [ $? -eq 0 ]; then
echo "✅ 完整备份恢复成功!"
else
echo "❌ 恢复失败!"
fi
else
echo "操作已取消"
fi
}

# 恢复单个数据库
restore_single_database() {
local backup_path="$1"

# 列出可恢复的数据库
echo ""
echo "可恢复的数据库:"

# 检查是否有单独的数据库备份
if [ ! -d "$backup_path/separate_databases" ]; then
echo "没有单独的数据库备份目录"
return
fi

# 获取数据库文件列表
local db_files=("$backup_path/separate_databases"/*.sql.gz)
if [ ${#db_files[@]} -eq 0 ] || [ ! -f "${db_files[0]}" ]; then
echo "没有单独的数据库备份文件"
return
fi

# 显示数据库列表
local db_count=1
declare -a DB_NAMES

for db_file in "${db_files[@]}"; do
if [ -f "$db_file" ]; then
local db_name=$(basename "$db_file" .sql.gz)
local size=$(du -h "$db_file" 2>/dev/null | cut -f1 || echo "未知")
echo "$db_count. $db_name ($size)"
DB_NAMES[$db_count]="$db_name"
((db_count++))
fi
done

echo ""
read -p "请选择数据库序号 (0取消): " db_choice

if [[ "$db_choice" =~ ^[0-9]+$ ]] && [ "$db_choice" -ge 1 ] && [ "$db_choice" -lt $db_count ]; then
local DB_NAME="${DB_NAMES[$db_choice]}"

echo ""
echo "⚠️ 警告: 这将覆盖数据库 $DB_NAME!"
read -p "确认恢复数据库 $DB_NAME? (y/N): " confirm

if [[ "$confirm" =~ ^[Yy]$ ]]; then
echo "开始恢复数据库 $DB_NAME..."

# 删除现有数据库
docker exec $CONTAINER_NAME mysql -u$MYSQL_USER -p$MYSQL_PASSWORD -e "DROP DATABASE IF EXISTS \`$DB_NAME\`;" 2>/dev/null

# 创建数据库
docker exec $CONTAINER_NAME mysql -u$MYSQL_USER -p$MYSQL_PASSWORD -e "CREATE DATABASE \`$DB_NAME\`;" 2>/dev/null

if [ $? -ne 0 ]; then
echo "❌ 创建数据库失败!"
return
fi

# 恢复数据库
zcat "$backup_path/separate_databases/${DB_NAME}.sql.gz" | \
docker exec -i $CONTAINER_NAME mysql -u$MYSQL_USER -p$MYSQL_PASSWORD "$DB_NAME"

if [ $? -eq 0 ]; then
echo "✅ 数据库 $DB_NAME 恢复成功!"

# 验证恢复结果
echo "验证恢复结果..."
local table_count=$(docker exec $CONTAINER_NAME mysql -u$MYSQL_USER -p$MYSQL_PASSWORD --skip-column-names -e "USE \`$DB_NAME\`; SHOW TABLES;" 2>/dev/null | wc -l)
if [ $? -eq 0 ]; then
echo "✅ 数据库 $DB_NAME 包含 $table_count 个表"
fi
else
echo "❌ 恢复失败!"
fi
else
echo "操作已取消"
fi
elif [ "$db_choice" = "0" ]; then
echo "操作已取消"
else
echo "无效的选择"
fi
}

# 检查容器状态
check_container() {
if ! docker ps --filter "name=$CONTAINER_NAME" --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}$"; then
echo "❌ 错误: 容器 $CONTAINER_NAME 未运行!"
return 1
fi

# 测试 MySQL 连接
if ! docker exec $CONTAINER_NAME mysql -u$MYSQL_USER -p$MYSQL_PASSWORD -e "SELECT 1" >/dev/null 2>&1; then
echo "❌ 错误: 无法连接到 MySQL 数据库,请检查用户名和密码"
return 1
fi

return 0
}

# 主循环
main() {
# 检查容器状态
if ! check_container; then
exit 1
fi

while true; do
show_menu

read -p "请选择备份序号 (0退出): " choice

if [ "$choice" = "0" ]; then
echo "退出恢复工具"
exit 0
fi

if [ -z "${BACKUP_PATHS[$choice]}" ]; then
echo "无效的选择,请重试"
read -p "按 Enter 继续..."
continue
fi

local SELECTED_BACKUP="${BACKUP_PATHS[$choice]}"
local BACKUP_NAME=$(basename "$SELECTED_BACKUP")

echo ""
echo "已选择备份: $BACKUP_NAME"
echo ""

# 显示备份摘要
show_backup_summary "$SELECTED_BACKUP"

echo ""
echo "恢复选项:"
echo "1. 恢复完整备份 (覆盖所有数据库)"
echo "2. 恢复单个数据库"
echo "3. 返回上级菜单"
echo ""

read -p "请选择恢复选项: " restore_option

case $restore_option in
1)
restore_full_backup "$SELECTED_BACKUP"
;;
2)
restore_single_database "$SELECTED_BACKUP"
;;
3)
continue
;;
*)
echo "无效的选项"
;;
esac

echo ""
read -p "按 Enter 继续..."
done
}

# 执行主程序
main "$@"

使用方式:

1
2
3
4
5
# 1. 给执行权限
chmod +x interactive-restore.sh

# 2. 运行脚本
./interactive-restore.sh

四、参数说明与安全提示

4.1 常用参数

参数 说明 示例
-u 用户名 -u root
-p 密码(注意无空格) -p123456
-e 执行 SQL 语句 -e "SHOW DATABASES;"
-it 交互式终端 -it

4.2 安全建议

  1. 密码安全:避免在命令行明文显示密码

    1
    2
    3
    4
    5
    6
    # 方式1:手动输入(推荐)
    docker exec -it mysql-container mysql -u root -p

    # 方式2:使用环境变量
    export MYSQL_PWD=your_password
    docker exec mysql-container mysql -u root -e "SHOW DATABASES;"
  2. 容器状态:确保容器正在运行

    1
    docker ps | grep mysql
  3. 权限控制:使用最小权限账户进行操作

  4. 定期测试恢复:定期验证备份文件的可用性


五、快速测试指南

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1. 测试备份功能
docker exec mysql-container mysqldump -u root -p123456 --all-databases > /tmp/test_backup.sql

# 2. 验证备份文件
ls -lh /tmp/test_backup.sql
head -n 5 /tmp/test_backup.sql

# 3. 测试查询功能
docker exec mysql-container mysql -u root -p123456 -e "SHOW DATABASES;"

# 4. 测试恢复功能
./interactive-restore.sh

# 5. 清理测试文件
rm /tmp/test_backup.sql

六、总结与建议

核心原则:尽量不进容器

场景 推荐操作 命令示例
手动备份 宿主机直接执行 docker exec mysql容器 mysqldump -u 用户 -p密码 数据库 > 备份.sql
已进容器 备份到 /tmp/ 后复制 容器内备份 → docker cp 复制 → 清理临时文件
自动化备份 使用备份脚本 + crontab backup-mysql.sh + 定时任务
日常查看 宿主机查询 docker exec mysql容器 mysql -u 用户 -p密码 -e "SQL语句"
数据恢复 使用恢复脚本 ./interactive-restore.sh

最佳实践

  1. 自动化备份:使用提供的 backup-mysql.sh 脚本实现定时双重备份
  2. 交互式恢复:使用 interactive-restore.sh 脚本安全恢复数据
  3. 定期验证:至少每月测试一次备份文件的恢复流程
  4. 版本控制:对备份和恢复脚本进行版本管理
  5. 监控告警:监控备份任务的执行状态和磁盘空间

提示:所有命令中的容器名、用户名、密码、数据库名请替换为你的实际值。对于生产环境,建议将备份脚本化并定期测试恢复流程。

本文为作者原创 转载时请注明出处 谢谢

乱码三千 – 点滴积累 ,欢迎来到乱码三千技术博客站

如何使用 Docker 快速部署 OpenClaw 客户端

发表于 2026-04-21

前言

手动安装 OpenClaw 虽然不难,但是操作比较繁琐,尤其是针对新机器,需要反复安装 Node 和 Git 环境,相当耗时,而且容易出错。

为了能以最快的速度体验上龙虾,今天我们以 Docker 的方式,来快速进行 OpenClaw 的安装。

具体部署流程

1. 创建自定义网络

1
docker network create c_bridge

创建自定义网络,方便后期多容器交互。

2. 创建数据映射目录

1
mkdir -p /root/songjian/openclaw

创建数据持久化目录,确保 OpenClaw 配置和数据不会丢失。

3. 搜索 OpenClaw 镜像

1
docker search openclaw

或访问 Docker Hub 查看可用镜像:https://hub.docker.com/r/openclaw/openclaw

4. 拉取镜像

1
2
# 拉取最新版本
docker pull ghcr.io/openclaw/openclaw:latest

镜像大小约 300MB,拉取速度取决于网络状况。

5. 创建容器

1
2
3
4
5
6
7
8
9
10
11
docker run -d \
--name=c_openclaw \
--restart=always \
--network=c_bridge \
-p 3012:3000 \
-p 18789:18789 \
-v /root/songjian/openclaw:/root/.openclaw \
-h c_openclaw \
-e TZ=Asia/Shanghai \
-e OPENCLAW_SKIP_ONBOARD=true \
ghcr.io/openclaw/openclaw:latest

参数解释:

  • -d:后台运行
  • --name=c_openclaw:指定容器名称
  • --restart=always:自动重启策略
  • --network=c_bridge:加入自定义网络
  • -p 3012:3000:将内部 3000 端口映射到宿主机 3012
  • -p 18789:18789:网关通信端口
  • -v /root/songjian/openclaw:/root/.openclaw:挂载数据目录
  • -h c_openclaw:设置容器主机名
  • -e TZ=Asia/Shanghai:设置时区
  • -e OPENCLAW_SKIP_ONBOARD=true:跳过首次引导

6. 进入容器并配置

1
2
3
4
5
6
7
8
# 查看容器是否正常运行
docker ps | grep c_openclaw

# 查看容器日志
docker logs -f c_openclaw

# 进入容器终端
docker exec -it c_openclaw /bin/sh

在容器内部执行以下命令完成初始配置:

1
2
# 运行初始化向导
openclaw onboard

7. 访问 OpenClaw

  • 管理后台:浏览器打开 http://你的服务器IP:3012
  • 网关地址:ws://你的服务器IP:18789

8. 验证服务状态

1
2
3
4
5
# 查看容器内服务状态
docker exec c_openclaw openclaw gateway status

# 检查服务健康状态
curl http://localhost:3012/health

常见问题

1. 端口冲突

如果 3012 或 18789 端口已被占用,可修改为其他端口:

1
2
# 例如使用 3013:3000
-p 3013:3000

2. 权限问题

如果遇到权限错误,可尝试修改数据目录权限:

1
sudo chmod 777 -R /root/songjian/openclaw

3. 镜像拉取失败

可尝试使用国内镜像源:

1
docker pull registry.cn-hangzhou.aliyuncs.com/openclaw/openclaw:latest

4. 配置文件丢失

所有配置文件保存在 /root/songjian/openclaw 目录,定期备份此目录即可。

管理命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 停止容器
docker stop c_openclaw

# 启动容器
docker start c_openclaw

# 重启容器
docker restart c_openclaw

# 删除容器(谨慎操作)
docker rm -f c_openclaw

# 查看容器资源使用
docker stats c_openclaw

升级到新版本

1
2
3
4
5
6
7
8
# 1. 停止并删除旧容器
docker stop c_openclaw && docker rm c_openclaw

# 2. 拉取新镜像
docker pull ghcr.io/openclaw/openclaw:latest

# 3. 使用相同命令重新创建容器
# 数据和配置会保留,因为是挂载的

总结

通过 Docker 部署 OpenClaw,你可以在 5 分钟内完成安装和配置,无需关心 Node.js 版本、系统依赖等问题。这种方式特别适合快速体验、测试环境部署

现在你已经成功部署了 OpenClaw,可以开始使用强大的 AI 智能体功能了!

本文为作者原创 转载时请注明出处 谢谢

乱码三千 – 点滴积累 ,欢迎来到乱码三千技术博客站

12…51

乱码三千

android程序员一枚,擅长java,kotlin,python,金融投资,欢迎交流~

510 日志
146 标签
RSS
© 2026 乱码三千
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%