feat(k8s): 一键部署脚本 + 跨云适配 + 云相关清单拆分到 examples

新增 kubernetes/install.sh(一条命令部署):
- 云平台识别:读节点 providerID,识别 TKE / EKS / ACK / CCE / GKE / AKS / K3s / 自建
- StorageClass 三级探测:显式 STORAGE_CLASS → is-default-class 注解 → 集群唯一 SC
- 自建集群兜底:无任何 SC 时自动创建静态 local PV
  (hostPath + DirectoryOrCreate + nodeAffinity + WaitForFirstConsumer + Retain)
- Secret 幂等生成:已存在则跳过,绝不覆盖(避免密钥轮换导致历史凭证无法解密)
- 失败分类诊断:ImagePullBackOff / CreateContainerConfigError / PVC Pending
  按云给出对应仓库格式与推荐 SC 名
- 环境变量:WITH_NODEPORT / STORAGE_CLASS / LOCAL_STORAGE / NODE_NAME / PV_PATH / PV_SIZE

云相关清单拆到 kubernetes/examples/:
- ingress.yaml(阿里云 alb)、ingress-tke.yaml(腾讯云 qcloud)、secret.example.yaml
- 原因:留在根目录会导致 kubectl apply -f kubernetes/ 时 ingress 同名互相覆盖,
  且 secret.example.yaml 的占位符会把真实密钥顶掉

README 重写:
- 全部用法速查 + 环境变量一览
- 自建 K8s 存储方案选型(local PV / local-path-provisioner / OpenEBS / Longhorn / NFS)
- hostPath 数据运维:定位、热备份、跨节点迁移、卸载保留、常见坑
This commit is contained in:
opsx committed 2026-10-26 14:03:39 +00:00
1 parent 9f7c522157
commit 9a46a955d4
5 files changed
+704 -26

No files matched your search

