概述
健康检查对于一个pod而言,其重要性不言而喻。
k8s通过探针来实现健康检查。
探针
k8s提供三种探针:
- 存活探针:livenessProbe
- 就绪探针:readinessProbe
- 启动探针:startupProbe
存活探针
存活探针决定何时重启容器。例如,当应用在运行但无法取得进展时,存活探针可捕获这类死锁。如果一个容器的存活探针失败多次,kubelet将重启该容器。存活探针不会等待就绪探针成功。如果你想在执行存活探针前等待,可定义initialDelaySeconds,或使用启动探针。
就绪探针
就绪探针决定何时容器准备好开始接受流量。这种探针在等待应用执行耗时的初始任务时非常有用,例如建立网络连接、加载文件和预热缓存。如果就绪探针返回的状态为失败,k8s会将该Pod从所有对应服务的端点中移除。就绪探针在容器的整个生命期内持续运行。
启动探针
启动探针检查容器内的应用是否已启动。启动探针可用于对慢启动容器(即应用启动耗时比较久)进行存活性检测,避免它们在启动运行之前就被kubelet杀掉。如果配置这类探针,它会禁用存活检测和就绪检测,直到启动探针成功为止。仅在启动时执行,不像存活探针和就绪探针那样周期性地运行。
样例
一个供参考的配置示例:
spec:
replicas: 1
selector:
matchLabels:
app: demo
template:
metadata:
creationTimestamp: null
labels:
app: demo
spec:
containers:
- name: demo
livenessProbe:
httpGet:
path: /health
port: tcp
scheme: HTTP
timeoutSeconds: 1
periodSeconds: 10
successThreshold: 1
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: tcp
scheme: HTTP
timeoutSeconds: 1
periodSeconds: 10
successThreshold: 1
failureThreshold: 3
startupProbe:
httpGet:
path: /health
port: tcp
scheme: HTTP
initialDelaySeconds: 60
timeoutSeconds: 1
periodSeconds: 5
successThreshold: 1
failureThreshold: 3
restartPolicy: Always
terminationGracePeriodSeconds: 30
配置解读
其中:
- initialDelaySeconds:指定kubelet在执行第一次探测前要等待N秒。容器启动后要等待多少秒后才启动启动、存活和就绪探针。如果定义了启动探针,则存活探针和就绪探针的延迟将在启动探针已成功之后才开始计算。如果periodSeconds的值大于initialDelaySeconds,则initialDelaySeconds将被忽略。默认是0秒,最小值是0。
- timeoutSeconds:探测超时后等待多少秒。默认值是1秒。最小值是1。
- periodSeconds:指定kubelet每N秒执行一次探测。默认是10秒。最小值是1。当容器未就绪时,ReadinessProbe可能会在除配置的periodSeconds间隔以外的时间执行。这是为了让Pod更快地达到可用状态。
- successThreshold:探针在失败后,被视为成功的最小连续成功数。默认值是1。存活和启动探测的这个值必须是1,最小值为1。
- failureThreshold:探针连续失败N次后,k8s认为总体上检查已失败:容器状态未就绪、不健康、不活跃。默认值为3,最小值为1。对于启动探针或存活探针而言,如果至少有N个探针已失败,k8s会将容器视为不健康并为这个特定的容器触发重启操作。kubelet遵循该容器的terminationGracePeriodSeconds设置。对于失败的就绪探针,kubelet继续运行检查失败的容器,并继续运行更多探针; 因为检查失败,kubelet将Pod的Ready状况设置为false。
- terminationGracePeriodSeconds:为kubelet配置从为失败的容器触发终止操作到强制容器运行时停止该容器之前等待的宽限时长。默认值是继承Pod级别的terminationGracePeriodSeconds值(如果不设置则为30秒),最小值为1。
另外:
- 一般情况下,三个探针的
path
配置为同一个路径; - 路径名一般是
/health
,当然也可配置为/hc
,即health check
的缩写;
暴露接口
k8s提供三种探针,用于判断pod的健康状态,进而可实现健康检查、监控告警、自动重启等一系列后续操作。但是有个前提,应用需要暴露一个可用的接口,如上面的/health
。接口,也可以叫做端点,endpoint。
Java
对于非Spring Boot应用,可手动新增/health
接口:
@RestController
public class HealthController {
@GetMapping("/health")
public String health() {
return "OK";
}
}
Spring Boot
对于Spring Boot应用,引入如下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
应用即默认暴露actuator/health
端点:
丰富/health
端点暴露的信息,配置如下:
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
全局
每个Java应用都需要手动新增/health
Controller接口,或在pom.xml
文件里引入依赖,能否将此操作统一管理呢?
当然可以。
在框架库里新增自动配置类,然后每个Java业务
应用加入此框架库:
import org.springframework.util.ReflectionUtils;
@Slf4j
@Component
@RequiredArgsConstructor
public class HealthyConfiguration implements ApplicationRunner {
private final RequestMappingHandlerMapping mapping;
@Override
public void run(ApplicationArguments args) {
RequestMappingInfo info = RequestMappingInfo.paths("/health")
.methods(RequestMethod.GET)
.produces(MediaType.APPLICATION_JSON_VALUE)
.options(mapping.getBuilderConfiguration())
.build();
Method healthMethod = ReflectionUtils.findMethod(getClass(), "health", HttpServletRequest.class, HttpServletResponse.class);
mapping.registerMapping(info, this, healthMethod);
}
@ResponseBody
public R<String> health(HttpServletRequest request, HttpServletResponse response) {
return R.success("ok", "ok");
}
}
问题
上面啰嗦一大堆,终于即将引出遇到的问题。
某个Grails应用,在build.gradle
里有加入awesome-security
框架库,此框架库里有上述HealthyConfiguration全局配置类;启动类有增加Bean扫描路径相关注解配置:
// 没有package定义
import grails.boot.GrailsApp
import grails.boot.config.GrailsAutoConfiguration
import groovy.transform.CompileStatic
import org.springframework.boot.web.servlet.ServletComponentScan
import org.springframework.context.annotation.ComponentScan
@CompileStatic
@ComponentScan('com.tesla')
@ServletComponentScan("com.tesla")
class Application extends GrailsAutoConfiguration {
static void main(String[] args) {
GrailsApp.run(Application, args)
}
}
应用启动成功
(注意此处字体):
Postman模拟请求一个接口,可看到如下正常的输出与返回:
问题1-404 not found
但是Postman请求http://localhost:port/health
,却返回404 not found
?
IDEA控制台输出:
2025-02-24 23:21:50.177 --> WARN [traceId=29951c61f59941b4 spanId=29951c61f59941b4 sampled=true] --- [nio-8867-exec-5] o.s.web.servlet.PageNotFound : No mapping for GET /health
问题2-liquibase
关于liquibase,有两类问题(现象)
NullPointerException
本地以Debug模式启动应用后,不过几分钟,就会发现IDEA控制台打印出如下内容:
具体的报错StackTrace信息如下:
2025-02-24 23:15:23.038 --> ERROR --- [ main] o.s.boot.SpringApplication : Application run failed
liquibase.exception.LockException: java.lang.NullPointerException
at liquibase.lockservice.StandardLockService.listLocks(StandardLockService.java:446) ~[liquibase-core-4.9.1.jar:na]
at liquibase.lockservice.StandardLockService.waitForLock(StandardLockService.java:260) ~[liquibase-core-4.9.1.jar:na]
at liquibase.Liquibase.lambda$update$1(Liquibase.java:214) ~[liquibase-core-4.9.1.jar:na]
at liquibase.Scope.lambda$child$0(Scope.java:180) ~[liquibase-core-4.9.1.jar:na]
at liquibase.Scope.child(Scope.java:189) ~[liquibase-core-4.9.1.jar:na]
at liquibase.Scope.child(Scope.java:179) ~[liquibase-core-4.9.1.jar:na]
at liquibase.Scope.child(Scope.java:158) ~[liquibase-core-4.9.1.jar:na]
at liquibase.Liquibase.runInScope(Liquibase.java:2405) ~[liquibase-core-4.9.1.jar:na]
at liquibase.Liquibase.update(Liquibase.java:211) ~[liquibase-core-4.9.1.jar:na]
at liquibase.Liquibase.update(Liquibase.java:197) ~[liquibase-core-4.9.1.jar:na]
at liquibase.integration.spring.SpringLiquibase.performUpdate(SpringLiquibase.java:314) ~[liquibase-core-4.9.1.jar:na]
at org.grails.plugins.databasemigration.liquibase.GrailsLiquibase.performUpdate(GrailsLiquibase.groovy:81) ~[database-migration-4.2.1-plain.jar:na]
at liquibase.integration.spring.SpringLiquibase.afterPropertiesSet(SpringLiquibase.java:269) ~[liquibase-core-4.9.1.jar:na]
at org.springframework.beans.factory.InitializingBean$afterPropertiesSet.call(Unknown Source) ~[na:na]
关键词:liquibase.exception.LockException: java.lang.NullPointerException
Could not acquire change log lock
和上面那个现象一样,本地以Debug模式启动应用后,不过几分钟,就会发现IDEA控制台打印出如下异常信息:
具体的报错StackTrace信息如下:
Exception in thread "main" java.lang.reflect.InvocationTargetException
Caused by: java.lang.reflect.UndeclaredThrowableException
Caused by: liquibase.exception.LockException: Could not acquire change log lock. Currently locked by DESKTOP-L20EH42 (192.168.1.119) since 2025/1/11 下午10:49
其中,DESKTOP-L20EH42 (192.168.1.119)
是我的Windows开发机。
关键词:liquibase.exception.LockException: Could not acquire change log lock.
相同点
并且发生上述NPE异常后,应用自动停止,很是莫名其妙,Postman发送请求失败:
没办法,只好再重启应用。
排查
两类问题三个现象(当然这也是在事后解决问题时才意识到的)混杂在一起,无头苍蝇。
耗费诸多时间。
几个现象:
- 测试环境之前是好的,最近发布测试环境,pod异常;
- 本地可以完美复现测试环境遇到的问题;
- 生产环境没有这个问题,测试环境有这个问题;
- 另外一个Grails应用
rag-admin
没有这个问题;
手动加接口
实在是无法解决问题,手动增加/health
接口:
UrlMappings.groovy
新增:
class UrlMappings {
static mappings = {
"/health"(controller: "options", method: "GET", action: "health")
}
}
OptionsController.groovy
新增:
def health = {
render R.success("ok", "ok")
}
本地请求/health
接口,返回200。
测试环境
测试环境发布成功
(注意字体)。
但是没过几分钟,k8s定时执行健康检查,检查失败,自动触发pod重启。
一直没搞定这个问题,如下图所示,自动重启几百次
进入到pod内,curl http://ip:8867/health明明是成功的啊:
为啥k8s认为检查失败呢???
看看别的正常的pod:
可以看到返回体格式不是严格一致。因为这个,k8s认为健康检查失败?
不确定,搁置。
DATABASECHANGELOGLOCK
手动增加/health
健康检查接口,本地启动应用,但是没过几分钟,就因为上面提到的问题2导致应用停止
,也就是Postman无法发送请求,无法断点调试代码。
这怎么能忍呢???
研究了一下liquibase,发现会自动生成如下两个表:
看到上面的**LOCK
表,再回想前面提到的两个LockException,貌似有点眉目。
看看表里的数据:
执行如下SQL释放锁:
UPDATE DATABASECHANGELOGLOCK SET LOCKED = 0, LOCKGRANTED = null, LOCKEDBY = null where ID = 1;
因为本地开发环境和测试环境共用一个数据库,所以本地可以完美复现测试环境/health
接口404 Not Found
的问题。
LOCKEDBY字段只会是本地开发机。
LOCKED=true
什么场景下,liquibase的DATABASECHANGELOGLOCK表里的LOCKED字段会变成true?
Liquibase在执行数据库变更操作时会使用DATABASECHANGELOGLOCK表来保证同一时间只有一个进程在修改数据库架构。具体来说:
- 当你运行诸如
liquibase update
之类的命令时,Liquibase会尝试获取锁以防止其他进程并发执行变更。成功获取锁后,它会将DATABASECHANGELOGLOCK表中对应行的LOCKED字段设置为true,表示当前有进程正在执行变更操作。 - 正常情况下,在变更操作结束后,Liquibase会将LOCKED字段重置为false,释放锁。
- 如果在变更过程中出现异常终止、网络中断或者进程崩溃,导致Liquibase无法正常释放锁,则LOCKED字段可能会一直保持为true,从而阻止后续的变更操作。
因此,LOCKED字段变成true的场景包括:
- 正在执行数据库更新时(锁已被正常获取)。
- 上一次更新操作异常终止或未能正确释放锁的情况下。
生产环境,LOCKED字段不会变成true,所以生产环境没有这个问题。
结论
测试数据库,将LOCKED字段更新为false后,pod则可以启动成功,健康检查通过。然后会看到Grails application running at http://localhost:8867 in environment: development
日志。
对于Grails应用,不管是本地Run模式还是Debug模式启动,或者测试环境发布,只有打印输出类似于下面这一行日志,才算真正的启动成功
:
Grails application running at http://localhost:8867 in environment: development
题外话
下图第一个test表示这是测试环境域名,第二个test表示这个test namespace。但是看日志,却打印输出:production
。
生产环境??这明明是测试环境啊。
Grails真难搞啊。