三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Docker Compose部署Java应用:从单体到容器化的生产级实践指南

Docker Compose部署Java应用:从单体到容器化的生产级实践指南

1. 从单体到容器:为什么Docker Compose是Java应用部署的“瑞士军刀”

如果你是一个Java开发者,或者正在负责一个Java项目的运维,那么“部署”这个词大概率会伴随你职业生涯的每一次心跳。从早期的WAR包往Tomcat的webapps目录里一扔,到后来用Maven插件配合Shell脚本进行远程部署,再到如今微服务架构下动辄十几个、几十个服务的编排难题,部署的复杂度一直在指数级增长。

我经历过最混乱的一次部署,是一个由Spring Boot、MySQL、Redis、Elasticsearch和Nginx组成的项目。当时,运维手册上密密麻麻记录了二十几个步骤:先装JDK,再配环境变量,然后启动MySQL并导入初始数据,接着调整Redis的maxmemory配置,还要确保Elasticsearch的JVM堆内存参数正确……任何一个步骤出错,或者顺序不对,整个应用就启动不起来。更头疼的是,开发、测试、生产环境的不一致,让“在我机器上是好的”这句名言成了日常。

直到我开始系统性地使用Docker Compose,这一切才变得清晰可控。它不是什么高深莫测的黑科技,而是一个用YAML文件来定义和运行多容器Docker应用的工具。你可以把它理解为一份标准化的、可执行的“部署清单”。对于Java应用,尤其是那些尚未完全云原生化、但已经超越简单单体的项目,Docker Compose提供了一条从传统部署平滑过渡到容器化部署的绝佳路径。它让你能用一份配置文件,在本地一键复现一个包含应用、数据库、缓存、消息队列的完整环境,这份配置文件本身,就是最权威的部署文档。

2. 蓝图绘制:解剖一份生产可用的Docker Compose文件

一份好的Docker Compose文件(通常是docker-compose.yml)是成功的一半。它不应该只是能跑起来,更应该体现对资源、网络、数据持久化和服务依赖的深思熟虑。下面,我们以一个典型的Spring Boot + MySQL + Redis的Web应用为例,拆解每个关键部分的配置逻辑。

2.1 服务定义:超越简单的镜像运行

服务的定义是核心。我们来看一个为生产环境考虑过的配置示例:

version: '3.8' services: # Java应用服务 backend-app: image: your-registry/your-java-app:${APP_VERSION:-latest} container_name: java-backend restart: unless-stopped depends_on: mysql: condition: service_healthy redis: condition: service_started environment: - SPRING_PROFILES_ACTIVE=prod - SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/app_db?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true - SPRING_DATASOURCE_USERNAME=${DB_USER} - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD} - SPRING_REDIS_HOST=redis - SPRING_REDIS_PORT=6379 - JAVA_OPTS=-Xmx512m -Xms256m -XX:+UseG1GC -Djava.security.egd=file:/dev/./urandom volumes: - ./logs:/app/logs - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro networks: - backend-network healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] interval: 30s timeout: 10s retries: 3 start_period: 60s deploy: resources: limits: cpus: '1' memory: 768M reservations: memory: 512M

关键点解析:

  1. restart: unless-stopped:这是生产环境的黄金法则。它确保容器在异常退出(非手动停止)时自动重启,极大地提高了服务的自愈能力。比always更友好,因为它尊重了管理员手动停止的意图。
  2. 智能的depends_on:传统的depends_on只控制启动顺序,不检查依赖服务是否“就绪”。我们这里使用了condition字段。对于MySQL,我们等待其健康检查通过(service_healthy);对于Redis,我们只等它启动(service_started),因为Redis通常启动极快。这避免了应用在数据库还没初始化完时就尝试连接导致的启动失败。
  3. 环境变量与配置分离:数据库密码等敏感信息通过${DB_PASSWORD}引用,这些值应该来自.env文件或运行时环境变量,绝不能硬编码在YAML文件中并提交到代码仓库。
  4. JVM参数调优 (JAVA_OPTS):这是Java容器化部署中最容易出问题的地方之一。-Xmx512m -Xms256m设置了堆内存上限和初始值,必须低于容器内存限制(deploy.resources.limits.memory),通常建议预留至少25%的内存给堆外内存(如线程栈、直接内存、JVM自身开销)。-Djava.security.egd=file:/dev/./urandom是为了解决在Linux容器内生成随机数可能阻塞的问题,对Spring Boot应用启动速度有显著影响。
  5. 健康检查 (healthcheck):这是实现服务高可用的基石。我们配置了对Spring Boot Actuator健康端点的检查。start_period给了应用60秒的启动宽限期,避免因启动慢而被误判为不健康。其他服务(如Nginx)可以基于此健康状态做路由决策。
  6. 资源限制 (deploy.resources):在单机或小型集群上,这能防止某个容器耗尽所有主机资源,导致系统不稳定。limits是硬限制,reservations是软预留。

