Kubernetes 集群与 Pod 状态巡检笔记

Kubernetes 集群与 Pod 状态巡检笔记

本文将完整巡检脚本和使用说明集中保存,适用于生产环境的只读状态巡检。执行前请确认目标集群、账号权限和检查范围。

阅读导航

完整脚本

将下面代码完整保存为 k8s-inspect.sh,使用 Bash 执行。脚本内部使用 Python 标准库解析 Kubernetes JSON,避免通过文本列或关键字匹配误判状态。依赖 Bash、Python 3.8+ 和 kubectl,不需要安装第三方 Python 包。

#!/usr/bin/env bash
# 只读 Kubernetes 巡检;需要 bash、python3 >= 3.8、kubectl。
set -euo pipefail
umask 077
command -v python3 >/dev/null 2>&1 || { echo '错误:需要 python3 >= 3.8' >&2; exit 3; }
exec python3 - "$@" <<'PY'
import argparse
import collections
import datetime as dt
import json
import os
import re
import shutil
import subprocess
import sys
import time
from decimal import Decimal, InvalidOperation

UTC = dt.timezone.utc


def timestamp(value):
    if not value:
        return None
    try:
        return dt.datetime.fromisoformat(value.replace('Z', '+00:00')).astimezone(UTC)
    except (ValueError, TypeError):
        return None


def quantity(value):
    """Kubernetes Quantity -> Decimal 基本单位(CPU 核,内存字节)。"""
    match = re.fullmatch(r'([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)([a-zA-Z]*)', str(value))
    if not match:
        raise ValueError('无法解析资源数量')
    factors = {'': 1, 'n': Decimal('1e-9'), 'u': Decimal('1e-6'), 'm': Decimal('.001'),
               'k': 1000, 'K': 1000, 'M': 1000**2, 'G': 1000**3, 'T': 1000**4,
               'P': 1000**5, 'E': 1000**6,
               **{s + 'i': 1024**i for i, s in enumerate('KMGTPE', 1)}}
    if match[2] not in factors:
        raise ValueError('未知资源单位')
    return Decimal(match[1]) * factors[match[2]]


def clean(value):
    # 避免终端控制字符;不把任意事件消息、日志或容器环境变量写入报告。
    return re.sub(r'[\x00-\x1f\x7f-\x9f]', '?', str(value))[:2000]


