第一章:Go采集容器化部署陷阱总览
将Go编写的采集服务(如HTTP爬虫、指标抓取器或日志收集器)容器化部署时,看似轻量便捷,实则暗藏多个易被忽视的运行时陷阱。这些陷阱往往在压测阶段或生产环境流量突增时集中爆发,导致采集中断、数据丢失或容器反复重启。
资源限制与Go运行时冲突
Docker默认不限制内存/CPU,但生产环境常配置--memory=512m --cpus=1。Go的GC触发阈值依赖堆大小,若容器内存上限过低(如runtime: out of memory panic。建议启动前显式设置:
# Dockerfile 片段
ENV GOMEMLIMIT=384MiB # Go 1.19+ 推荐替代 GOGC
ENV GOMAXPROCS=1 # 避免 CPU 限制下线程争抢
该配置使Go运行时主动将堆目标控制在内存限制的75%以内,并防止调度器在单核受限环境中过度抢占。
容器网络生命周期错配
采集服务常依赖长连接复用(如http.Transport.MaxIdleConnsPerHost),但Kubernetes中Pod IP可能因滚动更新或节点故障瞬时失效。若Go客户端未配置连接健康检查,旧连接会持续超时失败。需启用连接探测:
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second, // 启用TCP keepalive
}).DialContext,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
信号处理缺失导致优雅退出失败
容器收到SIGTERM后仅等待默认30秒即强制SIGKILL。若Go进程未监听os.Interrupt或syscall.SIGTERM,正在处理的采集任务将被粗暴中断。必须注册退出钩子:
func main() {
stopCh := make(chan os.Signal, 1)
signal.Notify(stopCh, syscall.SIGTERM, os.Interrupt)
go func() {
<-stopCh
log.Println("received SIGTERM, shutting down...")
server.Shutdown(context.WithTimeout(context.Background(), 10*time.Second))
os.Exit(0)
}()
// ... 启动采集服务
}
| 常见陷阱类型 | 典型现象 | 排查线索 |
|---|---|---|
| 内存限制误配 | OOMKilled事件,/sys/fs/cgroup/memory/memory.usage_in_bytes骤升 | docker stats + kubectl describe pod |
| DNS解析超时 | dial tcp: lookup xxx: no such host |
检查/etc/resolv.conf及CoreDNS日志 |
| 时区与时间戳偏差 | 采集时间戳全为UTC而非本地时区 | 确认镜像是否含tzdata并挂载/etc/localtime |
第二章:Docker网络模式选择与Go采集实践
2.1 Docker四种网络模式原理与适用场景分析
Docker 提供四种核心网络驱动:bridge、host、none 和 container,各自隔离粒度与连通性差异显著。
bridge 模式(默认)
容器通过虚拟网桥 docker0 通信,宿主机 NAT 转发外部流量:
docker run -d --network bridge --name web nginx
# --network bridge 可省略;自动分配 172.17.0.0/16 子网IP,端口需 -p 显式映射
逻辑:容器拥有独立 network namespace,经 veth-pair 连入 docker0,iptables 实现 SNAT/DNAT。适用于开发测试及多容器隔离部署。
host 模式
docker run -d --network host --name api nginx
# 容器直接复用宿主机网络栈,无网络隔离,端口无需映射但不可共用
适用于性能敏感服务(如监控代理),但牺牲安全性与端口冲突防护。
| 模式 | 网络隔离 | IP 分配 | 外部访问 | 典型场景 |
|---|---|---|---|---|
| bridge | ✅ | 自动 | -p 映射 | 微服务、Web 应用 |
| host | ❌ | 宿主IP | 直接暴露 | 性能关键型代理 |
| none | ✅ | 无IP | 不可达 | 安全沙箱、离线计算 |
| container | ✅(共享) | 共享NS | 同宿主 | 调试辅助容器 |
网络模式选择决策流
graph TD
A[需求:是否需外部访问?] -->|是| B{性能是否极致敏感?}
A -->|否| C[none 或 container]
B -->|是| D[host]
B -->|否| E[bridge]
2.2 Go HTTP客户端在bridge模式下的连接超时与DNS解析问题复现
在 Docker bridge 网络中,Go 的 http.Client 默认行为易受宿主机 DNS 配置与容器网络栈隔离双重影响。
复现环境配置
- 容器运行于
docker network create --driver bridge mynet /etc/resolv.conf中 DNS 为172.17.0.1(Docker daemon 内置 DNS)- Go 程序未显式配置
DialContext或Resolver
关键问题链
- DNS 解析走
net.DefaultResolver→ 调用getaddrinfo→ 受容器resolv.conf与 glibc(或 Go net/lookup)缓存策略影响 - TCP 连接阶段因网关 NAT 规则缺失,导致
DialTimeout触发前无有效响应
典型超时代码片段
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 3 * time.Second, // 实际连接等待上限
KeepAlive: 30 * time.Second,
}).DialContext,
},
}
Timeout=5s 是整个请求生命周期上限,但 DialContext.Timeout=3s 控制底层 TCP 握手——若 DNS 解析耗时 >2s(如上游 DNS 延迟或重试),则总超时必然提前触发。
| 阶段 | 默认行为 | bridge 模式风险点 |
|---|---|---|
| DNS 解析 | 使用容器 /etc/resolv.conf |
宿主机 DNS 不可达,无 fallback |
| TCP 连接 | 直连目标 IP(解析后) | bridge 网络需经 docker0 iptables,可能丢包 |
graph TD
A[HTTP Do] --> B[DNS Lookup]
B --> C{Success?}
C -->|No| D[Context Deadline Exceeded]
C -->|Yes| E[TCP Dial via Dialer.Timeout]
E --> F{Connect OK?}
F -->|No| D
2.3 host模式下Go采集服务端口冲突与权限绕过风险实测
在 host 网络模式下,容器直接共享宿主机网络命名空间,导致端口绑定无隔离。
端口冲突复现
# 启动首个采集服务(占用8080)
go run collector.go --port=8080 &
# 再次启动(成功!但实际覆盖前一进程监听)
go run collector.go --port=8080 &
⚠️ Go 的
net.Listen("tcp", ":8080")在 Linux 上默认启用SO_REUSEADDR,内核允许后启进程接管端口,造成前序服务静默中断。
权限绕过路径
- 容器以非 root 用户运行,但
host模式下可调用netstat -tuln读取所有进程监听端口 - 通过
/proc/net/tcp直接解析,无需CAP_NET_ADMIN
风险对比表
| 风险类型 | host 模式 | bridge 模式 |
|---|---|---|
| 端口抢占 | ✅ 可发生 | ❌ 隔离 |
| 本地端口扫描 | ✅ 无权限限制 | ❌ 需额外 CAP |
graph TD
A[容器进程] -->|host网络| B[宿主机socket子系统]
B --> C[bind 8080]
C --> D[覆盖/冲突原有监听]
2.4 overlay模式中gRPC采集服务跨节点通信的TLS证书分发实践
在overlay网络中,gRPC采集服务需在不同主机节点间建立双向mTLS连接。证书分发必须兼顾安全性与自动化,避免硬编码或手动拷贝。
证书生命周期管理策略
- 使用Cert-Manager + Vault动态签发短期证书(72小时有效期)
- 每个采集Pod通过ServiceAccount绑定
cert-manager.io/issuer注解 - 根CA证书由Kubernetes Secret挂载至
/etc/tls/ca.pem,客户端证书与密钥由volumeMounts注入
gRPC客户端TLS配置示例
# grpc-client-config.yaml
tls:
root_cert_file: "/etc/tls/ca.pem"
cert_file: "/etc/tls/tls.crt"
key_file: "/etc/tls/tls.key"
server_name: "collector.default.svc.cluster.local" # SNI匹配SAN
server_name必须与服务端证书中的DNS SAN完全一致,否则gRPC握手失败;root_cert_file用于验证服务端身份,cert_file+key_file用于客户端双向认证。
证书自动轮转流程
graph TD
A[Pod启动] --> B{检查/tls/tls.crt过期时间}
B -- <24h --> C[调用cert-manager API申请新证书]
B -- ≥24h --> D[继续使用当前证书]
C --> E[更新Secret并触发Pod滚动重启]
| 字段 | 用途 | 是否必需 |
|---|---|---|
tls.crt |
客户端证书 | 是 |
tls.key |
客户端私钥 | 是 |
ca.pem |
根CA公钥 | 是(启用mTLS时) |
2.5 macvlan模式下Go采集器绑定静态IP与ARP缓存失效应对方案
在macvlan网络中,Go采集器容器需直连物理网段,但默认DHCP易导致IP漂移,故需绑定静态IP并规避ARP缓存陈旧问题。
静态IP绑定配置
# 在容器启动时执行(host network namespace)
ip addr add 192.168.10.42/24 dev macvlan0
ip link set macvlan0 up
ip route add default via 192.168.10.1
192.168.10.42为采集器固定业务IP;macvlan0为macvlan子接口;via 192.168.10.1确保上行路由可达。该操作绕过Docker网络栈,实现L2直通。
ARP缓存刷新策略
| 触发场景 | 措施 | 生效范围 |
|---|---|---|
| 容器重启后 | arping -U -c 3 192.168.10.42 |
主机+网关ARP表 |
| 网关变更时 | ip neigh flush dev macvlan0 |
本地邻居缓存 |
主动宣告流程
graph TD
A[Go采集器启动] --> B[配置静态IP]
B --> C[发送免费ARP]
C --> D[刷新网关ARP缓存]
D --> E[启动HTTP/metrics服务]
第三章:/dev/random阻塞对Go采集性能的影响
3.1 Linux熵池机制与crypto/rand在Go采集初始化阶段的阻塞链路剖析
Linux内核通过/dev/random和/dev/urandom暴露熵池状态,其中前者在熵不足时阻塞读取,后者则非阻塞但可能返回低熵数据。
熵池依赖路径
- Go
crypto/rand.Read()默认调用syscall.Syscall(SYS_getrandom, ...)(Linux 3.17+) - 回退至
/dev/urandom(仅当getrandom(2)不可用或GRND_BLOCK未置位) - 若显式使用
rand.Reader = &devRandomReader{},则直连/dev/random
阻塞触发条件
// Go 1.22+ runtime/cgo/race.go 中隐式调用示例(简化)
func init() {
var buf [32]byte
_, err := rand.Read(buf[:]) // 可能阻塞:若系统熵池 < 128 bits 且使用 /dev/random
if err != nil {
panic(err) // 如 "read /dev/random: resource temporarily unavailable"
}
}
此调用在容器冷启动或嵌入式环境常因
/proc/sys/kernel/random/entropy_availgetrandom(2) 的GRND_BLOCK标志决定是否等待熵就绪。
| 来源 | 阻塞行为 | 熵依赖 | 典型场景 |
|---|---|---|---|
getrandom(GRND_BLOCK) |
是 | 强 | crypto/rand 初始化 |
/dev/random |
是 | 强 | 手动打开设备文件 |
/dev/urandom |
否 | 弱 | 大多数应用默认路径 |
graph TD
A[Go crypto/rand.Read] --> B{getrandom syscall available?}
B -->|Yes| C[getrandom(buf, GRND_BLOCK)]
B -->|No| D[/dev/random open/read]
C --> E[阻塞直至 entropy_avail ≥ 128 bits]
D --> E
3.2 使用getrandom()系统调用替代read(/dev/random)的Go内核适配实践
Linux 3.17+ 引入 getrandom(2),避免 /dev/random 的阻塞风险与上下文切换开销。Go 1.22+ 默认启用该路径,但需内核支持与 syscall 层适配。
内核能力探测逻辑
func supportsGetrandom() bool {
// 尝试无阻塞调用 getrandom(0, 0, GRND_NONBLOCK)
_, err := unix.Getrandom(nil, unix.GRND_NONBLOCK)
return err == nil || errors.Is(err, unix.EAGAIN)
}
该检测绕过 /dev/random 打开/读取流程,直接试探系统调用可用性;GRND_NONBLOCK 确保不因熵池不足而挂起。
Go 运行时调用路径对比
| 方式 | 系统调用次数 | 是否阻塞 | 内核态上下文切换 |
|---|---|---|---|
read(/dev/random) |
≥2(open+read) | 是(可能) | 频繁 |
getrandom() |
1 | 否(加标志) | 极少 |
随机数生成流程
graph TD
A[Go crypto/rand.Read] --> B{内核支持 getrandom?}
B -->|是| C[syscall.getrandom]
B -->|否| D[open/read /dev/random]
C --> E[返回加密安全随机字节]
3.3 采集Agent启动阶段熵饥饿导致goroutine卡死的火焰图定位与缓解策略
火焰图关键特征识别
当/dev/random阻塞时,crypto/rand.Read调用栈在火焰图中呈现高而窄的“尖峰”,顶部常为syscall.Syscall → read → runtime.entersyscall,伴随大量 goroutine 停留在 chan receive 或 select 状态。
根因验证代码
// 检测当前熵池水位(需 root 权限)
func checkEntropy() (int, error) {
data, err := os.ReadFile("/proc/sys/kernel/random/entropy_avail")
if err != nil {
return 0, err
}
avail, _ := strconv.Atoi(strings.TrimSpace(string(data)))
return avail, nil
}
该函数读取内核熵值;低于 160 时,crypto/rand 极易阻塞。注意:非 root 环境需降级使用 /dev/urandom。
缓解策略对比
| 方案 | 启动延迟 | 安全性 | 适用场景 |
|---|---|---|---|
预热 rand.Reader + io.CopyN |
低 | ★★★★☆ | 容器化 Agent |
替换为 crypto/rand.Read 的非阻塞封装 |
中 | ★★★★ | 通用部署 |
内核参数 sysctl -w kernel.random.read_wakeup_threshold=64 |
零 | ★★★☆ | 物理机集群 |
启动流程优化
graph TD
A[Agent Init] --> B{熵可用?}
B -->|<160| C[启动轻量熵收集协程]
B -->|≥160| D[直接初始化加密组件]
C --> E[周期性读取 /dev/urandom 并注入 rand.Reader]
E --> D
第四章:cgroup内存限制引发的OOM Kill深度排查
4.1 Go runtime内存模型与cgroup v1/v2内存子系统交互机制详解
Go runtime通过runtime.memstats与内核内存控制器协同工作,其核心在于mheap.sys与cgroup memory limit的动态对齐。
内存上限同步机制
当进程运行在cgroup v2中时,Go runtime周期性读取/sys/fs/cgroup/memory.max(v2)或/sys/fs/cgroup/memory.limit_in_bytes(v1),并触发mheap.grow()限流:
// src/runtime/mem_linux.go
func cgroupMemoryLimit() uint64 {
if v2 := readCgroup2File("/sys/fs/cgroup/memory.max"); v2 != math.MaxUint64 {
return v2 // v2 使用 "max","max" 为 "infinity" 时返回 MaxUint64
}
return readCgroup1File("/sys/fs/cgroup/memory.limit_in_bytes")
}
此函数返回硬限制值(单位字节),若为
max或-1则视为无限制;Go 1.19+ 默认启用v2优先探测逻辑。
关键差异对比
| 特性 | cgroup v1 | cgroup v2 |
|---|---|---|
| 配置路径 | /memory.limit_in_bytes |
/memory.max |
| 无限值表示 | -1 |
max |
| 统计接口 | memory.usage_in_bytes |
memory.current |
内存回收触发流程
graph TD
A[Go allocates heap] --> B{mheap.freeSpan < threshold?}
B -->|Yes| C[Trigger GC or mmap throttle]
C --> D[Read cgroup.memory.max]
D --> E[Adjust scavenger target]
4.2 GOMEMLIMIT与cgroup memory.max协同调控的实测对比(含pprof heap profile验证)
在容器化 Go 应用中,GOMEMLIMIT 与 cgroup v2 的 memory.max 存在语义重叠但行为差异显著:
GOMEMLIMIT是 Go 运行时内存上限(含堆+栈+runtime metadata),触发 GC 压力而非 OOM killmemory.max是内核级硬限,超限直接触发 cgroup OOM killer
实测环境配置
# 启动带双限制的容器(Go 1.22+)
docker run -it \
--memory=512M \
--kernel-memory=0 \
--ulimit memlock=-1:-1 \
-e GOMEMLIMIT=400MiB \
golang:1.22-alpine \
sh -c 'go run main.go & sleep 30 && go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap'
此命令同时施加
GOMEMLIMIT=400MiB(运行时软限)与memory.max=512M(内核硬限)。main.go启动 HTTP server 并持续分配切片,暴露/debug/pprof/heap。
关键观测指标对比
| 维度 | GOMEMLIMIT 单独启用 | memory.max 单独启用 | 双启用(协同) |
|---|---|---|---|
| GC 频率 | 显著升高(~3× baseline) | 无影响 | 与 GOMEMLIMIT 一致 |
| OOM kill 触发 | ❌ 不触发 | ✅ 超 512M 立即 kill | ✅ 仅当突破 512M 时触发 |
heap profile 中 inuse_space 峰值 |
≤400MiB | 可达 512M(GC 滞后) | 稳定 ≤400MiB,无突刺 |
内存压力传导路径
graph TD
A[应用分配内存] --> B{GOMEMLIMIT 是否接近?}
B -->|是| C[Go runtime 提前触发 GC]
B -->|否| D[继续分配]
C --> E[降低 inuse_space]
D --> F{RSS 是否 ≥ memory.max?}
F -->|是| G[cgroup OOM Killer 终止进程]
F -->|否| D
4.3 采集进程因RSS突增被OOM Killer终止的dmesg日志解析与go tool trace反向追踪
dmesg中典型的OOM事件片段
[123456.789012] Out of memory: Kill process 12345 (data-collector) score 842 or sacrifice child
[123456.789015] Killed process 12345 (data-collector) total-vm:2456780kB, anon-rss:1892340kB, file-rss:12340kB
anon-rss:1892340kB 表明匿名页内存高达 ~1.8GB,远超容器内存限制(如2GB),触发内核OOM Killer;score 842 是内核基于RSS、oom_score_adj等计算出的“最该杀”优先级。
go tool trace反向定位内存峰值点
go tool trace -http=:8080 trace.out
在浏览器打开后,进入 “Goroutine analysis” → “Flame graph”,聚焦 runtime.mallocgc 高频调用栈,可定位到 encoding/json.Unmarshal 在处理未限长日志行时持续分配大块 []byte。
关键内存增长路径(mermaid)
graph TD
A[HTTP handler] --> B[ReadLine without length limit]
B --> C[Allocate growing []byte per line]
C --> D[Unmarshal into struct with large embedded slices]
D --> E[No GC pressure relief → RSS spikes]
| 指标 | 正常值 | OOM前瞬时值 |
|---|---|---|
sys:mem:heap_alloc |
120MB | 1.7GB |
goroutines |
42 | 189 |
gc:pause_ns |
>200ms |
4.4 基于memcg eventfd监听的Go采集器主动降载与优雅退出机制实现
核心设计思想
利用 cgroup v1 memory.events 文件的 eventfd 通知能力,使 Go 采集器在内存压力突增时零轮询感知 OOM 风险,触发分级响应。
关键实现步骤
- 打开
/sys/fs/cgroup/memory/<path>/memory.events并epoll_ctl注册其关联的 eventfd - 启动 goroutine 阻塞等待
epoll_wait,避免 busy-loop - 解析
eventfd返回的uint64值,映射为low,high,oom,oom_kill事件类型
事件响应策略
| 事件类型 | 行为 | 持续时间 |
|---|---|---|
low |
降低采样频率(如 1s → 5s) | 动态可调 |
high |
暂停非核心指标上报 | ≤30s |
oom_kill |
触发 sync.Once 优雅关闭 |
立即执行 |
// 初始化 eventfd 监听(简化版)
efd, _ := unix.Eventfd(0, unix.EFD_CLOEXEC)
unix.Write(efd, []byte{0,0,0,0,0,0,0,1}) // arm once
// ... epoll 注册逻辑省略
Eventfd(0, EFD_CLOEXEC)创建不可继承的内核通知通道;写入1表示首次启用监听。unix.Read()返回的uint64值按位解析事件掩码,需结合/proc/self/cgroup动态定位当前 memcg 路径。
退出流程
graph TD
A[收到 oom_kill 事件] --> B[Stop metric collectors]
B --> C[Flush pending buffers]
C --> D[Close eventfd & epoll]
D --> E[os.Exit(0)]
第五章:Go采集容器化部署避坑指南总结
容器镜像体积失控的典型表现与修复路径
某电商实时日志采集服务使用 go build 默认编译出的二进制文件(14.2MB)直接打包进 golang:1.22-alpine 基础镜像,最终镜像达 187MB。经 dive 分析发现:/usr/local/go 和 CGO_ENABLED=1 引入的动态链接库冗余占比超63%。解决方案为启用静态编译并精简基础层:
FROM gcr.io/distroless/static-debian12
WORKDIR /app
COPY --from=builder /workspace/collector /app/collector
ENTRYPOINT ["/app/collector"]
构建后镜像压缩至 12.8MB,启动耗时从 3.2s 降至 0.4s。
Kubernetes资源限制引发的静默失败
在某金融风控采集集群中,Pod 设置 requests.memory: 64Mi 但实际运行时因 JSON 解析峰值内存达 92Mi,触发 OOMKilled。通过 kubectl top pods --containers 与 kubectl describe pod 交叉验证确认问题。修正配置如下表所示:
| 场景 | CPU Request | Memory Request | CPU Limit | Memory Limit |
|---|---|---|---|---|
| 高频日志解析(QPS>5k) | 200m | 256Mi | 500m | 512Mi |
| 低频指标上报(QPS | 50m | 128Mi | 200m | 256Mi |
环境变量注入导致的配置覆盖陷阱
某采集服务依赖 CONFIG_PATH=/etc/config.yaml 加载配置,但 Helm Chart 中误将 env: 字段写为:
env:
- name: CONFIG_PATH
value: "/tmp/config.yaml" # 覆盖了Dockerfile中ENV设置!
导致容器启动后读取空配置文件,连接 Kafka 失败。根本解法是统一采用 configMapKeyRef 注入,并在 Go 代码中添加配置校验逻辑:
if _, err := os.Stat(os.Getenv("CONFIG_PATH")); os.IsNotExist(err) {
log.Fatal("missing config file, aborting")
}
持久化路径未声明为Volume的灾难性后果
一个 Prometheus Exporter 在 /data/metrics.db 存储采集缓存,但 Deployment 未定义 volumeMounts。当 Pod 重启后,SQLite 数据库被清空,造成指标断点。修复方案需同步修改三处:
- 添加
emptyDirVolume 定义; - 在容器
volumeMounts中挂载/data; - 修改 Go 初始化逻辑,支持数据库自动重建与 schema 迁移。
日志输出格式与 stdout/stderr 混淆问题
某采集服务使用 log.Printf() 输出结构化 JSON,但因未重定向 os.Stderr,部分错误日志混入 stdout 流,导致 Loki 日志分级失效。强制标准化方案:
log.SetOutput(os.Stdout) // 所有日志走 stdout
log.SetFlags(0)
log.Println(`{"level":"info","msg":"started","pid":`+strconv.Itoa(os.Getpid())+`}`)
健康检查路径设计缺陷导致滚动更新中断
Liveness Probe 设置为 GET /healthz,但该端点内部调用 http://localhost:9090/metrics(同一 Pod 内部服务),当 metrics 接口因 GC 暂停卡顿 >5s,Probe 失败触发反复重启。改为轻量级检查:
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok")) // 不依赖任何外部依赖
})
多阶段构建中 go.mod 缓存失效的性能瓶颈
CI 流程中每次构建都重新 go mod download,导致平均构建时间增加 42s。通过复用 go cache 和 mod cache 实现加速:
COPY go.mod go.sum ./
RUN go mod download # 提前拉取依赖
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' -o collector .
时区配置缺失引发的时间戳错乱
某跨时区日志采集服务在 UTC 容器中解析 Asia/Shanghai 时间戳,导致所有 time.Now().Format("2006-01-02") 结果比本地晚 8 小时。解决方案是在基础镜像中嵌入时区数据:
FROM gcr.io/distroless/static-debian12
COPY --from=debian:bookworm-slim /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai
ENV TZ=Asia/Shanghai 