2.2 数据持久化:告别“失忆”的数据库

数据库容器默认是无状态的,停止即丢失数据。我们必须将数据目录挂载到宿主机。

services: mysql: image: mysql:8.0 container_name: app-mysql restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} MYSQL_DATABASE: app_db MYSQL_USER: ${DB_USER} MYSQL_PASSWORD: ${DB_PASSWORD} volumes: - mysql-data:/var/lib/mysql - ./mysql/conf.d:/etc/mysql/conf.d:ro - ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro networks: - backend-network healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u${DB_USER}", "-p${DB_PASSWORD}"] interval: 10s timeout: 5s retries: 5 command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci - --default-authentication-plugin=mysql_native_password redis: image: redis:7-alpine container_name: app-redis restart: unless-stopped command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} volumes: - redis-data:/data networks: - backend-network healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 10s timeout: 5s retries: 3 volumes: mysql-data: redis-data:

关键点解析:

  1. 命名卷 (mysql-data,redis-data):使用Docker管理的命名卷进行数据持久化,比绑定挂载(直接挂载宿主机路径)更易于备份、迁移和管理,性能也通常更好。Docker会自动在宿主机上管理这些卷的实际存储位置。
  2. 配置与初始化分离:将MySQL的自定义配置(如my.cnf)放在./mysql/conf.d目录下以只读方式挂载,清晰且易于版本控制。将数据库初始化脚本(建表、初始数据)放在./mysql/init.sql并挂载到/docker-entrypoint-initdb.d/,MySQL容器在首次启动时会自动执行。
  3. Redis持久化:通过--appendonly yes命令参数开启AOF持久化,确保缓存数据在重启后不丢失(根据业务场景选择RDB或AOF)。密码通过环境变量传入。
  4. 健康检查的差异:MySQL使用mysqladmin ping,而Redis使用redis-cli ping。注意,健康检查命令是在容器内部执行的,所以MySQL检查中可以使用本地主机(localhost)。

2.3 网络与前端接入:构建安全的服务边界

服务间通信和外部访问需要清晰的网络规划。

services: nginx: image: nginx:alpine container_name: app-nginx restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./ssl:/etc/nginx/ssl:ro - ./frontend-dist:/usr/share/nginx/html:ro depends_on: - backend-app networks: - frontend-network - backend-network networks: frontend-network: driver: bridge backend-network: driver: bridge internal: true # 内部网络,禁止外部访问

关键点解析:

  1. 网络隔离:我们创建了两个网络。backend-network被标记为internal: true,这意味着只有连接到这个网络的容器(Java App, MySQL, Redis)可以互相通信,而宿主机或其他未连接的网络无法访问它。这模拟了云环境中的私有子网,极大地增强了内部服务的安全性。frontend-network是给Nginx用的,它同时连接了前端网络和后端网络,充当了网关的角色。
  2. Nginx配置:Nginx的配置文件通过卷挂载进来,方便修改。它监听80/443端口,将请求代理到backend-app服务(通过Docker内部DNS,直接使用服务名backend-app即可访问)。静态前端文件(如Vue/React构建产物)可以挂载到/usr/share/nginx/html
  3. 端口暴露:只有Nginx暴露了端口到宿主机。Java应用、MySQL、Redis都不直接暴露端口,外部只能通过Nginx这一统一入口访问,符合最小权限原则。