class Inspector:
    def __init__(self, args):
        self.a = args
        self.now = dt.datetime.now(UTC)
        self.findings = []
        self.coverage = []
        self.counts = {}
        self.start = time.monotonic()
        self.context = args.context
        self.base = ['kubectl', '--request-timeout=' + str(args.request_timeout) + 's']
        if args.kubeconfig:
            self.base += ['--kubeconfig', args.kubeconfig]
        self.scope = ['-n', args.namespace] if args.namespace else ['-A']

    def add(self, level, kind, obj, detail):
        self.findings.append(dict(level=level, kind=kind, object=clean(obj), detail=clean(detail)))

    def ident(self, obj):
        m = obj.get('metadata', {})
        return (m.get('namespace', '') + '/' + m.get('name', '?')).lstrip('/')

    def age(self, value):
        t = timestamp(value)
        return max(0, (self.now - t).total_seconds()) if t else None

    def old(self, value, seconds):
        age = self.age(value)
        return age is not None and age >= seconds

    def run(self, argv):
        remaining = self.a.total_timeout - (time.monotonic() - self.start)
        if remaining <= 0:
            raise RuntimeError('巡检总采集时限已到')
        try:
            p = subprocess.run(self.base + argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                               timeout=min(self.a.command_timeout, remaining), check=False)
        except subprocess.TimeoutExpired:
            raise RuntimeError('命令超时') from None
        except OSError:
            raise RuntimeError('kubectl 无法执行') from None
        if p.returncode:
            # 不回显 stderr:认证插件、代理可能把凭据写入错误消息。
            err = p.stderr.decode('utf-8', errors='replace')
            category = next((label for token, label in [
                ('Forbidden', 'RBAC 权限不足'), ('Unauthorized', '认证失败'),
                ('NotFound', 'API/资源不存在'), ('could not find the requested resource', 'API/资源不存在'),
                ('x509', 'TLS 证书错误'), ('connection refused', '连接被拒绝'),
                ('i/o timeout', '网络超时')] if token in err), '请求失败(原始错误未写入报告)')
            raise RuntimeError(f'{category},退出码 {p.returncode}')
        return p.stdout.decode('utf-8')

    def collect(self, name, resource=None, path=None, extra=None, scoped=True):
        try:
            cmd = ['get', '--raw', path] if path else ['get', resource] + (self.scope if scoped else []) + [
                '-o', 'json', '--chunk-size=' + str(self.a.chunk_size)] + (extra or [])
            data = json.loads(self.run(cmd))
            if not isinstance(data, dict) or not isinstance(data.get('items'), list):
                raise ValueError('缺少 items 数组')
            self.coverage.append(dict(check=name, status='OK', count=len(data['items'])))
            self.counts[name] = len(data['items'])
            return data['items']
        except (RuntimeError, ValueError, UnicodeError) as exc:
            self.coverage.append(dict(check=name, status='UNKNOWN'))
            detail = str(exc) if isinstance(exc, RuntimeError) else '返回数据格式异常'
            self.add('UNKNOWN', '采集', name, detail)
            return None

    def nodes(self, items):
        for obj in items:
            key = self.ident(obj)
            status = obj.get('status', {})
            cond = {c['type']: c for c in status.get('conditions', [])}
            if cond.get('Ready', {}).get('status') != 'True':
                self.add('CRITICAL', 'Node', key, 'Ready=' + cond.get('Ready', {}).get('status', '缺失'))
            for name in ['MemoryPressure', 'DiskPressure', 'PIDPressure', 'NetworkUnavailable']:
                c = cond.get(name, {})
                if c.get('status') == 'True':
                    self.add('CRITICAL', 'Node', key, f'{name}=True reason={c.get("reason", "")}')
                elif c.get('status') == 'Unknown':
                    self.add('WARNING', 'Node', key, f'{name}=Unknown')
            if obj.get('spec', {}).get('unschedulable'):
                self.add('WARNING', 'Node', key, '已 cordon,禁止调度(可能是计划维护)')
        if not items:
            self.add('CRITICAL', 'Node', '集群', '未发现任何节点')

    def pods(self, items):
        severe_wait = {'CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull', 'CreateContainerConfigError',
                       'CreateContainerError', 'RunContainerError', 'InvalidImageName', 'ErrImageNeverPull'}
        for obj in items:
            key = self.ident(obj)
            m, s = obj.get('metadata', {}), obj.get('status', {})
            phase = s.get('phase', 'Unknown')
            if m.get('deletionTimestamp'):
                grace = obj.get('spec', {}).get('terminationGracePeriodSeconds', 30)
                if self.old(m['deletionTimestamp'], max(self.a.terminating_seconds, grace)):
                    self.add('WARNING', 'Pod', key, '长时间 Terminating;检查 finalizer、节点与存储卸载')
                continue
            if phase == 'Succeeded':
                continue
            if phase in ('Failed', 'Unknown'):
                self.add('CRITICAL', 'Pod', key, f'phase={phase} reason={s.get("reason", "")} node={obj.get("spec", {}).get("nodeName", "未调度")}')
            age = self.age(m.get('creationTimestamp'))
            mature = age is not None and age >= self.a.pending_seconds
            cond = {c['type']: c for c in s.get('conditions', [])}
            if phase == 'Pending' and mature:
                self.add('WARNING', 'Pod', key, f'Pending 已持续至少 {int(age)} 秒(Pod 年龄);PodScheduled={cond.get("PodScheduled", {}).get("status", "缺失")}')
            ready = cond.get('Ready', {})
            if phase == 'Running' and ready.get('status') != 'True':
                # 优先使用本次 Ready 变化时间,避免老 Pod 短暂探针失败立即报警。
                since = ready.get('lastTransitionTime') or m.get('creationTimestamp')
                if self.old(since, self.a.notready_seconds):
                    self.add('WARNING', 'Pod', key, f'Running 但 Ready={ready.get("status", "缺失")} reason={ready.get("reason", "")}')
            for field in ('initContainerStatuses', 'containerStatuses', 'ephemeralContainerStatuses'):
                for c in s.get(field, []):
                    target = key + ':' + c.get('name', '?')
                    state = c.get('state', {})
                    waiting = state.get('waiting', {})
                    reason = waiting.get('reason', '')
                    if reason in severe_wait:
                        self.add('CRITICAL', 'Container', target, f'{field}: waiting={reason}')
                    elif waiting and mature:
                        self.add('WARNING', 'Container', target, f'{field}: waiting={reason or "未知"}')
                    terminated = state.get('terminated', {})
                    if terminated and (terminated.get('exitCode', 0) != 0 or terminated.get('reason') == 'OOMKilled'):
                        self.add('CRITICAL', 'Container', target, f'当前终止 reason={terminated.get("reason", "")} exitCode={terminated.get("exitCode")}')
                    last = c.get('lastState', {}).get('terminated', {})
                    if last and self.age(last.get('finishedAt')) is not None and not self.old(last.get('finishedAt'), self.a.history_minutes * 60):
                        if last.get('exitCode', 0) != 0 or last.get('reason') == 'OOMKilled':
                            self.add('WARNING', 'Container', target, f'近期上次终止 reason={last.get("reason", "")} exitCode={last.get("exitCode")} finishedAt={last.get("finishedAt")}')
                    if c.get('restartCount', 0) >= self.a.restart_threshold:
                        self.add('WARNING', 'Container', target, f'生命周期累计重启={c["restartCount"]}(不是时间窗口增量)')

    def workloads(self, kind, items):
        for o in items:
            key = self.ident(o)
            spec, s, m = o.get('spec', {}), o.get('status', {}), o.get('metadata', {})
            if m.get('deletionTimestamp'):
                continue
            if kind == 'Deployment' and spec.get('paused'):
                self.add('WARNING', kind, key, 'Deployment 已暂停更新')
            if s.get('observedGeneration', 0) < m.get('generation', 0):
                self.add('WARNING', kind, key, '控制器尚未观察到最新 generation')
            if kind in ('Deployment', 'StatefulSet'):
                desired = spec.get('replicas', 1)
                ready = s.get('readyReplicas', 0)
                if ready < desired:
                    self.add('CRITICAL' if ready == 0 else 'WARNING', kind, key, f'ready={ready}/{desired}(发布或扩容期间也可能出现)')
                if kind == 'Deployment' and s.get('updatedReplicas', 0) < desired:
                    self.add('WARNING', kind, key, f'updated={s.get("updatedReplicas", 0)}/{desired}')
                if kind == 'Deployment' and s.get('availableReplicas', 0) < desired:
                    self.add('WARNING', kind, key, f'available={s.get("availableReplicas", 0)}/{desired}')
                if kind == 'StatefulSet' and spec.get('updateStrategy', {}).get('type', 'RollingUpdate') == 'RollingUpdate':
                    partition = spec.get('updateStrategy', {}).get('rollingUpdate', {}).get('partition', 0)
                    if s.get('updatedReplicas', 0) < max(0, desired - partition):
                        self.add('WARNING', kind, key, '更新副本未达到 RollingUpdate partition 要求')
            elif kind == 'DaemonSet':
                desired, ready = s.get('desiredNumberScheduled', 0), s.get('numberReady', 0)
                if ready < desired:
                    self.add('CRITICAL' if ready == 0 else 'WARNING', kind, key, f'ready={ready}/{desired}')
                if s.get('numberUnavailable', 0) or s.get('numberMisscheduled', 0):
                    self.add('WARNING', kind, key, f'unavailable={s.get("numberUnavailable", 0)} misscheduled={s.get("numberMisscheduled", 0)}')
                if spec.get('updateStrategy', {}).get('type', 'RollingUpdate') == 'RollingUpdate' and s.get('updatedNumberScheduled', 0) < desired:
                    self.add('WARNING', kind, key, f'updated={s.get("updatedNumberScheduled", 0)}/{desired}')
            for c in s.get('conditions', []):
                if (c.get('type'), c.get('status')) in [('Progressing', 'False'), ('ReplicaFailure', 'True')]:
                    self.add('CRITICAL', kind, key, f'{c["type"]}={c["status"]} reason={c.get("reason", "")}')

    def jobs(self, items):
        for o in items:
            s = o.get('status', {})
            for c in s.get('conditions', []):
                if c.get('type') in ('Failed', 'FailureTarget') and c.get('status') == 'True':
                    self.add('CRITICAL', 'Job', self.ident(o), f'{c["type"]} reason={c.get("reason", "")}')
            if s.get('failed', 0) and not any(c.get('type') in ('Failed', 'Complete', 'FailureTarget') and c.get('status') == 'True' for c in s.get('conditions', [])):
                self.add('WARNING', 'Job', self.ident(o), f'失败尝试={s["failed"]} active={s.get("active", 0)},可能仍在重试')

    def pvcs(self, items):
        for o in items:
            phase = o.get('status', {}).get('phase', 'Unknown')
            if phase == 'Lost':
                self.add('CRITICAL', 'PVC', self.ident(o), 'phase=Lost')
            elif phase != 'Bound' and self.old(o.get('metadata', {}).get('creationTimestamp'), self.a.pending_seconds):
                self.add('WARNING', 'PVC', self.ident(o), f'phase={phase};WaitForFirstConsumer 可能是预期状态')
            for c in o.get('status', {}).get('conditions', []):
                if c.get('status') == 'True':
                    self.add('WARNING', 'PVC', self.ident(o), f'condition={c.get("type")} reason={c.get("reason", "")}')

    def events(self, items):
        for o in items:
            latest = o.get('series', {}).get('lastObservedTime') or o.get('lastTimestamp') or o.get('eventTime') or o.get('metadata', {}).get('creationTimestamp')
            age = self.age(latest)
            if age is None:
                self.add('UNKNOWN', 'Event', self.ident(o), '无法确定事件时间')
            elif age <= self.a.history_minutes * 60:
                ref = o.get('involvedObject', {})
                target = '/'.join(filter(None, [ref.get('namespace'), ref.get('kind'), ref.get('name')]))
                self.add('WARNING', 'Event', target, f'reason={o.get("reason", "")} count={o.get("series", {}).get("count", o.get("count", 1))} last={latest}(可能已恢复)')

    def metrics(self, nodes, pods):
        # 使用结构化 Metrics API,避免解析 kubectl top 的列格式。
        for kind, source, path in [
            ('Node', nodes, '/apis/metrics.k8s.io/v1beta1/nodes'),
            ('Pod', pods, '/apis/metrics.k8s.io/v1beta1/' + ('namespaces/' + self.a.namespace + '/pods' if self.a.namespace else 'pods'))]:
            if source is None:
                continue
            items = self.collect(kind + 'Metrics', path=path)
            if items is None:
                continue
            lookup = {self.ident(o): o for o in source}
            seen = set()
            for metric in items:
                key = self.ident(metric)
                if key not in lookup:
                    continue
                seen.add(key)
                if timestamp(metric.get('timestamp')) is None or self.old(metric.get('timestamp'), self.a.metrics_max_age):
                    self.add('UNKNOWN', kind + 'Metrics', key, '指标时间缺失或已过期')
                    continue
                obj = lookup[key]
                if kind == 'Node':
                    self.check_usage(kind, key, metric.get('usage', {}), obj.get('status', {}).get('allocatable', {}))
                else:
                    specs = {c['name']: c for field in ('containers', 'initContainers', 'ephemeralContainers') for c in obj.get('spec', {}).get(field, [])}
                    for c in metric.get('containers', []):
                        limits = specs.get(c['name'], {}).get('resources', {}).get('limits', {})
                        self.check_usage('Container', key + ':' + c['name'], c.get('usage', {}), limits)
            for key, obj in lookup.items():
                eligible = kind == 'Node' or (obj.get('status', {}).get('phase') == 'Running' and not obj.get('metadata', {}).get('deletionTimestamp') and self.old(obj.get('status', {}).get('startTime') or obj.get('metadata', {}).get('creationTimestamp'), self.a.pending_seconds))
                if eligible and key not in seen:
                    self.add('UNKNOWN', kind + 'Metrics', key, '缺少资源使用指标')

    def check_usage(self, kind, key, usage, capacity):
        for resource in ('cpu', 'memory'):
            if resource not in capacity:
                continue  # 容器未配置 limit,不把缺少 limit 当成超限。
            try:
                cap = quantity(capacity[resource])
                used = quantity(usage[resource])
                if cap <= 0:
                    continue
                percent = float(used / cap * 100)
                if percent >= self.a.usage_percent:
                    base = 'allocatable' if kind == 'Node' else 'limit'
                    self.add('WARNING', kind + 'Usage', key, f'{resource}={percent:.1f}% of {base},单次采样')
            except (ValueError, KeyError, InvalidOperation):
                self.add('UNKNOWN', kind + 'Metrics', key, f'{resource} 指标缺失或无法解析')

    def execute(self):
        if not self.context:
            try:
                self.context = self.run(['config', 'current-context']).strip()
                if not self.context:
                    raise RuntimeError('当前 context 为空')
            except RuntimeError as exc:
                self.add('UNKNOWN', '连接', 'context', str(exc))
                return
        # 固定本次 context,后续不修改 kubeconfig,不受 current-context 被切换影响。
        self.base += ['--context', self.context]
        try:
            result = self.run(['get', '--raw', '/readyz']).strip()
            if result != 'ok':
                raise RuntimeError('readyz 未返回 ok')
            self.coverage.append(dict(check='APIServerReadyz', status='OK'))
        except RuntimeError as exc:
            self.add('UNKNOWN', 'APIServer', '/readyz', str(exc))
            self.coverage.append(dict(check='APIServerReadyz', status='UNKNOWN'))
        nodes = None
        if not self.a.skip_cluster_checks:
            nodes = self.collect('Nodes', 'nodes', scoped=False)
            if nodes is not None:
                self.nodes(nodes)
        else:
            self.coverage.append(dict(check='Nodes', status='SKIPPED'))
        pods = self.collect('Pods', 'pods')
        if pods is not None:
            self.pods(pods)
        for name, res, handler in [
            ('Deployment', 'deployments.apps', None), ('StatefulSet', 'statefulsets.apps', None),
            ('DaemonSet', 'daemonsets.apps', None), ('Job', 'jobs.batch', self.jobs),
            ('PVC', 'persistentvolumeclaims', self.pvcs),
            ('WarningEvents', 'events', self.events)]:
            items = self.collect(name, res, extra=['--field-selector=type=Warning'] if name == 'WarningEvents' else None)
            if items is not None:
                if handler:
                    handler(items)
                else:
                    self.workloads(name, items)
        if self.a.metrics:
            self.metrics(nodes, pods)
        else:
            self.coverage.append(dict(check='Metrics', status='SKIPPED'))

    def report(self):
        counts = collections.Counter(f['level'] for f in self.findings)
        # 采集不完整优先用 3;报告同时保留已经发现的 CRITICAL。
        code = 3 if counts['UNKNOWN'] else 2 if counts['CRITICAL'] else 1 if counts['WARNING'] else 0
        order = {'CRITICAL': 0, 'WARNING': 1, 'UNKNOWN': 2}
        self.findings.sort(key=lambda f: (order[f['level']], f['kind'], f['object']))
        report = dict(schema_version=1, started_at=self.now.isoformat(), finished_at=dt.datetime.now(UTC).isoformat(),
                      context=self.context, namespace=self.a.namespace or 'ALL', exit_code=code,
                      summary={k: counts[k] for k in ('CRITICAL', 'WARNING', 'UNKNOWN')},
                      coverage=self.coverage, object_counts=self.counts, findings=self.findings)
        lines = ['Kubernetes 只读巡检报告', f'时间(UTC): {report["started_at"]}',
                 f'Context: {clean(self.context)} | 命名空间: {clean(report["namespace"])}',
                 f'退出码: {code} | CRITICAL={counts["CRITICAL"]} WARNING={counts["WARNING"]} UNKNOWN={counts["UNKNOWN"]}',
                 '范围: API 状态快照;不等同于业务、网络、存储和控制平面完整健康验收。', '', '采集覆盖:']
        for c in self.coverage:
            lines.append(f'  {c["check"]}: {c["status"]}' + (f' ({c["count"]} 个对象)' if 'count' in c else ''))
        lines += ['', '异常明细(不截断条目):']
        lines += [f'[{f["level"]}] {f["kind"]} {f["object"]} | {f["detail"]}' for f in self.findings]
        if not self.findings:
            lines.append('已启用且成功采集的检查项未发现异常。')
        content = '\n'.join(lines) + '\n'
        if self.a.output_dir:
            os.makedirs(self.a.output_dir, mode=0o700, exist_ok=True)
            # 独占创建;不覆盖已有文件或跟随同名文件符号链接。
            stem = 'k8s-inspect-' + self.now.strftime('%Y%m%dT%H%M%S%fZ') + '-' + str(os.getpid())
            for suffix, body in [('.txt', content), ('.json', json.dumps(report, ensure_ascii=False, indent=2) + '\n')]:
                path = os.path.join(self.a.output_dir, stem + suffix)
                fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
                with os.fdopen(fd, 'w', encoding='utf-8') as stream:
                    stream.write(body)
                print('报告已保存: ' + path, file=sys.stderr)
        print(content, end='')
        return code


