把一套带界面的分布式数据同步中间件装进 Docker,再做成一条命令拉起来,这件事我前前后后折腾了不止一次。第一次手动搭 otter 测试环境那会儿,光是凑齐 Zookeeper、MySQL、Manager、Node 这一串依赖就耗掉半个下午,等真正能在管理页面上配置同步任务,天都黑了。所以我后来直接收拾了一套基于 Docker Compose 的“一键生成”方案,专门给测试环境用——只要机器上有 Docker,脚本一跑,几分钟就能得到一个可以立刻上手配置任务的 otter 环境。
这篇文章会把整套方案的组件结构、编排配置、脚本逻辑和验证过程完整拆开讲。适合三类人看:想快速体验 otter 但不想折腾部署的开发者,需要在 CI 里自动拉起测试同步环境的运维,以及刚接触数据同步、想弄明白这套东西到底由哪几块拼起来的同学。
1. 拆解 otter 测试环境的组件结构:为什么手动部署会劝退
1.1 otter 在数据同步生态里的角色
otter 是一套分布式数据库同步中间件,定位是解决异构数据库之间的数据迁移、实时同步和双向同步问题。它的底层依赖 Canal 解析 MySQL 的 binlog,所以整套系统的主角其实有两层:Canal 负责“读”,otter 负责“管”。
一个最简的 otter 测试环境里,至少要有这四类角色:
- Zookeeper:负责协调 Manager 和 Node 的分布式状态,是 otter 的注册中心和分布式锁依赖;
- MySQL 实例:既充当 otter Manager 的元数据库,也充当同步任务的源库和目标库;
- Manager:带 Web 管理界面的控制端,负责配置同步任务、调度节点、监控链路;
- Node:真正干活的同步节点,接收 Manager 下发的任务,通过 Canal 拉取 binlog 并写入目标库。
数据同步的完整链路是:源库产生变更 → binlog 记录变更 → Node 内嵌的 Canal 客户端解析 → 经过映射规则转换 → 写入目标库。整个过程中 Manager 只负责“下指令”,业务数据并不会经过 Manager,避免控制面成为瓶颈。
1.2 手动部署的三个真实痛点
手动部署这套环境,最难受的不是某个组件装不起来,而是组件之间的状态耦合。
第一是版本匹配问题。otter 的 Manager 和 Node 必须同版本,Canal 又要和 MySQL 的 binlog 格式兼容,Zookeeper 的版本也会影响客户端连接行为。只要其中一个版本漂移,就会出现 Manager 上看到 Node 在线,但任务一跑就报连接异常。
第二是网络互通问题。Node 在启动时会向 Manager 注册自己的 IP,如果容器 IP 和宿主机 IP 混在一起,Manager 回连 Node 的时候就会走到一个错误地址。这个坑我在后面第 6 章会专门说,手动部署时尤其容易踩。
第三是初始化脏数据。otter Manager 的元数据库不是空库就能用,需要先执行官方的 schema 脚本,还要在配置里指定 Zookeeper 地址、数据库连接串。手动部署时很多人会在这一步漏掉字符集或时区参数,然后管理页面上报一堆 500 错误。
1.3 测试环境对“一键生成”的三个硬性要求
既然目标是把测试环境变成一条命令的事,这套方案必须满足三个要求:
可重复性:同一份编排文件,今天跑和三个月后跑,得到的环境应该完全一致。这意味着不能用交互式安装步骤,所有配置必须固化到文件和环境变量里。
环境隔离:测试环境不能污染宿主机上已有的 MySQL、Zookeeper、端口。所以我会把所有组件放进自定义 Docker 网络,并且用固定的容器名和内部端口,宿主机只暴露少数必要端口。
快速验证:从命令执行到能在 Manager 页面配置任务,时间应该控制在几分钟内。而且要有明确的可观测信号,而不是靠盲猜“应该起来了”。
2. 一键生成的整体设计:镜像选型、目录结构与端口规划
2.1 镜像选型:能用现成的就不自己造
在镜像选择上,我的原则是“官方优先、固定版本、本地兜底”。
Zookeeper 和 MySQL 直接用 Docker Hub 的官方镜像,版本固定到具体小版本号,避免 latest 在半年后悄悄变了行为。otter 这边,公开镜像仓库里有社区维护的 Manager 和 Node 镜像,但版本相对固定,如果你的业务场景依赖特定 otter 版本,也可以自己基于官方发布包制作镜像——其实就是在 release 包基础上加一个启动脚本的事。
这里有个实际操作层面的建议:如果测试机会经常在离线网络环境使用,提前把几个镜像docker save成 tar 包放到本地,脚本里做一次“本地已存在则直接 load,不存在则去拉取”的兜底逻辑。我在实际使用中就是靠这个办法,在断网环境里也照样拉起整套环境。
2.2 目录结构设计
我习惯把整套方案按职责分成四个目录:
otter-docker/ ├── scripts/ # 一键启动、关闭、清理脚本 ├── compose/ # docker-compose 编排文件 ├── init/ # MySQL 初始化脚本、otter schema 文件 ├── conf/ # 可覆盖的配置文件模板 └── .env # 所有可调参数集中在这里这样设计的好处是:脚本只负责“顺序控制”,配置只放在.env和compose里,初始化 SQL 单独放init目录。任何人接手这套环境,打开.env就能知道端口是什么、密码是什么、镜像版本是什么,不需要翻脚本逻辑。
2.3 端口规划与网络模式
在端口规划上,我做了明确的内外区分:
| 组件 | 容器内部端口 | 宿主机映射端口 | 用途 |
|---|---|---|---|
| Zookeeper | 2181 | 可以映射,也可以不映射 | 内部协调,测试环境一般不需要从宿主机访问 |
| MySQL | 3306 | 13306 | 让测试人员用 Navicat 等客户端连接检查数据 |
| Manager | 8080 | 18080 | Web 管理界面 |
| Node | 2088 | 不需要映射 | 只允许 Manager 从 Docker 网络内部访问 |
这里有个关键思路:Node 端口不要映射到宿主机。因为 Node 是纯内部组件,暴露到宿主机反而会增加“Manager 拿到的 IP 不可达”的风险。所有组件都跑在同一个自定义 bridge 网络里,容器之间用服务名互相访问,Manager 通过http://node:2088访问 Node,IP 就不会乱。
3. 核心编排配置逐项解读:每个组件为什么这样配
3.1 Zookeeper:单机就够了,但健康检查要做对
测试环境用单机 Zookeeper 完全没问题,生产环境才需要 3 节点集群。在 Compose 里我配置了单机 Zookeeper,主要注意的是两件事:
zookeeper: image: zookeeper:3.6.3 container_name: otter-zookeeper restart: unless-stopped environment: ZOO_MY_ID: 1 volumes: - ./data/zookeeper:/data healthcheck: test: ["CMD", "zkServer.sh", "status"] interval: 5s timeout: 3s retries: 20加healthcheck是必要的。otter 的启动顺序很敏感:Manager 和 Node 都要在启动时连接 Zookeeper,如果 Zookeeper 还没就绪就启动它们,会因为连接超时退出。脚本里我会等 Zookeeper 的 health 变成 healthy,再往后走。
3.2 MySQL:一个实例三个库,还是两个实例分开跑
MySQL 是这套环境里最灵活的组件,因为同一个 otter 环境既需要元数据库,也至少需要一源一目标两个业务库。我把两种方案都列出来:
- 单实例三库方案:用一个 MySQL 容器,创建
otter_manager、source_db、target_db三个库。优点是省资源,适合只想体验管理界面和同步配置的轻量测试; - 双实例方案:两个 MySQL 容器,一个当源库一个当目标库,更贴近真实的生产模拟,比如测试主从复制、延迟监控、双向同步时就需要这种隔离。
我的测试环境默认用双实例方案,因为“源库”和“目标库”放在同一个实例里,会让人对 binlog 解析的语义产生误判。双实例的成本也就多几百 MB 内存,换来的是概念清晰。
MySQL 的 binlog 配置是必须提前做好的,用自定义配置文件覆盖默认配置:
[mysqld] server-id=1 log-bin=mysql-bin binlog-format=ROW binlog-row-image=full gtid_mode=ON enforce_gtid_consistency=ONbinlog-format=ROW是 otter 能工作的前提。Canal 解析 ROW 格式的 binlog 才能拿到每行数据变更的前后镜像。另外注意server-id不能和别的 MySQL 实例冲突,双实例方案里第二个实例要改成server-id=2。
3.3 Manager:数据库连接和 Zookeeper 地址都要显式指定
Manager 容器本身不存数据,它把调度信息、任务配置、同步日志全部存到元数据库里。所以启动 Manager 之前,元数据库必须已经初始化完成。
Compose 里我会给 Manager 传三个关键配置项:数据库 JDBC 连接串、数据库账号密码、Zookeeper 地址。以官方发布包支持的启动方式为例,可以用环境变量传递:
manager: image: otter-manager:4.2.19 container_name: otter-manager ports: - "18080:8080" depends_on: mysql-source: condition: service_healthy mysql-target: condition: service_healthy zookeeper: condition: service_healthy environment: OTTER_MANAGER_DB_HOST: mysql-manager OTTER_MANAGER_DB_PORT: "3306" OTTER_MANAGER_DB_NAME: otter_manager OTTER_MANAGER_DB_USER: otter OTTER_MANAGER_DB_PASSWORD: otter OTTER_MANAGER_ZOOKEEPER: zookeeper:2181depends_on配合condition: service_healthy是 Compose 里最值得养成的习惯。默认的depends_on只管容器启动顺序,不管容器内服务是否就绪。MySQL 容器起来了不代表端口能连上,必须有健康检查配合才能真正控制顺序。如果不想用 Compose 的 healthcheck 机制,脚本里轮询 TCP 端口也行,但那就少了一层自动保障。
3.4 Node:最容易被忽略的注册逻辑
Node 在 otter 里是一个独立注册的客户端。它在启动时会向 Manager 上报自己的 IP 和端口,Manager 后续的所有任务下发都基于这个地址。所以 Node 配置的核心不是资源参数,而是“让 Manager 能访问到 Node”。
这里有一个常见的 Compose 配置误区:在 Node 容器里通过环境变量设置HOST为宿主机 IP,然后宿主机把 Node 端口映射出来。这样确实能让 Manager 通过宿主机 IP 访问到 Node,但引入了对宿主机网络的依赖,而且换一台机器就得改配置。
更干净的做法是让 Manager 和 Node 在同一个 Docker 网络里,Node 直接把自己的容器 IP 注册给 Manager。我的做法是在 Node 启动脚本里用hostname -i获取容器自身 IP,再写入 otter 的nid配置,保证注册 IP 一定在 Docker 网络内可达:
node: image: otter-node:4.2.19 container_name: otter-node depends_on: manager: condition: service_healthy environment: OTTER_NODE_IP: "" OTTER_MANAGER_URL: http://manager:8080 volumes: - ./conf/node/nid:/otter/node/nidnid文件里其实就一行内容:本机 IP +:+ 端口。很多人在这一步踩坑是因为手动部署时写了 localhost,结果 Manager 上能看到 Node 上线,但执行任务时 Node 自己连不上自己。
4. 一键脚本的逻辑拆解:从拉镜像到 Manager 可用的完整链路
4.1 脚本步骤树
整个一键生成脚本的流程,可以抽象成七步:
- 检查本机 Docker 环境和 Compose 插件是否可用;
- 加载或拉取所有镜像;
- 生成
.env和容器数据卷目录; - 启动 Zookeeper 和两个 MySQL,等待健康检查通过;
- 执行 otter Manager 元数据库初始化 SQL;
- 启动 Manager 和 Node,等待 Manager 的 HTTP 接口可用;
- 打印访问地址、账号密码和下一步操作指引。
核心脚本我贴在下面,逻辑做了省略,保留主干:
#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "${SCRIPT_DIR}/.." echo "==> 检查 docker 环境..." docker info >/dev/null 2>&1 || { echo "Docker 未运行"; exit 1; } docker compose version >/dev/null 2>&1 || { echo "缺少 compose 插件"; exit 1; } echo "==> 加载镜像..." bash scripts/load_images.sh echo "==> 启动基础组件(zookeeper/mysql)..." docker compose up -d zookeeper mysql-source mysql-target echo "==> 等待基础组件健康..." bash scripts/wait_healthy.sh otter-zookeeper otter-mysql-source otter-mysql-target echo "==> 初始化 otter manager 元数据库..." docker compose exec -T mysql-manager mysql -uotter -potter -e \ "CREATE DATABASE IF NOT EXISTS otter_manager DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" docker compose exec -T mysql-manager sh -c \ "mysql -uotter -potter otter_manager < /init/otter-manager-schema.sql" echo "==> 启动 manager 和 node..." docker compose up -d manager node echo "==> 等待 manager web 可用..." bash scripts/wait_http.sh http://localhost:18080/ 30 echo "==> 完成!" echo "Manager 地址: http://localhost:18080/" echo "默认账号/密码: admin/admin"4.2 健康检查与等待逻辑
脚本里最容易出问题的一块是“等待”。我见过不少人直接sleep 30固定等待,这在本地环境可能能跑通,但换一台慢一点的机器就崩了。
我用的是一个轮询函数,通过docker inspect查容器的 health 状态:
wait_healthy() { local container_name="$1" local timeout="${2:-60}" local waited=0 while [ "$waited" -lt "$timeout" ]; do local status status=$(docker inspect --format '{{.State.Health.Status}}' "$container_name" 2>/dev/null || echo "missing") if [ "$status" = "healthy" ]; then echo "[OK] ${container_name} is healthy" return 0 fi waited=$((waited + 2)) sleep 2 done echo "[FAIL] ${container_name} 超时未就绪" docker logs --tail 100 "$container_name" return 1 }同样的思路应用到 HTTP 接口,用curl探测 Manager 的 8080 端口返回码。这套逻辑比固定 sleep 可靠得多,也让“一键”两个字名副其实——任何一步出问题,脚本会直接打印对应容器的日志,定位问题不需要再手动翻半天。
4.3 幂等性设计:重复执行不会炸
一键脚本有一个容易忽略的要求——可以重复执行。如果测试环境已经起来了,再次执行脚本,不应该因为端口冲突或者数据库已存在而报错退出。
为了实现幂等,我做了几个约定:
- MySQL 初始化建库用
CREATE DATABASE IF NOT EXISTS; - schema 导入前先判断目标库里是否已有表,有就跳过;
- 已存在的容器如果正在运行,
docker compose up -d会自动跳过; - 数据目录和配置文件不存在时才从模板复制。
这样整个脚本既可以用作首次部署,也可以用作“确认环境还在不在”的巡检工具。CI 里跑完一轮同步测试后,直接重新执行脚本,就能把环境恢复到初始状态。
5. 实际验证:一个最简单的 MySQL 同步任务怎么跑通
5.1 Manager 页面上的配置路径
环境起来后,浏览器打开http://localhost:18080/,用默认账号登录。第一次配置任务,我建议按这个路径走:
- 添加数据源:在“数据源管理”里把源库
source_db和目标库target_db都加进去,填好 JDBC 连接串、账号密码; - 创建 Channel:一个 Channel 对应一条独立同步链路;
- 创建 Pipeline:在 Channel 下面加一条 Pipeline,选择源数据源、目标数据源,并选定同步数据表;
- 配置映射规则:如果源表和目标表结构一致,可以直接使用“自动映射”,otter 会根据表名自动生成字段映射;
- 开启同步:启动 Pipeline,otter 会先做一次全量数据同步,然后进入增量监听状态。
这里要注意,Manager 界面上配置的源库连接地址不能用localhost,要填 Docker 网络内的服务名,比如mysql-source:3306。因为 Manager 容器要访问的不是宿主机上的 MySQL,而是同网络里的另一个容器。
5.2 从建表到数据复制的完整验证
配置完成后,我做了一套简单的验证操作。
先在源库建一张测试表:
CREATE TABLE `member` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(64) DEFAULT NULL, `created_at` datetime DEFAULT NULL, PRIMARY KEY (`id`) );插入几行数据,然后立刻到目标库查同一张表:
SELECT COUNT(*) FROM member;如果能看到相同行数,说明全量同步生效。接着在源库执行一条 UPDATE,再刷新目标库对应记录,确认增量同步也通了。
要特别留意的是查询时间。otter 的同步延迟在本地 Docker 环境通常只有几百毫秒,如果查到 1 分钟以上,基本可以判断链路里有阻塞,优先去看 Node 日志。
5.3 观察同步链路状态的几个信号
验证阶段最常用到的观测手段有三个:
- Manager 的 Pipeline 状态页:会显示同步进度、延迟时间、已同步行数;
- Node 日志:默认在容器内的 logs 目录,通过
docker logs otter-node查看; - 目标库的变更时间:用
created_at这类业务时间字段对比源库,可以直观估算同步延迟。
我个人的习惯是先看 Manager 页面的延迟指标,再看 Node 日志有没有报错。如果延迟正常且没有 ERROR 日志,数据同步基本就是健康的。怀疑同步没生效时,不要去改任务配置,先确认源库的 binlog 是否开启,这是最简单也最常被忽略的一步。
6. 我在实际搭建中踩过的几个坑与修复方式
6.1 Node 注册 IP 不可达
这是我在这套方案里踩过最深的一个坑。容器起来后,Manager 上能看到 Node 在线,但对于 Pipeline 的状态永远是“启动中”,Node 日志里反复出现连接被拒绝。
排查后发现,Node 默认取的是容器启动时的内网 IP,但因为我把 Node 的 2088 端口映射到了宿主机,Manager 拿着容器 IP 去访问时,走的是 Docker 网络内部链路,本来应该通。奇怪的是实际上 Manager 容器通过node:2088能连通,问题出在 otter 的 Node 配置里有一个独立的ip参数,它优先使用这个参数注册给 Manager,而不是从网络栈自动获取。
修复方式很简单:在 Node 的启动配置里显式指定注册 IP,或者干脆去掉映射端口、让所有访问都走 Docker 网络,让 Node 脚本用hostname -i自动上报正确地址。我现在这套 Compose 方案里,OTTER_NODE_IP默认为空,就是让 Node 自己判断,实测很稳。
6.2 Manager 元数据库连接超时
第一次启动 Manager 时,Web 界面能打开,但是一登录就报数据库连接超时。查日志发现 Manager 连元数据库用的连接串包含了serverTimezone=Asia/Shanghai参数,而我初始化 MySQL 时没有设置时区,导致连接被拒绝。
问题根源是 MySQL 驱动在高版本 JDK 下对时区敏感。解决办法有两个:在 MySQL 容器的启动命令里加--default-time-zone=+08:00,或者在 Manager 的数据库环境变量里省去时区参数。我在init目录里放了一个时区配置,彻底规避这个问题。
6.3 表结构不一致导致映射规则报错
测试环境里源库和目标库表结构不可能完全一样,但很多初次接触的人直接勾选“自动映射”,然后发现字段对不上,任务起不来。
otter 的映射规则虽然支持自动匹配,但一旦源表出现目标库没有的字段,Pipeline 就会在运行时报字段映射错误。更稳妥的做法是:手动打开映射规则页面,逐字段确认对应关系;对不需要同步的字段,直接把映射关系删掉,不要试图靠自动映射解决一切。
6.4 Docker 数据卷里的初始化脚本不执行
MySQL 官方镜像的/docker-entrypoint-initdb.d目录会在首次初始化时执行其中的.sql脚本。但如果这个目录挂载了宿主机数据卷,而且数据卷里已经存在数据,脚本就不会再执行。
我一开始把整个 MySQL 数据目录都挂载到宿主机,结果每次修改初始化 SQL 后重建容器,脚本都不生效,看起来像 SQL 写错了。排查半天才意识到是数据卷持久化导致初始化只跑第一次。现在的做法是:初始化脚本单独挂载到/docker-entrypoint-initdb.d,数据目录用 Docker volume 而不是 bind mount,这样既能持久化数据,又能确保脚本在首次创建时执行。
6.5 同步任务偶发中断后的恢复
本地测试时如果直接重启 Docker 容器,Node 有时候会丢失正在执行的 Canal 任务,Pipeline 停在“启动中”状态不动。
otter 在这块的容错机制是任务状态持久化在元数据库里,Manager 定期做状态检查。如果遇到 Pipeline 异常后又无法自动恢复,最直接的办法是在 Manager 页面停掉 Pipeline,再重新启动一次。这套操作在测试环境完全可以接受,不用去折腾数据。如果频繁出现这种情况,就应该看看是不是 Zookeeper 连接不稳定,或者 Node 内存不足导致线程挂掉。
最后分享一个我实际用得很顺手的小技巧:在scripts目录里额外放一个reset.sh,作用是停掉容器、删除 volume、清空整个环境。一键生成脚本负责“从零到一”,重置脚本负责“回到零”。这样每次跑完一轮测试,一条命令就能把环境打回原形,再跑一条命令又能重新生成一套干净环境。对于需要反复验证同步规则的人来说,这个组合比手动清理省力得多。