3. Java应用容器化:镜像构建的艺术与陷阱

Docker Compose负责编排,而容器的基础是镜像。如何为Java应用构建一个高效、安全的Docker镜像,是另一个核心课题。

3.1 多阶段构建:打造精益镜像

直接使用openjdk:17-jdk作为运行镜像,会包含完整的JDK,体积庞大(约500MB)。我们应该使用多阶段构建,最终只包含运行所需的JRE。

# 第一阶段:构建阶段 FROM maven:3.8.6-eclipse-temurin-17 AS builder WORKDIR /app COPY pom.xml . # 利用Maven的依赖缓存,只有当pom.xml变化时才重新下载依赖 RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests # 第二阶段:运行阶段 FROM eclipse-temurin:17-jre-alpine WORKDIR /app # 安装必要的工具,如curl用于健康检查 RUN apk add --no-cache curl # 从构建阶段复制jar包 COPY --from=builder /app/target/*.jar app.jar # 创建一个非root用户运行应用,增强安全性 RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser # 使用环境变量传递JVM参数,更灵活 ENV JAVA_OPTS="" ENTRYPOINT exec java $JAVA_OPTS -jar app.jar

关键点解析:

  1. 基础镜像选择:构建阶段使用带Maven的JDK镜像。运行阶段使用eclipse-temurin:17-jre-alpinealpine版本基于轻量级的Alpine Linux,最终镜像体积可以控制在150MB左右,比完整JRE镜像小得多。eclipse-temurin是Adoptium Temurin发行版的官方镜像,是OpenJDK的一个流行、稳定的发行版。
  2. 依赖缓存优化:先单独复制pom.xml并执行mvn dependency:go-offline,这样只要依赖不变,Docker就能利用构建缓存,跳过耗时的依赖下载步骤,极大加速后续构建。
  3. 非Root用户运行:以root用户运行容器应用是安全大忌。我们创建了appuser用户并切换过去,遵循了最小权限原则。如果应用需要写入特定目录(如日志),需要确保该目录对appuser有写权限(可以通过Dockerfile的RUN chown或启动时挂载卷并设置权限)。
  4. 灵活的启动命令:使用ENTRYPOINT exec ...的形式,可以让Java进程成为容器的1号进程,使其能正确接收Unix信号(如SIGTERM),实现优雅关闭。JVM参数通过环境变量JAVA_OPTS传入,方便在docker-compose.yml中覆盖。

3.2 镜像构建与版本管理

docker-compose.yml中,我们通常使用build指令来构建镜像,但在生产环境,更推荐使用CI/CD流水线构建镜像并推送到私有镜像仓库,然后在Compose文件中使用image指令指定带版本标签的镜像。

开发/测试环境(使用构建):

services: backend-app: build: context: ./backend dockerfile: Dockerfile image: myapp-backend:local # 给本地构建的镜像打个标签

生产环境(使用预构建镜像):

services: backend-app: image: my-registry.com/myteam/myapp-backend:v1.2.3 pull_policy: always # 确保每次启动拉取最新镜像

注意:关于java: outofmemoryerror: insufficient memory这个常见错误。在容器中,JVM默认读取的是宿主机的内存信息,而不是容器的内存限制。这意味着即使你通过-m 512m限制了容器内存,JVM可能仍会试图分配接近宿主机内存大小的堆。解决方案:务必在JAVA_OPTS中明确设置-Xmx(例如-Xmx384m),确保其值显著小于容器内存限制,并为堆外内存(Metaspace, Direct Buffer, Thread Stack等)留出空间。对于Java 8u131+ 和 Java 10+,可以使用-XX:+UseContainerSupport(默认已开启)和-XX:MaxRAMPercentage=75.0这样的参数,让JVM自动根据容器内存限制来调整堆大小,更为智能。

4. 实战部署与运维:从一键启动到日常维护

有了完善的配置文件,部署就变成了简单的命令。但运维的细节决定成败。

4.1 一键启动与停止

在项目根目录(即docker-compose.yml所在目录)执行:

# 启动所有服务(后台运行) docker-compose up -d # 查看所有服务状态和日志 docker-compose ps docker-compose logs -f backend-app # 跟踪某个服务的日志 # 停止并移除所有容器、网络(但保留数据卷) docker-compose down # 停止并移除所有容器、网络、数据卷(危险!会丢失数据库数据!) # docker-compose down -v

关键点解析:docker-compose up -d会构建镜像(如果配置了build)、创建网络、卷,并按依赖顺序启动所有容器。-d代表后台运行。docker-compose down是安全的停止方式,它会停止容器并清理网络,但默认不会删除数据卷,这是为了保护你的数据库数据。只有当你确定要彻底清理环境时,才使用-v参数。

4.2 环境配置与敏感信息管理

永远不要将密码写在docker-compose.yml里。最佳实践是使用.env文件。

  1. 创建.env文件(并加入.gitignore):
    APP_VERSION=v1.0.0 DB_ROOT_PASSWORD=your_strong_root_password DB_USER=app_user DB_PASSWORD=your_strong_db_password REDIS_PASSWORD=your_strong_redis_password
  2. docker-compose.yml中引用:${DB_PASSWORD}
  3. Docker Compose会自动读取同目录下的.env文件。你也可以通过--env-file参数指定其他文件。

对于更复杂或需要共享的配置(如不同环境的差异),可以使用多个Compose文件覆盖。例如,有一个基础的docker-compose.yml,一个用于覆盖开发配置的docker-compose.override.yml(自动加载),一个用于生产的docker-compose.prod.yml(需手动指定)。

# 使用生产配置启动 docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d

4.3 日志与监控:洞察容器内部

日志是排查问题的生命线。我们将应用日志挂载到了宿主机./logs目录,方便使用tail,grep等工具查看,或接入ELK等日志系统。

对于监控,除了容器本身的资源监控(docker stats),更应关注应用指标。Spring Boot Actuator暴露了/actuator/metrics,/actuator/prometheus端点。可以搭配Prometheus和Grafana,在Compose中再加入监控组件:

services: prometheus: image: prom/prometheus volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml networks: - monitoring-network ports: - "9090:9090" grafana: image: grafana/grafana environment: - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD} volumes: - grafana-data:/var/lib/grafana networks: - monitoring-network ports: - "3000:3000"

然后在prometheus.yml中配置抓取backend-app:8080/actuator/prometheus的指标。

4.4 数据备份与恢复

数据卷是命根子。定期备份命名卷至关重要。

# 备份MySQL数据卷 docker run --rm -v mysql-data:/source -v $(pwd)/backups:/backup alpine tar czf /backup/mysql-backup-$(date +%Y%m%d).tar.gz -C /source . # 恢复MySQL数据卷(危险操作,先停止MySQL服务) # docker-compose stop mysql # docker run --rm -v mysql-data:/target -v $(pwd)/backups:/backup alpine sh -c "rm -rf /target/* && tar xzf /backup/mysql-backup-20231027.tar.gz -C /target" # docker-compose start mysql

关键心得:

  1. 启动顺序不是万能药depends_on解决启动顺序,但解决不了“就绪”问题。一定要配合健康检查,这是实现服务韧性(Resilience)的关键。
  2. 内存是硬通货:Java容器化最大的坑就是内存。时刻牢记:容器内存限制 > JVM堆内存 + 堆外内存 + 系统开销。使用docker stats监控容器的实际内存使用情况,并据此调整JVM参数。
  3. 镜像标签即版本:永远为生产环境镜像使用明确的版本标签(如v1.2.3),禁止使用latest。这保证了部署的可追溯性和回滚能力。
  4. Compose不是生产编排的终点:对于更复杂的多实例、跨主机、滚动更新、服务发现等需求,Docker Compose单机版就力不从心了。这时需要考虑 Kubernetes(K8s)或 Docker Swarm。但Compose文件的结构(服务定义、网络、卷)与K8s的YAML有诸多相似之处,是学习更高级编排工具的良好基础。很多项目会同时维护docker-compose.yml(用于本地开发测试)和k8s/目录下的部署文件(用于生产环境)。
← 返回列表