《工具配置》目录

第 1 章

Docker 起一套本地环境——MySQL、Redis、Nginx 一条命令搞定

本章目标:

  1. 先做判断:本地环境哪些部分值得容器化、哪些原生安装更省事——不是无脑 all-in Docker
  2. 逐段看懂 compose 文件每一段在防什么坑:环境变量、端口映射、数据卷、健康检查、重启策略
  3. 理解容器网络模型:为什么应用里连 localhost 连不上数据库,服务名为什么能通
  4. 掌握数据卷的生命周期:什么时候丢数据、怎么备份、怎么彻底清干净
  5. 拿走一份十连排错清单:daemon 没起、端口被占、时区差 8 小时、旧卷残留……

本章基线:compose 文件 = 教程仓 hiring-ai-assistant/docker/docker-compose.yml(演示环境真实在用)| 服务器侧运行态输出来自腾讯云主机的生产容器(Halo + MySQL + Umami + PostgreSQL,2026-11-01 实拍;镜像的具体版本号已脱敏——发出来的运维资料不写版本号,1.3.6 会讲版本纪律为什么重要)| 作者本机是 Docker Desktop 27.5.1 + 原生安装的 MySQL——这个「混合状态」本身就是 1.1 节要讨论的判断题

1.1 先想清楚:本地环境要不要容器化

1.1.1 原生安装的真实代价

MySQL 直接装在 Mac 上,第一年没什么问题。代价从第二年开始出现,而且每一条都很具体:

  • 版本矩阵:老项目锁在 MySQL 5.7(客户服务器就是 5.7,升了怕出兼容问题),新项目用 8.0,教程要跑 8.4 的新特性——一台机器装三个版本的 MySQL,卸载旧版时的残留(brew services、/usr/local/mysql、launchd 守护进程)能折腾一晚上。
  • 「新机半天」:换电脑或重装系统,凭记忆把环境装一遍:MySQL、Redis、Node 版本、JDK 版本,每个都有自己的安装方式和配置文件位置,半天就过去了。
  • 「在我机器上能跑」:新同事入职,照文档装环境,卡在一个没人记得的配置上(大小写敏感、默认字符集、版本差异),一卡就是一下午。
  • 和生产不一样:本地 MySQL 的某个行为,到客户服务器上另一个版本的 MySQL 里不一样了,这个问题到联调那天才暴露。

1.1.2 容器化也有代价

公平起见,反方向的账也要算,不然就成了布道文:

  • 磁盘:一个 MySQL 镜像 600MB 上下,Redis 算小的也要 40MB,再算上镜像层叠出来的中间层,一块 256GB 的硬盘经不起几个项目各拉一套。
  • 一层抽象的调试成本:「文件写进去了为什么读不到」(写到容器层了,没挂卷)、「端口为什么通不了」(映射写错或绑了回环)这类问题,要先分清是应用的问题还是 Docker 的问题。
  • 后台常驻:Docker Desktop 本身吃 1–2GB 内存。写代码时并不总需要数据库跑着。

1.1.3 判断标准:三个问题

和消息队列那一章的判断方式一样,要不要上这套东西,取决于三个问题的答案,不取决于「大家都用」:

  • 换环境频率:多久换一次机器或重装一次系统?一年一次以上的,容器化的收益立刻兑现——环境定义就是一个文件,进 git,新机器 git clone + docker compose up -d。
  • 项目数量:同时维护几个项目、几个数据库版本?两个以上,版本矩阵问题出现,容器化是为数不多的干净解法。
  • 交付形态:系统要不要交付到客户机房(私有化)?要的话,本地 compose 就是在预演交付——客户机器上装什么、起什么、数据放哪,你本地一模一样地跑一遍,交付当天才不会现想。

三个答案都平淡(单项目、低频、不交付),原生安装完全成立——工具是手段不是勋章。作者本机的真实状态就是活例子:Docker Desktop 装着、daemon 平时不开、MySQL 原生安装,因为当前只有一个人写一个主项目。但教程仓的演示环境选择了 compose,理由也很硬:读者环境千差万别,一个 compose 文件消灭一整类「在我机器上能跑」的往返沟通。