def positive(value):
    number = int(value)
    if number <= 0:
        raise argparse.ArgumentTypeError('必须为正整数')
    return number


def main():
    p = argparse.ArgumentParser(prog='k8s-inspect.sh', description='只读 K8s 巡检;退出码 0=无异常 1=告警 2=严重 3=采集/执行不完整。')
    p.add_argument('--context', default='', help='指定 kubeconfig context;默认固定当前 context')
    p.add_argument('--kubeconfig', help='kubeconfig 文件路径')
    p.add_argument('-n', '--namespace', help='仅检查指定命名空间;默认所有命名空间')
    p.add_argument('--skip-cluster-checks', action='store_true', help='跳过集群节点检查;仍检查 API readyz')
    p.add_argument('--metrics', action='store_true', help='启用 Metrics API;不可用时报告 UNKNOWN')
    p.add_argument('--output-dir', help='保存 TXT/JSON 报告的目录')
    for flag, default, help_text in [
        ('request-timeout', 15, '单次 API 请求超时秒数'), ('command-timeout', 60, '单个 kubectl 总超时秒数'),
        ('total-timeout', 600, '全部采集总时限秒数'), ('chunk-size', 200, '资源列表分页大小'),
        ('pending-seconds', 300, 'Pending、容器等待、PVC 非 Bound 的年龄阈值'),
        ('notready-seconds', 300, 'Running Pod 持续不 Ready 阈值'),
        ('terminating-seconds', 600, '终止超时阈值(兼顾配置的优雅退出期)'),
        ('restart-threshold', 5, '单容器生命周期累计重启阈值'),
        ('history-minutes', 60, '近期 Warning 事件和上次异常终止的回看分钟数'),
        ('usage-percent', 85, 'CPU/内存使用率告警百分比'), ('metrics-max-age', 180, '指标最大年龄秒数')]:
        p.add_argument('--' + flag, type=positive, default=default, help=help_text + f';默认 {default}')
    a = p.parse_args()
    if a.namespace and not re.fullmatch(r'[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?', a.namespace):
        p.error('namespace 格式无效')
    if a.usage_percent > 100:
        p.error('usage-percent 必须在 1~100 之间')
    if not shutil.which('kubectl'):
        print('错误:找不到 kubectl', file=sys.stderr)
        return 3
    inspector = Inspector(a)
    inspector.execute()
    return inspector.report()


