f536987a7e
- doc: add comment
165 lines
5.8 KiB
Go
165 lines
5.8 KiB
Go
// Package healthcheck 提供针对 Spring Boot Actuator 健康端点的采样与等待能力,
|
|
// 供更新执行器在启动容器后确认工作负载是否就绪。
|
|
package healthcheck
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
// maximumResponseBytes 限制 Actuator 响应体的最大读取字节数,防止异常大响应。
|
|
const maximumResponseBytes = 1 << 20
|
|
|
|
// ErrWorkloadStopped 表示工作负载在变为健康之前已经停止。
|
|
var ErrWorkloadStopped = errors.New("workload stopped before becoming healthy")
|
|
|
|
// RunningProbe 在每次健康采样前核对容器是否仍处于运行状态。
|
|
type RunningProbe func(context.Context) (bool, error)
|
|
|
|
// ActuatorReport 一次成功健康采样的非敏感摘要。
|
|
type ActuatorReport struct {
|
|
// Status 表示顶层健康状态,例如 UP、DOWN。
|
|
Status string
|
|
// Components 表示各子组件的健康状态映射。
|
|
Components map[string]string
|
|
}
|
|
|
|
// ActuatorChecker 等待 Spring Boot Actuator 顶层状态进入 UP。
|
|
// 它只采样健康状态,不负责重启容器。
|
|
type ActuatorChecker struct {
|
|
// client 执行健康检查 HTTP 请求的客户端。
|
|
client *http.Client
|
|
// interval 相邻两次采样之间的间隔。
|
|
interval time.Duration
|
|
}
|
|
|
|
// Check 执行一次健康采样,供事务恢复时核对已经记录意图的健康步骤。
|
|
func (c *ActuatorChecker) Check(ctx context.Context, endpoint string, running RunningProbe) (ActuatorReport, bool, error) {
|
|
if running == nil {
|
|
return ActuatorReport{}, false, errors.New("running probe is required")
|
|
}
|
|
if err := validateEndpoint(endpoint); err != nil {
|
|
return ActuatorReport{}, false, err
|
|
}
|
|
return c.sample(ctx, endpoint, running)
|
|
}
|
|
|
|
// NewActuatorChecker 创建固定间隔的 Actuator 检查器。
|
|
func NewActuatorChecker(client *http.Client, interval time.Duration) (*ActuatorChecker, error) {
|
|
if client == nil {
|
|
return nil, errors.New("HTTP client is required")
|
|
}
|
|
if interval <= 0 {
|
|
return nil, errors.New("health check interval must be positive")
|
|
}
|
|
return &ActuatorChecker{client: client, interval: interval}, nil
|
|
}
|
|
|
|
// Wait 在 timeout 范围内持续采样。容器停止时立即失败;其他未就绪结果保留到截止时间。
|
|
func (c *ActuatorChecker) Wait(ctx context.Context, endpoint string, timeout time.Duration, running RunningProbe) (ActuatorReport, error) {
|
|
if timeout <= 0 {
|
|
return ActuatorReport{}, errors.New("health check timeout must be positive")
|
|
}
|
|
if running == nil {
|
|
return ActuatorReport{}, errors.New("running probe is required")
|
|
}
|
|
if err := validateEndpoint(endpoint); err != nil {
|
|
return ActuatorReport{}, err
|
|
}
|
|
|
|
waitContext, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
var lastErr error
|
|
for {
|
|
report, ready, err := c.sample(waitContext, endpoint, running)
|
|
if ready {
|
|
return report, nil
|
|
}
|
|
if errors.Is(err, ErrWorkloadStopped) {
|
|
return ActuatorReport{}, err
|
|
}
|
|
lastErr = err
|
|
|
|
timer := time.NewTimer(c.interval)
|
|
select {
|
|
case <-waitContext.Done():
|
|
timer.Stop()
|
|
return ActuatorReport{}, fmt.Errorf("Actuator did not become UP within %s: %w", timeout, errors.Join(waitContext.Err(), lastErr))
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// validateEndpoint 校验 Actuator 端点必须是合法的 http URL 且包含主机部分。
|
|
func validateEndpoint(endpoint string) error {
|
|
parsed, err := url.ParseRequestURI(endpoint)
|
|
if err != nil || parsed.Scheme != "http" || parsed.Host == "" {
|
|
return fmt.Errorf("invalid Actuator HTTP endpoint: %q", endpoint)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sample 执行一次完整健康采样:先核对容器仍在运行,
|
|
// 再向 Actuator 端点发起 GET 请求并解析返回的 JSON 状态。
|
|
// 返回的报告在状态非 UP 时仍会携带实际采样到的状态信息。
|
|
func (c *ActuatorChecker) sample(ctx context.Context, endpoint string, running RunningProbe) (ActuatorReport, bool, error) {
|
|
isRunning, err := running(ctx)
|
|
if err != nil {
|
|
return ActuatorReport{}, false, fmt.Errorf("inspect workload before health check: %w", err)
|
|
}
|
|
if !isRunning {
|
|
return ActuatorReport{}, false, ErrWorkloadStopped
|
|
}
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return ActuatorReport{}, false, fmt.Errorf("create Actuator request: %w", err)
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
response, err := c.client.Do(request)
|
|
if err != nil {
|
|
return ActuatorReport{}, false, fmt.Errorf("request Actuator health: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(response.Body, maximumResponseBytes+1))
|
|
if err != nil {
|
|
return ActuatorReport{}, false, fmt.Errorf("read Actuator response: %w", err)
|
|
}
|
|
if len(body) > maximumResponseBytes {
|
|
return ActuatorReport{}, false, errors.New("Actuator response exceeds size limit")
|
|
}
|
|
if response.StatusCode != http.StatusOK {
|
|
return ActuatorReport{}, false, fmt.Errorf("Actuator returned HTTP status %d", response.StatusCode)
|
|
}
|
|
|
|
var payload actuatorPayload
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return ActuatorReport{}, false, fmt.Errorf("decode Actuator response: %w", err)
|
|
}
|
|
report := ActuatorReport{Status: payload.Status, Components: make(map[string]string, len(payload.Components))}
|
|
for name, component := range payload.Components {
|
|
report.Components[name] = component.Status
|
|
}
|
|
if payload.Status != "UP" {
|
|
return report, false, fmt.Errorf("Actuator status is %q", payload.Status)
|
|
}
|
|
return report, true, nil
|
|
}
|
|
|
|
// actuatorPayload Actuator health 端点的顶层 JSON 结构。
|
|
type actuatorPayload struct {
|
|
Status string `json:"status"`
|
|
Components map[string]actuatorComponent `json:"components"`
|
|
}
|
|
|
|
// actuatorComponent 健康检查组件的 JSON 结构,这里只关心其状态。
|
|
type actuatorComponent struct {
|
|
Status string `json:"status"`
|
|
}
|