你的情况 建议
单项目、低频换机、不交付私有化 原生安装没问题,别为了技术栈整齐上 Docker
两个以上项目用了不同版本的中间件 compose,一个项目一个目录互不干扰
私有化交付 必须容器化,本地 compose 就是交付预演
写教程/带人 必须容器化,你的读者环境你想象不到

1.2 心智模型:四个概念就够了

Docker 的概念名词很多(镜像分层、OCI、namespace、cgroup……),本地开发真正用到的只有四个,每个都能找到传统装机里的对应物:

graph LR
    subgraph 宿主机["你的 Mac / 服务器"]
        daemon["Docker daemon<br/>(后台守护进程,管理一切)"]
        subgraph 容器1["容器 mysql"]
            proc1["mysqld 进程"]
        end
        subgraph 容器2["容器 redis"]
            proc2["redis-server 进程"]
        end
        vol["数据卷 mysql-data<br/>(独立于容器,删容器不删卷)"]
        net["compose 网络<br/>(内置 DNS:服务名→容器 IP)"]
        proc1 --- vol
        proc2 --- net
    end
    镜像["镜像(模板)"] -. "docker compose up<br/>按模板创建" .-> 容器1
Docker 概念 传统装机对应物 一句话
镜像(image) 安装包 只读模板,一个镜像可以起任意多个容器
容器(container) 装好后跑着的那个进程 隔离的进程 + 它独享的文件系统视图
数据卷(volume) 数据目录 独立于容器存在;删容器不删卷,删卷才真删数据
compose 网络 一台交换机 + 内置 DNS 同一 compose 里的服务用服务名互相访问

生产服务器上跑 docker ps,还能看到 compose 项目名的命名规则实拍。服务器上两个 compose 项目(halo、umami)的容器长这样:

NAMES           IMAGE                                    STATUS
umami-umami-1   mirror.ccs.tencentyun.com/.../umami...   Up 6 days
umami-db-1      mirror.ccs.tencentyun.com/library/...    Up 6 days (healthy)
halo-halo-1     registry.fit2cloud.com/halo/halo:2.x     Up 5 days
halo-halodb-1   mysql:8.x                                Up 12 days (healthy)

命名规则是 {项目名}-{服务名}-{序号},项目名默认取 compose 文件所在目录的名字。这就是为什么一个服务器上能跑多套互不干扰的环境——每个 compose 项目一张独立网络,网络名同样带项目前缀(实拍:halo_halo_network、umami_umami_net),卷也按项目隔离。

1.3 逐段拆解 compose 文件

这是教程仓里真实在用的 compose 文件(hiring-ai-assistant/docker/docker-compose.yml),演示环境的 MySQL 和 Redis 就是它起的。逐段拆开,每一段都在防一个具体的坑:

services:
  mysql:
    image: mysql:8.0
    container_name: hiring-mysql
    environment:
      MYSQL_ROOT_PASSWORD: hiring_root
      MYSQL_DATABASE: hiringdb
      MYSQL_USER: hiring
      MYSQL_PASSWORD: hiring_pass
      TZ: Asia/Shanghai
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
      - ../src/main/resources/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uhiring", "-phiring_pass"]
      interval: 5s
      timeout: 5s
      retries: 20
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    container_name: hiring-redis
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 20
    restart: unless-stopped

volumes:
  mysql-data:
  redis-data:

1.3.1 environment:镜像的入口脚本约定

MYSQL_ROOT_PASSWORD、MYSQL_DATABASE、MYSQL_USER、MYSQL_PASSWORD 这四个变量不是 Docker 的功能,是 MySQL 官方镜像入口脚本的约定:首次初始化数据目录时,入口脚本读这些变量,建库、建用户、授权,一次做完。所以这些变量只在数据目录为空的第一次启动生效——这也是 1.6 节「改了密码没生效」类问题的根源。

两件配套的事:一是业务应用不要用 root 连,专用账号只授本库权限(上面的 hiring 用户),这个习惯从本地开发就开始养成;二是密码明文写在 compose 里的取舍——本地开发环境可以接受(反正进的是私有仓),生产环境换成 .env 文件并让 compose 读(env_file: 或变量替换),且 .env 进 .gitignore。

