从JVM到Kubernetes:Java应用在云原生时代的“生存法则”
文章目录
- 从JVM到Kubernetes:Java应用在云原生时代的“生存法则”
1. 引言:当“老派”Java遇上“新潮”云原生
各位Java老兵,各位云原生萌新,大家好!
还记得当年,我们部署一个Java应用是多么的“仪式感”十足:找一台物理机,装上JDK,配置好Tomcat,然后小心翼翼地把WAR包扔进去,再小心翼翼地启动。如果应用挂了,我们就得登录服务器,tail -f catalina.out
,然后祈祷日志里能告诉我们到底发生了什么。那时候,我们管这叫“运维”。
如今,时代变了。云计算、容器化、微服务、Kubernetes……这些词儿像雨后春笋一样冒出来,把我们这些“老派”Java开发者搞得有点懵。我们不禁要问:那个曾经“无所不能”的Java,还能在云原生时代“吃得开”吗?
答案是:当然可以! 但前提是,你得学会它的“生存法则”。
想象一下,你的Java应用原本住在一栋宽敞的独栋别墅里(物理机/虚拟机),有独立的花园(内存)、车库(CPU)、水电煤(网络)。现在,它要搬进一个现代化的高层公寓(Kubernetes集群)。公寓里每户面积不大(容器),但设施齐全,管理严格。你不能再像在别墅里那样随意挥霍水电煤了,你得遵守公寓的管理规定(K8s调度、资源限制、健康检查)。
这篇博客,就是一本为你量身定制的《Java应用云原生生存指南》。我们将从你最熟悉的JVM出发,一步步带你走进Docker和Kubernetes的世界,看看你的Java应用如何从一个“大块头”成功转型为一个“敏捷的云原生公民”。
准备好了吗?系好安全带,我们的“云原生生存之旅”即将启程!
第一章:JVM,我的“虚拟王国”
2.1. JVM的“三生三世”:字节码、运行时与垃圾回收
Java的口号是“Write Once, Run Anywhere”(一次编写,到处运行)。这背后的功臣,就是JVM(Java Virtual Machine,Java虚拟机)。
你可以把JVM想象成一个“虚拟王国”。你的Java代码(.java
文件)是这个王国的“法律草案”。编译器(javac
)负责把这份草案翻译成王国官方语言——字节码(.class
文件)。字节码是一种与平台无关的中间语言,它不直接对应任何特定的CPU指令。
当你的应用启动时,JVM这个“国王”就登基了。它负责加载字节码,并将其解释执行,或者通过JIT(Just-In-Time)编译器编译成本地机器码来执行,从而让应用真正“跑”起来。这就是JVM的“运行时”阶段。
而“垃圾回收”(Garbage Collection, GC),则是这个王国里最神秘也最重要的部门。它负责清理不再使用的对象(“垃圾”),释放内存空间,防止“内存泄漏”导致王国崩溃。
代码示例:一个简单的Java应用
// HelloWorld.java
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, 云原生世界!");
}
}
编译和运行:
javac HelloWorld.java
java HelloWorld
# 输出: Hello, 云原生世界!
2.2. 内存管理:堆、栈、方法区的“爱恨情仇”
JVM的内存空间主要分为几个区域,它们之间有着复杂的“爱恨情仇”:
- 堆(Heap):这是最大的一块内存区域,所有通过
new
关键字创建的对象实例都存放在这里。堆是垃圾回收器主要管理的区域。可以把它想象成王国的“公共土地”,大家都可以申请使用,但用完后必须归还(GC)。 - 栈(Stack):每个线程都有自己的私有栈,用于存储局部变量、方法调用信息(栈帧)。栈中的数据生命周期很短,方法执行完就自动弹出。可以把它想象成“临时办公室”,用完就清空。
- 方法区(Method Area):存储类信息、常量、静态变量、即时编译器编译后的代码等。在JDK 8及以后,这部分通常由元空间(Metaspace)实现,它使用的是本地内存(Native Memory),而不是堆内存。可以把它想象成“档案馆”,存放着王国的规章制度和历史记录。
代码示例:观察内存分配
public class MemoryDemo {
private static final String CONSTANT = "我住在方法区"; // 静态常量 -> 方法区
public static void main(String[] args) {
int localVar = 42; // 基本类型局部变量 -> 栈
String str = "我也是常量"; // 字符串常量 -> 方法区
Object obj = new Object(); // 对象实例 -> 堆
System.out.println(CONSTANT + " - " + str + " - " + obj);
}
}
2.3. 垃圾回收(GC):那个总在深夜打扰我的“清洁工”
GC是JVM的自动内存管理机制。它会定期或在内存不足时启动,找出那些不再被引用的对象,并回收它们占用的内存。
常见的GC算法有:
- 标记-清除(Mark-Sweep):先标记所有存活对象,然后清除未被标记的对象。缺点是会产生内存碎片。
- 复制(Copying):将存活对象复制到另一块内存区域,然后清除原区域。适用于新生代。
- 标记-整理(Mark-Compact):标记存活对象,然后将它们向一端移动,最后清理边界以外的内存。适用于老年代。
现代JVM(如HotSpot)采用分代收集策略,将堆分为新生代(Young Generation)和老年代(Old Generation)。新生代的对象“朝生暮死”,GC频繁但速度快(Minor GC)。老年代的对象存活时间长,GC不频繁但耗时长(Major GC/Full GC)。
问题来了:在云原生环境下,频繁的Full GC可能会导致应用“卡顿”,影响用户体验。因此,了解和调优GC至关重要。
2.4. JVM调优:从“佛系”到“内卷”的进化之路
JVM调优是一门艺术,也是一门科学。目标是让应用在有限的资源下,获得最佳的性能和稳定性。
关键JVM参数:
-Xms
和-Xmx
:设置堆的初始大小和最大大小。例如:-Xms512m -Xmx1024m
。-Xss
:设置线程栈大小。-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
:设置元空间大小。-XX:+UseG1GC
:使用G1垃圾回收器(推荐用于大内存应用)。-XX:+PrintGCDetails
:打印详细的GC日志,用于分析。
代码示例:一个简单的性能测试应用
import java.util.ArrayList;
import java.util.List;
public class GCDemo {
private static final List<byte[]> LIST = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
System.out.println("开始疯狂创建对象...");
// 模拟内存压力
for (int i = 0; i < 10000; i++) {
LIST.add(new byte[1024 * 1024]); // 每次添加1MB
if (i % 1000 == 0) {
System.out.println("已添加 " + i + " 个对象");
Thread.sleep(100); // 给GC一点时间
}
}
System.out.println("结束。");
}
}
运行这个应用,并添加GC日志参数:
java -Xms256m -Xmx512m -XX:+UseG1GC -XX:+PrintGCDetails -XX:+PrintGCDateStamps GCDemo
你会在控制台看到详细的GC日志,了解GC的频率、耗时和内存变化。
第二章:容器化,给应用穿上“紧身衣”
3.1. Docker:应用打包的“乐高大师”
如果说JVM是Java应用的“操作系统”,那么Docker就是整个应用的“操作系统”。它通过容器技术,将应用及其依赖(JDK、库、配置文件等)打包成一个轻量级、可移植的镜像(Image)。
Docker的核心思想是隔离和标准化。每个容器都像是一个独立的小房间,拥有自己的文件系统、网络和进程空间,互不干扰。这完美解决了“在我机器上能跑”的经典难题。
3.2. 从JAR到Docker镜像:一次“华丽的变身”
传统的Java应用打包成JAR或WAR文件,还需要在目标机器上安装JDK才能运行。而Docker镜像则“自给自足”,包含了运行所需的一切。
变身步骤:
- 编写
Dockerfile
:一个文本文件,定义了构建镜像的步骤。 - 运行
docker build
命令:根据Dockerfile
构建镜像。 - 运行
docker run
命令:基于镜像启动容器。
3.3. Java应用的Dockerfile最佳实践
一个优秀的Dockerfile
是高效、安全的基础。
最佳实践:
- 使用小基础镜像:优先使用
openjdk:17-jre-slim
而不是openjdk:17
,因为它更小,减少了攻击面。 - 多阶段构建:在构建阶段使用包含JDK的镜像编译代码,在最终镜像中只包含JRE和编译好的JAR包,进一步减小体积。
- 非root用户运行:为了安全,避免以root用户运行应用。
- 合理设置环境变量:如
JAVA_OPTS
。
代码示例:一个生产级的Java应用Dockerfile
# Dockerfile
# 第一阶段:构建
FROM openjdk:17-jdk-slim AS builder
WORKDIR /app
COPY .mvn/ .mvn
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline # 预下载依赖
COPY src ./src
# 使用非root用户构建
RUN adduser --system --shell /bin/bash --no-create-home --disabled-password --gecos "" appuser && \
chown -R appuser /app
USER appuser
RUN ./mvnw package -DskipTests
# 第二阶段:运行
FROM openjdk:17-jre-slim
# 创建非root用户
RUN adduser --system --shell /bin/bash --no-create-home --disabled-password --gecos "" appuser
USER appuser
# 设置工作目录
WORKDIR /app
# 从构建阶段复制JAR包
COPY --from=builder --chown=appuser:appuser /app/target/*.jar app.jar
# 暴露端口
EXPOSE 8080
# 设置JVM参数(示例)
ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC"
# 启动命令
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]
构建镜像:
docker build -t my-java-app:1.0 .
运行容器:
docker run -d -p 8080:8080 --name myapp my-java-app:1.0
3.4. JVM在容器中的“水土不服”与“适应性训练”
JVM在诞生时,还没有容器这个概念。因此,早期的JVM无法感知到容器施加的资源限制(如内存、CPU),它会认为自己可以使用宿主机的全部资源。这在容器环境中会导致严重问题:
- 内存超限:JVM根据宿主机内存设置了很大的堆,但容器内存限制较小,导致容器被OOM(Out of Memory)杀死。
- CPU资源浪费:JVM的GC线程数等参数基于宿主机CPU核心数,可能在容器中过度占用CPU。
解决方案:JVM的“适应性训练”
幸运的是,现代JVM(Java 8u131+,Java 9+)已经支持容器感知。
- 启用容器支持:
-XX:+UseContainerSupport
(默认已启用)。 - 限制堆内存:使用
-XX:MaxRAMPercentage
或-XX:InitialRAMPercentage
。例如,-XX:MaxRAMPercentage=75.0
表示堆最大使用容器内存限制的75%。 - 限制容器内存:在Docker或K8s中设置
memory
限制。
代码示例:在Docker中运行并限制内存
# 限制容器内存为512MB,并让JVM使用其中的70%作为堆
docker run -d -p 8080:8080 \
--memory=512m \
-e JAVA_OPTS="-XX:MaxRAMPercentage=70.0 -XX:+UseG1GC" \
--name myapp my-java-app:1.0
第三章:Kubernetes,云原生的“大本营”
4.1. Kubernetes核心概念:Pod、Deployment、Service
如果说Docker是单个应用的“打包和运行”工具,那么Kubernetes(简称K8s)就是一个容器编排系统,负责管理成百上千个容器的部署、扩展、网络和健康。
核心概念:
- Pod:K8s中最小的调度单元。一个Pod可以包含一个或多个紧密关联的容器(如应用容器和日志收集sidecar容器)。它们共享网络和存储。你可以把Pod想象成一个“家庭”,容器是家庭成员。
- Deployment:定义了Pod的期望状态,如副本数、使用的镜像版本。它确保指定数量的Pod副本始终在运行。如果Pod挂了,Deployment会自动创建新的。
- Service:为一组Pod提供一个稳定的网络入口(IP和DNS名称)。它实现了负载均衡和服务发现。即使Pod的IP变了,Service的IP不变。
4.2. 部署Java应用到K8s:从零到“Hello World”
让我们把之前构建的Java应用部署到K8s集群。
步骤:
- 将Docker镜像推送到镜像仓库(如Docker Hub, Harbor)。
- 编写K8s部署文件(YAML格式)。
- 使用
kubectl
命令应用部署。
代码示例:K8s部署文件 (deployment.yaml
)
apiVersion: apps/v1
kind: Deployment
metadata:
name: java-app-deployment
labels:
app: java-app
spec:
replicas: 3 # 期望运行3个Pod副本
selector:
matchLabels:
app: java-app
template:
metadata:
labels:
app: java-app
spec:
containers:
- name: java-app
image: your-dockerhub-username/my-java-app:1.0 # 替换为你的镜像
ports:
- containerPort: 8080
env:
- name: JAVA_OPTS
value: "-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: java-app-service
spec:
selector:
app: java-app
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: LoadBalancer # 在云环境中会创建外部负载均衡器
应用部署:
kubectl apply -f deployment.yaml
查看部署状态:
kubectl get deployments
kubectl get pods
kubectl get services
4.3. ConfigMap与Secret:应用配置的“保险箱”
在K8s中,硬编码配置是大忌。我们应该使用ConfigMap
和Secret
来管理配置。
- ConfigMap:存储非敏感的配置数据,如数据库URL、日志级别。
- Secret:存储敏感数据,如密码、API密钥。数据在etcd中是base64编码存储的(不是加密!)。
代码示例:使用ConfigMap配置Spring Boot应用
# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: java-app-config
data:
application.properties: |
server.port=8080
logging.level.root=INFO
spring.datasource.url=jdbc:mysql://mysql-service:3306/mydb
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}
在Deployment中挂载ConfigMap:
# 在deployment.yaml的containers部分添加
volumeMounts:
- name: config-volume
mountPath: /app/config
volumes:
- name: config-volume
configMap:
name: java-app-config
然后在应用启动时指定配置文件位置:
java $JAVA_OPTS -jar /app/app.jar --spring.config.location=file:/app/config/application.properties
Secret示例:
# secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: db-secret
type: Opaque
data:
DB_USER: cm9vdA== # base64编码的 "root"
DB_PASSWORD: cGFzc3dvcmQ= # base64编码的 "password"
在Deployment中以环境变量方式使用Secret:
env:
- name: DB_USER
valueFrom:
secretKeyRef:
name: db-secret
key: DB_USER
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-secret
key: DB_PASSWORD
4.4. 健康检查:Liveness与Readiness探针
K8s通过探针来监控Pod的健康状况。
- Liveness Probe(存活探针):检测应用是否“活着”。如果探测失败,K8s会重启该Pod。
- Readiness Probe(就绪探针):检测应用是否准备好接收流量。如果探测失败,K8s会将该Pod从Service的Endpoint列表中移除,不再转发流量。
对于Spring Boot应用,可以使用Actuator的/actuator/health
端点。
第四章:Java应用的“云原生化”改造
5.1. 12-Factor App:云原生应用的“宪法”
12-Factor App是构建现代化云原生应用的方法论,是每个开发者都应该熟读的“宪法”。
5.2. 配置外置化:告别application.properties
我们已经通过ConfigMap和Secret实现了配置外置化。这是12-Factor的第一条原则。
5.3. 无状态化:像风一样自由
12-Factor的第六条原则:将应用设计为无状态的。任何需要持久化的数据都应存储在外部服务(如数据库、Redis)中。
为什么重要?因为K8s可以随时杀死或迁移Pod。如果应用状态存储在Pod本地,一旦Pod被销毁,状态就丢失了。
代码示例:使用Redis存储会话
// Spring Boot中集成Redis
@Configuration
@EnableRedisHttpSession // 启用Redis存储HttpSession
public class RedisConfig {
@Bean
public LettuceConnectionFactory connectionFactory() {
return new LettuceConnectionFactory(new RedisStandaloneConfiguration("redis-service", 6379));
}
}
5.4. 优雅停机:体面地“说再见”
当K8s要终止一个Pod时(如升级、缩容),它会先发送SIGTERM
信号,然后等待一段时间(默认30秒),再发送SIGKILL
强制终止。
应用应该监听SIGTERM
信号,执行清理操作(如关闭数据库连接、处理完正在处理的请求),实现优雅停机。
代码示例:Spring Boot的优雅停机
# 在deployment.yaml中设置terminationGracePeriodSeconds
spec:
terminationGracePeriodSeconds: 60 # 给应用60秒时间优雅停机
containers:
- name: java-app
# ... 其他配置
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"] # 可选:给K8s一点时间从Service中移除Pod
在application.properties
中启用优雅停机:
# Spring Boot 2.3+
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
第五章:监控、日志与追踪——应用的“生命体征仪”
6.1. Prometheus + Grafana:监控的“黄金搭档”
- Prometheus:一个开源的监控和告警工具包。它通过HTTP拉取(pull)方式从目标(如你的Java应用)获取指标数据。
- Grafana:一个开源的可视化平台,可以连接Prometheus,创建精美的监控仪表盘。
代码示例:在Spring Boot中暴露Prometheus指标
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
# application.properties
management.endpoints.web.exposure.include=health,info,metrics,prometheus
访问 http://your-app:8080/actuator/prometheus
即可看到指标。
6.2. ELK/EFK:日志的“考古现场”
- Elasticsearch:分布式搜索和分析引擎。
- Logstash/Fluentd:日志收集和处理管道。
- Kibana:数据可视化界面。
在K8s中,通常使用EFK(Elasticsearch, Fluentd, Kibana)或Fluent Bit。
日志应以结构化(如JSON)格式输出,便于机器解析。
代码示例:使用Logback输出JSON日志
<!-- logback-spring.xml -->
<configuration>
<appender name="JSON_FILE" class="ch.qos.logback.core.FileAppender">
<file>logs/app.log</file>
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp/>
<logLevel/>
<message/>
<loggerName/>
<threadName/>
<mdc/>
<arguments/>
<stackTrace/>
</providers>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="JSON_FILE"/>
</root>
</configuration>
6.3. OpenTelemetry:分布式追踪的“时光机”
在微服务架构中,一个请求可能经过多个服务。分布式追踪可以帮助你追踪请求的完整路径,定位性能瓶颈。
OpenTelemetry 是一个开源的观测性框架,提供了API、SDK和工具,用于生成、收集、处理和导出遥测数据(追踪、指标、日志)。
第六章:服务网格与微服务——“群居”时代的协作法则
7.1. 微服务架构:拆分的“艺术”与“陷阱”
微服务将单体应用拆分为一系列小的、独立的服务。好处是易于开发、部署和扩展。但挑战也随之而来:服务发现、负载均衡、熔断、重试、安全等。
7.2. Istio:服务网格的“交通警察”
Istio 是一个流行的服务网格(Service Mesh)。它通过在每个Pod中注入一个Sidecar代理(通常是Envoy),将服务间的通信“接管”过来,从而在不修改应用代码的情况下,提供流量管理、安全、可观测性等功能。
7.3. Spring Cloud与Istio的“爱恨情仇”
Spring Cloud也提供了一套微服务解决方案(如Eureka, Ribbon, Hystrix)。当与Istio共存时,可能会有功能重叠和冲突。通常建议:
- 使用Istio处理东西向流量(服务间通信)。
- 使用Spring Cloud处理应用层逻辑(如与特定注册中心集成,如果必须的话)。
- 逐步将Spring Cloud的治理能力(如熔断)迁移到Istio。
第七章:Serverless与FaaS——“无服务器”的“极简主义”
8.1. Serverless:是“银弹”还是“鸡肋”?
Serverless(无服务器)让你无需管理服务器,只需关注代码。平台负责资源的自动伸缩和按需计费。
适用场景:事件驱动、批处理任务、API后端。
不适用场景:长时间运行、计算密集型任务。
8.2. Java在Serverless中的冷启动“顽疾”
Java应用启动慢,因为要加载JVM和类。在Serverless中,当没有请求时,实例会被销毁。新请求到来时,需要重新启动实例,这导致了冷启动延迟,可能长达几秒甚至十几秒。
8.3. Quarkus与GraalVM:Java的“轻量化革命”
- GraalVM:一个高性能的运行时,支持将Java应用编译成原生镜像(Native Image)。原生镜像启动极快(毫秒级),内存占用小。
- Quarkus:一个为GraalVM和Kubernetes设计的Java框架。它通过在构建时进行大量优化(如类路径扫描、依赖注入配置),使得应用能更好地编译成原生镜像。
代码示例:一个Quarkus应用
// GreetingResource.java
@Path("/hello")
public class GreetingResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String hello() {
return "Hello from Quarkus!";
}
}
构建原生镜像:
./mvnw package -Pnative
第八章:安全,无处不在的“守护神”
9.1. 镜像安全扫描:防止“带毒”的镜像
使用工具(如Trivy, Clair)扫描Docker镜像,检测其中的已知漏洞(CVE)。
9.2. 网络策略:Pod之间的“防火墙”
K8s的NetworkPolicy
可以定义Pod之间的网络通信规则,实现最小权限原则。
代码示例:NetworkPolicy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: app-network-policy
spec:
podSelector:
matchLabels:
app: java-app
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
role: frontend
ports:
- protocol: TCP
port: 8080
9.3. 密钥管理:Secret的“终极保险”
使用专门的密钥管理服务(如Hashicorp Vault, AWS KMS)来存储和管理Secret,而不是直接存放在K8s中。
第九章:CI/CD流水线——自动化部署的“永动机”
10.1. 从代码提交到生产上线:一次“全自动”之旅
CI/CD(持续集成/持续部署)是云原生的核心实践。典型的流水线:
- 代码提交:推送到Git仓库。
- CI:自动运行单元测试、代码扫描、构建Docker镜像、安全扫描。
- CD:将镜像推送到仓库,自动或手动部署到不同环境(Dev, Staging, Prod)。
10.2. Argo CD:GitOps的“践行者”
GitOps 是一种基于Git的CD模式。Argo CD 是一个流行的GitOps工具。它监控Git仓库中的K8s清单文件(YAML),一旦发现变更,就会自动将集群状态同步到期望状态。
第十章:未来展望——Java的“云原生”星辰大海
11.1. Java语言的持续演进
Java社区非常活跃,新版本(如Java 17, 21)不断引入新特性(Records, Pattern Matching, Virtual Threads),让Java更适合云原生。
11.2. Kubernetes生态的蓬勃发展
K8s生态日新月异,新的CRD(自定义资源定义)和Operator不断涌现,简化了数据库、消息队列等中间件的管理。
11.3. AIOps:智能运维的“未来之光”
利用AI和机器学习分析海量监控、日志数据,实现异常检测、根因分析、预测性维护,让运维更加智能。
结语:拥抱变化,做云时代的“弄潮儿”
从JVM到Kubernetes,Java应用的“生存环境”发生了翻天覆地的变化。我们不能再固守“单体应用+物理机”的旧思维。
云原生不是一蹴而就的,它是一场持续的演进。我们需要学习新的工具(Docker, K8s),掌握新的理念(12-Factor, GitOps),并勇于尝试新技术(Quarkus, Serverless)。
记住,技术是工具,目标是交付价值。无论技术如何变迁,解决业务问题、创造用户价值,才是我们作为开发者的终极使命。
所以,别再怀念那个“tail -f catalina.out”的年代了。拥抱变化,学习新知,让我们一起做云时代的“弄潮儿”!
愿你的应用,在云上,稳定运行,生生不息!