if __name__ == '__main__':
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        print('巡检已中断', file=sys.stderr)
        sys.exit(3)
    except (OSError, ValueError, TypeError, KeyError):
        # 不打印 traceback 或任意外部错误,避免报告携带凭据。
        print('巡检执行或报告写入失败,请检查输入数据、目录权限和磁盘空间;结果不完整。', file=sys.stderr)
        sys.exit(3)
PY

使用说明

脚本:k8s-inspect.sh。需要 Bash、Python 3.8+、kubectl,以及可访问目标集群的 kubeconfig。kubectl 版本应与集群版本兼容。

这是单次 API 状态巡检,适合作为人工巡检或定时任务的基础。不是业务可用性验证,也不能保证发现所有故障。首次上线建议人工执行,结合维护窗口、业务特点调整阈值。

1. 直接使用

chmod 750 k8s-inspect.sh

# 显式指定集群,检查所有命名空间,终端显示所有异常并保存 TXT/JSON。
./k8s-inspect.sh --context prod-cluster --output-dir ./reports

# 指定 kubeconfig。
./k8s-inspect.sh --kubeconfig /path/to/kubeconfig \
  --context prod-cluster --output-dir ./reports

# 只检查一个命名空间;仍然检查所有节点和 API readyz。
./k8s-inspect.sh --context prod-cluster -n production --output-dir ./reports

