第一章:Go服务平滑下线不丢请求:从信号捕获到goroutine协同退出的7层防御体系(含K8s就绪探针联动方案)
平滑下线的核心目标是:在收到终止信号后,拒绝新连接、处理完所有进行中请求、安全关闭后台协程,并与调度系统协同完成生命周期闭环。这需要七层递进式防护,缺一不可。
信号捕获与优雅入口拦截
注册 os.Interrupt 和 syscall.SIGTERM,使用 signal.Notify 配合带缓冲通道避免阻塞:
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
<-sigChan // 阻塞等待首次信号
收到信号后立即关闭 HTTP Server 的 listener,使新 TCP 连接被内核拒绝,但已建立连接仍可读写。
就绪探针动态降级
K8s readinessProbe 必须与下线流程联动。定义 /healthz/ready 接口,内部维护原子布尔变量 isShuttingDown:
var isShuttingDown atomic.Bool
func readyHandler(w http.ResponseWriter, r *http.Request) {
if isShuttingDown.Load() {
http.Error(w, "shutting down", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
收到终止信号后立即将 isShuttingDown.Store(true),K8s 在数秒内将 Pod 从 Endpoint 列表移除。
请求级上下文超时控制
所有 handler 必须使用 r.Context() 并设置合理截止时间(如 context.WithTimeout(r.Context(), 30*time.Second)),避免长尾请求无限阻塞。
后台任务协同退出
使用 sync.WaitGroup + context.WithCancel 管理 goroutine 生命周期。主 goroutine 调用 cancel() 后,各子 goroutine 检查 <-ctx.Done() 并执行清理逻辑。
连接池主动驱逐
调用 http.Server.Shutdown() 前,对自定义连接池(如 database/sql)执行 Close() 或 PingContext(ctx) 验证后释放资源。
日志与指标最终刷盘
在 Shutdown 回调中显式调用日志库的 Sync() 和监控 SDK 的 Flush(),确保最后一条日志和指标不丢失。
K8s terminationGracePeriodSeconds 对齐
确保 Deployment 中 terminationGracePeriodSeconds ≥ 应用最长 Shutdown 耗时(建议 ≥ 60s),为七层防御留出充足窗口。
| 防御层级 | 关键动作 | 失效后果 |
|---|---|---|
| 信号捕获 | 阻塞监听并触发下线流程 | 进程立即 kill,请求中断 |
| 就绪探针 | 主动返回 503 | 流量继续涌入,请求丢失 |
| 上下文超时 | 限制单请求生命周期 | goroutine 泄漏,连接堆积 |
第二章:信号捕获与生命周期感知机制
2.1 操作系统信号语义解析与Go runtime信号处理模型
操作系统信号是内核向进程传递异步事件的机制,如 SIGINT(中断)、SIGQUIT(退出)和 SIGUSR1(用户自定义)。POSIX 定义了信号的默认行为、可忽略性与阻塞性,但不可靠信号(如 SIGCHLD)可能丢失,而实时信号(SIGRTMIN+0)则支持排队。
Go runtime 对信号采取接管+重定向策略:
- 屏蔽所有 M 线程的
SIGURG,SIGWINCH等非关键信号 - 将
SIGQUIT,SIGTRAP等转发至runtime.sighandler - 仅允许
SIGPIPE保持默认行为(避免写断开管道时 panic)
Go 中显式注册信号处理器示例
package main
import (
"os"
"os/signal"
"syscall"
"log"
)
func main() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGUSR1, syscall.SIGUSR2) // 注册用户信号
go func() {
sig := <-sigs
log.Printf("Received %v", sig) // 非阻塞接收,由 runtime 信号轮询器投递
}()
select {} // 阻塞主 goroutine
}
逻辑分析:
signal.Notify并未调用sigaction(2)直接注册内核 handler,而是将信号加入 runtime 内部的sigtab表,并由sigtramp汇编桩函数捕获后,经sighandler转发至用户 channel。syscall.SIGUSR1参数值为 10(Linux x86_64),其语义完全由 Go runtime 解释,与 C 的signal()行为隔离。
关键信号语义对照表
| 信号 | 默认动作 | Go runtime 处理方式 | 是否可被 signal.Notify 捕获 |
|---|---|---|---|
SIGINT |
终止 | 转发至 os.Interrupt channel |
✅ |
SIGQUIT |
core dump | 启动 pprof 与 goroutine dump | ❌(runtime 专用) |
SIGUSR1 |
忽略 | 可注册,无默认行为 | ✅ |
信号分发流程(简化)
graph TD
A[Kernel 发送 SIGUSR1] --> B[Go runtime sigtramp 入口]
B --> C{是否在 sigtab 中注册?}
C -->|是| D[投递到用户 channel]
C -->|否| E[执行默认动作或忽略]
2.2 基于os.Signal的优雅中断监听实践与常见陷阱规避
Go 程序需响应 SIGINT(Ctrl+C)或 SIGTERM(如 Kubernetes 终止信号)实现平滑退出,os.Signal 是核心机制。
信号通道初始化
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
make(chan os.Signal, 1)创建带缓冲通道,避免首信号丢失;signal.Notify将指定信号转发至该通道;未显式注册的信号将触发默认终止行为。
常见陷阱对比
| 陷阱类型 | 后果 | 规避方式 |
|---|---|---|
| 无缓冲通道 | 首次信号可能被丢弃 | 使用 make(chan os.Signal, 1) |
| 忘记恢复默认行为 | 子进程继承信号忽略状态 | signal.Reset() 清理前调用 |
退出流程协调
graph TD
A[收到 SIGTERM] --> B[关闭 HTTP 服务]
B --> C[等待活跃请求完成]
C --> D[释放数据库连接]
D --> E[退出主 goroutine]
2.3 Context.WithCancel与信号触发的生命周期绑定实战
场景驱动:HTTP请求超时与中断协同
当长轮询服务需响应系统级中断(如 SIGTERM)时,WithCancel 是天然的生命周期锚点。
数据同步机制
使用 signal.Notify 将 OS 信号转为 Go 事件,并联动 cancel 函数:
ctx, cancel := context.WithCancel(context.Background())
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-sigCh
cancel() // 触发所有派生 ctx 的 Done() 关闭
}()
逻辑分析:
cancel()调用后,所有基于该 ctx 派生的ctx.Done()通道立即关闭;http.NewRequestWithContext(ctx)等 API 将自动中止 I/O。参数ctx是根上下文,cancel是唯一可控终止入口。
生命周期状态对照表
| 状态 | ctx.Err() 值 | Done() 是否关闭 |
|---|---|---|
| 初始 | nil | 否 |
| cancel() 调用 | context.Canceled | 是 |
| 超时触发 | context.DeadlineExceeded | 是 |
流程示意
graph TD
A[启动服务] --> B[注册 SIGTERM 监听]
B --> C[创建 WithCancel ctx]
C --> D[启动 HTTP Server]
D --> E{收到 SIGTERM?}
E -->|是| F[调用 cancel()]
F --> G[ctx.Done() 关闭 → 所有子操作退出]
2.4 多信号协同处理策略:SIGTERM优先级控制与SIGINT调试支持
在容器化与微服务场景中,进程需同时响应优雅终止(SIGTERM)与交互式中断(SIGINT),但二者语义与处置目标截然不同。
信号语义与调度优先级
SIGTERM:请求协作式关闭,应触发资源释放、连接 draining、状态持久化SIGINT:用户主动中断,常用于开发调试,允许快速退出而不等待清理完成
信号处理注册示例
#include <signal.h>
#include <stdio.h>
volatile sig_atomic_t shutdown_requested = 0;
volatile sig_atomic_t debug_interrupt = 0;
void handle_term(int sig) {
shutdown_requested = 1; // 标记主循环退出,启动完整清理流程
}
void handle_int(int sig) {
debug_interrupt = 1; // 触发轻量级调试钩子,不阻塞主线程
}
// 注册:SIGTERM 高优先级抢占,SIGINT 低干扰辅助
signal(SIGTERM, handle_term);
signal(SIGINT, handle_int);
逻辑分析:
sig_atomic_t保证跨信号安全读写;handle_term置位后主循环检测并执行close_db(),wait_graceful_shutdown()等耗时操作;handle_int仅记录快照或打印堆栈,避免竞争。
协同调度策略对比
| 信号 | 默认行为 | 清理耗时 | 调试支持 | 推荐使用场景 |
|---|---|---|---|---|
| SIGTERM | 终止 | ✅ 长 | ❌ 无 | Kubernetes pod 删除 |
| SIGINT | 中断 | ⚠️ 短 | ✅ 有 | Ctrl+C 本地调试 |
graph TD
A[收到信号] --> B{信号类型?}
B -->|SIGTERM| C[启动全量清理流程]
B -->|SIGINT| D[执行调试快照+非阻塞日志]
C --> E[等待连接draining完成]
D --> F[立即返回控制台]
2.5 容器环境信号透传验证:Docker/K8s中信号可达性测试方案
容器中进程信号(如 SIGTERM、SIGINT)能否准确送达应用主进程,直接影响优雅停机与健康治理能力。常见陷阱包括:PID 1 进程未正确转发信号、init 系统缺失、或 Kubernetes preStop 与实际信号接收存在时序偏差。
测试工具链设计
- 使用
busybox:stable启动带sleep infinity的调试容器 - 注入
trap捕获信号并输出日志 - 结合
docker kill -s与kubectl delete pod --grace-period对比行为
验证脚本示例
# 启动监听容器(PID 1 为 sleep)
docker run -d --name sigtest alpine:latest sh -c '
trap "echo \"[$(date)] SIGUSR1 received\" > /tmp/sig.log" USR1;
sleep infinity
'
# 发送信号并验证日志
docker kill -s USR1 sigtest && docker exec sigtest cat /tmp/sig.log
逻辑说明:
trap在 shell 中注册信号处理器;USR1避免与默认终止信号冲突;sleep infinity作为 PID 1 进程,不自动转发信号——需验证是否被 shell 正确捕获。若日志为空,表明信号未透传。
K8s 场景关键参数对照表
| 参数 | Docker CLI | Kubernetes Pod Spec | 影响维度 |
|---|---|---|---|
| 优雅终止期 | --grace-period=30 |
terminationGracePeriodSeconds: 30 |
决定 SIGTERM 发送后等待时长 |
| PID 1 行为 | --init(启用 tini) |
initContainers 或 securityContext.procMount: Default |
影响信号转发能力 |
graph TD
A[发送 SIGTERM] --> B{PID 1 进程类型}
B -->|sh/bash/sleep| C[需显式 trap 或 init]
B -->|tini/dumb-init| D[自动转发至子进程]
C --> E[信号丢失风险高]
D --> F[信号可达性保障]
第三章:HTTP服务优雅关闭核心路径
3.1 http.Server.Shutdown()底层原理与超时竞态分析
Shutdown() 的核心是优雅终止:先关闭监听套接字,再等待活跃连接完成处理。
数据同步机制
使用 sync.WaitGroup 跟踪活跃连接,并通过 atomic 标记服务器状态:
// server.go 简化逻辑
func (srv *Server) Shutdown(ctx context.Context) error {
srv.mu.Lock()
defer srv.mu.Unlock()
if srv.closeOnce.Do(func() { srv.closeDone = make(chan struct{}) }) {
close(srv.listenerClose) // 触发 accept loop 退出
}
// 启动超时等待
go func() {
srv.waitDone.Wait() // 等待所有 Conn.Close()
close(srv.closeDone)
}()
select {
case <-ctx.Done(): return ctx.Err()
case <-srv.closeDone: return nil
}
}
srv.waitDone.Wait() 阻塞直至所有 *conn 调用 srv.doneChan() 完成;srv.listenerClose 通知 accept 循环停止接收新连接。
关键竞态点
| 场景 | 风险 | 缓解机制 |
|---|---|---|
新连接在 listener.Close() 后、accept 退出前抵达 |
accept 返回 ErrClosed,但连接已建立 |
net.Listener 实现需原子关闭(如 tcpKeepAliveListener.Close()) |
Conn.Serve() 正执行 handler 时触发 shutdown |
可能阻塞超时 | http.Request.Context() 继承 Shutdown 上下文,支持主动中断 |
graph TD
A[Shutdown called] --> B[close listener]
B --> C[stop accept loop]
C --> D[wait for active Conns]
D --> E{timeout?}
E -->|yes| F[force close Conn]
E -->|no| G[all done]
3.2 连接 draining 机制实现:ActiveConn追踪与连接粒度等待
Draining 的核心在于按连接生命周期精准控制退出时机,而非粗粒度的进程级终止。
ActiveConn 状态管理
每个活跃连接被封装为 ActiveConn 对象,携带:
id(唯一标识)lastActivityAt(毫秒时间戳)drainStartedAt(可为空,标记 drain 开始时间)closeCallback(连接关闭后回调)
连接粒度等待策略
func (ac *ActiveConn) ShouldCloseNow() bool {
if ac.drainStartedAt == 0 {
return false // 未进入 draining 状态
}
return time.Since(ac.drainStartedAt) > 30*time.Second // 可配置超时
}
逻辑分析:该方法判断单个连接是否满足关闭条件。drainStartedAt 由负载均衡器或服务发现组件在收到 SIGTERM 后统一注入;30s 是默认 graceful shutdown 窗口,确保长尾请求完成。
状态迁移流程
graph TD
A[New Connection] -->|accept| B[Active]
B -->|SIGTERM received| C[Draining]
C -->|ShouldCloseNow==true| D[Closing]
C -->|activity detected| B
Drain 控制参数对比
| 参数 | 默认值 | 说明 |
|---|---|---|
drain_timeout |
30s | 连接最大等待时间 |
min_idle_time |
500ms | 最短空闲后才允许关闭 |
ignore_health_check |
true | drain 期间跳过健康探针干扰 |
3.3 TLS握手未完成连接的特殊处理与客户端兼容性保障
当客户端在TLS握手中途断连(如ClientHello后中断),服务端需避免资源泄漏并维持旧版客户端兼容性。
连接状态机兜底策略
// 为半开TLS连接设置超时与清理钩子
srv := &http.Server{
IdleTimeout: 15 * time.Second,
ConnState: func(c net.Conn, state http.ConnState) {
if state == http.StateNew || state == http.StateHandshaking {
// 启动握手超时计时器(非TLS层,由应用层管控)
startHandshakeTimer(c)
}
},
}
该配置确保未完成握手的连接在15秒内被强制关闭,防止SYN/TLS洪泛攻击;ConnState回调在Go HTTP服务器中精准捕获握手阶段状态,规避TLS库内部状态不可见问题。
兼容性关键参数对照
| 客户端类型 | 支持最低TLS版本 | 是否重试未完成握手 | 推荐服务端行为 |
|---|---|---|---|
| iOS 12 Safari | TLS 1.2 | 否 | 立即关闭,不重传 |
| Java 7u80 JVM | TLS 1.0 | 是(带退避) | 延迟5s后释放资源 |
握手异常处理流程
graph TD
A[收到ClientHello] --> B{证书/ALPN协商成功?}
B -- 否 --> C[记录warn日志,启动15s倒计时]
B -- 是 --> D[继续密钥交换]
C --> E[超时触发conn.Close()]
E --> F[释放fd与内存对象]
第四章:goroutine协同退出的七层防御模型
4.1 第一层防御:全局Context传播与goroutine启动守卫模式
在高并发微服务中,未受控的 goroutine 启动是上下文泄漏与资源失控的主因。核心原则是:所有 goroutine 必须显式继承或派生 context,且禁止裸调 go f()。
守卫式启动封装
func GoWithCtx(parent context.Context, f func(context.Context)) {
ctx, cancel := context.WithCancel(parent)
go func() {
defer cancel() // 确保退出时清理
f(ctx)
}()
}
parent 提供取消链路与超时继承;cancel() 在 goroutine 结束时自动触发下游清理,避免 context 泄漏。
常见违规模式对比
| 场景 | 危险写法 | 安全替代 |
|---|---|---|
| HTTP handler 中启协程 | go handleAsync(req) |
GoWithCtx(r.Context(), func(ctx) { handleAsync(ctx) }) |
| 定时任务 | go ticker.C |
GoWithCtx(ctx, func(c) { tickerLoop(c, ticker) }) |
Context 传播路径示意
graph TD
A[HTTP Request] --> B[Handler Context]
B --> C[GoWithCtx]
C --> D[Worker Goroutine]
D --> E[DB Query / RPC Call]
E --> F[自动继承 deadline & cancel]
4.2 第二层防御:长连接协程的主动注销与资源清理钩子
长连接协程在超时、异常断连或服务平滑下线时,若未及时释放,将导致句柄泄漏与内存堆积。为此需注入可插拔的清理钩子。
注册资源清理钩子
func RegisterCleanupHook(conn *websocket.Conn, userID string) {
// 在协程启动时绑定清理逻辑
defer func() {
if r := recover(); r != nil {
log.Printf("panic cleanup for %s: %v", userID, r)
}
}()
// 协程退出前统一触发
defer cleanupResources(userID, conn)
}
cleanupResources 接收 userID(用于定位会话上下文)与 conn(原始连接句柄),确保网络层与业务层资源同步释放。
清理流程关键阶段
- 关闭 WebSocket 连接(
conn.Close()) - 从用户在线状态表中移除条目
- 取消关联的定时任务(如心跳续期 Timer)
- 释放独占型资源(如临时缓存、锁)
| 阶段 | 调用时机 | 是否可重入 |
|---|---|---|
| 连接关闭 | defer conn.Close() |
否 |
| 状态注销 | Redis DEL 操作 | 是 |
| 缓存清理 | LRU cache evict | 是 |
graph TD
A[协程退出] --> B{是否已注册钩子?}
B -->|是| C[执行 cleanupResources]
B -->|否| D[仅关闭 conn]
C --> E[释放 Redis 状态]
C --> F[清除本地缓存]
C --> G[取消心跳 Timer]
4.3 第三层防御:定时任务协程的Graceful Stop抽象封装
核心设计原则
- 协程启动时注册
context.WithCancel,绑定生命周期; - 停止信号通过
sync.Once保证幂等性; - 任务执行中定期检测
ctx.Done()并主动退出。
Graceful Stop 接口定义
type ScheduledTask interface {
Start(ctx context.Context) error
Stop() error
}
该接口将启动与停止解耦,
Stop()不阻塞调用方,内部触发cancel()并等待任务自然收敛。
状态流转示意
graph TD
A[Running] -->|收到Stop| B[Stopping]
B --> C[Draining: 完成当前周期]
C --> D[Stopped]
关键实现片段
func (t *TickerTask) Stop() error {
t.once.Do(func() {
if t.cancel != nil {
t.cancel() // 触发 ctx.Done()
}
t.wg.Wait() // 等待所有 tick 处理完成
})
return nil
}
t.cancel()通知所有活跃 tick 循环退出;t.wg.Wait()确保最后一批任务(如数据库写入)提交完毕,避免数据截断。
4.4 第四层防御:Worker Pool模式下的任务队列阻塞等待与优雅终止
在高负载场景下,无界队列易引发OOM,而固定容量队列需配合阻塞等待与信号驱动的优雅终止机制。
阻塞等待策略
使用 LinkedBlockingQueue 的 poll(timeout, unit) 实现可控等待:
Task task = queue.poll(3, TimeUnit.SECONDS); // 超时返回null,避免永久阻塞
if (task == null) {
log.warn("No task available within timeout, checking shutdown signal...");
if (shutdownRequested.get()) break; // 响应中断信号
}
逻辑分析:poll() 避免线程无限挂起;3秒超时平衡响应性与资源利用率;shutdownRequested 为 AtomicBoolean,确保多线程可见性。
优雅终止流程
graph TD
A[收到 shutdown() 调用] --> B[设置 shutdownRequested = true]
B --> C[Worker 检测信号并完成当前任务]
C --> D[拒绝新任务入队]
D --> E[所有 Worker 自然退出循环]
关键参数对照表
| 参数 | 推荐值 | 说明 |
|---|---|---|
queueCapacity |
1024 | 防止内存溢出,需结合平均任务大小评估 |
idleTimeout |
5s | 空闲Worker等待新任务的最长时间 |
gracePeriod |
30s | 强制终止前的最大等待窗口 |
第五章:K8s就绪探针联动方案与全链路验证
场景背景与问题定位
某金融级微服务集群在灰度发布期间频繁出现流量 503 错误。经排查,发现 payment-service Pod 虽已通过 liveness 探针判定为健康,但其内部 gRPC 端口尚未完成服务注册(依赖 etcd 注册中心),导致 ingress controller 将请求转发至未就绪实例。根本症结在于就绪探针(readinessProbe)未与业务真实就绪状态对齐。
探针策略重构设计
采用分层就绪校验模型:底层检查端口连通性,中层验证依赖组件健康度,顶层确认业务服务注册状态。关键配置如下:
readinessProbe:
exec:
command:
- /bin/sh
- -c
- |
# 检查本地 HTTP 端口
nc -z localhost 8080 || exit 1
# 验证 Redis 连通性
redis-cli -h redis-primary -p 6379 ping >/dev/null || exit 1
# 校验服务是否在 etcd 中注册(使用 etcdctl v3)
ETCDCTL_API=3 etcdctl --endpoints=http://etcd:2379 get "/services/payment/v1" --prefix | grep -q "endpoint" || exit 1
initialDelaySeconds: 15
periodSeconds: 5
timeoutSeconds: 3
全链路验证拓扑
构建包含四层组件的验证闭环:
- 应用层:payment-service(Go + Gin)
- 注册中心:etcd v3.5.10(TLS 双向认证)
- 网关层:nginx-ingress-controller v1.9.6
- 流量注入:k6 客户端持续发送 200 QPS 的
/api/v1/transfer请求
自动化验证流程
| 通过 GitOps 工作流触发验证:当 Helm Chart 版本变更后,Argo CD 同步部署并自动执行验证 Job。该 Job 包含三阶段断言: | 阶段 | 检查项 | 期望结果 | 超时 |
|---|---|---|---|---|
| 启动期 | kubectl wait --for=condition=ready pod -l app=payment |
成功 | 120s | |
| 就绪期 | curl -s http://payment-svc:8080/healthz | jq -r '.ready' |
"true" |
30s | |
| 流量期 | kubectl logs k6-runner | grep "http_req_failed.*0" |
无失败记录 | 60s |
关键指标对比(灰度发布窗口期)
| 指标 | 旧方案(仅端口探测) | 新方案(三重校验) |
|---|---|---|
| 503 错误率 | 12.7% | 0.03% |
| 首次就绪平均耗时 | 8.2s | 14.6s(含依赖校验) |
| 实例被剔除延迟 | 32s(ingress 缓存) | 5.1s(实时 watch endpoints) |
Mermaid 验证流程图
flowchart TD
A[Deployment 更新] --> B[Pod 创建]
B --> C{readinessProbe 执行}
C -->|失败| D[Pod 不加入 Endpoints]
C -->|成功| E[Endpoints Controller 更新]
E --> F[Ingress Controller Watch 到变更]
F --> G[更新 upstream 配置]
G --> H[k6 发起请求]
H --> I{HTTP 200 响应?}
I -->|是| J[记录成功率]
I -->|否| K[捕获 503 并告警]
生产环境灰度数据
在华东1可用区的 3 个节点集群中,对 v2.3.1 版本进行 15 分钟灰度发布:共创建 12 个新 Pod,其中 11 个在 14±2.3 秒内通过就绪探测并接收流量;剩余 1 个因 etcd 网络抖动超时,在第 3 次探测后才就绪,期间零流量打入。Prometheus 监控显示 kube_pod_status_phase{phase="Running"} 与 kube_endpoint_address_available{endpoint="payment-svc"} 曲线完全同步,时间差
故障注入复盘
模拟 Redis 主节点宕机场景:手动删除 redis-primary Pod 后,新启动的 Pod 在 18 秒内完成选举,但 payment-service 的 readinessProbe 因第二步校验失败持续返回非零码,导致其始终不进入 Endpoints。运维人员通过 kubectl describe pod 查看 Events,快速定位到 Readiness probe failed: ... redis-cli ping failed 事件,避免了故障扩散。
配置管理最佳实践
将探针脚本抽取为 ConfigMap,通过 volumeMount 挂载至容器 /probe/ready.sh,实现探针逻辑与镜像解耦。Helm values.yaml 中定义:
probeConfig:
dependencies:
- name: redis
cmd: "redis-cli -h {{ .Release.Name }}-redis -p 6379 ping"
- name: etcd
cmd: "ETCDCTL_API=3 etcdctl --endpoints=http://etcd:2379 get /services/payment/v1" 