From 5a3a9f1d60953b606c7c65526ac80a7a6d867332 Mon Sep 17 00:00:00 2001 From: wenjiehs Date: Fri, 3 Jul 2026 02:39:26 +0800 Subject: [PATCH] TKE-31: add node pool scaling cookbook --- cookbook/node-pool/scale_node_pool.py | 291 ++++++++++++++++++ .../basics/node-pool/02-scale-node-pool.md | 39 ++- src/data/cookbooks.js | 103 +++++++ test/cookbooks.test.mjs | 9 +- 4 files changed, 439 insertions(+), 3 deletions(-) create mode 100644 cookbook/node-pool/scale_node_pool.py diff --git a/cookbook/node-pool/scale_node_pool.py b/cookbook/node-pool/scale_node_pool.py new file mode 100644 index 0000000..c712c5d --- /dev/null +++ b/cookbook/node-pool/scale_node_pool.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +""" +扩缩 TKE 标准节点池示例脚本 + +功能: 修改标准节点池的自动伸缩开关、最小节点数、最大节点数和节点标签 +使用方法: python3 scale_node_pool.py --cluster-id cls-xxxxxxxx --node-pool-id np-xxxxxxxx --min-nodes-num 3 --max-nodes-num 20 +文档链接: https://tke-workshop.github.io/basics/node-pool/02-scale-node-pool/ +""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +try: + from common.logger import setup_logger, LogContext +except ModuleNotFoundError: + logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") + + def setup_logger(name: str): + return logging.getLogger(name) + + class LogContext: + def __init__(self, logger: logging.Logger, operation: str): + self.logger = logger + self.operation = operation + + def __enter__(self): + self.logger.info(f"开始: {self.operation}") + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_type is None: + self.logger.info(f"完成: {self.operation}") + else: + self.logger.error(f"失败: {self.operation} - {exc_val}") + return False + +try: + from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException + from tencentcloud.tke.v20180525 import models +except ModuleNotFoundError: + TencentCloudSDKException = Exception + models = None + +logger = setup_logger(__name__) + + +def serialize_request(req: Any) -> dict[str, Any]: + if hasattr(req, "_serialize"): + return req._serialize() + return req + + +def build_label(value: str) -> Any: + if "=" not in value: + raise ValueError(f"标签必须使用 key=value 格式: {value}") + + name, label_value = value.split("=", 1) + if not name or not label_value: + raise ValueError(f"标签 key 和 value 不能为空: {value}") + + if models is None: + return {"Name": name, "Value": label_value} + + label = models.Label() + label.Name = name + label.Value = label_value + return label + + +def build_tag(value: str) -> Any: + if "=" not in value: + raise ValueError(f"资源标签必须使用 key=value 格式: {value}") + + key, tag_value = value.split("=", 1) + if not key or not tag_value: + raise ValueError(f"资源标签 key 和 value 不能为空: {value}") + + if models is None: + return {"Key": key, "Value": tag_value} + + tag = models.Tag() + tag.Key = key + tag.Value = tag_value + return tag + + +def build_modify_node_pool_request( + cluster_id: str, + node_pool_id: str, + enable_autoscale: bool | None = None, + min_nodes_num: int | None = None, + max_nodes_num: int | None = None, + labels: list[str] | None = None, + tags: list[str] | None = None, +) -> Any: + if models is None: + req = { + "ClusterId": cluster_id, + "NodePoolId": node_pool_id, + } + + if enable_autoscale is not None: + req["EnableAutoscale"] = enable_autoscale + if min_nodes_num is not None: + req["MinNodesNum"] = min_nodes_num + if max_nodes_num is not None: + req["MaxNodesNum"] = max_nodes_num + if labels: + req["Labels"] = [build_label(label) for label in labels] + if tags: + req["Tags"] = [build_tag(tag) for tag in tags] + + return req + + req = models.ModifyClusterNodePoolRequest() + req.ClusterId = cluster_id + req.NodePoolId = node_pool_id + + if enable_autoscale is not None: + req.EnableAutoscale = enable_autoscale + if min_nodes_num is not None: + req.MinNodesNum = min_nodes_num + if max_nodes_num is not None: + req.MaxNodesNum = max_nodes_num + if labels: + req.Labels = [build_label(label) for label in labels] + if tags: + req.Tags = [build_tag(tag) for tag in tags] + + return req + + +def validate_capacity(min_nodes_num: int | None, max_nodes_num: int | None): + if min_nodes_num is not None and min_nodes_num < 0: + raise ValueError("--min-nodes-num 不能小于 0") + if max_nodes_num is not None and max_nodes_num < 0: + raise ValueError("--max-nodes-num 不能小于 0") + if min_nodes_num is not None and max_nodes_num is not None and min_nodes_num > max_nodes_num: + raise ValueError("--min-nodes-num 不能大于 --max-nodes-num") + + +def describe_node_pool_detail(client, cluster_id: str, node_pool_id: str): + req = models.DescribeClusterNodePoolDetailRequest() + req.ClusterId = cluster_id + req.NodePoolId = node_pool_id + return client.DescribeClusterNodePoolDetail(req) + + +def modify_node_pool( + cluster_id: str, + node_pool_id: str, + region: str, + enable_autoscale: bool | None = None, + min_nodes_num: int | None = None, + max_nodes_num: int | None = None, + labels: list[str] | None = None, + tags: list[str] | None = None, + confirm_change: bool = False, +): + validate_capacity(min_nodes_num, max_nodes_num) + + req = build_modify_node_pool_request( + cluster_id=cluster_id, + node_pool_id=node_pool_id, + enable_autoscale=enable_autoscale, + min_nodes_num=min_nodes_num, + max_nodes_num=max_nodes_num, + labels=labels, + tags=tags, + ) + + if not confirm_change: + logger.warning("当前为 dry-run,未提交 ModifyClusterNodePool 请求。") + logger.warning("确认节点池状态、容量范围、业务驱逐风险和配额后,重新执行并添加 --confirm-change。") + print(json.dumps(serialize_request(req), ensure_ascii=False, indent=2)) + return None + + with LogContext(logger, f"修改节点池: {node_pool_id}"): + if models is None: + raise RuntimeError("缺少 Tencent Cloud Python SDK,请先执行: pip install -r cookbook/requirements.txt") + + from common.auth import get_tke_client + + client = get_tke_client(region) + + try: + detail = describe_node_pool_detail(client, cluster_id, node_pool_id) + logger.info(f"当前节点池: {getattr(detail, 'Name', None) or node_pool_id}") + logger.info(f"当前最小节点数: {getattr(detail, 'MinNodesNum', None)}") + logger.info(f"当前最大节点数: {getattr(detail, 'MaxNodesNum', None)}") + logger.info(f"当前期望节点数: {getattr(detail, 'DesiredNodesNum', None)}") + + resp = client.ModifyClusterNodePool(req) + except TencentCloudSDKException as e: + logger.error(f"修改节点池失败: {e}") + logger.error(f"错误码: {e.code}") + logger.error(f"错误消息: {e.message}") + raise + + logger.info("节点池修改请求已提交") + logger.info(f"RequestId: {resp.RequestId}") + return resp + + +def parse_enable_autoscale(value: str) -> bool: + normalized = value.lower() + if normalized == "true": + return True + if normalized == "false": + return False + raise argparse.ArgumentTypeError("--enable-autoscale 必须为 true 或 false") + + +def main(): + parser = argparse.ArgumentParser( + description="修改 TKE 标准节点池伸缩范围,默认 dry-run,需显式确认后才会提交变更", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + # 只输出 ModifyClusterNodePool 请求体,不提交变更 + python3 node-pool/scale_node_pool.py \\ + --cluster-id cls-xxxxxxxx \\ + --node-pool-id np-xxxxxxxx \\ + --enable-autoscale true \\ + --min-nodes-num 3 \\ + --max-nodes-num 20 + + # 确认后提交节点池伸缩范围变更 + python3 node-pool/scale_node_pool.py \\ + --cluster-id cls-xxxxxxxx \\ + --node-pool-id np-xxxxxxxx \\ + --enable-autoscale true \\ + --min-nodes-num 3 \\ + --max-nodes-num 20 \\ + --confirm-change + """, + ) + + parser.add_argument("--cluster-id", required=True, help="目标集群 ID") + parser.add_argument("--node-pool-id", required=True, help="目标节点池 ID") + parser.add_argument("--region", default="ap-guangzhou", help="地域") + parser.add_argument("--enable-autoscale", type=parse_enable_autoscale, help="是否开启自动伸缩: true 或 false") + parser.add_argument("--min-nodes-num", type=int, help="最小节点数") + parser.add_argument("--max-nodes-num", type=int, help="最大节点数") + parser.add_argument( + "--label", + action="append", + dest="labels", + help="节点标签,格式 key=value;可重复传入多个", + ) + parser.add_argument( + "--tag", + action="append", + dest="tags", + help="腾讯云资源标签,格式 key=value;可重复传入多个", + ) + parser.add_argument( + "--confirm-change", + action="store_true", + help="确认已完成配额、调度、PDB 和业务影响检查,并提交 ModifyClusterNodePool 请求", + ) + + args = parser.parse_args() + + try: + modify_node_pool( + cluster_id=args.cluster_id, + node_pool_id=args.node_pool_id, + region=args.region, + enable_autoscale=args.enable_autoscale, + min_nodes_num=args.min_nodes_num, + max_nodes_num=args.max_nodes_num, + labels=args.labels, + tags=args.tags, + confirm_change=args.confirm_change, + ) + except Exception as e: + logger.error(f"节点池修改失败: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/content/docs/basics/node-pool/02-scale-node-pool.md b/src/content/docs/basics/node-pool/02-scale-node-pool.md index 701aa2c..a2e9ece 100644 --- a/src/content/docs/basics/node-pool/02-scale-node-pool.md +++ b/src/content/docs/basics/node-pool/02-scale-node-pool.md @@ -9,14 +9,14 @@ title: "扩缩节点池" - **功能名称**: 扩缩标准节点池 - **API 版本**: 2018-05-25 - **API 名称**: `ModifyClusterNodePool` -- **文档更新时间**: 2026-06-17 +- **文档更新时间**: 2026-07-03 - **Agent 友好度**: ⭐⭐⭐⭐⭐ --- ## 功能概述 -通过 `ModifyClusterNodePool` 可以调整节点池的伸缩开关、最小节点数、最大节点数、标签、污点、资源标签和删除保护等配置。节点池扩缩容适用于业务高峰扩容、低峰缩容、压测临时扩容和成本优化。 +通过 `ModifyClusterNodePool` 可以调整节点池的伸缩开关、最小节点数、最大节点数、标签、污点、资源标签和删除保护等配置。官方 API 入参使用 `MinNodesNum` 和 `MaxNodesNum` 表示伸缩范围;不要把 AS 期望容量字段写成 `DesiredCapacity`、`MinSize` 或 `MaxSize`。节点池扩缩容适用于业务高峰扩容、低峰缩容、压测临时扩容和成本优化。 !!! warning "自动伸缩与手动期望数" 标准节点池由弹性伸缩能力管理节点数量。开启自动伸缩时,应主要调整 `MinNodesNum`、`MaxNodesNum` 和伸缩策略;如果需要固定节点数量,先确认当前节点池是否允许手动调整期望容量,避免与自动伸缩控制器互相覆盖。 @@ -47,6 +47,9 @@ title: "扩缩节点池" | Tags | 否 | Array | 腾讯云资源标签 | cost-center=cc-1001 | | DeletionProtection | 否 | Boolean | 删除保护开关 | true | +!!! note "官方参数来源" + `ModifyClusterNodePool` 官方 API 文档最后更新时间为 2026-05-27 13:00:01,输入参数包含 `ClusterId`、`NodePoolId`、`MinNodesNum`、`MaxNodesNum`、`EnableAutoscale`、`Labels.N`、`Taints.N`、`Tags.N` 和 `DeletionProtection` 等字段。本文示例仅使用这些官方字段。 + --- ## 操作步骤 @@ -115,6 +118,34 @@ resp = client.ModifyClusterNodePool(req) print(f"RequestId: {resp.RequestId}") ``` +### Step 5: 使用本地 Cookbook dry-run + +仓库提供了可复制的本地示例脚本,默认只序列化 `ModifyClusterNodePool` 请求体,不会提交 API 变更: + +```bash +cd cookbook +python3 node-pool/scale_node_pool.py \ + --cluster-id cls-xxxxxxxx \ + --node-pool-id np-xxxxxxxx \ + --region ap-guangzhou \ + --enable-autoscale true \ + --min-nodes-num 3 \ + --max-nodes-num 20 +``` + +确认节点池状态、CVM/CBS/ENI 配额、子网 IP、PDB 和业务可驱逐性后,再显式添加 `--confirm-change` 提交变更: + +```bash +python3 node-pool/scale_node_pool.py \ + --cluster-id cls-xxxxxxxx \ + --node-pool-id np-xxxxxxxx \ + --region ap-guangzhou \ + --enable-autoscale true \ + --min-nodes-num 3 \ + --max-nodes-num 20 \ + --confirm-change +``` + --- ## 验证步骤 @@ -171,3 +202,7 @@ kubectl describe pod -n - [查询节点池](./03-describe-node-pool.md) - [删除节点池](./04-delete-node-pool.md) - [节点维护](../node/03-maintain-node.md) + +## Cookbook 示例 + +- 完整可执行代码示例: [`cookbook/node-pool/scale_node_pool.py`](https://github.com/tke-workshop/tke-workshop.github.io/tree/main/cookbook/node-pool) diff --git a/src/data/cookbooks.js b/src/data/cookbooks.js index 96f93b9..a472d51 100644 --- a/src/data/cookbooks.js +++ b/src/data/cookbooks.js @@ -354,6 +354,109 @@ export const cookbooks = [ prompt: '请根据 TKE Workshop 的 describe-clusters Cookbook,查询 ap-guangzhou 下的 TKE 集群列表,并汇总每个集群的名称、ID、状态、版本和节点数。', }, + { + id: 'scale-node-pool', + title: '扩缩 TKE 标准节点池', + category: 'cluster', + language: 'Python', + description: '使用腾讯云 Python SDK 修改标准节点池的自动伸缩开关、最小节点数、最大节点数和节点标签。', + tags: ['TKE API', 'Node Pool', 'Autoscaling'], + estimatedTime: '10 分钟', + verified: true, + icon: '📈', + services: [ + { label: 'Python SDK', icon: '🐍' }, + { label: 'TKE API', icon: '☁️' }, + { label: '节点池', icon: '📈' }, + ], + source: buildSource({ + repo: 'tke-workshop/tke-workshop.github.io', + path: 'cookbook/node-pool', + }), + risk: { + level: '高', + cost: '扩容可能创建新的 CVM、CBS、ENI 等计费资源;缩容可能回收节点并影响工作负载调度。', + resourceImpact: '修改节点池伸缩范围和节点标签', + notice: '脚本默认 dry-run;执行 --confirm-change 前必须确认配额、子网 IP、PDB、Pod 可驱逐性和业务影响。', + }, + parameters: [ + { + name: '--cluster-id', + required: true, + example: 'cls-xxxxxxxx', + description: '目标 TKE 集群 ID。', + }, + { + name: '--node-pool-id', + required: true, + example: 'np-xxxxxxxx', + description: '目标标准节点池 ID。', + }, + { + name: '--region', + required: true, + example: 'ap-guangzhou', + description: '目标节点池所在地域。', + }, + { + name: '--min-nodes-num', + required: false, + example: '3', + description: '节点池最小节点数。', + }, + { + name: '--max-nodes-num', + required: false, + example: '20', + description: '节点池最大节点数。', + }, + { + name: '--confirm-change', + required: false, + example: '--confirm-change', + description: '确认提交 ModifyClusterNodePool 请求;不传时只输出请求体。', + }, + { + name: '--tag', + required: false, + example: 'cost-center=cc-1001', + description: '腾讯云资源标签,供自动化执行层按治理要求注入。', + }, + ], + relatedDocs: [ + { label: '环境准备', href: '/start/environment/' }, + { label: '扩缩节点池', href: '/basics/node-pool/02-scale-node-pool/' }, + { label: '查询节点池', href: '/basics/node-pool/03-describe-node-pool/' }, + ], + files: [ + 'cookbook/node-pool/scale_node_pool.py', + 'cookbook/common/auth.py', + 'cookbook/config.example.yaml', + ], + prerequisites: [ + '已准备腾讯云 SecretId 和 SecretKey。', + '已复制 cookbook/config.example.yaml 为 cookbook/config.yaml。', + '已确认目标 ClusterId、NodePoolId、地域、配额、子网 IP、PDB 和 Pod 可驱逐性。', + ], + commands: [ + 'cd cookbook', + 'pip install -r requirements.txt', + 'python3 node-pool/scale_node_pool.py --cluster-id cls-xxxxxxxx --node-pool-id np-xxxxxxxx --min-nodes-num 3 --max-nodes-num 20', + 'python3 node-pool/scale_node_pool.py --cluster-id cls-xxxxxxxx --node-pool-id np-xxxxxxxx --min-nodes-num 3 --max-nodes-num 20 --tag cost-center=cc-1001 --confirm-change', + ], + verification: [ + 'tccli tke DescribeClusterNodePoolDetail --Region ap-guangzhou --ClusterId cls-xxxxxxxx --NodePoolId np-xxxxxxxx', + '确认 MinNodesNum、MaxNodesNum、EnableAutoscale 与预期一致。', + 'kubectl get nodes -l node-pool=production-pool -o wide', + ], + cleanup: [ + '如本次仅调整伸缩范围,无额外测试资源需要删除。', + '如为验证创建了临时工作负载,验证后删除对应 Deployment、Job、Service 和 PVC。', + '缩容后检查节点、Pod、PVC、日志采集和监控组件状态,确认没有异常驱逐或遗留资源。', + ], + prompt: + '请根据 TKE Workshop 的 scale-node-pool Cookbook,先 dry-run 输出节点池修改请求体,确认配额、PDB 和业务影响后,再提交变更并查询节点池详情验证结果。', + }, { id: 'deploy-nginx', title: '部署 Nginx 应用', diff --git a/test/cookbooks.test.mjs b/test/cookbooks.test.mjs index 5c59917..2c95ce9 100644 --- a/test/cookbooks.test.mjs +++ b/test/cookbooks.test.mjs @@ -7,6 +7,7 @@ const legacyCookbookIds = [ 'create-cluster', 'delete-cluster', 'describe-clusters', + 'scale-node-pool', 'deploy-nginx', 'deploy-gpu-pod', 'tke-ai-playbook', @@ -35,7 +36,9 @@ test('migrates every legacy cookbook into the new cookbook collection', () => { test('cluster API docs link to local cookbook scripts', () => { const clusterCookbooks = new Map( cookbooks - .filter((cookbook) => ['create-cluster', 'delete-cluster', 'describe-clusters'].includes(cookbook.id)) + .filter((cookbook) => + ['create-cluster', 'delete-cluster', 'describe-clusters', 'scale-node-pool'].includes(cookbook.id) + ) .map((cookbook) => [cookbook.id, cookbook]) ); @@ -44,6 +47,10 @@ test('cluster API docs link to local cookbook scripts', () => { clusterCookbooks.get('describe-clusters')?.files?.includes('cookbook/cluster/describe_clusters.py'), true ); + assert.equal( + clusterCookbooks.get('scale-node-pool')?.files?.includes('cookbook/node-pool/scale_node_pool.py'), + true + ); }); test('every cookbook exposes execution experience metadata', () => {