云原生 Java 微服务应用开发手册
云原生环境的 Java 应用开发与传统的非容器化方式有许多不同之处,尤其是在构建和运行环节,传统方式需要构建 Jar 包,而云原生环境需要构建镜像,传统方式应用直接运行在操作系统之上,而云原生环境应用在容器内运行,传统方式应用只能在单机上运行,而云原生环境下借助于 Kubernetes 这样的容器编排工具,应用可以自动地被调度到任意的单个或多个节点上运行,更具弹性和容错性,本文将分享一些在云原生环境下开发一个真实的 Java 微服务应用的经验。
微服务划分
服务名 | 简介 | 备注 |
---|---|---|
parent | 父 POM | Maven 包物料清单,所有微服务中引用的 Maven 包版本在此统一指定 |
client | 微服务调用客户端 | 微服务内部 API SDK,可以被 common 包引用,但不能反过来 |
common | 微服务实现公共代码 | 各微服务之间共享的代码,避免代码冗余 |
gateway | 网关 | 各微服务外部 API 入口,包含认证、鉴权等通用功能 |
user | 用户 | 帐号、角色、租户等用户相关功能 |
dataset | 数据集 | 数据集,以及简单的数据流标注 |
annotation | 标注 | 复杂的工作流标注 |
storage | 存储 | 文件存储及元数据管理 |
admin | 管理后台 | 内部管理平台 |
websocket | 消息 | WebSocket 消息推送 |
为了降低本地开发环境的资源消耗,本地微服务可以通过各微服务的 Kubernetes 集群外端口来调用,而不需要在本地启动所有依赖的微服务。注意,此种方式会将各微服务接口直接暴露给外部,没有网关认证和鉴权的保护,请勿在开发环境保存涉密数据。
代码结构
干净架构介绍
Bob 大叔在 2012 年的一篇博文 The Clean Architecture 中提出了一种适用于复杂业务系统的软件架构方式。在干净架构出现之前,已经有一些其它架构,包括 Hexagonal Architecture、Onion Architecture、Screaming Architecture、DCI 和 BCE。这些架构在本质上都是类似的,都采用分层的方式来达到一个共同的目标,那就是分离关注。
采用干净架构的系统,可以达成以下目标:
- 框架无关性。干净架构不依赖于具体的框架和库,而仅把它们当作工具,因此不会受限于任何具体的框架和库。
- 可测试性。业务规则可以在没有 UI、数据库、Web 服务器等外部依赖的情况下进行测试。
- UI 无关性。UI 改变可以在不改动系统其它部分的情况下完成,比如把 Web UI 替换成控制台 UI。
- 数据库无关性。可以很容易地切换数据库类型,比如从关系型数据库 MySQL 切换到文档型数据库 MongoDB,因为业务规则并没有绑定到某种特定的数据库类型。
- 外部代理无关性。业务规则对外部世界一无所知,因此外部代理的变动不会影响到业务代码。
干净架构原图里涉及的概念比较多,容易造成大家理解上出现偏差。再加上干净架构并没有规定具体实施细节,因此在实施的时候不同的人会有不同的选择,有些选择甚至是错误的。为了简化和规范干净架构的实施,这里对干净架构进行了一些简化,并增加了一些约束,更多内容可参考这篇博文 干净架构最佳实践。
下面是精简后的干净架构图:
目录结构
以用户微服务为例,其项目目录结构如下:
.
├── adapter // 适配层,包括对外提供用例层服务(API、CLI 等),以及用例层调用外部服务
│ ├── Application.java
│ ├── api // 以 API 方式调用用例
│ │ ├── config // 框架配置
│ │ ├── controller // 控制器
│ │ └── plugin // 框架插件,过滤器、包装器等
│ ├── cli // 以 CLI 方式调用用例
│ │ └── job // 后台任务
│ ├── dto // 对外输入和输出的对象
│ ├── gui // 以 GUI 方式调用用例
│ └── port // 外部依赖服务接口实现
│ │── dao // 数据访问接口实现,可以使用 MyBatis 或 JPA,切换时不会影响到用例层
│ │── email // 邮件发送接口实现
│ │── mobile // 短信发送接口实现
│ └── pay // 支付接口实现
│── entity // 业务实体
│ ├── UserBO.java // 用户实体
│ └── RoleBO.java // 角色实体
│── usecase // 用例层,亦即业务逻辑层
│ ├── UserUsecase.java // 用户模块用例
│ ├── exception // 业务异常定义
│ └── port // 外部依赖服务接口定义
│ │── dao // 数据访问接口
│ │── email // 邮件发送接口
│ │── mobile // 短信发送接口
│ └── pay // 支付接口
└── util // 工具类
为了减少代码冗余,将各微服务中的公用代码抽取到了 common
包中,其目录结构跟微服务类似。
.
├── adapter // 适配层
│ ├── api
│ └── cli
└── util // 工具类
├── encoder
├── generator
├── jwt
└── pagination
为了方便微服务之间相互调用,将各微服务的内部 API 封装到了 client
包下,client
包可以被 common
包引用,但不能反过来 。
.
├── async // 异步调用,适合 WebFlux 环境
│ └── user
├── dto // 数据传输对象
│ ├── ApiResult.java
│ └── user
├── exception // API 异常
│ ├── UsecaseCode.java
│ └── UsecaseException.java
├── feign // Feign 配置
│ ├── ApiResultDecoder.java
└── sync // 同步调用
├── ApiConfiguration.java
├── RequestContextInterceptor.java
└── user
Maven 规范
- 各微服务的依赖包版本统一在
parent
包里指定,其它包引入依赖包时不要指定版本号; - POM 文件里依赖包引入按底层(比如驱动)到上层(比如 ORM 框架)的顺序,同一个 Group 的包相邻放置,测试相关的依赖包放在后面;
- common 包里不要直接引入 Spring Boot Starter 依赖包(比如
com.baomidou.mybatis-plus-boot-starter
),而是要引入底层包(比如com.baomidou.mybatis-plus
),以免其它包引入common
包时执行了不需要的自动配置;
日志规范
- 使用 Spring 自带的
CommonsRequestLoggingFilter
来打印请求日志,并通过配置项logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter
来控制是否打印请求日志(为 DEBUG 或更低时才打印); - Mybatis-Plus 通过
logging.level.<mapper-package-path>
来控制是否打印 SQL 日志(为 DEBUG 或更低时才打印); - 默认配置文件里日志级别应设为 INFO 或更高,各环境依各自需求来调整;
异常规范
- 使用
UsecaseException
来抛出业务异常,未被捕获的业务异常会在框架层面进行处理,返回 200 状态码,以及相应的业务错误码和描述,如果需要返回非 200 状态码可在控制器里显示构造并返回ResponseEntity
对象;
数据库规范
- 每个微服务使用单独的数据库,不能直接访问其它微服务的数据库,只能通过接口调用;
- 迁移文件统一放在
ops/db/migration
目录下,文件命名遵循 Flyway 规范,在此规范上稍做调整为“服务版本号_更新编号__描述”,比如V1.0_1__Add_new_table.sql
; - 为了可控性和简单性,使用手动迁移而不是 Flyway 自动迁移;
- 每次提测时(包括 Bug 修复),如有升级库表结构,那么需要创建一个新的升级文件,更新编号依次增加;
参考资料:
缓存规范
Redis 使用规范
引入 Spring Data Redis 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Spring Boot 配置:
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Value("${spring.redis.password}")
private String password;
/**
* 将对象以 JSON 格式保存到 Redis 里。不推荐使用默认的 JdkSerializationRedisSerializer,存在安全风险,
* 可读性也不好。
* @param objectMapperBuilder
* @return
*/
@Bean
public GenericJackson2JsonRedisSerializer jsonRedisSerializer(
Jackson2ObjectMapperBuilder objectMapperBuilder) {
var objectMapper = objectMapperBuilder.build();
// 配置 ObjectMapper,使得在序列化后的 JSON 对象里存放类型信息,以便反序列化时能获得正确类型的对象
GenericJackson2JsonRedisSerializer.registerNullValueSerializer(objectMapper, null);
objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
return new GenericJackson2JsonRedisSerializer(objectMapper);
}
/**
* 用来将对象转换为 Map,以便保存为 Redis Hash。
* @param objectMapper
* @return
*/
@Bean
public Jackson2HashMapper hashMapper(ObjectMapper objectMapper) {
return new Jackson2HashMapper(objectMapper, false);
}
/**
* 使用功能更强大和完善的 Lettuce,而非 Jedis
* @return
*/
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
var serverConfig = new RedisStandaloneConfiguration(host, port);
serverConfig.setPassword(password);
var clientConfig = LettucePoolingClientConfiguration.builder()
.readFrom(REPLICA_PREFERRED)
.build();
return new LettuceConnectionFactory(serverConfig, clientConfig);
}
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory redisConnectionFactory,
GenericJackson2JsonRedisSerializer jsonRedisSerializer) {
var template = new RedisTemplate<String, Object>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(RedisSerializer.string());
template.setValueSerializer(jsonRedisSerializer);
template.setHashKeySerializer(RedisSerializer.string());
template.setHashValueSerializer(jsonRedisSerializer);
return template;
}
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
return new StringRedisTemplate(redisConnectionFactory);
}
}
如果是把 Redis 当作 Key-Value 数据库来使用,推荐采用类似数据库表访问的 DAO 模式,为每种类型的数据创建一个 DAO 对象,每个 Key 对应一个主键 ID。
首先,定义 DAO 接口。通过 DAO 接口可以屏蔽底层的实现细节,对于调用者来说不用关心数据存放在哪,是 MySQL 这样的关系数据库还是 Redis 这样的 Key-Value 数据库,方便后续替换底层存储引擎。
public interface CounterDAO {
/**
* 获取计数
* @param key Key
* @return 计数
*/
Long get(String key);
/**
* 设置计数
* @param key Key
* @param value 计数
*/
void set(String key, Long value);
/**
* 变化量,返回更新后的计数,并发安全
* @param key Key
* @param delta 变化量
* @return 更新后的计数
*/
Long inc(String key, Long delta);
}
其次,实现 DAO。这里把一些公共的约定,比如 Key 命名前缀,提取到了抽象基类 AbstractRedisDAO
里。Key 命名遵循 <app>:<service>:<table>:<id>
规则,比如 basicai:user:user:1
。
@Component
public class CounterDAOImpl extends AbstractRedisDAO<RedisTemplate<String, Object>>
implements CounterDAO {
public CounterDAOImpl(RedisTemplate<String, Object> template) {
super(template, "user", "counter");
}
@Override
public Long get(String key) {
var value = (Integer) template.opsForValue().get(prefixedKey(key));
if (value == null) {
return 0L;
}
return Long.valueOf(value);
}
@Override
public void set(String key, Long value) {
template.opsForValue().set(prefixedKey(key), value);
}
@Override
public Long inc(String key, Long delta) {
return template.opsForValue().increment(prefixedKey(key), delta);
}
}
这里没有使用功能更强大也更复杂的 Redis Repository 来保存对象到 Redis Hash 里,也不推荐在 Redis 里存放结构比较复杂和对可靠性要求比较高的数据。
参考资料:
Spring Cache 使用规范
引入 Spring Cache 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
Spring Boot 配置:
@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {
/**
* 配置各个 Cache,包括 TTL、Prefix 等
* @param jsonRedisSerializer
* @return
*/
@Bean
public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer(
GenericJackson2JsonRedisSerializer jsonRedisSerializer
) {
var defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(60))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(jsonRedisSerializer))
.computePrefixWith(new CacheKeyPrefix() {
String SEPARATOR = ":";
@Override
public String compute(String cacheName) {
return "basicai" + SEPARATOR + "user" + SEPARATOR + cacheName + SEPARATOR;
}
});
return (builder) -> builder
.withCacheConfiguration("user", defaultCacheConfig
.entryTtl(Duration.ofMinutes(12 * 60)));
}
}
使用 Spring Cache 的注解来自动缓存方法结果。推荐明确指定 key,而不是依赖 Spring Cache 自动生成的,更具可读性。
@Component
public class UserDAOImpl extends AbstractDAO implements UserDAO {
private final UserMapper userMapper;
public UserDAOImpl(UserMapper userMapper) {
this.userMapper = userMapper;
}
@Override
@CachePut(cacheNames = "user", key = "#userBO.getId()")
public UserBO saveOrUpdate(UserBO userBO) {
User user = User.fromBO(userBO);
userMapper.saveOrUpdate(user);
return user.toBO();
}
@Override
@Cacheable(cacheNames = "user", key = "#id")
public Optional<UserBO> findById(Long id) {
return Optional.ofNullable(userMapper.selectById(id))
.map(User::toBO);
}
}
参考资料:
Job 规范
Job 用来执行一些后台任务,比如缩略图生成、消息处理等,Job 跟 API 服务一样,都是经由 Kubernetes 来调度运行,具体可参考官方文档 Jobs。
应用入口
为了避免同一个服务针对不同运行方式分别打包,统一使用一个应用来启动 Web 服务和执行 Job,通过命令行参数来区分以哪种方式运行,同时通过 --job
参数来区分不同 Job。
@SpringBootApplication(scanBasePackages = "ai.basic.basicai.user.adapter")
@EnableFeignClients(basePackages = "ai.basic.basicai.client.sync")
public class Application implements ApplicationRunner, ApplicationListener<ContextClosedEvent> {
private static Logger logger = LoggerFactory.getLogger(Application.class);
@Autowired
private ApplicationContext context;
private Thread jobThread;
public static void main(String[] args) {
var app = new SpringApplication(Application.class);
var arguments = new DefaultApplicationArguments(args);
var jobValues = arguments.getOptionValues("job");
// 未指定 job 参数时启动 Web 服务,否则不启动
if (CollectionUtils.isEmpty(jobValues)) {
app.setWebApplicationType(WebApplicationType.SERVLET);
} else {
app.setWebApplicationType(WebApplicationType.NONE);
}
app.run(args);
}
@Override
public void onApplicationEvent(ContextClosedEvent event) {
if (jobThread != null) {
jobThread.interrupt();
try {
jobThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
@Override
public void run(ApplicationArguments args) throws Exception {
var jobValues = args.getOptionValues("job");
// 未指定 job 参数时直接返回
if (CollectionUtils.isEmpty(jobValues)) {
return;
}
var jobName = jobValues.get(0);
var job = (AbstractJob) context.getBean(jobName);
if (ObjectUtil.isNotNull(job)) {
jobThread = new Thread(job);
jobThread.start();
jobThread.join();
var exitCode = SpringApplication.exit(context);
System.exit(exitCode);
} else {
logger.error(String.format("Unknown job %s", jobName));
}
}
}
编写 Job
所有 Job 统一放在 cli.job
包下,继承于 AbstractJob
基类。
public abstract class AbstractJob implements Runnable {
private static final Logger logger = LoggerFactory.getLogger(AbstractJob.class);
private final String name;
protected AbstractJob(String name) {
this.name = name;
}
public String getName() {
return name;
}
@Override
public void run() {
logger.info("Start running job {}", name);
try {
work();
} catch (InterruptedException e) {
logger.info("Interrupt running job {} ", name);
} catch (Exception e) {
logger.info("Exception running job {}: {}", name, e.getMessage());
e.printStackTrace();
}
logger.info("End running job {}", name);
}
/**
* 真正要做的工作,由子类实现。对于长期运行的任务,注意在合适的时机检查当前线程是否被中断,以便结束运行。
* @throws Exception
*/
protected abstract void work() throws Exception;
}
短期运行 Job 示例:
public class ShortJob extends AbstractJob {
private static Logger logger = LoggerFactory.getLogger(ShortJob.class);
public ShortJob() {
super(ShortJob.class.getSimpleName());
}
@Override
protected void work() {
logger.info("Do some work");
}
}
长期运行 Job 示例:
public class LongJob extends AbstractJob {
private static Logger logger = LoggerFactory.getLogger(LongJob.class);
public LongJob() {
super(LongJob.class.getSimpleName());
}
/**
* 在循环间隙检查当前线程是否被中断,如果是则退出循环,单次循环处理时长不要超过 30s(Kubernetes 默认的容器优雅
* 终止时间)。
* @throws Exception
*/
@Override
protected void work() throws Exception {
while (true) {
if (Thread.currentThread().isInterrupted()) {
break;
}
logger.info("Do some work");
Thread.sleep(10 * 1000);
}
}
}
构建镜像
此外,在 Dockerfile 里最好使用 ENTRYPOINT
而不是 CMD
来指定启动命令,这样在执行 Job 时只需指定命令参数,否则需要指定完整的命令。
FROM openjdk:11
RUN apt update && \
apt install -y iputils-ping curl wget netcat
WORKDIR /app
COPY target/user-1.0-SNAPSHOT.jar ./
RUN mkdir -p config
EXPOSE 8080
ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "user-1.0-SNAPSHOT.jar"]
执行 Job
假设镜像名为 basicai/backend/user
,那么可以按如下的命令来分别启动 Web 服务和执行 Job。
# 启动 Web 服务
docker container run --rm basicai/backend/user
# 执行 Job
docker container run --rm basicai/backend/user java -jar app.jar --job=short
在 Kubernetes 里可通过下面的部署文件来执行有结束状态的短期 Job。对于每次部署时都需要执行的长期 Job(比如消息队列消费者),需要使用 Deployment 来部署,并且建议添加到 CI deployment 文件里,跟 API 服务一起部署。
# 有结束状态的短期 Job,需手动执行,执行之前需先删除现有同名 Job,Kubernetes Job 不允许更新
apiVersion: batch/v1
kind: Job
metadata:
name: user-shortjob
spec:
# 完成次数
completions: 1
# 并发数
parallelism: 1
# 失败重试次数
backoffLimit: 6
template:
spec:
# 提前准备好拉取镜像的凭证
imagePullSecrets:
- name: basicai-registry
restartPolicy: Never
containers:
- name: user-shortjob
image: user
env:
- name: JAVA_OPTS
value: "-XX:InitialRAMPercentage=20.0 -XX:MinRAMPercentage=80.0 -XX:MaxRAMPercentage=80.0 -XshowSettings:vm"
- name: APP_ARGS
value: "--job=short"
resources:
requests:
memory: 1Gi
cpu: 100m
limits:
memory: 2Gi
cpu: 1
volumeMounts:
- name: config
mountPath: /app/config
volumes:
- name: config
configMap:
name: user
定时执行 Job
Linux 下通常使用 cron job 来执行定时任务,同样地 Kubernetes 也提供了 CronJob 资源类型来在集群里调度和执行定时任务,比如下面的示例中每小时执行一次任务。CronJob 的 jobTemplate.spec 类型是 JobSpec,因此也就自然支持普通 Job 的 parallelism、completions 等并发控制参数。
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: user-cronjob
namespace: basicai-backend
spec:
# 执行频率
schedule: "0 * * * *"
jobTemplate:
spec:
template:
spec:
nodeSelector:
dedicated: app
# 提前准备好拉取镜像的凭证
imagePullSecrets:
- name: basicai-registry
restartPolicy: OnFailure
containers:
- name: user-cronjob
image: user
env:
- name: JAVA_OPTS
value: "-XX:InitialRAMPercentage=20.0 -XX:MinRAMPercentage=80.0 -XX:MaxRAMPercentage=80.0 -XshowSettings:vm"
- name: APP_ARGS
value: "--job=cronjob --management.metrics.tags.application=basicai-user-cronjob --management.metrics.export.prometheus.pushgateway.enabled=true"
resources:
requests:
memory: 1Gi
cpu: 100m
limits:
memory: 2Gi
cpu: 1
volumeMounts:
- name: config
mountPath: /app/config
volumes:
- name: config
configMap:
name: user
单元测试规范
因为集成测试会依赖各种外部服务,执行起来比较困难也比较慢,并且通过功能强大的 API 测试工具(比如 Apifox)就能够基本达到集成测试的要求,因此这里不推荐编写集成测试,而是把精力集中到更易于执行的单元测试上。对于单元测试,也要重点针对业务逻辑复杂的用例层接口去做,不需要对只进行简单增删改查的 DAO 接口去做。
用例层单元测试
@ExtendWith(SpringExtension.class)
public class UserUsecaseTest {
@TestConfiguration
static class UserControllerTestConfiguration {
@Bean
public UserUsecase userUsecase() {
return new UserUsecase();
}
}
@Autowired
private UserUsecase userUsecase;
@MockBean
private UserDAO userDAO;
@MockBean
private RoleDAO roleDAO;
@MockBean
private RandomGenerator randomGenerator;
@MockBean
private PasswordEncoder passwordEncoder;
@MockBean
private UserEmailPublisher emailPublisher;
@MockBean
private StorageService storageService;
@BeforeEach
public void setUp() {
var jagger = UserBO.builder().id(1L)
.username("jaggerwang")
.email("jaggerwang@gmail.com")
.nickname("Jagger Wang")
.build();
Mockito.when(userDAO.findById(jagger.getId(), UserBO.Status.ACTIVE))
.thenReturn(Optional.of(jagger));
}
@Test
public void whenValidId_thenUserShouldBeFound() {
var id = (Long) 1L;
var user = userUsecase.info(id);
assertDoesNotThrow(() -> {
user.get();
});
assertEquals(id, user.get().getId());
}
}
- 使用了
@ExtendWith
注解而不是@SpringBootTest
,避免启动一个完整的应用上下文,从而加快单元测试执行速度; - 使用
@TestConfiguration
注解来声明了一个只给本单元测试使用的配置类,里面可以定义本单元测试里需要自动注入的 Bean,比如被测试的用例对象; - 对于被测试的用例对象依赖的其它 Bean 对象,可以通过
@MockBean
注解来声明; - 在
setUp
方法里使用Mockito
来 Mock 被测试的用例对象方法里所调用的其它依赖对象的方法,注意这里不需要 Mock 依赖对象的所有方法;
控制器层单元测试
@ExtendWith(SpringExtension.class)
@WebMvcTest(UserController.class)
@ContextConfiguration(classes = TestApplication.class)
public class UserControllerTest {
@Autowired
private MockMvc mvc;
@MockBean
private UserUsecase userUsecase;
@MockBean
private TeamUsecase teamUsecase;
@BeforeEach
public void setUp() {
var jagger = UserBO.builder().id(1L)
.username("jaggerwang")
.email("jaggerwang@gmail.com")
.nickname("Jagger Wang")
.build();
Mockito.when(userUsecase.info(jagger.getId()))
.thenReturn(Optional.of(jagger));
Mockito.when(teamUsecase.findMemberByUserId(jagger.getId()))
.thenReturn(List.of());
}
@Test
public void whenValidId_thenUserShouldBeFound() throws Exception {
var id = 1;
mvc.perform(MockMvcRequestBuilders.get(String.format("/user/info/%d", id)))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.code", Matchers.is("OK")))
.andExpect(MockMvcResultMatchers.jsonPath("$.data.user.id", Matchers.is(id)));
}
}
- 相比用例层单元测试,控制器层单元测试使用了
@ContextConfiguration
注解来指定了测试配置类,否则会自动向上搜索到主应用类,从而导致自动配置各种不需要的组件,还会引起报错;
单元测试编写好后,注意打开 CI/CD 流水线里 Maven 构建任务的测试开关,以便在每次构建时自动执行单元测试。
部署规范
配置规范
src/main/resources
目录下的application.yml
配置文件只存放环境无关的配置,并且按照严格模式来配置,比如关闭 debug、日志级别设为 info 或更严格;- 环境相关的配置放到对应的环境配置目录
ops/<env>
下; - Spring 默认会从
optional:classpath:/,optional:classpath:/config/,optional:file:./,optional:file:./config/
目录里加载配置文件,可以通过spring.config.location
参数来设置,或者通过spring.config.additional-location
来添加额外的,更多细节可参考 External Application Properties; - 开发和测试环境为了调试方便可以使用
*
来暴露所有 Actuator 端点,生产环境请明确指定要暴露的端点,以免信息泄漏,Spring Boot 默认只会暴露 info 和 health 端点,更多细节可参考 Exposing Endpoints;
资源使用规范
- 容器遵循单一职责原则,不要在单个容器里同时运行多种计算类型的任务,比如把前台 API 服务跟后台异步任务混合在一起。每种计算类型的任务应独立为单独的容器,一是可以避免任务之间相互影响,二是方便 Kubernetes 把不同计算类型的任务调度到对应的节点上并配置不同的升级策略,三是可以针对不同计算类型的任务配置重启策略或优雅终止方式。
- Java 8u191 及 Java 9 之后已经可以正确感知到容器资源限制,为了避免 JVM 堆内存设置跟容器限制不同步,请使用
InitialRAMPercentage
(初始堆内存大小,默认为 25%)、MinRAMPercentage
(容器内存小于 200MB 时的最大堆内存) 和MaxRAMPercentage
(容器内存大于 200MB 时的最大堆内存) 按百分比来设置堆内存用量; - 为不同计算类型的任务设置合理的 Resource Limit,如果不清楚可以先按小的设,后续再按需扩容;
容器应用优雅终止规范
- Kubernetes 接收到停止 Pod 指令后,会将 Pod 标记为 Terminating 状态,并停止转发流量给该 Pod,然后通知容器运行时停止 Pod 下的各个容器;
- 容器运行时发送 TERM 信号(默认为
SIGTERM
,可在 Dockerfile 里通过STOPSIGNAL
指令修改)给容器内的所有进程,Kubernetes 等待该容器停止,等待时间默认为 30s(可通过terminationGracePeriodSeconds
来修改); - 容器内的应用如果需要优雅终止,那么需处理
SIGTERM
信号,等待现有任务完成、清理资源等; - 如果等待容器停止超时,Kubernetes 将触发强制关闭过程,此时容器运行时会发送
SIGKILL
给容器内的所有进程,系统强制杀掉所有进程,然后容器终止; - Kubernetes 执行其它 Pod 相关的资源清理操作;
参考资料:
Spring 应用优雅关闭
- 为了避免应用关闭时强行终止当前请求的处理,可设置
server.shutdown=graceful
,默认等待时间为 30s,该值可通过spring.lifecycle.timeout-per-shutdown-phase
来修改; - Spring 默认的
TaskExecutor
会强行终止正在执行的任务,可通过setWaitForTasksToCompleteOnShutdown
方法来开启等待,为了避免无限等待,可通过setAwaitTerminationSeconds
方法来设置等待时长(默认为 0); - 对于长期运行的后台 Job,需要在合适的时机检查当前线程是否被中断,从而结束执行,否则会被强行终止;
- 无论是单个请求处理、
TaskExecutor
终止等待时长,还是后台 Job 中断检查间隔时长,都不要超过 Kubernetes 的容器优雅终止等待时长(默认为 30s);
参考资料: