Java 项目部署之 Docker工具快速入门: DockerCompose 部署微服务集群

概述

一个 cloud-demo 集群里有网关、订单、用户、Nacos、MySQL 五个容器,手敲五条 docker run 不只是累------容器 IP 每次重建都变,微服务根本不知道该往哪儿注册。Compose 文件就是为了解决这件事:把整集群的启动参数写成一份 YAML,让容器之间用服务名互相找到对方。

纲要

  • 要解决什么:手工部署微服务集群的四处别扭------启动顺序、网络互通、IP 漂移、命令不可复用
  • Compose 文件 :version / services / image / build / ports / environment / volumes / $PWD
  • 核心改动 :把 localhost 全部换成 Compose 里的服务名(nacos、mysql)
  • 打包约定 :三个微服务的 jar 都必须叫 app.jar,靠 pom 里的 <finalName>app</finalName> 实现
  • 部署动作 :上传 cloud-demo → docker compose up -d → ps / logs -f / restart / down
  • 关键原理:Compose 自建 bridge 网络 + 内嵌 DNS,容器内用服务名解析到对方容器 IP
  • 踩坑清单 :注册中心地址写 localhost、Nacos 未就绪导致 Connection refused、端口冲突、app.jar 名字/目录搞错、旧命令 docker-compose(横线)与新命令 docker compose(空格)

Compose 到底替你省了什么

先别急着写 YAML,看看不写会怎样。要手工跑起这套集群,你得敲这么一串:

bash 复制代码
docker run -d --name nacos -e MODE=standalone -p 8848:8848 nacos/nacos-server
docker run -d --name mysql -e MYSQL_ROOT_PASSWORD=123 \
  -v $PWD/mysql/data:/var/lib/mysql \
  -v $PWD/mysql/conf:/etc/mysql/conf.d/ \
  mysql:5.7.25
# 然后分别进三个目录 docker build,再 docker run,还得让它们连同一个自定义网络......
docker network create cloud-net
docker run -d --name userservice --network cloud-net cloud-demo-userservice
docker run -d --name orderservice --network cloud-net cloud-demo-orderservice
docker run -d --name gateway --network cloud-net -p 10010:10010 cloud-demo-gateway

对照一下:

问题 手工 docker run docker-compose.yml
命令录入 六条以上长命令,参数全靠记忆 一个文件写完,up 一条启动
容器网络 必须自己 docker network create 再逐个 --network Compose 自动创建项目专用网络,全部服务默认加入
服务发现 容器 IP 重启就变,写死 IP 会挂 内嵌 DNS,用服务名访问,重建也不变
中间件顺序 人肉保证先起 Nacos 再起业务服务 可配 depends_on 表达依赖关系(但注意它不等就绪)
复用到别的机器 复制粘贴命令文本 复制整个目录 + 一条命令

一个判断原则:只要某组容器需要互相认识、需要成套起停,就值得放进 Compose 文件。单容器无所谓,集群就必须写。

准备部署目录

这次要部署的工程结构是这样的,day03-Docker/资料/cloud-demo 目录下每个微服务一个子目录,各自带 Dockerfile:

text 复制代码
cloud-demo/
├── docker-compose.yml          # 集群编排文件,唯一入口
├── gateway/                    # 网关服务
│   ├── Dockerfile
│   └── app.jar                 # 打包后拷进来(初始为空)
├── user-service/               # 用户服务
│   ├── Dockerfile
│   └── app.jar
├── order-service/              # 订单服务
│   ├── Dockerfile
│   └── app.jar
└── mysql/                      # 数据库数据与配置
    ├── conf/
    │   └── hmy.cnf
    └── data/                   # 已预置 cloud_user、cloud_order 库表
        ├── cloud_user/
        └── cloud_order/

三个微服务的 Dockerfile 内容完全一致:

dockerfile 复制代码
FROM java:8-alpine
COPY ./app.jar /tmp/app.jar
ENTRYPOINT java -jar /tmp/app.jar

这里有个隐含约定必须记住:COPY ./app.jar 是相对构建上下文的路径 ,所以 jar 包的文件名必须叫 app.jar,且必须放在 Dockerfile 同级目录。名字错了,构建阶段就会报:

text 复制代码
COPY failed: file not found in build context or excluded by .dockerignore: stat app.jar: file not found

mysql/data 里预置了 cloud_user、cloud_order,mysql/conf/hmy.cnf 是要覆盖给容器的配置。要说明的是:这份 data 目录来自课程资料包,跟着资料走的话表和数据都是现成的;如果你是自己从零搭,data 目录是空的,需要先执行建表 SQL(见同系列《服务拆分-案例Demo》一篇的 cloud-user.sql / cloud-order.sql),或者偷个懒------把 SQL 文件放进挂载到 /docker-entrypoint-initdb.d 的目录里,MySQL 镜像首次初始化时会自动执行里面的 .sql、.sh 脚本:

ini 复制代码
[mysqld]
skip-name-resolve
character_set_server=utf8
datadir=/var/lib/mysql
server-id=1000

skip-name-resolve 这条在容器场景里很重要。MySQL 默认会对客户端 IP 做反向域名解析,容器网络的 IP 通常解析不出 PTR 记录,连接会卡在建立阶段;关掉解析,直接走 IP 授权,连接就快也稳。

Compose 文件逐段拆

下面是 cloud-demo/docker-compose.yml 的真实内容,一字未改:

yaml 复制代码
version: "3.2"

services:
  nacos:
    image: nacos/nacos-server
    environment:
      MODE: standalone
    ports:
      - "8848:8848"
  mysql:
    image: mysql:5.7.25
    environment:
      MYSQL_ROOT_PASSWORD: 123
    volumes:
      - "$PWD/mysql/data:/var/lib/mysql"
      - "$PWD/mysql/conf:/etc/mysql/conf.d/"
  userservice:
    build: ./user-service
  orderservice:
    build: ./order-service
  gateway:
    build: ./gateway
    ports:
      - "10010:10010"

五个服务的角色和关键区别:

service 来源 是否暴露端口 为什么这样配
nacos 官方镜像 nacos/nacos-server 是,8848 要能从浏览器进控制台看注册实例,MODE: standalone 等价以前的 -m standalone 单机模式
mysql 官方镜像 mysql:5.7.25 不暴露 只对集群内部访问,端口映射出去等于把数据库开到公网
userservice build: ./user-service 不暴露 走网关转发,不允许外部直连绕过鉴权
orderservice build: ./order-service 不暴露 同上
gateway build: ./gateway 是,10010 全集群唯一入口,认证、鉴权、路由都在这里做

build: ./gateway 写的是目录不是文件 ,Compose 会进这个目录找 Dockerfile 并执行 docker build。这里的路径相对的是 compose 文件所在目录,不是你敲命令的位置。

volumes 里那个 $PWD 是个实用技巧。为什么不能写成绝对路径 /root/cloud-demo/mysql/data?因为这套目录今天是放在 Windows 的课件文件夹里,明天要扔到某台 Linux 服务器的任意路径下,写死就废了。$PWD 是 Compose 在执行时取 shell 的当前目录来替换,只要在 cloud-demo 目录下敲命令,就一定能找到正确的 mysql/data 和 mysql/conf。

version: "3.2" 这行在新版 Compose(v2 及 Docker Compose Spec)里已经废弃,写了会打印一条告警,不写也完全能用。老项目里见到的基本都是这个格式。

容器之间凭什么用服务名互访

这是整个方案里最该弄清楚的一点。以前本地跑,配置写 localhost:8848;一旦进了容器,localhost 指的就是当前容器自己------userservice 在自己容器里找 8848,当然连不上 Nacos。

Compose 在 up 的时候做了两件事(用 Mermaid 表示整个拓扑和网络边界):
#mermaid-svg-89uZhdH8kXeiCIRC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-89uZhdH8kXeiCIRC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-89uZhdH8kXeiCIRC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-89uZhdH8kXeiCIRC .error-icon{fill:#552222;}#mermaid-svg-89uZhdH8kXeiCIRC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-89uZhdH8kXeiCIRC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-89uZhdH8kXeiCIRC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-89uZhdH8kXeiCIRC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-89uZhdH8kXeiCIRC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-89uZhdH8kXeiCIRC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-89uZhdH8kXeiCIRC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-89uZhdH8kXeiCIRC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-89uZhdH8kXeiCIRC .marker.cross{stroke:#333333;}#mermaid-svg-89uZhdH8kXeiCIRC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-89uZhdH8kXeiCIRC p{margin:0;}#mermaid-svg-89uZhdH8kXeiCIRC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-89uZhdH8kXeiCIRC .cluster-label text{fill:#333;}#mermaid-svg-89uZhdH8kXeiCIRC .cluster-label span{color:#333;}#mermaid-svg-89uZhdH8kXeiCIRC .cluster-label span p{background-color:transparent;}#mermaid-svg-89uZhdH8kXeiCIRC .label text,#mermaid-svg-89uZhdH8kXeiCIRC span{fill:#333;color:#333;}#mermaid-svg-89uZhdH8kXeiCIRC .node rect,#mermaid-svg-89uZhdH8kXeiCIRC .node circle,#mermaid-svg-89uZhdH8kXeiCIRC .node ellipse,#mermaid-svg-89uZhdH8kXeiCIRC .node polygon,#mermaid-svg-89uZhdH8kXeiCIRC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-89uZhdH8kXeiCIRC .rough-node .label text,#mermaid-svg-89uZhdH8kXeiCIRC .node .label text,#mermaid-svg-89uZhdH8kXeiCIRC .image-shape .label,#mermaid-svg-89uZhdH8kXeiCIRC .icon-shape .label{text-anchor:middle;}#mermaid-svg-89uZhdH8kXeiCIRC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-89uZhdH8kXeiCIRC .rough-node .label,#mermaid-svg-89uZhdH8kXeiCIRC .node .label,#mermaid-svg-89uZhdH8kXeiCIRC .image-shape .label,#mermaid-svg-89uZhdH8kXeiCIRC .icon-shape .label{text-align:center;}#mermaid-svg-89uZhdH8kXeiCIRC .node.clickable{cursor:pointer;}#mermaid-svg-89uZhdH8kXeiCIRC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-89uZhdH8kXeiCIRC .arrowheadPath{fill:#333333;}#mermaid-svg-89uZhdH8kXeiCIRC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-89uZhdH8kXeiCIRC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-89uZhdH8kXeiCIRC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-89uZhdH8kXeiCIRC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-89uZhdH8kXeiCIRC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-89uZhdH8kXeiCIRC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-89uZhdH8kXeiCIRC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-89uZhdH8kXeiCIRC .cluster text{fill:#333;}#mermaid-svg-89uZhdH8kXeiCIRC .cluster span{color:#333;}#mermaid-svg-89uZhdH8kXeiCIRC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-89uZhdH8kXeiCIRC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-89uZhdH8kXeiCIRC rect.text{fill:none;stroke-width:0;}#mermaid-svg-89uZhdH8kXeiCIRC .icon-shape,#mermaid-svg-89uZhdH8kXeiCIRC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-89uZhdH8kXeiCIRC .icon-shape p,#mermaid-svg-89uZhdH8kXeiCIRC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-89uZhdH8kXeiCIRC .icon-shape .label rect,#mermaid-svg-89uZhdH8kXeiCIRC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-89uZhdH8kXeiCIRC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-89uZhdH8kXeiCIRC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-89uZhdH8kXeiCIRC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Compose 自建 bridge 网络 cloud-demo_default
宿主机
10010 端口映射
8848 端口映射
http://userservice:8081
http://orderservice:8081
注册 + 拉取配置 nacos:8848
注册 + 拉取配置 nacos:8848
拉取路由 nacos:8848
jdbc:mysql://mysql:3306
jdbc:mysql://mysql:3306
解析
解析
解析
浏览器 / curl

192.168.101.xx:10010
Nacos 控制台

:8848
gateway

服务名: gateway

容器端口 10010
user-service

服务名: userservice
order-service

服务名: orderservice
注册中心

服务名: nacos

端口 8848
数据库

服务名: mysql

端口 3306
内嵌 DNS

服务名 → 容器 IP

具体机制拆开说:

  • Compose 会创建一个默认网络,名字默认是 {项目目录名}_default,本例即 cloud-demo_default,文件里所有 service 自动加入。
  • Docker 在这个网络上起了内嵌 DNS(容器内 /etc/resolv.conf 指向 Docker 的 DNS)。当 userservice 解析 mysql 时,DNS 直接返回名为 mysql 的容器当前 IP。
  • 容器重建后 IP 会变,但 DNS 记录会同步更新,所以只要服务名不变,配置就不用动。这就是为什么配置里绝不能写死 IP。
  • 端口一律写容器内部 端口,不是映射后的宿主机端口。gateway 访问 userservice:8081,8081 是 user-service 容器里 Tomcat 监听的端口,跟宿主机毫无关系。

一次完整的注册与调用,时序是这样的:
Browser mysql userservice nacos Compose 内嵌 DNS gateway Browser mysql userservice nacos Compose 内嵌 DNS gateway #mermaid-svg-py6jYBbPdDEXXpXS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-py6jYBbPdDEXXpXS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-py6jYBbPdDEXXpXS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-py6jYBbPdDEXXpXS .error-icon{fill:#552222;}#mermaid-svg-py6jYBbPdDEXXpXS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-py6jYBbPdDEXXpXS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-py6jYBbPdDEXXpXS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-py6jYBbPdDEXXpXS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-py6jYBbPdDEXXpXS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-py6jYBbPdDEXXpXS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-py6jYBbPdDEXXpXS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-py6jYBbPdDEXXpXS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-py6jYBbPdDEXXpXS .marker.cross{stroke:#333333;}#mermaid-svg-py6jYBbPdDEXXpXS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-py6jYBbPdDEXXpXS p{margin:0;}#mermaid-svg-py6jYBbPdDEXXpXS .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-py6jYBbPdDEXXpXS text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-py6jYBbPdDEXXpXS .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-py6jYBbPdDEXXpXS .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-py6jYBbPdDEXXpXS .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-py6jYBbPdDEXXpXS .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-py6jYBbPdDEXXpXS #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-py6jYBbPdDEXXpXS .sequenceNumber{fill:white;}#mermaid-svg-py6jYBbPdDEXXpXS #sequencenumber{fill:#333;}#mermaid-svg-py6jYBbPdDEXXpXS #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-py6jYBbPdDEXXpXS .messageText{fill:#333;stroke:none;}#mermaid-svg-py6jYBbPdDEXXpXS .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-py6jYBbPdDEXXpXS .labelText,#mermaid-svg-py6jYBbPdDEXXpXS .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-py6jYBbPdDEXXpXS .loopText,#mermaid-svg-py6jYBbPdDEXXpXS .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-py6jYBbPdDEXXpXS .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-py6jYBbPdDEXXpXS .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-py6jYBbPdDEXXpXS .noteText,#mermaid-svg-py6jYBbPdDEXXpXS .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-py6jYBbPdDEXXpXS .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-py6jYBbPdDEXXpXS .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-py6jYBbPdDEXXpXS .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-py6jYBbPdDEXXpXS .actorPopupMenu{position:absolute;}#mermaid-svg-py6jYBbPdDEXXpXS .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-py6jYBbPdDEXXpXS .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-py6jYBbPdDEXXpXS .actor-man circle,#mermaid-svg-py6jYBbPdDEXXpXS line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-py6jYBbPdDEXXpXS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 阶段一:启动注册 阶段二:外部请求 解析 nacos 返回 nacos 容器 IP 注册实例 gateway / 拉取路由配置 解析 mysql 返回 mysql 容器 IP 建立数据源连接池 注册实例 userservice GET /user/1 (宿主机:10010) 解析 userservice 返回 userservice 容器 IP 转发 GET /user/1 SELECT * FROM tb_user 结果集 JSON JSON

depends_on 要不要加?这份原始文件里没写。想表达"userservice 依赖 nacos 和 mysql",可以补一段:

yaml 复制代码
  userservice:
    build: ./user-service
    depends_on:
      - nacos
      - mysql

但要注意一句:depends_on 只保证启动顺序 (先创建并启动 nacos 容器),不保证就绪 (Nacos 的 8848 端口已经能接受连接)。所以加了它,下面那个 Nacos 连接失败的坑照样会踩。真要解决得用 healthcheck + condition: service_healthy,或者在微服务侧配注册重试。

改微服务配置:localhost 全部换成服务名

这一步漏了,集群起来也是坏的。要改三个服务的配置,规则很简单:写 Compose 里的服务名,端口写容器内端口。