1.3.2 ports:绑哪个地址比端口号更要紧

"3306:3306" 的完整含义是「宿主机的 0.0.0.0:3306 → 容器 3306」,也就是宿主机所有网卡都在监听 3306。本机开发无所谓;放到云服务器上,这就是把数据库端口挂到公网——安全组一开,自动化扫描器当天就能摸到,弱密码直接被爆破。

生产服务器上的做法实拍(docker inspect 的端口段):

halo-halo-1     端口: {"8090/tcp":[{"HostIp":"127.0.0.1","HostPort":"8091"}]}
umami-umami-1   端口: {"3000/tcp":[{"HostIp":"127.0.0.1","HostPort":"3001"}]}
halo-halodb-1   端口: 3306/tcp(未映射到宿主机)

三个容器的三个选择,正好是三级教材:Halo 和 Umami 的管理端口只绑 127.0.0.1(外面由 nginx 反代,见 Nginx 反向代理要点),数据库端口干脆不映射,容器网络内部直连。本地开发想跟生产对齐习惯,写法是 "127.0.0.1:3306:3306"——宿主机上照样连,外面扫不到。

1.3.3 volumes:数据卷和初始化 SQL 是两件事

这个 compose 里挂了两个卷,性质完全不同,混在一起看容易误解:

  • mysql-data:/var/lib/mysql 是命名卷(named volume):MySQL 的真实数据目录,容器删了它还在。volumes: 末段声明这个名字,Docker 托管实际存储位置。
  • ...schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro 是 bind mount(把宿主机文件映射进容器,:ro 只读)。它利用的是 MySQL 镜像的另一个约定:数据目录为空、首次初始化时,按文件名顺序执行 /docker-entrypoint-initdb.d/ 下的 .sql 和 .sh。

注意「只在数据目录为空时执行」这半句——初始化 SQL 不是每次启动都跑的脚本。卷里有数据之后再重启、再 up,01-schema.sql 都不会执行。想让某段初始化逻辑每次都跑,它不该放这里,该在应用侧做(Flyway/Liquibase 这类迁移工具,或者启动时的幂等建表)。文件名带 01- 前缀,就是为了多个初始化脚本时的执行顺序可控。

1.3.4 healthcheck:让「活着」可以被检查

没有健康检查,docker ps 里容器是 Up 就只能说明进程在,不能说明服务可用——MySQL 进程起来了、还在刷初始化日志、暂时不接受连接的阶段,Up 照样显示。健康检查把「可用」变成一个可查询的状态:

Up 12 days (healthy)               ← 健康检查通过
Up 30 seconds (health: starting)   ← 还在检查
Up 2 minutes (unhealthy)           ← 连续失败,该看日志了

mysqladmin ping 是比「跑一条真 SQL」轻的探针(只测服务应答,不碰业务表);interval: 5s + retries: 20 给了首次初始化足够的时间——MySQL 8 首次初始化要二三十秒,retries 给小了容器会在还没初始化完时就被标成 unhealthy。应用侧配合 depends_on: {condition: service_healthy}(compose v2 语法),就能做到「MySQL 健康了才起应用」,把启动时序的坑从「靠重试碰运气」变成确定行为。

1.3.5 restart: unless-stopped 的准确含义

四种重启策略的差别,一张表说清:

策略 宕机后 宿主机重启后 手动 stop 后
no(默认) 不拉起 不拉起 不拉起
on-failure 拉起(可限次数) 不拉起 不拉起
always 拉起 拉起 下次 daemon 启动时拉起
unless-stopped 拉起 拉起(除非 stop 过) 永不自动拉起

unless-stopped 和 always 唯一的差别在最后一列:手动 docker stop 过的容器,unless-stopped 尊重你的意愿不再拉起,always 会在 daemon 重启后把它拉回来。生产服务器上四个容器全部是 unless-stopped(实拍确认过)——服务器重启后 Halo、数据库自动回来,而你有意停掉的东西保持停着,这个语义最符合直觉。

顺带一个扫尾:compose 文件第一行的 version: '3.8' 在 Compose v2 里已经废弃,起环境时会看到一行警告 the attribute version is obsolete。教程仓里保留它是因为旧版兼容,新写的 compose 文件可以不写这行。