# 命名空间账号没有节点读取权限时,明确跳过节点检查。
# 此时报告中的 Nodes 为 SKIPPED,不能理解为节点正常。
./k8s-inspect.sh --context prod-cluster -n production \
  --skip-cluster-checks --output-dir ./reports

# 开启 CPU / 内存使用率检查(需要 Metrics API 及权限)。
./k8s-inspect.sh --context prod-cluster --metrics --output-dir ./reports

./k8s-inspect.sh --help

不要使用 sudo,除非本机凭据访问确实需要;通常普通用户即可执行。脚本不会切换或修改 current-context,每次运行固定所选 context。默认未指定 --context 时读取当前 context,生产环境建议始终显式指定。

2. 能发现哪些异常

检查对象 异常判断
API Server /readyz 不可访问、未就绪、认证失败、超时;报告 UNKNOWN,继续尝试其他检查
Node Ready 非 True;MemoryPressure、DiskPressure、PIDPressure、NetworkUnavailable 为 True;压力状态 Unknown;已 cordon;节点列表为空
Pod Failed、Unknown、Evicted 等失败原因;长时间 Pending;Running 但持续不 Ready;长时间 Terminating
容器 CrashLoopBackOff、ImagePullBackOff、ErrImagePull、配置/创建/启动错误;当前非零退出;OOMKilled;近期上次异常退出;生命周期累计重启过多
Init / 临时容器 同样检查等待、退出、重启;正常完成的 init 容器不会因已终止被报警
Deployment ready/available/updated 副本不足,generation 尚未被控制器观察,Progressing=False、ReplicaFailure=True,暂停更新
StatefulSet ready 副本不足,滚动更新副本未满足 partition,generation 落后;兼顾 OnDelete 策略
DaemonSet ready 不足、unavailable、misscheduled、滚动更新未完成、generation 落后
Job Failed / FailureTarget 条件,尚在重试的失败尝试;已完成 Job 不因历史失败次数报警
PVC Lost、长期非 Bound、Resize 等为 True 的状态条件;Pending 可能是 WaitForFirstConsumer 的预期状态
Warning Event 最近指定时间窗口内最后出现的 Warning,显示对象、reason、次数与时间;历史事件可能已恢复
可选资源使用率 Node CPU/内存占 allocatable;容器 CPU/内存占其 limit;缺失或过期指标标记 UNKNOWN

