1. 从"能跑起来"到"用得顺手":容器化常用服务的选型逻辑
很多人学完Docker的基础命令之后,会卡在同一个地方:镜像能拉、容器能起,但真要把手头那几套常用服务搬进容器里长期跑,心里就没底了。我自己最开始也是这样,docker run敲得飞起,结果数据库数据丢了、缓存连不上、反向代理配置改一次崩一次。后来踩的坑多了才明白,Docker安装常用服务这件事,难点从来不在"装",而在于怎么装得让数据不丢、网络通、重启能自愈、升级不翻车。
这篇内容就是围绕这个阶段来写的。它适合已经会用docker pull、docker run、docker ps这些基础命令,但还没系统地把数据库、缓存、Web服务、反向代理这类常用组件容器化落地的朋友。我会把每个服务在容器里跑的时候,那些文档里不写、但实际部署一定会遇到的问题拆开讲,包括数据持久化怎么设计、网络怎么规划、配置文件怎么挂载、健康检查怎么做、升级时怎么不丢数据。
先明确一个核心判断:不是所有服务都适合无脑塞进Docker。有些服务容器化收益极高,比如Redis、Nginx、PostgreSQL这类无状态或状态可外置的;有些则要谨慎,比如需要特殊内核模块、需要直接操作宿主硬件、或者对IO延迟极其敏感的服务。所以第一步不是急着敲命令,而是先想清楚这个服务容器化之后,它的"状态"放在哪、它的"网络"怎么暴露、它的"配置"怎么管理。这三个问题想明白了,后面所有操作都是水到渠成。
我下面会按服务类型分章节来讲,每个服务都会给出完整的可复现命令、参数解释、以及我在实际使用中总结的注意事项。命令都是可以直接抄的,但更重要的是理解每条参数背后的意图,这样你换一个服务也能自己推导出来。
2. 数据持久化:容器化服务最容易翻车的第一道坎
2.1 为什么"数据卷"这件事必须一开始就想清楚
容器本身是无状态的,这句话很多人听过,但真正理解它的人不多。容器的文件系统是分层的,最上面那层可写层跟着容器生命周期走——容器删了,写进去的数据就没了。这不是bug,这是设计。所以任何有数据要保留的服务,第一件事就是决定数据放哪。
Docker提供了三种方式:匿名卷、具名卷、绑定挂载。匿名卷是-v /容器内路径这种写法,Docker自动分配一个宿主机目录,名字是一串哈希,你根本记不住,删容器的时候如果没加-v参数,卷还会残留一堆垃圾。具名卷是-v 卷名:/容器内路径,卷名你自己起,Docker统一管理,位置在/var/lib/docker/volumes/下面。绑定挂载是-v /宿主机绝对路径:/容器内路径,你完全控制宿主机上的位置。
我的建议很直接:生产环境优先用具名卷,需要频繁改配置文件或者要跟宿主机其他程序共享数据时用绑定挂载。匿名卷只在临时测试时用,用完记得docker volume prune清掉。
这里有个很多人忽略的点:具名卷第一次创建时,如果容器内目标路径本来有文件,Docker会把容器内的文件复制到卷里。这个行为叫"卷初始化"。但如果你用的是绑定挂载,宿主机目录会直接覆盖容器内路径,容器内原有的文件就看不见了。这就是为什么很多人挂载Nginx配置目录之后,发现默认的nginx.conf不见了——因为宿主机目录是空的,直接把容器内的覆盖了。
注意:绑定挂载一个空目录到容器内非空目录时,容器内原有内容会被隐藏。正确做法是先
docker cp把默认配置拷出来,或者先起一个临时容器把配置导出。
2.2 具名卷与绑定挂载的实操对比
我拿MySQL举例。用具名卷的写法:
docker volume create mysql_data docker run -d \ --name mysql8 \ -e MYSQL_ROOT_PASSWORD=your_strong_password \ -v mysql_data:/var/lib/mysql \ -p 3306:3306 \ mysql:8.0用绑定挂载的写法:
mkdir -p /opt/mysql/data /opt/mysql/conf docker run -d \ --name mysql8 \ -e MYSQL_ROOT_PASSWORD=your_strong_password \ -v /opt/mysql/data:/var/lib/mysql \ -v /opt/mysql/conf:/etc/mysql/conf.d \ -p 3306:3306 \ mysql:8.0两种写法都能跑,但适用场景不同。具名卷的好处是Docker帮你管理权限和生命周期,备份的时候用docker run --rm -v mysql_data:/data -v $(pwd):/backup alpine tar czf /backup/mysql_backup.tar.gz -C /data .就能打包。绑定挂载的好处是你随时能进/opt/mysql/data看文件,改配置直接编辑宿主机文件,配合版本管理工具还能追踪配置变更。
我个人的习惯是:数据用具名卷,配置用绑定挂载。数据卷不需要我天天进去看,交给Docker管理更省心;配置文件我需要随时改、随时对比、随时回滚,放宿主机上更顺手。
2.3 权限问题:容器内UID和宿主机UID不一致的坑
绑定挂载还有一个绕不开的问题:权限。容器里的进程通常以某个非root用户运行,比如MySQL容器里mysqld以UID 999运行,Nginx以UID 101运行。如果宿主机目录的属主不是这个UID,容器启动时就会报权限错误。
解决办法有两个。一是启动前把宿主机目录属主改成容器内对应的UID:
chown -R 999:999 /opt/mysql/data二是启动容器时用--user参数指定UID,但这样又可能跟容器内其他文件的权限冲突。我一般用第一种,虽然看起来粗暴,但最省事。如果你不知道容器内进程的UID是多少,可以起一个临时容器进去id一下:
docker run --rm -it mysql:8.0 id mysql这个命令会输出mysql用户在容器内的UID和GID,照着改宿主机目录权限就行。
3. 网络规划:让容器之间"找得到"彼此
3.1 默认bridge网络的局限
刚上手的时候,大家一般都用-p把容器端口映射到宿主机,然后通过localhost:端口访问。单机单服务没问题,但一旦服务多了,问题就来了:容器A要访问容器B,用localhost不行,因为每个容器有自己的网络命名空间;用宿主机IP也不行,因为容器重启IP会变。
Docker默认的bridge网络里,容器之间可以通过IP互相访问,但IP是动态分配的,重启就变。而且默认bridge网络不支持自动DNS解析,你没法用容器名当主机名去连。这就是为什么很多人第一次搭多容器应用时,应用连不上数据库——不是密码错了,是网络不通。
3.2 自定义bridge网络:容器互联的正确姿势
解决办法是创建自定义bridge网络:
docker network create app_net然后把需要互联的容器都加进去:
docker run -d --name mysql8 --network app_net \ -e MYSQL_ROOT_PASSWORD=your_strong_password \ -v mysql_data:/var/lib/mysql \ mysql:8.0 docker run -d --name redis6 --network app_net \ -v redis_data:/data \ redis:6-alpine这样mysql8和redis6之间就可以直接用容器名互相访问了。比如在应用容器里连数据库,主机名写mysql8就行,Docker内置的DNS会自动解析成对应容器的IP。这个机制在自定义网络里才生效,默认bridge网络是没有的。
我一般会按用途划分网络,比如app_net给应用和数据库用,proxy_net给反向代理和Web服务用。需要跨网络通信的容器就同时加入两个网络。这样做的目的是隔离——数据库不应该被反向代理直接访问,只应该被应用访问。
3.3 端口暴露:哪些该映射,哪些不该
-p参数把容器端口映射到宿主机,格式是宿主机端口:容器端口。但不是什么端口都需要映射。数据库的3306、缓存的6379,这些端口只需要被同一网络内的其他容器访问,不需要暴露到宿主机。暴露出去反而增加安全风险。
我的原则是:只有需要从宿主机外部访问的服务才映射端口。比如Nginx的80和443要映射,因为用户要访问;应用服务的8080要映射,因为Nginx要转发过来;数据库和缓存的端口一律不映射,只走容器内部网络。
如果确实需要从宿主机访问数据库做调试,可以映射到一个非标准端口,比如-p 13306:3306,并且配合防火墙规则限制来源IP。不要直接把3306暴露到公网,这是最基本的安全常识。
4. 逐个击破:常用服务的容器化实操
4.1 MySQL:初始化参数与字符集设置
MySQL容器化最常被吐槽的就是字符集问题。默认情况下,MySQL 8.0的字符集是utf8mb4,但排序规则是utf8mb4_0900_ai_ci,有些老应用不兼容。如果你需要改成utf8mb4_general_ci,可以在启动时加参数:
docker run -d \ --name mysql8 \ --network app_net \ -e MYSQL_ROOT_PASSWORD=your_strong_password \ -e MYSQL_DATABASE=app_db \ -e MYSQL_USER=app_user \ -e MYSQL_PASSWORD=app_password \ -v mysql_data:/var/lib/mysql \ -v /opt/mysql/conf:/etc/mysql/conf.d \ mysql:8.0 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_general_ci \ --default-authentication-plugin=mysql_native_password最后那个--default-authentication-plugin=mysql_native_password很关键。MySQL 8.0默认用caching_sha2_password认证插件,很多老版本的客户端连不上,报"Authentication plugin cannot be loaded"。加上这个参数就退回老插件,兼容性最好。
MYSQL_DATABASE、MYSQL_USER、MYSQL_PASSWORD这三个环境变量只在数据卷为空时生效,也就是第一次初始化的时候。如果数据卷里已经有数据了,这些变量会被忽略。所以如果你改了密码想生效,要么进容器手动改,要么把数据卷删了重新初始化——后者会丢数据,慎用。
4.2 Redis:持久化策略与内存限制
Redis容器化相对简单,但有两个点要注意:持久化和内存。
持久化方面,Redis默认的RDB快照是开启的,数据会写到/data目录。所以只要把/data挂载出来就行:
docker run -d \ --name redis6 \ --network app_net \ -v redis_data:/data \ redis:6-alpine \ redis-server --appendonly yes--appendonly yes开启AOF持久化,数据安全性更高,但性能会略降。如果只是做缓存用,不需要持久化,可以不加这个参数,甚至可以把--save ""加上彻底关闭RDB。
内存限制方面,Redis默认没有内存上限,容器内存有多大它就用多大,容易把宿主机内存吃满。建议启动时加--maxmemory参数:
redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lruallkeys-lru表示内存满了之后按LRU算法淘汰key。如果是做缓存,这个策略最合适;如果是做队列或者存储,就不能随便淘汰,得用noeviction然后自己监控内存。
4.3 Nginx:配置挂载与静态资源服务
Nginx容器化最典型的用法是反向代理加静态资源服务。配置目录挂载是关键:
mkdir -p /opt/nginx/conf.d /opt/nginx/html /opt/nginx/logs docker run -d \ --name nginx \ --network proxy_net \ -p 80:80 \ -p 443:443 \ -v /opt/nginx/conf.d:/etc/nginx/conf.d \ -v /opt/nginx/html:/usr/share/nginx/html \ -v /opt/nginx/logs:/var/log/nginx \ nginx:alpine注意这里挂载的是/etc/nginx/conf.d而不是/etc/nginx。因为主配置文件nginx.conf里有一行include /etc/nginx/conf.d/*.conf;,我们只需要把自己的站点配置放到conf.d目录里就行,不用动主配置。这样升级Nginx镜像时,主配置跟着镜像走,不会因为挂载覆盖导致配置丢失。
站点配置示例,比如/opt/nginx/conf.d/app.conf:
server { listen 80; server_name app.example.com; location / { proxy_pass http://app:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里的proxy_pass http://app:8080里的app是应用容器的名字,前提是Nginx容器和应用容器在同一个自定义网络里。这就是前面强调自定义网络的原因——用容器名当主机名,配置写起来干净,容器重建IP变了也不用改配置。
4.4 PostgreSQL:初始化脚本与数据目录权限
PostgreSQL容器化跟MySQL类似,但初始化方式不太一样。官方镜像支持在/docker-entrypoint-initdb.d目录放.sql、.sh、.sql.gz文件,容器第一次初始化时会按字母顺序执行。这个机制很适合做数据库初始化:
mkdir -p /opt/pg/init cat > /opt/pg/init/01_create_tables.sql << 'EOF' CREATE TABLE users ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); EOF docker run -d \ --name postgres15 \ --network app_net \ -e POSTGRES_PASSWORD=your_strong_password \ -e POSTGRES_DB=app_db \ -v pg_data:/var/lib/postgresql/data \ -v /opt/pg/init:/docker-entrypoint-initdb.d \ postgres:15PostgreSQL的数据目录权限比MySQL更敏感。官方镜像要求数据目录必须是UID 999所有,而且权限必须是700。如果你用绑定挂载,启动前一定要:
mkdir -p /opt/pg/data chown -R 999:999 /opt/pg/data chmod 700 /opt/pg/data否则容器启动会报initdb: could not change permissions of directory然后退出。这个坑我踩过不止一次,后来干脆数据目录一律用具名卷,省得折腾权限。
5. 编排与自愈:让服务"挂了能自己起来"
5.1 重启策略:unless-stopped是最实用的选择
docker run的时候加--restart参数可以设置重启策略。可选值有no、on-failure、always、unless-stopped。我一般用unless-stopped,意思是容器意外退出时自动重启,但我手动docker stop的容器不会自动起来。这个策略最符合直觉——机器重启后服务自动恢复,但我主动停掉的服务就保持停止状态。
docker run -d --restart unless-stopped --name mysql8 ...always的问题是,你手动停了容器,Docker守护进程重启后它还是会自己起来,有时候会让人困惑。on-failure只在非零退出码时重启,适合那些正常退出就不该重启的一次性任务。
5.2 健康检查:让Docker知道服务"真的好了"
容器在运行不等于服务可用。MySQL容器启动了,但数据库可能还在初始化,这时候应用连上去就会失败。健康检查就是解决这个问题的:
docker run -d \ --name mysql8 \ --health-cmd="mysqladmin ping -h localhost -u root -p$MYSQL_ROOT_PASSWORD" \ --health-interval=10s \ --health-timeout=5s \ --health-retries=5 \ ...--health-cmd是检查命令,返回0表示健康,非0表示不健康。--health-interval是检查间隔,--health-retries是连续失败几次后标记为unhealthy。用docker ps看STATUS列会显示healthy或unhealthy。
健康检查的价值在于,配合编排工具(比如Docker Compose的depends_on加condition: service_healthy)可以实现"等数据库真正就绪了再启动应用"。单纯用depends_on只能保证启动顺序,不能保证服务可用性,这是很多人踩过的坑。
5.3 日志管理:别让日志把磁盘撑爆
Docker默认的日志驱动是json-file,日志文件会一直增长,除非你配置了轮转。我见过不止一次因为容器日志把磁盘写满导致宿主机挂掉的情况。解决办法是在启动时限制日志大小:
docker run -d \ --log-driver json-file \ --log-opt max-size=10m \ --log-opt max-file=3 \ ...这样每个容器最多保留3个日志文件,每个最大10MB,总共不超过30MB。对于大多数服务够用了。如果要全局配置,可以改/etc/docker/daemon.json:
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }改完重启Docker守护进程,之后新建的容器都会应用这个配置。注意已经存在的容器不会自动应用,需要重建。
6. 升级与备份:服务跑起来之后的事
6.1 镜像升级的正确流程
服务跑起来之后,总有一天要升级。比如MySQL从8.0.32升到8.0.35,或者Redis从6升到7。升级流程我总结为四步:备份数据、拉新镜像、停旧容器、起新容器。
以MySQL为例:
# 1. 备份数据 docker exec mysql8 mysqldump -u root -p$MYSQL_ROOT_PASSWORD --all-databases > /opt/backup/mysql_$(date +%Y%m%d).sql # 2. 拉新镜像 docker pull mysql:8.0.35 # 3. 停旧容器(数据卷保留) docker stop mysql8 && docker rm mysql8 # 4. 起新容器,挂载同一个数据卷 docker run -d --name mysql8 --network app_net \ -e MYSQL_ROOT_PASSWORD=your_strong_password \ -v mysql_data:/var/lib/mysql \ mysql:8.0.35关键点是数据卷不变,容器重建。MySQL 8.0.x之间的小版本升级通常兼容,但跨大版本(比如5.7到8.0)就不一定了,需要先查官方升级文档,有时候需要跑mysql_upgrade或者用逻辑备份导出再导入。
Redis的升级更简单,因为Redis的RDB和AOF格式向后兼容,新版本能读旧版本的数据文件。但反过来不行,旧版本读不了新版本的文件。所以升级前备份,升级后如果发现问题要回滚,得把数据也回滚。
6.2 数据备份的三种方式
备份这件事,我建议至少做到两种方式结合。第一种是逻辑备份,比如mysqldump、pg_dump、redis-cli BGSAVE,导出的是可读的SQL或RDB文件,恢复灵活,但速度慢。第二种是物理备份,直接打包数据卷目录:
docker run --rm \ -v mysql_data:/data \ -v /opt/backup:/backup \ alpine tar czf /backup/mysql_data_$(date +%Y%m%d).tar.gz -C /data .这个命令起一个临时alpine容器,同时挂载数据卷和备份目录,把数据卷内容打包。速度快,但恢复时要求目标环境的MySQL版本一致。
第三种是文件系统快照,如果宿主机用的是LVM或者ZFS,可以直接对数据卷所在的分区做快照。这个最快,但依赖底层存储支持,不是所有环境都有。
我一般用逻辑备份加物理备份组合:每天逻辑备份一次,每周物理备份一次。逻辑备份用来做精细恢复,物理备份用来做整机迁移。
6.3 容器重建时的配置漂移问题
容器化之后,配置漂移是个隐蔽的坑。比如你手动进容器改了一个配置文件,当时生效了,但下次容器重建时改动就丢了。所以所有需要持久化的配置都必须通过挂载或者环境变量注入,不能直接改容器内的文件。
我见过有人进MySQL容器改my.cnf,改完重启容器发现配置没了,因为容器重建时用的是镜像里的默认配置。正确做法是把自定义配置放在宿主机目录,挂载到/etc/mysql/conf.d下面。这样容器重建时配置还在,而且配置变更可以纳入版本管理。
同理,Nginx的站点配置、应用的配置文件、证书文件,都应该挂载进去,而不是docker cp进去。docker cp只适合临时调试,不适合长期管理。
7. 我在实际部署中总结的几条经验
第一条,不要用latest标签。latest是个移动标签,今天拉的是8.0.35,明天可能就变成8.0.36了。生产环境一定要用具体版本号,比如mysql:8.0.35、redis:6.2-alpine。这样升级是可控的,不会因为某次docker pull意外升级导致兼容性问题。
第二条,环境变量里的密码不要写在命令行历史里。docker run -e MYSQL_ROOT_PASSWORD=xxx这种写法,密码会出现在~/.bash_history和docker inspect的输出里。更好的做法是用--env-file指定一个权限为600的文件:
cat > /opt/mysql/.env << 'EOF' MYSQL_ROOT_PASSWORD=your_strong_password MYSQL_DATABASE=app_db EOF chmod 600 /opt/mysql/.env docker run -d --name mysql8 --env-file /opt/mysql/.env ...这样密码不会出现在命令行历史里,docker inspect虽然还能看到,但至少不会随手history就泄露。
第三条,定期清理无用资源。Docker用久了会积累一堆停止的容器、悬空镜像、无用卷和网络。定期跑一下:
docker system prune -a --volumes这个命令会清理所有停止的容器、所有未被使用的镜像、所有未被使用的卷和网络。注意--volumes会删掉未被使用的卷,如果有些卷你手动创建但暂时没挂载,也会被删掉。所以跑之前先docker volume ls确认一下。
第四条,给容器起有意义的名字。docker run --name mysql8比让Docker随机分配名字好得多。随机名字像pedantic_einstein这种,过两天你根本不知道哪个容器是干嘛的。名字也是容器间互相访问的主机名,起个好名字一举两得。
第五条,文档化你的部署命令。我习惯把每个服务的docker run命令写在一个deploy.sh脚本里,加上注释说明每个参数的作用。这样换一台机器部署时,直接跑脚本就行,不用凭记忆敲命令。脚本本身也可以纳入版本管理,配置变更一目了然。
这套东西说起来不复杂,但真正落地的时候,细节决定成败。数据卷挂载路径写错一个字符,可能就意味着数据丢失;网络没规划好,可能就意味着服务之间连不上;健康检查没配,可能就意味着应用在数据库还没就绪时就启动然后崩溃。这些坑我都踩过,希望你看完这篇之后能少走点弯路。