1.3.6 image:版本号怎么写

mysql:8.0、redis:7-alpine 里的标签值得单独说两句。永远不要用 latest——今天 pull 和三个月后 pull 拿到的东西可能不同,环境就从「可复现」变回「看运气」。生产服务器把版本钉得更死(精确到具体小版本),教程仓用次版本号(8.0、7-alpine):主版本内兼容,又能吃到补丁。-alpine 后缀是精简底座(Redis 从 130MB 缩到 40MB),代价是 glibc 换成 musl,极少数依赖 glibc 的东西会出兼容问题——数据库、缓存这类标准服务用 alpine 没问题,冷门镜像先确认。

1.4 起停与日常操作

日常就这几个命令,语义的边界比命令本身重要:

命令 干什么 数据
docker compose up -d 创建并启动(已存在则按需重建) 卷不动
docker compose ps 看状态(含健康检查结果) —
docker compose logs -f mysql 追某个服务的日志 —
docker compose exec mysql bash 进容器排查 —
docker compose restart mysql 重启服务 卷不动,数据在
docker compose stop / start 停 / 起(容器保留) 数据在
docker compose down 删容器 + 网络,保留卷 数据在
docker compose down -v 删容器 + 网络 + 卷 数据没了

两张最容易记混的牌:down 之后 up,数据还在(卷在),而且初始化 SQL 不会重跑(数据目录非空);down -v 之后 up,数据没了、初始化 SQL 重新执行一遍。想要一个「绝对干净」的首次启动状态,用的就是 down -v。

1.5 容器网络:localhost 为什么连不上

这是新手第一大坑,值得把机制讲透。

1.5.1 每个容器有自己独立的网络栈

容器不是宿主机上的一个普通进程——它有自己的网络命名空间:自己的网卡、自己的 127.0.0.1、自己的端口空间。所以容器里的 localhost 指的是容器自己,不是你的 Mac。应用跑在 IDE 里、MySQL 跑在容器里时,应用连 localhost:3306 连的是应用进程自己——什么都没有,连接拒绝。

反方向同样成立:容器里想访问宿主机上跑的服务(比如应用还没容器化、只是想测一下容器内到宿主的通路),用的不是 localhost,而是 Docker Desktop 提供的特殊域名 host.docker.internal(Mac/Windows 内置;Linux 服务器上要加 extra_hosts: ["host.docker.internal:host-gateway"])。

1.5.2 服务名就是 DNS

同一个 compose 文件里的服务,Docker 会把服务名注册进这张 compose 网络的内置 DNS。这不是文档说说,在 Halo 容器里实测(halo 的 compose 里数据库服务名叫 halodb):

$ docker exec halo-halo-1 getent hosts halodb
172.18.0.2      halodb

mysql、redis 这些服务名同理,在 compose 网络里直接解析到对端容器的内网 IP。将来把应用也容器化,JDBC 地址写 jdbc:mysql://mysql:3306/hiringdb——不是写宿主机 IP,也不要依赖 container_name 起的名字(它能解析,但服务名才是 compose 的正式约定)。

这正是 1.3.2 实拍里 halo-halodb-1 不映射端口也无所谓的原因:Halo 容器和它同一个 compose 网络,直接连 mysql:3306,端口根本不需要暴露到宿主机,更不需要暴露到公网。

1.5.3 推荐的本地姿势:应用在 IDE,基础设施在容器

教程项目当前的形态是最常见的开发形态:应用在 IDE 里跑(宿主机),MySQL/Redis 在容器里跑。这靠 1.3.2 的端口映射打通:容器把 3306 映射到宿主机,宿主机上的应用连 localhost:3306,localhost 这一次是对的——因为应用进程就在宿主机上。

两边的地址写法放一起看,这是本章最值得记住的一张对照表:

谁连谁 地址 为什么
宿主机上的应用 → 容器里的 MySQL localhost:3306(映射过) 应用在宿主机,走端口映射
容器里的应用 → 同 compose 的 MySQL mysql:3306 compose 内置 DNS 解析服务名
容器里的应用 → 宿主机上的服务 host.docker.internal:端口 容器的 localhost 是它自己