user-service/src/main/resources/application.yml:

yaml 复制代码
server:
  port: 8081
spring:
  datasource:
    url: jdbc:mysql://mysql:3306/cloud_user?useSSL=false
    username: root
    password: 123
    driver-class-name: com.mysql.jdbc.Driver
  application:
    name: userservice
  cloud:
    nacos:
      server-addr: nacos:8848   # 原来是 localhost:8848

如果注册中心地址配在 bootstrap 里(SpringCloud Alibaba 的常见写法),那 bootstrap.yml 同样要改:

yaml 复制代码
spring:
  application:
    name: userservice
  cloud:
    nacos:
      server-addr: nacos:8848   # 原来是 localhost:8848
      config:
        file-extension: yaml

order-service 同构,只是库和用户名不同:

yaml 复制代码
server:
  port: 8081
spring:
  datasource:
    url: jdbc:mysql://mysql:3306/cloud_order?useSSL=false
    username: root
    password: 123
    driver-class-name: com.mysql.jdbc.Driver
  application:
    name: orderservice
  cloud:
    nacos:
      server-addr: nacos:8848

gateway 不连库,只有一个 Nacos 地址和路由断言:

yaml 复制代码
server:
  port: 10010
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: nacos:8848
    gateway:
      routes:
        - id: user-service
          uri: lb://userservice        # lb:// 后面的名字要在 Nacos 里能查到
          predicates:
            - Path=/user/**
        - id: order-service
          uri: lb://orderservice
          predicates:
            - Path=/order/**

注意 lb://userservice 里的名字是服务在注册中心注册的名字 (spring.application.name),而 jdbc:mysql://mysql 里的 mysql 是Compose 的服务名。两者恰好同名只是巧合------userservice 和 orderservice 刚好也注册了和 Compose 服务名一样的名字,方便起见保持一致就好,别理解为同一个东西。

打包:让每个微服务都产出 app.jar

Dockerfile 里写死了 COPY ./app.jar,所以打出来的 jar 必须叫这个名字。默认打包名是 {artifactId}-{version}.jar,得在 pom 里指定:

xml 复制代码
<build>
  <!-- 服务打包的最终名称,必须与 Dockerfile 中 COPY 的文件名一致 -->
  <finalName>app</finalName>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
  </plugins>
</build>

这段要加到 user-service、order-service、gateway 三个模块的 pom.xml 里,父工程的 pom 加了没用。

三个都叫 app.jar 会不会冲突?不会。它们落在各自模块的 target/ 目录下,路径天然隔离:

text 复制代码
cloud-demo-project/
├── user-service/target/app.jar
├── order-service/target/app.jar
└── gateway/target/app.jar

先 clean 再 package,避免上一次的产物干扰:

bash 复制代码
mvn clean package -DskipTests

部署流程图

#mermaid-svg-rzxCtZKJCpzrPeUd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rzxCtZKJCpzrPeUd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rzxCtZKJCpzrPeUd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rzxCtZKJCpzrPeUd .error-icon{fill:#552222;}#mermaid-svg-rzxCtZKJCpzrPeUd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rzxCtZKJCpzrPeUd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rzxCtZKJCpzrPeUd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rzxCtZKJCpzrPeUd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rzxCtZKJCpzrPeUd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rzxCtZKJCpzrPeUd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rzxCtZKJCpzrPeUd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rzxCtZKJCpzrPeUd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rzxCtZKJCpzrPeUd .marker.cross{stroke:#333333;}#mermaid-svg-rzxCtZKJCpzrPeUd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rzxCtZKJCpzrPeUd p{margin:0;}#mermaid-svg-rzxCtZKJCpzrPeUd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-rzxCtZKJCpzrPeUd .cluster-label text{fill:#333;}#mermaid-svg-rzxCtZKJCpzrPeUd .cluster-label span{color:#333;}#mermaid-svg-rzxCtZKJCpzrPeUd .cluster-label span p{background-color:transparent;}#mermaid-svg-rzxCtZKJCpzrPeUd .label text,#mermaid-svg-rzxCtZKJCpzrPeUd span{fill:#333;color:#333;}#mermaid-svg-rzxCtZKJCpzrPeUd .node rect,#mermaid-svg-rzxCtZKJCpzrPeUd .node circle,#mermaid-svg-rzxCtZKJCpzrPeUd .node ellipse,#mermaid-svg-rzxCtZKJCpzrPeUd .node polygon,#mermaid-svg-rzxCtZKJCpzrPeUd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rzxCtZKJCpzrPeUd .rough-node .label text,#mermaid-svg-rzxCtZKJCpzrPeUd .node .label text,#mermaid-svg-rzxCtZKJCpzrPeUd .image-shape .label,#mermaid-svg-rzxCtZKJCpzrPeUd .icon-shape .label{text-anchor:middle;}#mermaid-svg-rzxCtZKJCpzrPeUd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rzxCtZKJCpzrPeUd .rough-node .label,#mermaid-svg-rzxCtZKJCpzrPeUd .node .label,#mermaid-svg-rzxCtZKJCpzrPeUd .image-shape .label,#mermaid-svg-rzxCtZKJCpzrPeUd .icon-shape .label{text-align:center;}#mermaid-svg-rzxCtZKJCpzrPeUd .node.clickable{cursor:pointer;}#mermaid-svg-rzxCtZKJCpzrPeUd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rzxCtZKJCpzrPeUd .arrowheadPath{fill:#333333;}#mermaid-svg-rzxCtZKJCpzrPeUd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rzxCtZKJCpzrPeUd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rzxCtZKJCpzrPeUd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rzxCtZKJCpzrPeUd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rzxCtZKJCpzrPeUd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rzxCtZKJCpzrPeUd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rzxCtZKJCpzrPeUd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rzxCtZKJCpzrPeUd .cluster text{fill:#333;}#mermaid-svg-rzxCtZKJCpzrPeUd .cluster span{color:#333;}#mermaid-svg-rzxCtZKJCpzrPeUd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rzxCtZKJCpzrPeUd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rzxCtZKJCpzrPeUd rect.text{fill:none;stroke-width:0;}#mermaid-svg-rzxCtZKJCpzrPeUd .icon-shape,#mermaid-svg-rzxCtZKJCpzrPeUd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rzxCtZKJCpzrPeUd .icon-shape p,#mermaid-svg-rzxCtZKJCpzrPeUd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rzxCtZKJCpzrPeUd .icon-shape .label rect,#mermaid-svg-rzxCtZKJCpzrPeUd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rzxCtZKJCpzrPeUd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rzxCtZKJCpzrPeUd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rzxCtZKJCpzrPeUd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
修改微服务配置

localhost → 服务名
pom 加 finalName

打 app.jar
拷贝 jar 到

各服务 Dockerfile 同级目录
上传 cloud-demo 到 Linux
docker compose up -d
自动 build 镜像
创建网络 + 启动容器
Nacos 是否已就绪?
微服务注册失败

Connection refused
docker compose restart

userservice orderservice gateway
docker compose ps / logs -f

curl 网关验证

拷 jar 包到部署目录

这一步容易手滑:三个 app.jar 长得一模一样,拷错了目录服务就错位了。

text 复制代码
# 项目名称       →  cloud-demo 下的目标目录
user-service     →  cloud-demo/user-service/app.jar
order-service    →  cloud-demo/order-service/app.jar
gateway          →  cloud-demo/gateway/app.jar

用命令拷(在项目根目录执行),比拖拽靠谱:

bash 复制代码
DEPLOY=/path/to/cloud-demo
cp user-service/target/app.jar   $DEPLOY/user-service/app.jar
cp order-service/target/app.jar  $DEPLOY/order-service/app.jar
cp gateway/target/app.jar        $DEPLOY/gateway/app.jar

拷完别嫌麻烦,逐个确认一下文件确实落到了对应目录:

bash 复制代码
ls -lh cloud-demo/*/app.jar

一键部署

把整个 cloud-demo 目录传到 Linux(FTP、scp、rz 均可),进目录执行:

bash 复制代码
cd /tmp/cloud-demo
docker compose up -d

-d 是后台运行。不加的话输出会一直挂在终端,Ctrl+C 就会把整个集群停掉。执行过程能看到 Compose 在逐个 build 镜像:gateway、userservice、orderservice 各自的 app.jar 被打进 java:8-alpine 基础镜像,然后全部容器一次性拉起。

集群起停、查日志的完整命令:

bash 复制代码
# 后台构建镜像并启动全部服务
docker compose up -d

# 查看本项目下所有容器状态(不写 service 名就是全部)
docker compose ps

# 实时跟踪全部服务日志
docker compose logs -f

# 只看某个服务,比如排查 user-service
docker compose logs -f userservice

# 重启指定服务(Nacos 就绪后重试注册用得上)
docker compose restart gateway userservice orderservice

# 停止并移除容器、网络(-v 会连匿名数据卷一起删,谨慎)
docker compose down

# 只停不清,容器还在,方便 docker compose start 拉回
docker compose stop

# 进容器内部排障
docker compose exec userservice sh

# 配置改了想重建,加 --build 强制重打镜像
docker compose up -d --build userservice

验证服务真的活了:

bash 复制代码
docker compose ps
curl http://localhost:10010/user/1
curl http://localhost:10010/order/101

两条 curl 的预期输出:

json 复制代码
{"id":1,"username":"柳岩","address":"湖南省衡阳市"}
json 复制代码
{"id":101,"price":699900,"name":"Apple 苹果 iPhone 12","num":1,"user":{"id":1,"username":"柳岩","address":"湖南省衡阳市"}}

第二条里 user 字段有值,说明网关 → 订单服务 → 用户服务整条链路都通了。

还可以去 Nacos 控制台核对注册情况:浏览器打开 http://localhost:8848/nacos,默认账号密码是 nacos/nacos,服务列表里应该能看到 userservice 和 orderservice 各一个实例。

远程访问就把 localhost 换成虚拟机 IP,例如 http://192.168.101.100:10010/user/1。

Compose 子命令速览

Compose 的子命令跟单容器操作几乎是一一对应的,把 docker <动词> <容器名> 换成 docker compose <动词> <服务名> 就行:

目的 单容器命令 Compose 命令
创建并启动 docker run docker compose up
停止 docker stop docker compose stop
启动已有容器 docker start docker compose start
重启 docker restart docker compose restart
查看日志 docker logs docker compose logs -f
查看状态 docker ps docker compose ps
进入容器 docker exec -it docker compose exec
删除 docker rm docker compose down

有一个版本差异值得单独说:老教程、包括在 2023 年以前的资料里,命令写作 docker-compose up -d(中间是横线 ),那是一个独立的 Python 二进制。Docker 后来把它作为插件集成进 CLI,命令变成 docker compose up -d(中间是空格)。

对比项 docker-compose docker compose
形态 独立二进制,需单独下载到 /usr/local/bin/ 并 chmod +x Docker CLI 插件,装了新版 Docker 自带
版本标识 docker-compose version docker compose version
现状 Docker Compose v1,2023 年 7 月起停止维护 Docker Compose v2,官方推荐写法
兼容性 两者读取的 compose 文件基本一致 国内很多镜像加速器把 v2 插件一并放进 Docker 源

两者的参数设置大体兼容,唯一常见的区别是 docker-compose ps 默认输出格式跟 v2 略不同。踩到 docker: 'compose' is not a docker command 这个报错,说明环境里只有老的 v1 二进制,继续用横线写法即可。

实战踩坑清单

注册中心地址还写着 localhost

现象:容器都起来了,docker compose ps 看着也没 Exit,但 Nacos 控制台上服务列表空的,日志里刷:

text 复制代码
java.net.ConnectException: Connection refused
    at com.alibaba.nacos.client.config.impl.ClientWorker.getServerConfig

原因就是配置文件里 server-addr 还停在 localhost:8848,容器内的 localhost 是它自己。改成 nacos:8848 后要重新打包,改了 yml 不重新打 jar 是没有效果的------配置是被打进 jar 里的。

Nacos 启动太慢,微服务注册失败后不重试

这是课程里实测复现过的坑。首次 up -d 时,Compose 并发拉起所有容器,userservice 和 orderservice 的启动速度远快于 Nacos 初始化。等 Nacos 慢慢起来时,这两个服务早注册失败卡住了:

text 复制代码
userservice  | 启动中... 连接 nacos:8848 Connection refused
nacos        | Nacos started successfully in stand alone mode