+338 -26
View File
@@ -1,41 +1,332 @@
# opsx-loki 部署到 Kubernetes
## 文件
命名空间固定:**`log`**(日志相关资源统一放这里)
| 文件 | 说明 |
|---|---|
| `namespace.yaml` | `log` 命名空间(已有可跳过) |
| `secret.example.yaml` | JWT/ENCRYPTION 密钥模板(建议用 `kubectl create secret` 命令创建,不要提交真实值) |
| `pvc.yaml` | 平台数据卷(30Gi,默认 StorageClass) |
| `deployment.yaml` | 平台本体,**单副本 + Recreate** |
| `service.yaml` | ClusterIP `opsx-loki:8080` |
| `ingress.yaml` | 可选,按云厂商的 ingressClass 调整 |
---
## 部署步骤
## 一条命令部署(推荐)
```bash
# 1) 密钥(只生成一次,之后复用;丢失会导致已保存的凭证无法解密)
bash kubernetes/install.sh
```
脚本会依次完成:
1. 创建 `log` 命名空间
2. **生成随机密钥** `JWT_SECRET` / `ENCRYPTION_KEY` 写入 Secret `opsxloki-secrets`
(已存在则跳过,**不会覆盖**)
3. 创建 PVC `opsxloki-data`(30 GiB,使用集群默认 StorageClass)
4. 部署 `opsx-loki`(单副本 + Recreate)
5. 创建 Service `opsx-loki:8080`(ClusterIP)
6. 等待 Pod 就绪并输出访问方式
**需要 NodePort(配 ingress 时)**:
```bash
WITH_NODEPORT=1 bash kubernetes/install.sh
```
**幂等性**:重复执行只做增量更新,不会覆盖密钥、不会重建数据卷。
---
## 全部用法速查
| 场景 | 命令 |
|------|------|
| **标准部署**(自动探测云平台 + StorageClass)| `bash kubernetes/install.sh` |
| 同时开 NodePort(配 ingress 用)| `WITH_NODEPORT=1 bash kubernetes/install.sh` |
| 指定 StorageClass | `STORAGE_CLASS=cbs bash kubernetes/install.sh` |
| 指定 SC(自建用 local-path)| `STORAGE_CLASS=local-path bash kubernetes/install.sh` |
| **自建集群强制用节点本地盘**(hostPath)| `LOCAL_STORAGE=force bash kubernetes/install.sh` |
| 只诊断不动手(无变更)| `LOCAL_STORAGE=off bash kubernetes/install.sh` |
| **指定本地存储的节点/路径/容量** | `NODE_NAME=node1 PV_PATH=/data/opsxloki PV_SIZE=50Gi bash kubernetes/install.sh` |
| 组合:自建 + 指定节点 + NodePort | `LOCAL_STORAGE=force NODE_NAME=node1 WITH_NODEPORT=1 bash kubernetes/install.sh` |
**环境变量一览**:
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `WITH_NODEPORT` | `0` | `1` = 额外创建 NodePort Service(30080)|
| `STORAGE_CLASS` | 空(自动探测)| 显式指定 StorageClass 名称 |
| `LOCAL_STORAGE` | `auto` | `auto` 无 SC 时自动建本地 PV / `off` 只诊断 / `force` 强制本地盘 |
| `NODE_NAME` | 空(第一个 Ready 节点)| hostPath PV 绑定的节点 |
| `PV_PATH` | `/var/lib/opsxloki-data` | hostPath 在宿主机上的路径 |
| `PV_SIZE` | `30Gi` | PV 容量声明 |
---
## 环境要求
| 项 | 要求 |
|----|------|
| Kubernetes | 1.20+ |
| StorageClass | **脚本自动探测**(default 注解 → 唯一 SC → 手动 `STORAGE_CLASS=`);自建集群无 SC 时会提前报错并给出解决方案,不会白等 |
| 镜像 | 节点可拉 `lghost/opsxloki:latest`(或改成自己的 Harbor/ACR/TCR/SWR) |
| 权限 | 有 `log` 命名空间的 create/apply 权限 |
## 脚本自动处理什么
`install.sh` 不是无脑 apply,它会先探测环境再动手:
| 场景 | 脚本行为 |
|------|---------|
| **云平台识别** | 读节点 `providerID`,识别出 腾讯云 TKE / AWS EKS / 阿里云 ACK / 华为云 CCE / GCP GKE / Azure AKS / K3s / 自建 |
| **StorageClass** | ① 显式 `STORAGE_CLASS=xxx` → ② `is-default-class` 注解 → ③ 集群唯一 SC → ④ **都没有则自动创建节点本地存储**(静态 local PV,零外部依赖) |
| **Secret** | 不存在则用 `openssl rand -hex 32` 生成;**已存在则跳过,绝不覆盖** |
| **PVC** | 把探测到的 SC 显式写进渲染后的 pvc.yaml(避免依赖隐式默认行为) |
| **就绪等待** | `rollout status` 最多 180s,超时后按**失败原因分类诊断** |
| **镜像拉不到** | 诊断为 `ImagePullBackOff` 时,按云给出对应仓库格式(TCR / ACR / SWR / Harbor) |
| **PVC 未绑定** | 诊断为 StorageClass 问题时,按云给出推荐 SC 名(cbs / gp3 / alicloud-disk-essd / csi-disk) |
**脚本不做什么**(需要人工决策的部分):
- ❌ 不自动改镜像仓库(不知道你的仓库地址,只给格式提示)
- ❌ 不覆盖已存在的 Secret / 已有数据卷
---
## 自建 K8s(kubeadm 裸装)的存储怎么办
自建集群最常见的坑就是**没有 StorageClass** → PVC 永久 Pending。脚本内置三种处理方式:
### 默认行为(`LOCAL_STORAGE=auto`):自动创建节点本地存储
检测到集群完全没有 SC 时,脚本自动创建一个**静态 local PV**:
```yaml
StorageClass: opsxloki-local
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer # 先定 Pod 再绑 PV
reclaimPolicy: Retain # 误删 PVC 不丢数据
PersistentVolume: opsxloki-data-pv
hostPath: { path: /var/lib/opsxloki-data, type: DirectoryOrCreate }
nodeAffinity: 锁定一台节点
```
**特点**:
| 优点 | 代价 |
|------|------|
| **零外部依赖**(不拉任何镜像,国内网络友好)| 数据固定在单节点,Pod 不会漂移到别处 |
| kubelet 自动建目录(`DirectoryOrCreate`)| 只能供给一个 PVC(单实例场景够用)|
| Pod 一定调度到数据所在节点(`nodeAffinity`)| 节点损毁需从备份恢复(见「持久化」的 CSI 快照)|
**实测数据**:PVC 从 Pending 到 Bound **5 秒**,Pod 正常读写宿主机目录。
### 想要动态供给(支持多 PVC / 多实例)
装 Rancher **local-path-provisioner**(自建集群事实标准):
```bash
kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/master/deploy/local-path-storage.yaml
STORAGE_CLASS=local-path bash kubernetes/install.sh
```
### 想先看着不动手(`LOCAL_STORAGE=off`)
```bash
LOCAL_STORAGE=off bash kubernetes/install.sh
```
只打印诊断和 4 种解法,不做任何变更。
### 自建 K8s 存储方案选型
| 方案 | 外部依赖 | 适用 | 备注 |
|------|---------|------|------|
| **静态 local PV**(本脚本默认)| **零** | 单副本、数据固定 | 最省事,国内网络友好 |
| **local-path-provisioner** | 2 个镜像 | 需要动态供给/多 PVC | 自建集群首选,社区标准 |
| **OpenEBS LocalPV** | Helm + 较多镜像 | 要 LVM/ZFS 精细控制 | 较重 |
| **Longhorn** | Helm,每节点装 | 需要副本/高可用 | 每节点约 1C2G 开销 |
| **NFS + nfs-subdir-provisioner** | 需现成 NFS 服务器 | 多节点共享 | 依赖外部 NFS |
> 对 opsx-loki 这类**单副本 + JSON DB 单写者**的应用,**local PV 完全够用**,
> 不需要为了"高可用"上 Longhorn —— 副本存储反而会和"单写者"约束冲突。
---
## hostPath 数据的运维(自建集群必读)
自建集群用 hostPath 时,**数据在宿主机目录里**,不在云盘上。下面这些操作要会。
### 数据在哪
```bash
# 1) 找到 PV 绑定的节点和路径
kubectl get pv opsxloki-data-pv -o custom-columns=\
'NAME:.metadata.name,PATH:.spec.hostPath.path,NODE:.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]'
# 2) 去那台节点上看
NODE=<上面的 NODE 值>
ssh root@${NODE} 'ls -la /var/lib/opsxloki-data/'
# 应看到:opsxloki.json kubeconfigs/ known_hosts
```
### 备份(不用停机)
```bash
NODE=<节点>
TS=$(date +%Y%m%d-%H%M%S)
ssh root@${NODE} "tar czf /tmp/opsxloki-backup-${TS}.tar.gz -C /var/lib opsxloki-data"
scp root@${NODE}:/tmp/opsxloki-backup-${TS}.tar.gz ./
```
> `opsxloki.json` 写入是原子的(临时文件 + fsync + rename),**热备份安全**,不用停 Pod。
### 迁移到另一台节点
```bash
OLD=<旧节点> NEW=<新节点>
# 1) 停止写入
kubectl -n log scale deploy opsx-loki --replicas=0
# 2) 从旧节点拉取数据
ssh root@${OLD} 'tar czf - -C /var/lib opsxloki-data' > /tmp/opsxloki-data.tar.gz
# 3) 推到新节点并解包
scp /tmp/opsxloki-data.tar.gz root@${NEW}:/tmp/
ssh root@${NEW} 'mkdir -p /var/lib && tar xzf /tmp/opsxloki-data.tar.gz -C /var/lib && ls -la /var/lib/opsxloki-data'
# 4) 改 PV 的 nodeAffinity 指向新节点
kubectl patch pv opsxloki-data-pv --type=json -p \
"[{\"op\":\"replace\",\"path\":\"/spec/nodeAffinity/required/nodeSelectorTerms/0/matchExpressions/0/values\",\"value\":[\"${NEW}\"]}]"
# 5) 恢复
kubectl -n log scale deploy opsx-loki --replicas=1
kubectl -n log rollout status deploy/opsx-loki
```
### 卸载但保留数据
```bash
# 删工作负载和服务(数据卷保留)
kubectl -n log delete deploy opsx-loki svc opsx-loki
# PVC 保留(Retain 策略,删了也不会丢 PV 里的数据)
# 想彻底清掉的话:
# kubectl delete pvc opsxloki-data
# kubectl delete pv opsxloki-data-pv
# 然后去节点上 rm -rf /var/lib/opsxloki-data
# 如果要连脏数据一起重置重装:
kubectl -n log delete pvc opsxloki-data
kubectl delete pv opsxloki-data-pv
kubectl delete sc opsxloki-local
ssh root@${NODE} 'rm -rf /var/lib/opsxloki-data'
# 然后重新 bash install.sh
```
### 常见坑
| 现象 | 原因 | 处理 |
|------|------|------|
| Pod 一直 Pending | PV 的 nodeAffinity 指向的节点不可用 | `kubectl get nodes` 确认该节点 Ready,或按上面「迁移」流程改节点 |
| Pod Pending 且报 `node(s) didn't match node affinity` | Pod 被调度到其他节点 | 正常,scheduler 会重试;若持续,检查 PV 的 nodeAffinity 是否写错节点名 |
| 换节点后数据"消失" | 数据在新节点上不存在 | 走「迁移到另一台节点」流程,别只改 nodeAffinity |
| 磁盘写满 | hostPath 用节点本地盘 | `df -h /var/lib`,迁移到大盘或改 `PV_PATH` |
---
## 目录结构
```
kubernetes/
├── install.sh # 一键部署脚本(推荐入口)
├── namespace.yaml # log 命名空间
├── pvc.yaml # 数据卷 30Gi(storageClassName 留空 → 用默认 SC)
├── deployment.yaml # 平台本体(单副本 + Recreate,必须)
├── service.yaml # ClusterIP opsx-loki:8080
├── service-nodeport.yaml # NodePort 30080(配 ingress 时用)
└── examples/ # 云相关配置,不参与默认部署
├── ingress.yaml # 阿里云 ACK(alb class)
├── ingress-tke.yaml # 腾讯云 TKE(qcloud class)
└── secret.example.yaml # Secret 模板(仅供手工创建参考)
```
> ⚠️ `examples/` 里的文件**故意不放在 `kubernetes/` 根目录**:
> 它们是云厂商相关的(ingress class 各云不同),混在默认目录里会让
> `kubectl apply -f kubernetes/` 在不同云上互相覆盖或报错。
---
## 手动部署(等价于 install.sh,供需要精细控制时用)
```bash
# 1) 命名空间
kubectl apply -f kubernetes/namespace.yaml
# 2) 密钥(⚠️ 必须先做,否则 Pod 起不来)
# 密钥只生成一次,丢失会导致已保存的凭证无法解密
kubectl -n log create secret generic opsxloki-secrets \
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
--from-literal=ENCRYPTION_KEY="$(openssl rand -hex 32)"
# 2) 数据卷 + 平台
kubectl apply -f kubernetes/namespace.yaml
# 3) 数据卷 + 平台 + 服务
kubectl apply -f kubernetes/pvc.yaml
# 修改 deployment.yaml 里的 image 为实际仓库地址后:
kubectl apply -f kubernetes/deployment.yaml
kubectl apply -f kubernetes/service.yaml
# 3) 可选:Ingress(不需要就跳过,继续走外部 WAF 回源)
kubectl apply -f kubernetes/ingress.yaml
# 4) 等待就绪
kubectl -n log rollout status deployment/opsx-loki --timeout=180s
```
> ❗**不要直接 `kubectl apply -f kubernetes/`**:Secret 不在目录里(密钥不能进 Git),
> 缺 Secret 时 Pod 会一直 `CreateContainerConfigError`。
---
## 访问
**临时(本地端口转发)**:
```bash
kubectl -n log port-forward svc/opsx-loki 8080:8080
# 浏览器打开 http://localhost:8080
```
**初始账号**:`admin` / `admin123` ← **首次登录请立即改密**
---
## 配 Ingress(可选)
以腾讯云 TKE 为例(其他云见 `examples/`):
```bash
# 1) 先开 NodePort(TKE 的 qcloud ingress 后端要求 NodePort 类型 Service)
WITH_NODEPORT=1 bash kubernetes/install.sh
# 2) 改域名后应用
vi kubernetes/examples/ingress-tke.yaml # 改 host
kubectl apply -f kubernetes/examples/ingress-tke.yaml
# 3) 查 CLB 分配的公网 IP,把域名解析过去
kubectl -n log get ingress opsx-loki
```
| 云 | ingressClassName | 备注 |
|----|------------------|------|
| 腾讯云 TKE | `qcloud` | 后端 Service 必须 NodePort |
| 阿里云 ACK | `alb` | 用 `alb.ingress.kubernetes.io/*` 注解 |
| AWS EKS | `alb` 或 `nginx` | 需装 AWS Load Balancer Controller |
| 华为云 CCE | `nginx` 或 `cce` | — |
| 自建 | `nginx` | 需装 ingress-nginx |
---
## 持久化(重要)
- `/app/data` 挂 PVC,保存 `opsxloki.json`、`kubeconfigs/`、`known_hosts`。
- **必须 `replicas: 1` + `strategy: Recreate`**:JSON DB 是单写者;滚动更新时两个 Pod 同时挂同一个 RWO PVC 会互相覆盖。
- **必须 `replicas: 1` + `strategy: Recreate`**:JSON DB 是单写者;滚动更新时两个 Pod
同时挂同一个 RWO PVC 会互相覆盖。
- DB 写入本身是原子的(临时文件 + fsync + rename),容器被杀不会写坏文件。
- 备份建议用 CSI VolumeSnapshot(AWS EBS / 腾讯 CBS / 华为 EVS / 阿里云 ESSD 都支持同一套 `snapshot.storage.k8s.io` API):
### 备份:CSI VolumeSnapshot
各云都支持同一套 `snapshot.storage.k8s.io` API(AWS EBS / 腾讯 CBS / 华为 EVS / 阿里云 ESSD):
```yaml
apiVersion: snapshot.storage.k8s.io/v1
@@ -49,31 +340,52 @@ spec:
persistentVolumeClaimName: opsxloki-data
```
---
## 多云兼容
| 项 | 做法 |
|---|---|
| 存储类 | `pvc.yaml` 不写 `storageClassName`,用集群默认 SC;没有默认 SC 时填对应云的 SC 名(AWS `gp3` / 腾讯 `cbs` / 华为 `csi-disk` / 阿里云 `alicloud-disk-essd`) |
| 镜像仓库 | `deployment.yaml` 的 `image` 填目标云可拉的仓库(Harbor / ACR / TCR / SWR) |
| Loki 存储 | 平台已支持 S3 / OSS / COS / OBS,均为 S3 兼容,无需云厂商 SDK |
| 存储类 | `pvc.yaml` **不写** `storageClassName`,用集群默认 SC;没有默认 SC 时填对应云的名字(AWS `gp3` / 腾讯 `cbs` / 华为 `csi-disk` / 阿里云 `alicloud-disk-essd`)|
| 镜像仓库 | `deployment.yaml` 的 `image` 填目标云可拉的仓库(Harbor / ACR / TCR / SWR)|
| Loki 存储 | 平台已支持 S3 / OSS / COS / OBS,均 S3 兼容,无需云厂商 SDK |
| 查询链路 | 平台与 Loki 同集群时,数据源选「集群服务」(namespace `log`、service `loki`、port `3100`),无需 Ingress/WAF |
| Namespace | 固定 `log`,跨云一致 |
---
## 与外部实例的关系
平台的 JSON DB 单写者,**不要两个实例共用同一份数据卷**。要么:
- 集群内新实例 + 迁移旧 `opsxloki.json`(停旧实例后拷贝);或
- 保留外部 229 实例做带外管理,集群内实例使用独立数据卷(两套数据)。
- 保留外部实例做带外管理,集群内实例使用独立数据卷(两套数据)。
---
## 日志
- 平台**只写 stdout**(Go/GIN 默认),不写日志文件;`kubectl logs` / `docker logs` 可直接看,Pod 部署无需额外配置。
- Docker Compose:已配置 `json-file` 轮转 `max-size=50m`、`max-file=5`。
- Kubernetes:由 kubelet 统一轮转,节点级配置建议:
- 平台**只写 stdout**(Go/GIN 默认),不写日志文件;`kubectl logs` 可直接看。
- Kubernetes 由 kubelet 统一轮转,节点级配置建议:
```yaml
# /var/lib/kubelet/config.yaml
containerLogMaxSize: 50Mi
containerLogMaxFiles: 5
```
也可以用 kubelet 启动参数 `--container-log-max-size=50Mi --container-log-max-files=5`。
- Loki / Promtail 同样输出到 stdout,由容器运行时轮转;不要在容器内写业务日志文件。
- 应用自身的部署日志写在 DB(`deployment_logs`),可在部署详情里查看,不占容器日志空间。
---
## 常见问题
| 现象 | 原因 | 解决 |
|------|------|------|
| Pod `CreateContainerConfigError` | 缺 Secret `opsxloki-secrets` | 跑 `install.sh`,或手动 `kubectl create secret` |
| Pod `Pending`,PVC `Pending` | 集群没有默认 StorageClass | `pvc.yaml` 里填 `storageClassName` |
| Pod `ImagePullBackOff` | 节点拉不到 `lghost/opsxloki:latest` | 换镜像地址(ACR/TCR/SWR/Harbor) |
| `kubectl apply -f kubernetes/` 报 ingress 错误 | 误把 examples 里的 ingress 一起 apply 了 | 只 apply 根目录的 yaml,ingress 单独处理 |
| 重启后数据丢失 | `/app/data` 没挂 PVC | 确认 `deployment.yaml` 的 volumeMounts |
| 两个 Pod 同时写 DB | 用了 RollingUpdate 或多副本 | 必须 `replicas: 1` + `strategy: Recreate` |
+33
View File
@@ -0,0 +1,33 @@
# 腾讯云 TKE 专用 Ingress 模板
#
# 与官方 ingress.yaml 的区别:
# 官方用 alb(阿里云)→ TKE 不支持
# TKE 用 ingressClassName: qcloud,且后端 Service 必须是 NodePort 类型
#
# 使用前:
# 1) 先创建 NodePort Service:kubectl apply -f kubernetes/service-nodeport.yaml
# 2) 把下面 host 改成你要用的域名
# 3) kubectl apply -f kubernetes/ingress-tke.yaml
# 4) 在腾讯云控制台/DNS 把该域名解析到 CLB 的公网 IP
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: opsx-loki
namespace: log
annotations:
# CLB 健康检查(可选,TKE 默认会用 TCP 检查)
cloud.tencent.com/health-check-path: /api/v1/health
cloud.tencent.com/health-check-interval: "10"
spec:
ingressClassName: qcloud
rules:
- host: loki.astx.club # ← 改成你的域名
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: opsx-loki-nodeport # ← 必须是 NodePort 类型 Service
port:
number: 8080
File renamed without changes.
File renamed without changes.
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env bash
# ============================================================================
# opsx-loki 一键部署
#
# 自动适配:阿里云 ACK / 腾讯云 TKE / AWS EKS / 华为云 CCE / GCP GKE / 自建 K8s
# 命名空间固定:log
# 幂等:重复执行不覆盖密钥、不重建数据卷
#
# 用法:
# bash kubernetes/install.sh # 自动探测
# WITH_NODEPORT=1 bash kubernetes/install.sh # 同时开 NodePort(配 ingress)
# STORAGE_CLASS=local-path bash kubernetes/install.sh # 手动指定 StorageClass
# ============================================================================
set -euo pipefail
NS="log"
WITH_NODEPORT="${WITH_NODEPORT:-0}"
STORAGE_CLASS="${STORAGE_CLASS:-}"
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
info() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m✅\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m⚠️ \033[0m %s\n' "$*"; }
die() { printf '\033[1;31m❌\033[0m %s\n' "$*" >&2; exit 1; }
# ============================================================================
# 0) 前置检查
# ============================================================================
command -v kubectl >/dev/null 2>&1 || die "未找到 kubectl,请先安装"
kubectl version --request-timeout=5s >/dev/null 2>&1 || die "kubectl 无法连接集群,请检查 kubeconfig"
# ============================================================================
# 0.1) 云平台识别(信息展示 + 后续诊断用)
# ============================================================================
detect_cloud() {
local pid=""
pid=$(kubectl get nodes -o jsonpath='{.items[0].spec.providerID}' 2>/dev/null || true)
case "${pid}" in
qcloud://*) echo "腾讯云 TKE" ;;
aws://*) echo "AWS EKS" ;;
aliyun://*) echo "阿里云 ACK" ;;
huaweicloud*|huawei*) echo "华为云 CCE" ;;
gce://*) echo "GCP GKE" ;;
azure://*) echo "Azure AKS" ;;
k3s://*) echo "K3s(自建)" ;;
*) echo "自建 / 其他 K8s" ;;
esac
}
# ============================================================================
# 0.2) StorageClass 探测
# 优先级:显式 STORAGE_CLASS > default 注解 > 唯一 SC > 失败
# 注意:结果写入全局变量(RESOLVED_SC / SC_ERROR),不要用 $(...) 调用,
# 否则子 shell 里的赋值无法回传
# ============================================================================
RESOLVED_SC=""
SC_ERROR=""
resolve_storage_class() {
local sc="" all="" count=0
RESOLVED_SC=""
SC_ERROR=""
# 1) 显式指定
if [ -n "${STORAGE_CLASS}" ]; then
if ! kubectl get sc "${STORAGE_CLASS}" >/dev/null 2>&1; then
SC_ERROR="指定的 StorageClass '${STORAGE_CLASS}' 不存在。现有:$(kubectl get sc -o jsonpath='{range .items[*]}{.metadata.name}{" "}{end}' 2>/dev/null)"
return 1
fi
RESOLVED_SC="${STORAGE_CLASS}"; return 0
fi
# 2) 带 default 注解的 SC
sc=$(kubectl get sc -o jsonpath='{range .items[?(@.metadata.annotations.storageclass\.kubernetes\.io/is-default-class=="true")]}{.metadata.name}{"\n"}{end}' 2>/dev/null | head -1 || true)
if [ -n "${sc}" ]; then RESOLVED_SC="${sc}"; return 0; fi
# 3) 集群里只有一个 SC → 直接用它
all=$(kubectl get sc -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null || true)
count=$(printf '%s' "${all}" | grep -c . || true)
if [ "${count}" = "1" ]; then RESOLVED_SC="${all}"; return 0; fi
# 4) 无法确定
return 1
}
# ============================================================================
# 0.3) 自建集群兜底:无任何 StorageClass 时,创建「静态 local PV」
#
# 设计取舍:
# - 零外部依赖:不拉 local-path-provisioner 镜像(国内网络常常拉不到)
# - hostPath + DirectoryOrCreate:kubelet 自动建目录,无需预先 mkdir
# - nodeAffinity 锁节点:Pod 永远调度到数据所在节点
# - WaitForFirstConsumer:先定 Pod 再绑 PV,避免跨节点错绑
# - reclaimPolicy: Retain:误删 PVC 不丢数据
#
# 代价:数据固定在一台节点,节点损毁需从备份恢复(README 有 CSI 备份说明)
# ============================================================================
LOCAL_SC_NAME="opsxloki-local"
LOCAL_PV_NAME="opsxloki-data-pv"
LOCAL_PV_SIZE="${PV_SIZE:-30Gi}"
LOCAL_PV_PATH="${PV_PATH:-/var/lib/opsxloki-data}"
pick_local_node() {
if [ -n "${NODE_NAME:-}" ]; then echo "${NODE_NAME}"; return 0; fi
kubectl get nodes --no-headers \
-o custom-columns=NAME:.metadata.name,READY:.status.conditions[-1].status 2>/dev/null \
| awk '$2=="True"{print $1; exit}'
}
create_local_storage() {
local node
node="$(pick_local_node)"
[ -n "${node}" ] || { warn "找不到 Ready 节点,无法创建本地存储"; return 1; }
info "创建节点本地存储:节点=${node} 路径=${LOCAL_PV_PATH} 容量=${LOCAL_PV_SIZE}"
cat <<EOF | kubectl apply -f - >/dev/null
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ${LOCAL_SC_NAME}
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Retain
EOF
cat <<EOF | kubectl apply -f - >/dev/null
apiVersion: v1
kind: PersistentVolume
metadata:
name: ${LOCAL_PV_NAME}
labels:
app: opsx-loki
spec:
capacity:
storage: ${LOCAL_PV_SIZE}
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ${LOCAL_SC_NAME}
hostPath:
path: ${LOCAL_PV_PATH}
type: DirectoryOrCreate
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- ${node}
EOF
ok "已创建 StorageClass ${LOCAL_SC_NAME} + PV ${LOCAL_PV_NAME}(节点 ${node})"
warn "注意:数据固定在该节点 ${LOCAL_PV_PATH},Pod 不会漂移到其他节点"
RESOLVED_SC="${LOCAL_SC_NAME}"
return 0
}
CLOUD="$(detect_cloud)"
info "opsx-loki 部署开始(namespace: ${NS})"
info "集群: $(kubectl config current-context 2>/dev/null || echo unknown)"
info "平台: ${CLOUD}"
# LOCAL_STORAGE 三态:auto(默认,无 SC 时自动建)/ off(只提示)/ force(强制建,测试用)
LOCAL_STORAGE="${LOCAL_STORAGE:-auto}"
if [ "${LOCAL_STORAGE}" = "force" ]; then
warn "LOCAL_STORAGE=force:强制使用节点本地存储"
create_local_storage || die "创建节点本地存储失败"
elif ! resolve_storage_class; then
if [ -n "${SC_ERROR}" ]; then
die "${SC_ERROR}"
fi
echo ""
if [ "${LOCAL_STORAGE}" = "auto" ]; then
warn "集群没有可用 StorageClass(自建集群常见)→ 自动启用节点本地存储"
create_local_storage || die "创建节点本地存储失败"
else
warn "未找到可用的 StorageClass,且 LOCAL_STORAGE=off"
echo ""
echo " 现有 StorageClass:"
kubectl get sc 2>/dev/null | sed 's/^/ /' || echo " (无)"
echo ""
echo " 解决办法(选一个):"
echo " 1) 手动指定(推荐,若已有可用 SC):"
echo " STORAGE_CLASS=<sc名称> bash kubernetes/install.sh"
echo ""
echo " 2) 标记某个 SC 为默认:"
echo " kubectl patch sc <sc名称> -p '{\"metadata\":{\"annotations\":{\"storageclass.kubernetes.io/is-default-class\":\"true\"}}}'"
echo ""
echo " 3) 自建集群安装 local-path-provisioner(动态供给,支持多 PVC):"
echo " kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/master/deploy/local-path-storage.yaml"
echo " STORAGE_CLASS=local-path bash kubernetes/install.sh"
echo ""
echo " 4) 让脚本用节点本地存储(零依赖):"
echo " 去掉 LOCAL_STORAGE=off 重跑即可"
echo ""
die "StorageClass 未就绪,部署中止(避免 PVC 永久 Pending)"
fi
else
ok "StorageClass: ${RESOLVED_SC}"
fi
# ============================================================================
# 1) 命名空间
# ============================================================================
kubectl apply -f "${DIR}/namespace.yaml"
# ============================================================================
# 2) Secret(幂等:已存在则跳过,绝不覆盖)
# ============================================================================
rand_hex() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 32
elif [ -c /dev/urandom ]; then
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'
else
die "无法生成随机密钥(缺 openssl 且无 /dev/urandom)"
fi
}
if kubectl -n "${NS}" get secret opsxloki-secrets >/dev/null 2>&1; then
info "Secret opsxloki-secrets 已存在,跳过(不覆盖现有密钥)"
else
info "生成随机密钥 JWT_SECRET / ENCRYPTION_KEY"
kubectl -n "${NS}" create secret generic opsxloki-secrets \
--from-literal=JWT_SECRET="$(rand_hex)" \
--from-literal=ENCRYPTION_KEY="$(rand_hex)" >/dev/null
ok "Secret 已创建"
fi
# ============================================================================
# 3) 数据卷(显式写入探测到的 StorageClass)
# ============================================================================
RENDER_DIR="$(mktemp -d)"
trap 'rm -rf "${RENDER_DIR}"' EXIT
awk -v sc="${RESOLVED_SC}" '
/^spec:/ && !done { print; print " storageClassName: " sc; done=1; next }
{ print }
' "${DIR}/pvc.yaml" > "${RENDER_DIR}/pvc.yaml"
kubectl apply -f "${RENDER_DIR}/pvc.yaml"
# ============================================================================
# 4) 工作负载 + 服务
# ============================================================================
kubectl apply -f "${DIR}/deployment.yaml"
kubectl apply -f "${DIR}/service.yaml"
if [ "${WITH_NODEPORT}" = "1" ]; then
kubectl apply -f "${DIR}/service-nodeport.yaml"
ok "NodePort Service 已创建(30080)"
fi
# ============================================================================
# 5) 等待就绪(失败时给出按云定制的诊断)
# ============================================================================
info "等待 Deployment 就绪(最多 180 秒)..."
if ! kubectl -n "${NS}" rollout status deployment/opsx-loki --timeout=180s; then
echo ""
warn "部署未在 180 秒内就绪,开始诊断:"
echo ""
kubectl -n "${NS}" get po -l app=opsx-loki -o wide 2>/dev/null | sed 's/^/ /'
REASON=$(kubectl -n "${NS}" get po -l app=opsx-loki -o jsonpath='{.items[0].status.containerStatuses[0].state.waiting.reason}' 2>/dev/null || true)
echo ""
case "${REASON}" in
ImagePullBackOff|ErrImagePull)
warn "镜像拉取失败(${REASON})"
echo " → 当前镜像: $(grep -E '^\s+image:' "${DIR}/deployment.yaml" | head -1 | awk '{print $2}')"
echo " → 该节点拉不到 Docker Hub。请改为目标云可拉的仓库后重跑:"
echo " 腾讯云 TCR : ccr.ccs.tencentyun.com/<ns>/opsxloki:latest"
echo " 阿里云 ACR : registry.cn-hangzhou.aliyuncs.com/<ns>/opsxloki:latest"
echo " 华为云 SWR : swr.cn-north-4.myhuaweicloud.com/<ns>/opsxloki:latest"
echo " 自建 Harbor: harbor.your.domain/library/opsxloki:latest"
;;
CreateContainerConfigError)
warn "容器配置缺失(通常是 Secret 不存在)"
echo " → 检查: kubectl -n ${NS} get secret opsxloki-secrets"
;;
"")
PVC_PHASE=$(kubectl -n "${NS}" get pvc opsxloki-data -o jsonpath='{.status.phase}' 2>/dev/null || echo unknown)
if [ "${PVC_PHASE}" != "Bound" ]; then
warn "PVC 未绑定(phase=${PVC_PHASE})"
echo " → 检查 StorageClass: kubectl -n ${NS} describe pvc opsxloki-data"
echo " → ${CLOUD} 建议的 SC:"
case "${CLOUD}" in
腾讯云*) echo " cbs" ;;
AWS*) echo " gp3 / gp2" ;;
阿里云*) echo " alicloud-disk-essd" ;;
华为云*) echo " csi-disk" ;;
*) echo " local-path(需装 local-path-provisioner)" ;;
esac
fi
;;
esac
echo ""
kubectl -n "${NS}" describe po -l app=opsx-loki 2>/dev/null | tail -15 | sed 's/^/ /'
die "部署未完成,请按上面提示处理"
fi
ok "Deployment 已就绪"
# ============================================================================
# 6) 输出访问信息
# ============================================================================
POD=$(kubectl -n "${NS}" get po -l app=opsx-loki -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "-")
POD_IP=$(kubectl -n "${NS}" get po -l app=opsx-loki -o jsonpath='{.items[0].status.podIP}' 2>/dev/null || echo "-")
NODE=$(kubectl -n "${NS}" get po -l app=opsx-loki -o jsonpath='{.items[0].spec.nodeName}' 2>/dev/null || echo "-")
echo ""
echo "════════════════════════════════════════════════════════"
ok "opsx-loki 部署完成"
echo "════════════════════════════════════════════════════════"
echo " 平台 : ${CLOUD}"
echo " Namespace : ${NS}"
echo " 存储类 : ${RESOLVED_SC}"
echo " Pod : ${POD} (node=${NODE} ip=${POD_IP})"
echo " Service : opsx-loki.${NS}.svc.cluster.local:8080"
[ "${WITH_NODEPORT}" = "1" ] && echo " NodePort : <任意节点IP>:30080"
echo ""
echo " 初始账号 : admin / admin123 ← 首次登录请立即改密"
echo ""
echo " 本地访问 : kubectl -n ${NS} port-forward svc/opsx-loki 8080:8080"
echo " 浏览器打开 http://localhost:8080"
echo ""
echo " 配 Ingress(可选,先跑 WITH_NODEPORT=1 bash install.sh):"
case "${CLOUD}" in
腾讯云*) echo " kubectl apply -f ${DIR}/examples/ingress-tke.yaml # qcloud class" ;;
阿里云*) echo " kubectl apply -f ${DIR}/examples/ingress.yaml # alb class" ;;
*) echo " 参照 ${DIR}/examples/ 改 ingressClassName 与注解" ;;
esac
echo "════════════════════════════════════════════════════════"