等哪天应用也容器化了(交付私有化前的最后一步),只需要把 JDBC 地址从 localhost:3306 改成 mysql:3306,其他什么都不动——这也是「基础设施先行容器化」的好处:迁移路径是平滑的,环境差异被压缩成一个地址字符串。

1.6 数据卷:什么时候丢数据

1.6.1 三种挂载,先分清

类型 写法 存在哪 本地开发用途
命名卷 mysql-data:/var/lib/mysql Docker 托管目录 数据库数据(默认首选)
bind mount ./schema.sql:/init/01.sql:ro 宿主机指定路径 挂配置文件、初始化脚本
匿名卷 不写宿主侧 Docker 随机目录 不推荐——等于失控的状态

1.6.2 备份与清空

备份一条命令,从宿主机直接拿走数据:

docker compose exec mysql sh -c \
  'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" hiringdb' > backup-$(date +%F).sql

反过来,测试数据堆多了想彻底重来:docker compose down -v 删卷重建(1.4 节的语义表)。唯一删掉命名卷的路径就是 -v,删容器、删镜像都不碰它——这也解释了下一节的坑。

1.6.3 「改了 compose 怎么不生效」

三个高频变体,根源都是旧状态还活着:

  • 改了密码/环境变量:environment 只在初始化时生效,卷里的旧数据带着旧账号。重来 = down -v 再 up(数据全清,先 dump)。
  • 改了卷映射:旧命名卷还挂着旧数据,新映射指向的是新卷(空的)。docker volume ls 看一眼就清楚。
  • 改了 compose 没重启:compose 的配置在容器创建时定格,改完要 up -d(它检测到配置变化会重建对应容器)。

1.6.4 时区差 8 小时

MySQL 官方镜像默认 UTC,select now() 比北京时间慢 8 小时,本地看数据时间戳全是「凌晨」。这个坑有多常见——它现在就活在作者的生产服务器上(2026-11-01 实拍,halo 的数据库容器没配 TZ):

$ docker exec halo-halodb-1 date
Sun Nov  1 13:38:34 UTC 2026      ← 容器:UTC 下午 1 点
$ date
Sun Nov  1 09:38:34 PM CST 2026   ← 宿主机:晚上 9 点半

正好差 8 小时。compose 里的 TZ: Asia/Shanghai 就是治这个的(教程仓的 MySQL 服务配了):容器系统时区设对,now() 就对。应用层用时间戳做展示或按天统计的,这一小时都错不得——考勤、日结类业务差 8 小时就是差一个营业日。排查时先 docker exec <容器> date 和宿主机对一下表。

1.7 生产实拍:这套东西长成了什么样

把腾讯云那台生产主机的真实状态放进来收尾(2026-11-01 实拍),本地 compose 的每个字段都能在这里找到对应物:

Docker version 29.x(build 号已脱敏)

NAMES           IMAGE                        STATUS               PORTS
umami-umami-1   .../umami:postgresql-v2.x    Up 6 days            127.0.0.1:3001->3000/tcp
umami-db-1      .../postgres:16-alpine       Up 6 days (healthy)  5432/tcp
halo-halo-1     .../halo/halo:2.x            Up 5 days            127.0.0.1:8091->8090/tcp
halo-halodb-1   mysql:8.x                    Up 12 days (healthy) 3306/tcp

(镜像列截断了 registry 前缀:完整来源是腾讯云内网源 mirror.ccs.tencentyun.com 和 fit2cloud 官方源,见本节末段观察点。)

四个观察点,每个都对应前文的一节:

  • 精确版本号:数据库和应用镜像都钉在具体版本上——没有 latest(1.3.6)。
  • 健康检查生效:两个数据库容器都带 (healthy),compose 的 healthcheck 不是本地开发的摆设(1.3.4)。
  • 回环绑定:对外服务只绑 127.0.0.1,交给 nginx 反代;数据库端口不映射(1.3.2)。
  • 内网镜像源:mirror.ccs.tencentyun.com/...——腾讯云内网镜像仓库,拉取快且不占公网带宽。本地开发用不上,云上部署值得抄。

