1. 先把部署这件事的底层账算清楚Docker 玩到第二年你会发现真正耗时间的不是敲命令而是每个软件第一次落地时那一堆从来没人写在文档首页的参数。这篇接着上一批部署笔记往下写聊的是数据库、中间件、运维平台这几类需要长期常驻的服务怎么做容器化部署。标题里的“常见软件部署”听着平淡实际上坑密度极高MySQL 起不来八成是权限Elasticsearch 起不来八成是内核参数RabbitMQ 登不上八成是默认用户只能本机访问这些问题排查起来都要花掉半天。这篇适合两类人看。一类是刚把 Docker 装好、准备把公司测试环境那几台机器上的服务逐个搬进容器的朋友照着做能少走两三天的弯路另一类是已经跑起来一堆容器、但总觉得数据不踏实、备份不敢做、日志一天涨几十 G 的朋友里面关于数据卷、资源限制、日志轮转的部分对你有用。我不会讲 Docker 是什么、镜像和容器的区别这些入门内容默认你已经会docker run、会看docker logs剩下的我们直接谈工程细节。一个前置提醒本文所有示例里的密码、端口、目录、域名全是占位符直接抄命令可以但密码必须换掉。我见过太多人把示例里的123456原封不动搬到线上然后某天被人扫到端口数据被清空还留个勒索说明。容器化最大的错觉就是“起了就是好了”实际上起了只是开始。1.1 为什么我坚持数据卷和配置文件必须外置容器被设计成无状态的、随时可以删掉重建的东西这个特性对 Web 应用是优点对数据库就是灾难。所以第一件事永远是把数据目录和配置文件从容器里搬出来挂到宿主机上。这样做有三个直接好处。第一镜像升级变成一件没有心理负担的事。你的 MySQL 从 8.0.32 升到 8.0.36直接停容器、换镜像、启容器数据还在原来的宿主机目录里跟容器生命周期完全解耦。如果不外置你得先docker commit或者docker cp把数据拷出来拷的过程中服务还得停风险陡增。第二备份逻辑变得极其简单。数据库的物理备份、配置文件版本管理、迁移到另一台机器全部退化成对普通目录和文件的操作rsync、tar、git 都能用上。这一点在出事的时候价值巨大——凌晨三点你不会想去研究怎么从 overlay2 存储层里把数据捞出来。第三配置可审计。配置文件放在宿主机上可以进版本库谁改了哪一行一目了然。放在容器里改完这次下次重建就忘了。我的目录规范长这样一套用到底不搞花样/opt/docker/ ├── mysql/ │ ├── data/ # 数据目录属主必须是容器内 mysql 用户的 UID │ ├── conf/ # 自定义 my.cnf │ └── init/ # 首次初始化执行的 sql ├── redis/ │ ├── data/ │ └── conf/redis.conf ├── nginx/ │ ├── conf.d/ │ ├── html/ │ └── logs/ └── ...每个服务一个父目录数据、配置、日志、初始化脚本分开放。命名统一用服务名小写不用拼音不用缩写。这套规范看着朴素但它带来的好处是任何一台新机器我只要把/opt/docker整个同步过去改一下密码半小时就能拉起全套环境。注意挂载宿主机目录前一定先mkdir -p建好并且确认属主。Docker 遇到不存在的宿主机路径会自动创建一个属主 root 的空目录容器内的非 root 用户往里写就会 Permission denied。这个现象在 MySQL、PostgreSQL、Elasticsearch 上极其常见。1.2 网络模式怎么选端口怎么规划容器网络这一块新手最容易犯的错是全用-p把端口暴露到宿主机然后端口号随手写写完自己都记不住哪个是哪个。我的做法分两层。需要被外部访问的比如 Nginx 的 80/443、GitLab 的 Web 端口映射到宿主机。只在容器之间互访的比如应用连 MySQL 的 3306不映射宿主机端口只放进同一个自定义 bridge 网络用容器名当主机名访问。这样做的好处很实际数据库端口不出宿主机外部扫不到安全边界一下就清楚了。建网络一条命令docker network create --subnet172.20.0.0/16 app-net自定义网络比默认 bridge 强的地方在于它自带 DNS 解析mysql这个容器名在app-net里就是一个可解析的主机名应用配置里写jdbc:mysql://mysql:3306/xxx就行不用管容器 IP 怎么变。默认 bridge 网络不支持这个只能靠--link已废弃或者写死 IP都是坑。端口规划我用一个简单的分段约定避免冲突端口段用途示例3306 / 6379 / 5432 / 27017数据类仅内网不映射MySQL、Redis、PG、Mongo8080-8199Web 类应用Jenkins 80809000-9099对象存储与管理面板MinIO 9000/9001Portainer 900015672 / 5672消息队列管理端与协议端RabbitMQ9200 / 5601检索与分析Elasticsearch、Kibana约定好之后写在 wiki 或者 README 里团队里谁加新服务先查表。这个习惯看着官僚但能省掉“为什么我的服务起不来”里至少三成的排查时间。1.3 资源限制与日志轮转部署时就该配这两个东西绝大多数人是出事之后才补的。容器默认不限制 CPU 和内存一个跑飞的应用能把宿主机内存吃光然后整机上所有服务一起挂。日志默认用 json-file 驱动且不轮转一个疯狂打日志的服务几天就能把磁盘写满写满之后 Docker 自己都起不来。所以我的原则是部署命令里必须带--memory、--cpus和--log-opt。写成模板就是这样docker run -d \ --name some-service \ --restart unless-stopped \ --memory 2g --memory-swap 2g \ --cpus 2 \ --log-opt max-size50m --log-opt max-file5 \ --network app-net \ image:tag--memory-swap和--memory设成相等意思是禁用 swap宁可 OOM 让容器重启也不要让它慢慢 swap 把整机拖死。max-size50m max-file5意味着单服务日志上限 250MB超出的自动滚动丢弃这个量级对排查问题够用对磁盘友好。这几个参数不是可选项。我在一台 8G 内存的测试机上吃过亏一个没限制内存的搜索服务跑全量索引内存一路涨到把系统 OOM killer 触发连带着上面跑的数据库一起被干掉恢复花了两个小时。从那之后我所有docker run都是从一个写好的 shell 模板改出来的模板第一行就是这几项。2. 数据库类服务的容器化落地数据库是容器化争议最大的部分。反方的理由是 IO 损耗、数据安全、运维复杂度正方的理由是环境一致、版本切换快、测试环境搭建成本低。我的实际结论是测试和中小规模生产完全可以跑容器核心交易库要谨慎评估。关键在于你有没有把数据卷、备份、监控这三件事做扎实。2.1 MySQL 8 部署参数和初始化顺序是重点先上完整命令再逐条解释为什么这么写。mkdir -p /opt/docker/mysql/{data,conf,init} docker run -d \ --name mysql \ --restart unless-stopped \ --network app-net \ --memory 4g --memory-swap 4g \ --log-opt max-size50m --log-opt max-file5 \ -v /opt/docker/mysql/data:/var/lib/mysql \ -v /opt/docker/mysql/conf/my.cnf:/etc/mysql/conf.d/my.cnf \ -v /opt/docker/mysql/init:/docker-entrypoint-initdb.d \ -e MYSQL_ROOT_PASSWORD换成强密码 \ -e TZUTC \ mysql:8.0.36 \ --character-set-serverutf8mb4 \ --collation-serverutf8mb4_0900_ai_ci \ --lower_case_table_names1 \ --default-time-zone08:00几个关键点。字符集必须在启动参数里给不能只靠配置文件。utf8mb4是现在唯一正确的选择它支持完整的四字节字符。老项目里那些utf8实际是 utf8mb3在存某些特殊字符时直接报错或者截断。排序规则用utf8mb4_0900_ai_ci这是 MySQL 8 默认的、性能更好的规则如果是从 5.7 迁移过来的老库为了兼容可能要退回utf8mb4_general_ci这一点要提前确认否则迁移后某些排序结果会变。lower_case_table_names是个大坑。这个参数在 MySQL 8 里只能在数据目录初始化时设置一旦初始化完成再改就会启动报错提示参数不匹配。所以第一次起容器之前就要定好Linux 上默认是 0大小写敏感从 Windows 或 macOS 迁移过来的项目通常是 1不敏感。判断方法很简单看你的代码里表名是不是大小写混用。定错了只能删数据目录重来。内存参数innodb_buffer_pool_size值得单独说。它是 MySQL 性能的第一影响因子经验值是分配给它物理内存的 50%~70%。容器给了 4G那么 buffer pool 可以设 2.5G 左右。写进my.cnf[mysqld] innodb_buffer_pool_size 2560M innodb_flush_log_at_trx_commit 1 innodb_log_file_size 512M max_connections 500 slow_query_log 1 long_query_time 2 slow_query_log_file /var/lib/mysql/slow.loginnodb_flush_log_at_trx_commit1是最安全的设置每次事务提交都刷盘牺牲一点性能换数据不丢。innodb_log_file_size设大一点能显著减少 checkpoint 频率512M 到 1G 是常见区间但注意它和 buffer pool 的比例别失衡。慢查询日志一定要开这是后期优化的唯一依据。初始化脚本目录/docker-entrypoint-initdb.d只在数据目录为空时执行一次。这个特性非常好用你可以把建库、建用户、导入基础数据的 SQL 放进去容器第一次启动自动执行。但它也只执行这一次后面你再往里加 sql 文件不会生效。想重新执行只能清空 data 目录这又是一次血泪教训别问我怎么知道的。实操心得初始化 SQL 文件名按01-xxx.sql、02-yyy.sql排序命名官方镜像会按文件名字典序执行。乱起名会导致依赖顺序出问题比如建表脚本跑在建库脚本前面。2.2 Redis 部署持久化和内存策略要一起定Redis 的容器化最简单也最容易埋雷。简单在于它就是个单文件进程雷在于大多数人对持久化策略没概念重启一次数据全没才发现根本没配。docker run -d \ --name redis \ --restart unless-stopped \ --network app-net \ --memory 2g --memory-swap 2g \ -v /opt/docker/redis/data:/data \ -v /opt/docker/redis/conf/redis.conf:/usr/local/etc/redis/redis.conf \ redis:7.2 \ redis-server /usr/local/etc/redis/redis.conf --requirepass 换成强密码配置文件里至少要包含这几项appendonly yes appendfsync everysec maxmemory 1500mb maxmemory-policy allkeys-lru save 900 1 save 300 10 protected-mode yesappendonly yes打开 AOF配合everysec策略最多丢一秒的数据这是持久性和性能的平衡点。maxmemory设成容器内存的 70%~75%剩下的留给 fork 时的 copy-on-write 开销和客户端缓冲区。这个余量不能省——Redis 做 RDB 快照时要 fork 子进程极端情况下内存占用会接近翻倍设太满直接触发 OOM。maxmemory-policy的选法取决于你的用途当纯缓存用选allkeys-lru内存满了淘汰最久未用的当存储用比如存 session 或者队列选noeviction满了就报错让上层感知到问题而不是悄悄丢数据。选错了后果很不一样前者在生产上安全后者在数据重要时安全。注意protected-mode yes加上requirepass是必须的组合。如果只开 protected-mode 不设密码外部访问会被拒绝如果设了密码但没开 protected-mode那么任何能连到端口的人只要知道密码就能操作。两个一起开最稳妥。2.3 PostgreSQL 与 MongoDB 的差异点这两个我放一起讲因为它们的初始化逻辑跟 MySQL 不太一样容易踩坑。PostgreSQL 官方镜像用环境变量POSTGRES_PASSWORD设超级用户密码POSTGRES_USER改默认用户名默认是 postgresPOSTGRES_DB指定初始库名。注意如果只设POSTGRES_PASSWORD不设POSTGRES_USER创建出来的是 postgres 用户如果设了POSTGRES_USER就只创建这个用户不再创建 postgres 用户。这个细节很多人没注意结果应用连不上。它的数据目录是/var/lib/postgresql/data挂载时有个小陷阱官方镜像要求这个目录要么是空的要么是已经初始化过的 PG 数据目录。如果你挂载的宿主机目录里恰好有其他文件比如不小心放了.gitkeep或者备份文件启动会报错directory exists but is not empty。解决办法是挂到/var/lib/postgresql/data/pgdata子目录同时设PGDATA/var/lib/postgresql/data/pgdata这也是官方推荐写法。MongoDB 的坑在于默认没有认证。启动时不加--auth任何人连上就能读写。加了--auth之后首次又没有用户得先进容器用 localhost 例外创建一个管理员再开认证重启。完整流程是先不带 auth 启动docker exec -it mongo mongosh进去执行db.createUser({user:admin,pwd:xxx,roles:[{role:root,db:admin}]})然后停容器、加--auth参数重启。这个顺序不能省。3. 中间件与基础服务的部署要点中间件这类服务的特点是配置比数据重要所以挂载策略要侧重配置文件同时注意镜像本身的安全默认值往往不适合生产。3.1 RabbitMQ默认用户登不上不是 bug第一次部署 RabbitMQ 的人几乎都会遇到这个问题管理界面打开了用 guest/guest 登录提示失败日志里写着user guest can only connect via localhost。这不是配置错了是官方出于安全考虑默认只允许 guest 用户从本机访问。正确做法是启动时通过环境变量创建自己的账号docker run -d \ --name rabbitmq \ --restart unless-stopped \ --network app-net \ --memory 1g --memory-swap 1g \ -p 15672:15672 -p 5672:5672 \ -e RABBITMQ_DEFAULT_USERappuser \ -e RABBITMQ_DEFAULT_PASS换成强密码 \ -v /opt/docker/rabbitmq/data:/var/lib/rabbitmq \ rabbitmq:3.13-management两个细节值得说。一是镜像 tag 要带-management否则没有管理界面插件装了也没用。二是/var/lib/rabbitmq这个目录挂载后里面的.erlang.cookie会持久化集群部署时所有节点的 cookie 必须一致这是 RabbitMQ 集群能组起来的前提。如果你做集群先用一个容器生成 cookie再拷给其他节点别让每个容器自己随机生成。3.2 Nginx配置文件热加载的正确姿势Nginx 容器最常被用来做反向代理和静态资源服务。我的做法是把整个conf.d目录挂进去而不是挂单个文件这样加站点只要往里丢一个.conf文件再 reload不用动容器。docker run -d \ --name nginx \ --restart unless-stopped \ --network app-net \ -p 80:80 -p 443:443 \ -v /opt/docker/nginx/conf.d:/etc/nginx/conf.d \ -v /opt/docker/nginx/html:/usr/share/nginx/html \ -v /opt/docker/nginx/logs:/var/log/nginx \ -v /opt/docker/nginx/certs:/etc/nginx/certs:ro \ nginx:1.25改完配置之后重载用docker exec nginx nginx -t docker exec nginx nginx -s reload。先-t检查语法再 reload这个习惯能避免一次配置写错导致整个入口挂掉。注意不要用docker restart代替 reload重启会断开所有长连接瞬间的请求失败在高峰期很扎眼。日志挂载出来是为了让宿主机上的日志采集工具能直接读不用进容器。同时配合前面的--log-opt容器标准输出还有一份双保险。3.3 MinIO自建对象存储的最小可用配置有对象存储需求又不想依赖外部服务时MinIO 是首选单二进制、S3 协议兼容、部署极简。docker run -d \ --name minio \ --restart unless-stopped \ --network app-net \ -p 9000:9000 -p 9001:9001 \ -e MINIO_ROOT_USERminioadmin \ -e MINIO_ROOT_PASSWORD换成强密码 \ -v /opt/docker/minio/data:/data \ minio/minio:latest \ server /data --console-address :90019000 是 API 端口供程序调用9001 是控制台端口供人看。这两个别搞反我一开始只映射了 9000打开浏览器一片空白还以为是镜像问题。MINIO_ROOT_PASSWORD有长度要求至少 8 位太短会直接启动失败日志里有一行很不起眼的提示。生产环境我更推荐把它放到 Nginx 后面用域名加 HTTPS 访问并且只映射 API 端口控制台端口不对外。毕竟控制台是完整的文件管理界面暴露在公网上风险太大。4. 运维与应用平台的部署取舍这一类服务的共同点是内存占用大、初始化慢、对权限敏感部署时别抠资源。4.1 Portainer容器管理面板值不值得装Portainer 的定位是给不熟悉命令行的同事一个可视界面同时让自己在手机或者临时设备上也能看容器状态。装它的命令要注意一点必须挂载 docker.sock否则它管不了任何东西。docker run -d \ --name portainer \ --restart unless-stopped \ -p 9443:9443 \ -v /var/run/docker.sock:/var/run/docker.sock \ -v /opt/docker/portainer:/data \ portainer/portainer-ce:latest挂载 docker.sock 相当于把宿主机的容器管理权限全交出去了所以这个端口绝对不能暴露到公网。我的做法是只监听内网地址或者干脆用-p 127.0.0.1:9443:9443只允许本机访问需要时用隧道进去看。这个细节很多人忽略装完之后直接-p 9443:9443对外等于给了所有人一个 root 级别的入口。另外Portainer 初始访问要在 5 分钟内完成管理员账号设置超时后需要重启容器重新生成初始化令牌这一点新手经常卡住。4.2 Jenkins 与 CI 构建节点的资源账Jenkins 容器化是我见过最容易低估资源的场景。一个空白 Jenkins 加上几个插件1G 内存勉强够一旦加上流水线构建构建过程中跑 Maven 或者 npm内存和 CPU 需求直线上升。docker run -d \ --name jenkins \ --restart unless-stopped \ --network app-net \ --memory 4g --memory-swap 4g --cpus 4 \ -p 8080:8080 -p 50000:50000 \ -v /opt/docker/jenkins/home:/var/jenkins_home \ -v /var/run/docker.sock:/var/run/docker.sock \ -v /opt/docker/jenkins/cache:/root/.cache \ jenkins/jenkins:lts-jdk1750000 端口是构建节点的通信端口如果你打算加 agent 就必须映射。挂载 docker.sock 是为了让流水线里能直接docker build这个用法有安全争议但在内部环境里是最省事的方案。一个非常实际的问题/var/jenkins_home的属主。Jenkins 容器内是 UID 1000 的用户在跑如果宿主机目录属主是 root容器启动直接报Permission denied然后退出。解决办法是chown -R 1000:1000 /opt/docker/jenkins/home或者启动时加--user root不推荐会带来后续文件权限混乱。还有时区问题。Jenkins 的定时任务、构建时间戳、日志时间全都依赖容器时区默认是 UTC跟本地时间差 8 小时排查问题时对不上。挂载宿主机的时区文件最简单-v /etc/localtime:/etc/localtime:ro这样不用管具体时区名容器跟宿主机保持一致。4.3 Elasticsearch内核参数不调根本起不来Elasticsearch 是这些服务里部署失败率最高的一个原因基本集中在三个地方。第一是vm.max_map_count。这个内核参数默认是 65530ES 需要至少 262144不够就会在启动日志里报max virtual memory areas vm.max_map_count [65530] is too low然后退出。解决方式是在宿主机执行sysctl -w vm.max_map_count262144并写进/etc/sysctl.conf持久化。注意这个参数是宿主机的容器里改不了。第二是内存锁定。ES 建议开启bootstrap.memory_locktrue防止堆内存被换出。但容器要支持这个需要--ulimit memlock-1:-1否则报了警告也不好使。第三是 JVM 堆设置。ES_JAVA_OPTS-Xms2g -Xmx2g两个值必须相等这是 ES 的硬性要求。堆大小建议不超过物理内存的 50%且不超过 32G超过之后 JVM 的指针压缩失效性能反而下降。剩下的内存留给 Lucene 的段文件缓存这部分对查询性能影响很大。docker run -d \ --name es \ --restart unless-stopped \ --network app-net \ --memory 4g --memory-swap 4g \ --ulimit memlock-1:-1 --ulimit nofile65536:65536 \ -e discovery.typesingle-node \ -e ES_JAVA_OPTS-Xms2g -Xmx2g \ -e xpack.security.enabledfalse \ -v /opt/docker/es/data:/usr/share/elasticsearch/data \ elasticsearch:8.12.0discovery.typesingle-node是单节点模式的必填项不写会一直尝试组建集群然后超时。xpack.security.enabledfalse只在纯内网测试环境可以这么干生产必须开启并配证书否则等于把数据裸奔。5. 踩过的坑与排查速查这一节全部来自实际踩坑按症状反查原因遇到问题可以直接对着找。5.1 权限类问题八成是 UID 不匹配容器内的进程以某个 UID 运行宿主机目录属于另一个 UID写不进去这是容器部署第一大故障源。表现形式五花八门MySQL 报Cant create/write to fileES 报AccessDeniedExceptionJenkins 报Permission denied本质都一样。快速确认方法docker exec 容器名 id看容器内用户的 UID然后ls -ln 宿主机目录看属主 UID不一致就chown。注意要用数字 UID 而不是用户名因为宿主机上可能根本没有对应的用户。下面是几个常见服务的运行 UID服务容器内 UID目录处理MySQL999chown -R 999:999PostgreSQL999chown -R 999:999Elasticsearch1000chown -R 1000:1000Jenkins1000chown -R 1000:1000Redis999一般无要求data 目录可写即可还有一个隐蔽的坑SELinux 开启的宿主机上即使权限对了挂载目录仍然不可写因为安全上下文没放行。加:z或者:Z挂载选项能解决比如-v /opt/docker/mysql/data:/var/lib/mysql:z。5.2 启动即退出先看日志再看逻辑容器docker ps看不到、docker ps -a显示 Exited这种时候别猜直接docker logs --tail 100 容器名。九成的启动失败在日志最后十行里写得很清楚。我总结了几类高频报错port is already allocated宿主机端口被占。用ss -lntp | grep 端口号找占用者要么换端口要么停掉冲突进程。no space left on device磁盘满了。先df -h看根分区再du -sh /var/lib/docker/*找大头通常是日志或者没清理的悬空镜像docker system prune能救急。iptables: No chain/target/match by that name防火墙规则被清过重启 Docker 服务能重建但这说明有人在动系统要查清楚。exec format error镜像架构和宿主机不一致比如在 ARM 机器上拉了 amd64 镜像检查uname -m和镜像支持的架构。5.3 数据安全的三条硬规矩第一永远不要在docker-compose down后面加-v。这个参数会连数据卷一起删掉而且不提示。生产环境我甚至建议不用 compose 管理有状态服务改用docker run脚本因为 compose 的批量操作太方便了方便到误操作成本极高。第二备份要在宿主机层面做不要在容器层面做。给 MySQL 做备份正确的姿势是docker exec mysql mysqldump ...把结果输出到宿主机目录或者直接把数据目录整体快照。docker commit做镜像备份是错误做法那个镜像又大又没法用。第三镜像 tag 不要用 latest。用 latest 意味着某天自动拉取更新后服务可能因为版本变化起不来。所有服务都固定到具体的小版本比如mysql:8.0.36、redis:7.2.4升级走显式操作。提示定期跑一次docker image prune -a清理无标签镜像但执行前确认没有依赖悬空镜像的容器。这个命令和docker system prune -a --volumes必须区分清楚后者会删掉所有未使用的数据卷那里面可能有你半年没碰但还需要的数据。我在实际使用中发现一个规律凡是部署时多花十分钟配了资源限制、日志轮转、目录属主、时区这四项的服务后面几乎不出问题凡是图快直接docker run -d --name x x一把梭的一周内必来找。容器化降低了启动门槛但没有降低运维要求这两件事经常被混淆。还有一个后补的小技巧把每个服务的启动命令写成独立的.sh文件放进版本库文件里带注释说明每个参数为什么这么写半年后回来升级你会感谢当时的自己。