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 upmem_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 cx!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 · 单文件零依赖工具集