还有一个「没配」的观察:docker inspect 显示内存限制为 0(未限制)。单机跑着图省事可以接受,容器之间真要互相防内存挤兑时,compose 里加 mem_limit(或 deploy.resources.limits)把它限住。

1.8 排错清单:现象 → 原因 → 一条命令

按踩中概率排序。前两条是作者真实踩过的。

  • Cannot connect to the Docker daemon:daemon 没起。Docker Desktop 要先启动应用本体(服务器上是 systemctl start docker)。docker ps 报这个错,先想 daemon 再想别的。
  • Bind for 0.0.0.0:3306 failed: port is already allocated:端口被占——最常见的占用者是本机原生安装的 MySQL(brew services / 系统服务在跑)。lsof -i :3306 看谁在听,要么停原生服务,要么 compose 改映射宿主端口("13306:3306")。
  • 容器反复重启(Restarting):先看日志别猜——docker compose logs --tail 50 mysql。常见根因:初始化变量不完整(MySQL 要求 root 密码或 MYSQL_ALLOW_EMPTY_PASSWORD)、数据卷里是坏状态的旧数据。
  • 应用报 Connection refused,但 docker ps 显示 Up:看 STATUS 列有没有 (health: starting)——MySQL 还在首次初始化,Up 不等于可用(1.3.4)。等 healthy,或给应用配 depends_on 的 service_healthy。
  • select now() 差 8 小时:镜像默认 UTC(1.6.4),compose 里 TZ: Asia/Shanghai,改完要重建容器(up -d)。
  • down 之后再 up,数据「丢了」:八成是用了 down -v。先 docker volume ls 确认卷还在不在(1.4)。
  • 改了初始化 SQL 不生效:初始化只跑在空数据目录首启(1.3.3)。down -v 才会重跑,或者把变更走迁移工具。
  • docker-compose 命令不存在:v1 的独立命令已被 v2 取代,新写法是空格——docker compose up -d。CI 脚本里容易留着旧写法。
  • 拉不起镜像、运行报 exec format error:Apple Silicon 上拉到了仅 amd64 的镜像。官方主流镜像早已多架构,冷门镜像加 platform: linux/amd64(靠 Rosetta 模拟,性能有折损)。
  • 磁盘满了:docker system df 看占用大户,docker image prune 清悬空镜像。-a 会把没用到的镜像全删,慎用——下次 up 全要重拉。

1.9 本章小结

一张表收走全部日常操作;判断问题回 1.1 的三个问题:

场景 命令
首次起环境 docker compose up -d,等 healthcheck 变 healthy
日常开关 docker compose stop / start(数据都在)
看状态/日志 docker compose ps、docker compose logs -f mysql
进容器排查 docker compose exec mysql bash
备份数据库 1.6.2 的 mysqldump 一条命令
彻底重置 docker compose down -v(数据清空,初始化重跑)
改了配置生效 docker compose up -d(重建变更的容器)

1.10 练习题

  1. 验证初始化语义:给教程 compose 的 schema.sql 里加一张临时表,up -d 后进容器确认它存在;再 stop/start 确认还在;再 down/up 确认还在;最后 down -v/up 确认重新出现——把 1.3.3 和 1.4 的语义亲手过一遍。
  2. 抓一次端口冲突:保持本机原生 MySQL 运行,把 compose 里 "3306:3306" 改成 "127.0.0.1:3306:3306" 再起一次;如果原生服务占着 3306,体会报错信息,然后改成 "13306:3306" 起——排错清单第 2 项的完整预演。
  3. 做一次备份恢复:用 1.6.2 的命令 dump 出 SQL,down -v 清卷重新 up,再把 dump 导回去,核对数据行数。跑通它,你就有了一套最小可用的数据安全底线。
  4. 对比两种连法:在容器里 getent hosts mysql 看 DNS 解析结果,再从宿主机 nc -vz localhost 3306 测端口映射——把 1.5.3 对照表的两条路径亲手验证一遍。
  5. 读生产配置:找一台你能操作的云主机(或回顾 1.7 的实拍),对照本文检查四个点:版本号是否精确、健康检查是否配置、端口绑定在哪个地址、重启策略是什么。

1.11 延伸读