Pod Succeeded 不作为异常。副本期望为 0 不单独告警。已进入删除流程的 Pod 只检查删除是否超时,避免正常退出被当成故障。

事件和异常不截断条目,也不默认排除 kube-system。各检查独立采集,因此结果不是同一时刻的原子快照,发布、扩容过程中可能看到暂时不一致。

3. 默认阈值与调整

参数 默认值 含义
--pending-seconds 300 Pod Pending、容器等待、PVC 非 Bound 的年龄阈值
--notready-seconds 300 Running Pod 持续不 Ready 的阈值
--terminating-seconds 600 删除时间戳超过阈值才告警,同时兼顾配置的优雅退出期
--restart-threshold 5 每个容器生命周期累计重启次数,不是最近 5 分钟增量
--history-minutes 60 Warning 事件、上次异常终止的回看窗口
--usage-percent 85 CPU/内存单次采样百分比告警阈值
--metrics-max-age 180 指标超过多少秒视为过期
--request-timeout 15 单次 API 请求超时秒数
--command-timeout 60 一个 kubectl 进程总超时秒数,包含分页和认证过程
--total-timeout 600 所有采集过程总时限秒数;到期后的检查标记 UNKNOWN
--chunk-size 200 列表 API 分页大小
./k8s-inspect.sh --context prod-cluster \
  --pending-seconds 600 --notready-seconds 300 \
  --restart-threshold 10 --history-minutes 30 \
  --metrics --usage-percent 90 --output-dir ./reports

Pending 使用 Pod 创建时间作近似;NotReady 优先使用 Ready 条件的 lastTransitionTime。不提供跨次状态缓存,因此无法判断“最近 N 分钟重启次数”。请用 Prometheus 等时序监控计算速率。

工作负载副本不足按当前状态报告,不设置发布宽限期;维护或滚动更新期间需要人工结合发布状态判断。长优雅退出的 Pod 会在配置阈值与优雅退出时间两者较大者之后告警。

4. 结果与退出码

退出码 含义
0 已启用且成功采集的检查项未发现异常;SKIPPED 不表示通过
1 存在 WARNING,没有 CRITICAL 或 UNKNOWN
2 存在 CRITICAL,采集未出现 UNKNOWN
3 有 UNKNOWN、依赖缺失、执行失败或报告写入失败;即使同时发现 CRITICAL 也优先返回 3

参数使用错误由参数解析器返回 2,并在 stderr 输出用法;因此接入告警前应先验证固定的调用参数。

TXT 方便阅读,JSON 含 summary、coverage、object_counts、findings,方便自动处理。summary 统计的是异常条目数,同一 Pod 可以有多条异常,不是异常 Pod 个数。

默认不启用 Metrics,coverage 会显示 SKIPPED;开启后 Metrics API 缺失、无权限、指标缺失或过期会标记 UNKNOWN。未配置容器 limit 的资源无法计算其占 limit 比例,跳过该比例判断;这不代表资源使用一定安全。指标为单次采样,不能作为持续高负载结论。

5. 权限与访问范围