SpringCloud Alibaba 在注册失败后并没有做重试,服务进程虽然没退出,但已经不参与注册了。两条处置路子:

bash 复制代码
# 快速办法:Nacos 就绪后,重启三个业务服务让他们重新注册
docker compose restart gateway userservice orderservice
docker compose logs -f userservice

生产环境更稳的作法是分阶段部署 :先把 nacos、mysql 单独 up 起来,确认控制台能打开后再 up 业务服务;其余微服务可以一次性部署。

bash 复制代码
# 先起基础设施
docker compose up -d nacos mysql
# 等 Nacos 控制台可访问后再起业务服务
docker compose up -d userservice orderservice gateway

端口冲突

up 时报 Bind for 0.0.0.0:10010 failed: port is already allocated。宿主机上 10010 或 8848 已经被别的进程占了(很可能是上次没 down 干净的容器)。定位和处理:

bash 复制代码
# 查谁占了端口
docker ps --format "table {{.Names}}\t{{.Ports}}" | grep -E '10010|8848'
lsof -i:10010
# 停掉旧的再起
docker compose down

另外记住:Compose 内部多个 service 映射同一个宿主机端口会冲突,但容器内部端口相同完全没关系------它们在不同的网络命名空间里。userservice 和 orderservice 都用 8081,这是常态。

jar 包名字或摆放位置不对

COPY failed: file not found in build context 或镜像起来了却是旧代码,通常是两件事:忘了加 <finalName>app</finalName>(打出来叫 user-service-1.0.0.jar),或者 jar 拷到了 cloud-demo/ 根目录而不是对应子目录。逐个确认:

bash 复制代码
ls -l cloud-demo/gateway/ cloud-demo/user-service/ cloud-demo/order-service/

MySQL 挂载目录为空导致表不见了

$PWD/mysql/data 依赖你在 cloud-demo 目录下执行命令。如果在别处执行(比如 docker compose -f cloud-demo/docker-compose.yml up -d),$PWD 取到的是上级目录,挂载进去就是空目录,MySQL 会重新初始化一个全新实例,cloud_user 表自然查不到。始终先 cd 到 compose 文件所在目录。

MySQL 连不上时还要留意:jdbc:mysql://mysql:3306 里的用户名和密码要与 MYSQL_ROOT_PASSWORD 对得上,本例是 root/123。

官方文档

总结

  • Compose 文件本质就是把一堆 docker run 参数翻译成 YAML,up 一条命令起一整个集群。
  • 集群内部通信一律走服务名,别写 IP 也别写 localhost;Compose 的内嵌 DNS 才是这套方案能跑起来的基础。
  • 只有该对外暴露的东西才配 ports:网关 10010、Nacos 8848 可以,MySQL 和业务微服务不行。
  • build 指向的是 Dockerfile 所在目录,路径相对 compose 文件;$PWD 保证挂载路径到哪台机器都能用,代价是必须在 compose 文件所在目录执行命令。
  • Nacos 这类启动慢的基础设施,最好单独先部署再拉业务服务;depends_on 解决不了就绪等待。
  • 命令写法取决于环境:新版用 docker compose(空格),只有老二进制时才用 docker-compose(横线)。
相关推荐
BullSmall1 小时前
SonarQube 9.9.8-community 适配 Jenkins 版本说明
java·前端·jenkins
威哥爱编程1 小时前
【AI全栈12-10】Spring Boot 用多模态做商品图审核 / 发票识别
java·spring boot·后端
轮子大叔1 小时前
Java8和Java17常见区别
java
威哥爱编程1 小时前
【AI全栈12-09】Spring Boot 用流式输出做打字机对话:点下去不再干等
java·spring boot·后端
威哥爱编程1 小时前
【AI全栈12-08】Spring Boot 用 MCP 统一接入内部系统:让 AI 接一次,全公司复用
java·spring boot·后端
deviant-ART1 小时前
Java 并发编程:深入理解 synchronized 的使用与原理
java·开发语言·后端
斯内普吖1 小时前
(开源)校园充电宝租借管理系统实战指南 基于 Java + SSM + Vue + MySQL
java·vue.js·mysql·开源
爱喝热水的呀哈喽1 小时前
hm探查。
java·前端·人工智能
代码什么用2 小时前
Java Web 21-22
java·开发语言·web