docker run 转 docker-compose,复盘拆出 12 个坑
需求听起来简单到不值一提:输入一条 docker run 命令,输出等价的 docker-compose.yml。
字符串进,字符串出,没有网络请求,没有状态。我给自己排了 30 分钟。
最后花了一整天。这篇文章把过程中拆出来的 12 个坑全部摊开——包括一个写这篇文章时复查代码才抓出来的真 bug(第 7 坑,60 进制那个)。完整实现 300 行左右,已开源成单文件工具,文末有链接,全部代码都能对着看。

第一段流水线:分词,split(' ') 活不过第一个测试用例
最直觉的写法一行搞定:
var tokens = cmd.split(' ');
然后第一个真实命令就把它打穿了:
docker run -e "MSG=hello world" \
--name web nginx
两个问题:引号里的空格不能切;行尾的 \ 续行符要当空格处理。正则也不好使——引号可以是单引号也可以是双引号,还能出现在参数中间(--env="A=b c")。
老老实实写状态机,核心就 12 行:
function tokenize(cmd){
cmd = String(cmd||'').replace(/\\\r?\n/g,' ').trim(); // 坑1:先吃掉续行符
var out=[], cur='', q=null;
for(var i=0;i<cmd.length;i++){
var c=cmd[i];
if(q){ if(c===q) q=null; else cur+=c; } // 引号内:只认配对引号
else if(c==='"'||c==="'") q=c; // 引号外:进入引号态
else if(/\s/.test(c)){ if(cur){out.push(cur);cur='';} }
else cur+=c;
}
if(cur) out.push(cur);
return out;
}
一个 q 变量记住当前在哪种引号里,出来的 token 已经自动脱掉引号。这是整个转换器的地基,它错一个字符,后面全错。
第二段流水线:参数解析,docker 的 flag 有三副面孔
同一个环境变量,用户可能写成三种形态:
docker run -e A=b img # 短选项 + 空格
docker run --env A=b img # 长选项 + 空格
docker run --env=A=b img # 长选项 + 等号
第三种最阴险:--env=A=b 里有两个 =,只能切第一刀(坑 2):
var key=a, val=null, eq=a.indexOf('=');
if(a.slice(0,2)==='--' && eq>0){ key=a.slice(0,eq); val=a.slice(eq+1); }
接着是坑 3:-d 后面不跟值,-p 后面必须跟一个值。你不查表就没法知道下一个 token 是「当前 flag 的值」还是「下一个 flag」。没有捷径,把 docker run 的参数表抄成两个白名单:
var FLAG_ARG ={'-p':1,'--publish':1,'-v':1,'--volume':1,'-e':1,'--env':1,
'--name':1,'--restart':1,'--network':1, /* ...30+ 个 */};
var FLAG_BOOL={'-d':1,'--detach':1,'--rm':1,'--privileged':1,
'-t':1,'--tty':1,'-i':1,'--interactive':1, /* ... */};
坑 4 是分界线问题:docker run -w /app node:20 npm start 里,npm start 不是参数,是容器启动命令。规则是:遇到第一个不以 - 开头且不是某个 flag 的值的 token,它就是镜像名;镜像名之后的一切原样进 command,哪怕它长得像 flag:
if(o.image){ o.command.push(a); i++; continue; } // 镜像名之后:全是 command
if(a[0]!=='-'){ o.image=a; i++; continue; } // 第一个裸 token:镜像名
docker run img --rm 里的 --rm 是传给容器的参数,不是 docker 的——这条规则保证了语义正确。
语义翻译:有些参数在 compose 世界里根本不存在
坑 5:-d 和 --rm 没有对应物。compose 服务天生就是后台常驻的,-d 直接丢弃;--rm 的「退出即删」语义靠 docker compose down 实现,也丢弃。丢弃是对的,但要静默丢弃,不能报错——用户的命令没有错。
坑 6:--network mynet 要写两处。服务里声明加入网络,顶层还得声明这个网络是外部已存在的,漏掉第二处 docker compose up 直接报错:
services:
web:
networks:
- mynet
networks: # 这一段最容易忘
mynet:
external: true
坑 10:资源限制有版本裂缝。--memory 512m 在 compose v2 语法里是 mem_limit,在 v3 + Swarm 里是 deploy.resources.limits。本地开发场景 docker compose up 对 mem_limit 的支持最直接,选它——但选择本身就是坑,你得知道自己在哪条兼容线上。
第三段流水线:YAML 输出,看起来最简单,坑最深
坑 7,本文最值钱的一个:YAML 1.1 的 60 进制陷阱。
写这篇文章时我复查 yq()(引号函数),突然意识到一个问题:
ports:
- 53:53
不带引号的 53:53,会被 YAML 1.1 解析器读成 60 进制整数 3233(53×60+53)。你想映射 DNS 端口,实际得到一个叫 3233 的东西。只要冒号两边都是 ≤59 的纯数字就会中招,22:22(SSH)、25:25(SMTP)全在雷区。
而我的 yq() 白名单里恰好放行了冒号——意味着端口全部裸奔。修复:端口一律强制加引号,其他字段维持白名单策略:
function yqq(s){ // 强制引号版,专供 ports
return '"' + String(s).replace(/\\/g,'\\\\').replace(/"/g,'\\"') + '"';
}
list('ports', o.ports, true); // force=true → 走 yqq
顺手加了回归断言,让这个坑永远不会复活:
ok('yaml sexagesimal guard',
A.toCompose('docker run -p 53:53 img').indexOf('- "53:53"') > 0);
修复的 commit 已推送。这是「写文章」这件事直接改进了代码的一次实证——把实现讲给别人听的过程,就是最严格的 code review。
坑 8 是引号的整体策略:全部加引号最安全但产物很丑,全部不加则 A=b c、x!y 这类值会炸。折中:安全字符白名单(字母数字 ._/:@-)放行,其余加引号并转义反斜杠和双引号。
坑 9:用户没写 --name 时服务名从哪来?从镜像名剥:去 tag、去 registry 路径、洗掉 YAML 不认的字符,全空则兜底 app:
var svc = o.name
|| String(o.image).split(':')[0].split('/').pop()
.replace(/[^A-Za-z0-9_-]/g,'')
|| 'app';
// registry.example.com/team/api-server:v2 → api-server
收尾的两个工程决策
坑 11:未知参数怎么办? docker run 的参数有上百个,白名单不可能穷尽。原则是诚实优先:认不出的参数收进 unknown[],转换照常完成,但在产物顶部用注释亮出来:
# 警告: 未识别参数 --some-exotic-flag
services:
...
用户拿到的是「95 分答案 + 明确告诉你哪 5 分要自己补」,这比假装 100 分或直接罢工都有用。
坑 12:一个 HTML 文件怎么写测试? 这个工具是单文件应用(一个 HTML 就是全部),没有构建链,那测试怎么跑?答案是把纯函数层和 UI 层拆成两个 <script>:第一个只有纯函数,末尾挂双导出:
if (typeof module !== 'undefined' && module.exports) {
module.exports = { tokenize, parseDockerRun, toCompose }; // Node 测试用
}
if (typeof window !== 'undefined') {
window.DockerForgePure = { tokenize, parseDockerRun, toCompose }; // 浏览器用
}
测试脚本用正则抠出第一个 <script> 的内容,new Function 在 Node 里直接执行——不装任何测试框架,23 条断言毫秒级跑完。UI 层则用 jsdom 做冒烟测试。CI 里 node _test.js && node smoke.js 两行搞定。
写在最后
一条命令的转换器,麻雀虽小:分词状态机、参数语法的三形态、两个世界的语义翻译、YAML 的历史包袱、防御性输出、零框架测试——每一层都有值得较真的细节。
三个可以直接带走的结论:
1. 凡是「把一种字符串变成另一种字符串」的需求,都比看起来难,难点永远在两种格式各自的边角语义里;
2. YAML 里的值,拿不准就加引号,尤其是 ports 这种冒号分隔的字段;
3. 把实现写成文章是性价比最高的 code review——第 7 个坑就是这么抓出来的。
工具已开源(MIT,单 HTML 文件,双击即用,数据不出本机):
- DockerForge 在线版:DockerForge — 离线 docker run 转 docker-compose 转换器
- 源码(300 行,含全部测试):https://github.com/wangzifan396-wzf/DockerForge
- 同系列 82 款单文件工具导航:nano-tools · 单文件零依赖工具集
更多推荐
所有评论(0)