所有 Kubernetes 操作均为读取:

  • 当前 context:本地 kubectl config current-context;显式指定时无需读取。
  • 非资源 URL:GET /readyz。
  • 核心 API:list nodes、pods、persistentvolumeclaims、events。
  • apps API:list deployments、statefulsets、daemonsets。
  • batch API:list jobs。
  • 开启 Metrics 后:metrics.k8s.io 的 nodes、pods 列表读取。

所有命名空间检查需要相应集群范围授权。-n 限制命名空间资源,nodes 和 /readyz 仍为集群级检查;--skip-cluster-checks 仅跳过 nodes。事件使用服务端 type=Warning 过滤,但时间窗口在客户端判断。

不要为了运行脚本直接授予 cluster-admin。使用现有的受控只读账号,按需要授权;无权限的项目会明确列为 UNKNOWN。脚本不读取 Secret 资源、不抓日志、不执行容器命令、不重启、不删除、不修改资源。

注意:读取完整 Pod 对象本身可能读取到明文环境变量或 annotations;脚本仅在内存解析,不保存原始对象。报告不输出这些字段或任意事件 message,也不回显 kubectl 原始 stderr,避免认证插件错误带出凭据。报告仍有集群、命名空间、对象名称等运维信息,请放在受控目录。

创建报告时使用独占文件名、0600 权限;新建输出目录为 0700。已有目录的权限不自动修改,目录路径应由可信用户管理。报告写入失败返回 3。脚本不会自动清理历史报告,需要另外制定保留策略。

6. 定时巡检示例

Linux 使用 flock 防止任务重叠。下面是 crontab 示例,需要将路径、context 和 PATH 改为现场值,确保账号能非交互认证:

PATH=/usr/local/bin:/usr/bin:/bin
*/10 * * * * /usr/bin/flock -n /opt/k8s-inspect/inspect.lock /opt/k8s-inspect/k8s-inspect.sh --kubeconfig /opt/k8s-inspect/kubeconfig --context prod-cluster --output-dir /opt/k8s-inspect/reports >> /opt/k8s-inspect/cron.log 2>&1

目录应由运行账号管理;日志应轮转。cron 本身不会把脚本退出码自动转换成消息告警;接入现有监控平台时读取退出码和 JSON。脚本无通知、外发数据功能。

大集群先按命名空间评估运行耗时,再调整分页、超时和巡检频率。分页降低单次 API 响应大小,但 kubectl 和脚本仍会在本地汇总完整列表,内存随对象数量增长。默认串行请求,避免每个 Pod 单独发起 describe/logs 请求。

7. 发现异常后的只读核查

脚本有意只展示结构化状态及 reason。需要详细错误消息、日志时,在受控终端按对象核查,注意输出中的敏感信息:

kubectl --context prod-cluster describe node NODE
kubectl --context prod-cluster -n NAMESPACE describe pod POD
kubectl --context prod-cluster -n NAMESPACE logs POD -c CONTAINER --tail=100
kubectl --context prod-cluster -n NAMESPACE logs POD -c CONTAINER --previous --tail=100
kubectl --context prod-cluster -n NAMESPACE describe deployment DEPLOYMENT
kubectl --context prod-cluster -n NAMESPACE describe pvc PVC

不要仅凭历史 OOM、累计重启次数或 Warning 事件直接重启业务;先判断是否仍在发生及业务影响。

8. 边界与验证情况

本版本不检测:业务 HTTP 成功率、DNS 实际解析、Service/Ingress 端到端连通、空 EndpointSlice、HPA/CronJob/PDB 策略、PV/CSI 后端真实读写、证书到期、etcd 多成员一致性、宿主机完整磁盘/内核状态、GPU 硬件和云平台状态。API readyz 只反映所连接 API 端点的就绪状态,不证明所有控制平面实例健康。

Event 受集群保留时间限制;Pod 被删除后无法从当前对象恢复历史状态。未创建出 Pod 的 CronJob 调度问题也不能仅凭 Pod 列表发现。应结合集群监控、业务探测及平台专用巡检使用。

交付前已通过 Bash 语法检查、ShellCheck,以及 8 组本地模拟测试,覆盖节点/Pod/容器、工作负载、Job/PVC/事件、资源单位与阈值、报告权限、退出码、RBAC 失败、缺失指标和超时。未连接实际 Kubernetes 集群验证;实际版本、RBAC、认证插件和集群规模需要在目标环境首次运行确认。

判断依据参考 Kubernetes 官方文档:Node Status、Node-pressure Eviction、Pod Conditions。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容