背景

我自己部署SpringBoot项目时,服务器是华为云最低配——1核2G。跑着一个MySQL、一个Redis、一个Nacos,还剩多少内存给应用?不到500M。

如果按默认JVM参数跑,一个SpringBoot启动就吃掉300M+堆内存,加上Metaspace和线程栈,随随便便上500M。两个服务部署上去,OOM。

所以必须压JVM内存。最终我把单个服务堆内存压到了192M,服务正常运行。分享这个过程中踩的坑和最终配置。

选基础镜像

SpringBoot官方推荐 openjdk 镜像,但那玩意 400M+。换成精简版:

FROM eclipse-temurin:17-jre-alpine

jre 而不是 jdk,alpine 而不是完整版。体积从 400M 降到 180M。不需要JDK编译能力,运行环境有JRE就够了。

如果需要更极致的,可以用 distroless,但调试不方便,生产环境用 alpine 够好了。

JVM内存怎么算

总内存2G,减去:

  • 操作系统:~200M

  • MySQL:~400M

  • Redis:~100M

  • Nacos:~300M

  • Docker本身:~200M

剩余约 800M。跑两个SpringBoot + 一个网关,每个应用不能超过 200M。

JVM启动参数

ENV JAVA_OPTS="-Xms128m -Xmx192m \
  -XX:MetaspaceSize=64m -XX:MaxMetaspaceSize=96m \
  -XX:MaxDirectMemorySize=32m \
  -XX:ReservedCodeCacheSize=32m \
  -XX:+UseSerialGC \
  -XX:+TieredCompilation \
  -XX:TieredStopAtLevel=1 \
  -XX:+UseContainerSupport \
  -XX:InitialRAMPercentage=25.0 \
  -XX:MaxRAMPercentage=50.0"

逐项解释为什么这么设:

堆内存 -Xms128m -Xmx192m 初始128M,最大192M。初始设低一点避免一启动就占满,让其按需增长。192M对于大多数CRUD服务足够了——主要是查数据库和Redis,不做什么计算密集操作。

元空间 -XX:MetaspaceSize=64m -XX:MaxMetaspaceSize=96m SpringBoot一个项目上百个类,元空间得给够。64M起步,最大96M。实测下来不会超过80M。

直接内存 -XX:MaxDirectMemorySize=32m NIO用的。如果用了Netty(比如WebClient),这块要留一点。32M保守够用。

GC策略 -XX:+UseSerialGC 单核CPU用SerialGC反而最快。别一上来就往G1上招呼,小堆用SerialGC停顿短、内存开销小。

编译优化 -XX:+TieredCompilation -XX:TieredStopAtLevel=1 关闭C2编译,只用C1。C2编译慢、内存消耗大。小应用不用追求极致吞吐,C1编译足够,还能省Metaspace。

容器感知 -XX:+UseContainerSupport Docker里必须加,否则JVM默认读宿主机的内存而不是容器的。不加这个参数,-XX:MaxRAMPercentage 不会生效。

百分比设限 -XX:InitialRAMPercentage=25.0 -XX:MaxRAMPercentage=50.0 配合 UseContainerSupport 使用。如果Docker分配了400M内存,最大堆只用到200M。比直接写 -Xmx 灵活。

完整Dockerfile

FROM eclipse-temurin:17-jre-alpine
​
RUN addgroup -S app && adduser -S app -G app
USER app
​
WORKDIR /app
COPY target/*.jar app.jar
​
ENV JAVA_OPTS="-Xms128m -Xmx192m \
  -XX:MetaspaceSize=64m -XX:MaxMetaspaceSize=96m \
  -XX:MaxDirectMemorySize=32m \
  -XX:ReservedCodeCacheSize=32m \
  -XX:+UseSerialGC \
  -XX:+TieredCompilation -XX:TieredStopAtLevel=1 \
  -XX:+UseContainerSupport \
  -XX:InitialRAMPercentage=25.0 -XX:MaxRAMPercentage=50.0"
​
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

几个注意点:

  • addgroup/adduser 创建非root用户,安全问题

  • USER app 之后的文件权限要注意

  • EXPOSE 只是文档作用,实际端口映射在 docker-compose 里配

docker-compose 配置

services:
  app-gateway:
    image: aipdt-java:v17
    container_name: gateway
    ports:
      - "8080:8080"
    volumes:
      - ./gateway.jar:/app/app.jar
      - ./logs/gateway:/app/logs
    environment:
      - JAVA_OPTS=-Xms128m -Xmx192m
    networks:
      - aipdt-cloud
    restart: unless-stopped
​
  app-workflow:
    image: aipdt-java:v17
    container_name: workflow
    ports:
      - "8897:8897"
    volumes:
      - ./workflow.jar:/app/app.jar
      - ./logs/workflow:/app/logs
    environment:
      - JAVA_OPTS=-Xms128m -Xmx192m
    networks:
      - aipdt-cloud
    restart: unless-stopped
​
networks:
  aipdt-cloud:
    external: true

配置外挂(application.yml不同环境的差异)通过Nacos配置中心管理,镜像统一。

踩过的坑

1. alpine镜像DNS解析慢

alpine默认用musl libc,DNS解析行为跟glibc不同。有时服务间通过容器名通信会很慢。

解决:Java层面加 -Djava.net.preferIPv4Stack=true,或者用 openjdk:slim 替代 alpine。

2. Docker内存限制不等于JVM感知

很多教程让你用 docker run -m 400m 限制容器内存,然后JVM屁颠屁颠就适配了。不是的。 必须加 -XX:+UseContainerSupport,Java 10+才有这个参数。

如果没加,JVM读到的是宿主机内存(比如2G),堆内存会设到500M+,然后超过容器的400M限制,被Docker直接kill。

3. 公网/内网端口不混用

Docker容器间通信用内网(container name),外部访问才走宿主机端口。不要把数据库端口暴露到公网,配置里只开放应用端口。

总结

  1. 低配服务器跑Docker,JVM堆内存128-192M够用

  2. 别用默认JVM参数,手动配堆、元空间、GC

  3. -XX:+UseContainerSupport 是Docker必备

  4. 配置外挂、镜像统一,多环境一致性靠Nacos

我正用"一个人+AI"模式做独立开发,微信小程序"面狮狮"已上线,欢迎体验。更多技术分享:https://gitee.com/yao113088/jiguang-dev

更多推荐