Hyperpod Node Debugger
AWS LabsOfficialPublished by AWS Labs
- Skill
- Workflow
- Cloud and DevOps
- Apache-2.0
- Low risk
- Has scripts
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
The HyperPod Node Debugger provides a structured, read-only diagnostic workflow for identifying and resolving issues on individual nodes within a HyperPod cluster. It covers a wide range of failure modes, including EFA networking, GPU/accelerator hardware faults (XID, ECC, NVLink), Slurm node states, resource exhaustion, and container runtime errors. The skill emphasizes a safe, non-destructive triage process, requiring explicit customer approval for any remediation steps, and includes specific guidance for Infrastructure-as-Code (IaC) environments to prevent configuration drift.
What it does
- Performs read-only triage of cluster nodes to identify health failures.
- Maps detected symptoms to specific diagnostic sections for targeted troubleshooting.
- Provides verified commands for remediation, including rebooting or replacing nodes.
- Includes safety checks for IaC-managed resources to prevent configuration drift.
- Supports both EKS and Slurm orchestrators for HyperPod environments.
- Guides the user through a structured investigate-reboot-replace escalation path.
When to use it
- When a specific node in a HyperPod cluster is unhealthy, unresponsive, or stuck.
- When training jobs hang due to suspected hardware or networking issues on a single node.
- When GPU/accelerator errors (e.g., XID, ECC, NVLink) are suspected.
- When investigating node-level resource exhaustion (disk, memory, OOM).
- When Slurm nodes are marked as down or drained unexpectedly.
Advantages
- Standardizes the diagnostic process for complex HyperPod node failures.
- Reduces manual effort by automating the collection of cluster and node health signals.
- Prioritizes safety by enforcing a read-only triage phase before any remediation.
- Provides clear guidance on when to escalate to AWS Support.
- Integrates with existing IaC workflows to maintain infrastructure integrity.
Usage examples
- “My node i-0123456789abcdef0 is stuck in a Pending state. How do I check it?”
- The agent will run the triage script focused on that specific instance ID, identify the failure reason (e.g., lifecycle script or hardware), and provide the corresponding diagnostic steps.
- “I suspect a GPU hardware failure on node i-0987654321fedcba0. What should I do?”
- The agent will guide you to check for XID or ECC errors using the provided diagnostic commands and, if confirmed, suggest the appropriate batch-reboot or batch-replace procedure.
Install
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/awslabs/hyperpod-node-debugger/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip hyperpod-node-debugger.zip -d .agents/skills
Documentation by AWS Labs
HyperPod Node Debugger
Operating policy. Run read-only diagnostics yourself. Never run a command that changes cluster, node, or workload state — present each one as a Suggested command (run this yourself) block and wait for the customer. Destructive order: investigate → reboot → replace (replace destroys root + secondary volumes; not supported on Slurm controller nodes). Never discard training state, logs, or caches on speculation.
IaC note (always include with mutation commands). When you suggest any command that changes cluster, VPC, SG, subnet, or EKS configuration (e.g. authorize-security-group-*, modify-vpc-attribute, update-cluster, kubectl label/cordon/drain, create namespace, set env daemonset), ask the customer first whether the cluster / VPC / SG is managed by Infrastructure-as-Code (CloudFormation, CDK, Terraform, Pulumi). If yes, tell them: "Apply this change in your IaC source first, then deploy through the pipeline — running the command directly will drift from your template and the next stack update may overwrite it." If they need to fix the issue immediately and the IaC change will follow, flag the drift explicitly so they remember to reconcile.
Read-only triage. scripts/triage-cluster.sh (and helpers check-efa-sg.sh, check-node-reachability.sh, check-vpc-config.sh) read state and print each issue as [FAIL] ... → references/node-diagnostics-detail.md § <section>. Catalog of customer-ticket patterns: references/node-issue-catalog.md.
Workflow
Collect cluster name, region, suspect instance ID, exact error string from logs.
Run
scripts/triage-cluster.sh(add--node <INSTANCE-ID>to focus one node).For every
[FAIL]/ issue entry,Readthe referenced section.Present: what script detected (copy the line verbatim), root cause, exact command(s) with instance/SG IDs filled in, blast radius (e.g. "reboots i-xxx", "wipes volumes on replacement"). For any command that mutates cluster/VPC/SG/EKS state, ask whether the affected resource is IaC-managed and surface the drift warning from the operating-policy note above.
Wait for explicit customer approval. Destructive order: investigate → reboot → replace.
Re-run triage to confirm. Iterate if not cleared.
Step 1: Triage
bash scripts/triage-cluster.sh --cluster <CLUSTER_NAME_OR_ARN> --region <REGION>
# Focus on one node:
bash scripts/triage-cluster.sh --cluster <CLUSTER_NAME_OR_ARN> --region <REGION> --node <INSTANCE_ID>One pass collects: cluster status + NodeRecovery, events, per-node health (HyperPod + EKS labels, Slurm states), VPC/SG snapshot, CloudWatch availability, SSM readiness, on-node resource checks (disk, memory, /dev/shm, OOM, NVMe, time sync, SSM agent), Slurm node→instance mapping.
Tags: [PASS] passed · [FAIL] issue with a → references/... pointer · [WARN] advisory · [INFO] informational. Priorities: P0 blocks operation · P1 degraded · P2 informational.
Step 2: Match signal → section
Events (list-cluster-events) — provisioning-time:
| Event | Section |
|---|---|
"EFA health checks did not run successfully" (public-doc verbatim signal) | A: EFA/SG |
| Instance bootstrap or network-misconfiguration event | A + B: VPC |
| Lifecycle-script failure or timeout | D: Lifecycle |
| Insufficient-capacity or AZ-mismatch failure at creation | C: Capacity |
Hardware failure / UnschedulablePendingReplacement | F: Hardware |
EKS labels:
| Label | Section |
|---|---|
node-health-status: UnschedulablePendingReplacement | F |
node-health-status: UnschedulablePendingReboot | F |
deep-health-check-status: Failed | G → F |
Symptoms:
| Symptom | Section |
|---|---|
| Training hangs at NCCL init / AllReduce | A → E |
Slurm node down / "Node unexpectedly rebooted" | H: Slurm |
| Jobs stuck PENDING / COMPLETING | H |
| Auto-repair not triggering | F |
| GPU not visible / XID / ECC errors | G |
| GPU row-remap pending/failed / silent NaNs / DCGM Fail | G § G.1.a/b |
Disk full / OOM / "Cannot allocate memory" | I: Resources |
| Wrong vCPU count (e.g. 96 instead of 192 on p5.48xlarge) | J: Config |
| Container CrashLoopBackOff / runtime crash | M: Container Runtime |
aws-node CrashLoopBackOff / gRPC 50051 refused | O: CNI / Pod Networking |
| Pods stuck Pending with no IP / CNI error | O |
DNS resolution / enableDnsSupport | B § B.2 |
| Public subnet / IGW misconfigured | B § B.3 |
| Missing VPC endpoints (ECR / STS / FSx) | B § B.4 |
| EKS VPC / SG mismatch with HyperPod | B § B.5 |
| Kernel panic / watchdog / hung task | N: Kernel |
| Need shell on a node | K: SSM |
| Collect logs for AWS Support | L: Log Collection |
A: EFA / Security Group
Per the HyperPod prerequisites doc, the SG must allow all inbound and outbound to itself. scripts/check-efa-sg.sh validates self-ref rules on every cluster SG. On-node EFA check via scripts/check-node-reachability.sh over SSM. Full: § A.
B: VPC / Routing
SG/subnet VPC mismatch, missing S3 Gateway endpoint, EKS auth mode, worker→controller routing, VPC DNS support, private-subnet + NAT / VPC endpoints, EKS↔HyperPod VPC alignment. scripts/check-vpc-config.sh. Full: § B.
C: Capacity / AZ
Insufficient-capacity failure at creation, or no subnets in the AZ where capacity is available. Check AZ offerings via describe-instance-type-offerings, then change subnet AZ or use Flexible Training Plans / ODCR. Full: § C.
D: Lifecycle Scripts
Surfaced in cluster events + CloudWatch under LifecycleConfig/<group>/<instance-id>. Common: S3 connectivity, IAM gaps, CRLF line endings, infinite loops, parameter-name mismatch. Full: § D.
E: Software Versions
Delegate to hyperpod-version-checker to compare NVIDIA driver, CUDA, NCCL, EFA installer, OFI NCCL, PyTorch across nodes. Ensure job env has FI_PROVIDER=efa, FI_EFA_USE_DEVICE_RDMA=1, NCCL_SOCKET_IFNAME=^lo,docker. Full: § E.
F: Hardware / Auto-Repair
Confirm NodeRecovery=Automatic, inspect the EKS health labels + sagemaker.amazonaws.com/fault-details annotation, and read the SagemakerHealthMonitoringAgent/<group>/<instance> CloudWatch stream. HMA runs passive background checks on GPU and Neuron state and reboots the node on count mismatch (per the HMA doc: "if there's a mismatch between the expected number of GPUs … and the count returned by nvidia-smi, then HMA reboots the node"; same for neuron-ls). Manual recovery order: reboot first, replace only if reboot fails; the preferred path is the batch APIs (BatchReboot/BatchReplaceClusterNodes). Full: § F · patterns: node-issue-catalog.md.
G: GPU / Accelerator
NVIDIA (p4d/p5/g5/g6): nvidia-smi + dmesg over SSM for Xid, ECC, thermal throttling. Xid classification per NVIDIA's catalog: 13 Graphics Engine Exception (application-level), 31 GPU memory page fault (application, can be driver/HW), 63 GPU memory remapping event (HW/ECC), 71 CE4 Error (HW copy engine), 74 NVLink Error (HW), 79 GPU has fallen off the bus (PCIe bus), 109 Context Switch Timeout Error (HW). Any uncorrectable ECC → drain and replace. Row-remap state is the authoritative silent-degradation signal (§ G.1.a).
Trainium / Inferentia (trn1/trn2/inf2): Neuron SDK — neuron-ls, neuron-top, neuron-monitor. nvidia-smi does not apply.
GPU / accelerator failures flow into § F for reboot / replace. Full: § G.
H: Slurm Node Management
Node down/unresponsive, unexpected reboots, stuck PENDING/COMPLETING jobs, Slurm-to-instance-ID translation. Primary access is SSM; diagnose slurmd first, fix the root cause, then start/resume the node per § H. Full: § H.
I: Resource Exhaustion
Disk full (HyperPod root volume defaults to 100 GB and is not intended to grow post-creation), OOM, os.fork() memory error, /dev/shm exhaustion, inode exhaustion. Fork-memory fix: export FI_EFA_USE_HUGE_PAGE=0. Redirect bulk data to /opt/sagemaker (secondary EBS) or /opt/dlami/nvme (instance store). Full: § I.
J: Configuration
p5.48xlarge reports 96 vCPU instead of 192 → set ThreadsPerCore=2 via update-cluster. Full: § J.
K: Node Access via SSM
No direct SSH on HyperPod. Target format sagemaker-cluster:<CLUSTER_ID>_<GROUP>-<INSTANCE_ID>. Failures: plugin missing, wrong prefix, IAM, VPC endpoints. Full: § K.
L: Log Collection
Delegate to hyperpod-issue-report for S3-stored bundles. Key CloudWatch streams: LifecycleConfig/<group>/<instance-id>, SagemakerHealthMonitoringAgent/<group>/<instance-id>. Full: § L.
M: Container Runtime
CrashLoopBackOff, OOMKilled, ImagePullBackOff, RunContainerError on EKS. kubectl describe pod + on-node crictl ps -a, journalctl -u containerd. Full: § M.
N: Kernel & System
Kernel panic, watchdog timeout, soft lockup, unexpected reboots not explained by HyperPod health monitoring. dmesg | grep -iE 'panic|watchdog|hung_task|NMI' + journalctl -b -1. nvrm-related signatures point at NVIDIA driver crashes. Full: § N.
O: CNI / Pod Networking
VPC CNI (aws-node) failures, IPAMD errors, gRPC 127.0.0.1:50051 refused, pods stuck Pending with FailedCreatePodSandBox. Script auto-checks aws-node, kube-proxy, CoreDNS. Full: § O.
Prerequisites
awsCLI v2, recent enough to support the HyperPod cluster commands (describe-cluster,list-cluster-nodes,batch-reboot-cluster-nodes,batch-replace-cluster-nodes)python3,bash4+ (associative arrays are required by the scripts)kubectlauthenticated to the EKS cluster (K8s checks skipped if absent)session-manager-pluginfor on-node hardware checksunbuffer(from theexpectpackage) — optional; if missing, SSM on-node probes are skipped while the rest of the triage still runs. Install viayum install expect/apt install expect.
Defaults
Region — required: pass
--regionor set$AWS_DEFAULT_REGION.Target scope — all nodes;
--node <ID>focuses one.Event window — up to 500 most recent events (5 × 100, paginated).
Node list cap — up to 20,000 nodes (200 × 100); warns on cap.
SSM probes — 180 s per node with retry-on-throttle.
Colors — auto-disabled on non-TTY;
--no-colorto force off.
Error handling
| Failure | Script | Tell the customer |
|---|---|---|
aws sts get-caller-identity fails | Exit 1 | "Fix AWS credentials and rerun." |
describe-cluster fails | Exit 1 after listing region's clusters | "Confirm cluster name and region." |
sagemaker:* / ec2:* / logs:* AccessDenied | Warn, add Missing IAM permission for <API>, continue | "Grant the listed IAM action and rerun." |
kubectl absent or unauthenticated | Skip K8s checks | "Install/authenticate kubectl (see § K)." |
session-manager-plugin absent | Skip on-node probes | "Install session-manager-plugin (see § K)." |
SSM start-session fails or times out (180s) | Mark node unreachable with → § K pointer | "Rerun with --node <ID> to isolate; verify SSM agent on the node." |
| Cluster > 20,000 nodes | First 20,000 paginated; warn | "Use --node to target specific nodes." |
Exit codes: 0 triage complete · 1 cluster not found or fatal prerequisite missing.
IAM permissions
Read-only diagnostic — covers triage-cluster.sh, check-efa-sg.sh, check-vpc-config.sh, and check-node-reachability.sh:
{
"Action": [
"sagemaker:DescribeCluster",
"sagemaker:DescribeClusterNode",
"sagemaker:ListClusterNodes",
"sagemaker:ListClusterEvents",
"sagemaker:ListClusters",
"eks:DescribeCluster",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSubnets",
"ec2:DescribeVpcs",
"ec2:DescribeVpcAttribute",
"ec2:DescribeVpcEndpoints",
"ec2:DescribeRouteTables",
"ec2:DescribeNetworkInterfaces",
"ec2:DescribeInstances",
"ec2:DescribeInstanceTypeOfferings",
"ec2:DescribeInstanceTypes",
"logs:DescribeLogGroups",
"logs:DescribeLogStreams",
"logs:FilterLogEvents",
"ssm:StartSession",
"ssm:TerminateSession",
"service-quotas:GetServiceQuota"
]
}sts:GetCallerIdentity is implicit — it requires no IAM action. SSM on HyperPod uses start-session against sagemaker-cluster:<cluster-id>_<group>-<iid> targets — not send-command against bare instance IDs. For remediation commands, grant the matching write permission (e.g. ec2:AuthorizeSecurityGroupIngress / Egress, ec2:RevokeSecurityGroupIngress / Egress, ec2:ModifyVpcAttribute, sagemaker:UpdateCluster, sagemaker:BatchRebootClusterNodes, sagemaker:BatchReplaceClusterNodes). Not needed for the diagnostic itself.
Skill delegation
| Need | Use |
|---|---|
| Cluster creation / deployment failures | hyperpod-cluster-debugger (§ A / B / C / H + --validate) |
| Cluster-wide SSM outage | hyperpod-cluster-debugger § F |
| Single-node SSM failure | stay here — § K |
| Cluster-wide EFA health-check failure at creation time | hyperpod-cluster-debugger § A |
| Single-node EFA failure post-provisioning | stay here — § A |
| NCCL AllReduce / collective-op timeouts (distributed) | hyperpod-nccl |
| Silent GPU NaNs on a specific node (row-remap / DCGM) | stay here — § G.1 (even if discovered by NCCL) |
| Post-deployment cluster-wide management | hyperpod-cluster-debugger |
| Shell / commands on nodes | hyperpod-ssm |
| CUDA / NCCL / EFA version comparison | hyperpod-version-checker |
| Diagnostic bundle for AWS Support | hyperpod-issue-report |
| Training performance / MFU degradation | hyperpod-mfu-debugger |
Escalate to AWS Support
Escalate when:
SG rules correct and reachability passes but EFA still fails.
VPC correct but K8s bootstrap fails — check VPC flow logs for REJECT.
Hardware failure where replacement keeps failing (bad physical host).
Node replacement fails with an insufficient-capacity signal despite a valid ODCR.
Before opening the case
# 1. Cluster identity + affected node status
aws sagemaker describe-cluster --cluster-name <CLUSTER> --region <REGION>
aws sagemaker list-cluster-nodes --cluster-name <CLUSTER> --region <REGION> \
--query "ClusterNodeSummaries[?InstanceId=='<INSTANCE_ID>']"
# 2. Triage bundle (scoped to the affected node where possible)
bash scripts/triage-cluster.sh --cluster <CLUSTER> --region <REGION> --node <INSTANCE_ID> > triage.txt
# 3. Per-node log/config bundle to S3 (delegates to hyperpod-issue-report)
# See skills/hyperpod-issue-report/SKILL.md for the exact invocation.Include in the case
Cluster name + ARN and AWS region
Orchestrator (EKS or Slurm)
Affected instance IDs / node names / instance-group names
Timestamp window (UTC start / end) of the failure
Exact error strings observed (copy verbatim from pod logs, CloudWatch, dmesg, events)
XID numbers / ECC counts / DCGM output where hardware is implicated
triage.txtfrom step 2 aboveS3 URI of the
hyperpod-issue-reportbundle from step 3
Patterns from real customer tickets: node-issue-catalog.md.
Files
- ATTRIBUTION.mdAdded by AutoGineer928 B
- LICENSELicence file added by AutoGineer9.9 KiB
- NOTICELicence file added by AutoGineer89 B
- SKILL.md20.4 KiB
- references/node-diagnostics-detail.md69.0 KiB
- references/node-issue-catalog.md5.6 KiB
- scripts/check-efa-sg.shScript13.2 KiB
- scripts/check-node-reachability.shScript17.0 KiB
- scripts/check-vpc-config.shScript20.5 KiB
- scripts/triage-cluster.shScript57.6 KiB
scripts/triage-cluster.sh
#!/usr/bin/env bash
# triage-cluster.sh — read-only HyperPod node triage.
#
# Collects signals to route node issues to the right reference section:
# - Cluster status, orchestrator, NodeRecovery
# - Cluster events (root-cause signal for provisioning failures)
# - Per-node health (HyperPod + EKS labels, Slurm state)
# - VPC / SG config
# - SSM reachability to compute nodes (hardware checks)
#
# Read-only: never modifies cluster state, never prints remediation commands.
# Each [FAIL] / added issue carries a pointer of the form
# "... → references/node-diagnostics-detail.md § <section>"
# which the hyperpod-node-debugger skill uses to look up remediation.
#
# Usage:
# bash triage-cluster.sh --cluster <name-or-arn> --region <region>
# bash triage-cluster.sh --cluster <name-or-arn> --region <region> --node <instance-id>
#
# Exit codes:
# 0 No critical (P0/P1) issues; P2 informational findings are allowed.
# 1 One or more critical issues, or a fatal prerequisite / cluster-not-found.
# 2 Invalid argument.
set -euo pipefail
for cmd in aws python3; do
command -v "$cmd" &>/dev/null || {
echo "ERROR: '$cmd' is required but not found." >&2
exit 1
}
done
HAS_UNBUFFER=true
if ! command -v unbuffer &>/dev/null; then
HAS_UNBUFFER=false
fi
CLUSTER=""
REGION="${AWS_DEFAULT_REGION:-}"
TARGET_NODE=""
USE_COLOR=true
usage() {
cat <<EOF
Usage: $0 --cluster <name-or-arn> --region <region> [options]
Options:
--cluster <name-or-arn> HyperPod cluster name or ARN (required)
--region <region> AWS region (required unless \$AWS_DEFAULT_REGION is set)
--node <instance-id> Focus on a single instance ID
--no-color Disable ANSI colors
-h, --help This message
Read-only diagnostic. Every [FAIL] line carries a pointer like
"→ references/node-diagnostics-detail.md § <section>" which the
hyperpod-node-debugger skill uses to look up remediation.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--cluster) [[ $# -lt 2 ]] && { echo "ERROR: --cluster needs a value"; exit 2; }
[[ ! "$2" =~ ^(arn:aws[a-z-]*:sagemaker:[a-z0-9-]+:[0-9]{12}:cluster/[a-z0-9]{12}|[a-zA-Z0-9]([-a-zA-Z0-9]{0,62}))$ ]] && { echo "ERROR: --cluster must be a valid HyperPod cluster name or ARN (got '$2')"; exit 2; }
CLUSTER="$2"; shift 2 ;;
--region) [[ $# -lt 2 ]] && { echo "ERROR: --region needs a value"; exit 2; }
[[ ! "$2" =~ ^[a-z]{2}(-[a-z]+){1,2}-[0-9]+$ ]] && { echo "ERROR: --region must be a valid AWS region (got '$2')"; exit 2; }
REGION="$2"; shift 2 ;;
--node) [[ $# -lt 2 ]] && { echo "ERROR: --node needs a value"; exit 2; }
[[ ! "$2" =~ ^i-[0-9a-f]{8,17}$ ]] && { echo "ERROR: --node must be an EC2 instance ID (i-xxxxxxxx...)"; exit 2; }
TARGET_NODE="$2"; shift 2 ;;
--no-color) USE_COLOR=false; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1"; usage; exit 2 ;;
esac
done
[[ -z "$CLUSTER" ]] && {
echo "Usage: $0 --cluster <name-or-arn> --region <region> [--node <instance-id>]"
exit 1
}
if [[ -z "$REGION" ]]; then
echo "ERROR: --region is required (or set AWS_DEFAULT_REGION before running)." >&2
exit 2
fi
_CREDS=$(aws sts get-caller-identity --output json 2>&1) || {
echo "ERROR: AWS credentials not configured or expired."
echo "$_CREDS"
echo ""
echo "→ references/node-diagnostics-detail.md § K (Node Access via SSM) for credential setup"
exit 1
}
# Auto-disable colors when stdout is not a TTY (agent-piped / redirected).
if ! [ -t 1 ] || [ "${TERM:-}" = "dumb" ]; then
USE_COLOR=false
fi
if "$USE_COLOR"; then
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
else
RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; NC=''
fi
section() { echo ""; echo -e "${BOLD}${CYAN}════════════════════════════════════════════════════════${NC}"; echo -e "${BOLD}${CYAN} $1${NC}"; echo -e "${BOLD}${CYAN}════════════════════════════════════════════════════════${NC}"; }
ok() { echo -e " ${GREEN}[PASS]${NC} $1"; }
warn() { echo -e " ${YELLOW}[WARN]${NC} $1"; }
bad() { echo -e " ${RED}[FAIL]${NC} $1"; }
info() { echo -e " ${BOLD}[INFO]${NC} $1"; }
ISSUES_FOUND=()
add_issue() {
local priority="${2:-P1}"
ISSUES_FOUND+=("${priority}|$1")
}
aws_check_perms() {
local result="$1" api_name="$2"
if echo "$result" | grep -qiE "AccessDenied|UnauthorizedOperation|not authorized|AuthorizationError"; then
warn "Permission denied: $api_name — results may be incomplete"
add_issue "Missing IAM permission for $api_name → references/node-diagnostics-detail.md § K (Node Access via SSM)" "P1"
return 0
fi
return 1
}
_TEMP_FILES=()
cleanup_temp() {
[[ ${#_TEMP_FILES[@]} -gt 0 ]] && rm -f "${_TEMP_FILES[@]}" 2>/dev/null || true
}
trap cleanup_temp EXIT
# Run a shell command on a HyperPod node via SSM.
#
# HyperPod uses a SageMaker-managed instance fleet, so `aws ssm send-command`
# with a bare instance-id is not supported. The supported path is
# `aws ssm start-session` with target `sagemaker-cluster:<cluster-id>_<group>-<iid>`
# and document `AWS-StartNonInteractiveCommand`.
#
# Usage: ssm_run_on_node <instance-id> <instance-group-name> "<shell command>"
# Returns remote stdout. start-session does not propagate the remote exit code.
ssm_run_on_node() {
local iid="$1" grp="$2" cmd="$3"
[[ -z "$iid" || -z "$grp" || -z "$cmd" ]] && return 1
[[ ! "$iid" =~ ^i-[0-9a-f]{8,17}$ ]] && return 1
[[ -z "${CLUSTER_ID:-}" ]] && return 1
[[ ! "$grp" =~ ^[A-Za-z0-9._-]+$ ]] && return 1
if [[ "${HAS_UNBUFFER:-true}" != "true" ]]; then
echo " [SKIP] on-node SSM probe skipped — install 'unbuffer' (expect package) to enable" >&2
return 1
fi
local target="sagemaker-cluster:${CLUSTER_ID}_${grp}-${iid}"
local tmp; tmp=$(mktemp 2>/dev/null) || return 1
chmod 600 "$tmp" 2>/dev/null || true
_TEMP_FILES+=("$tmp")
# Embed the command as base64 because AWS-StartNonInteractiveCommand
# collapses newlines in a single command element.
local cmd_b64
cmd_b64=$(printf '%s' "$cmd" | base64 | tr -d '\n') || return 1
local remote="bash -c \"echo $cmd_b64 | base64 -d | bash\""
python3 -c "import json,sys; print(json.dumps({'command':[sys.argv[1]]}))" "$remote" > "$tmp" || return 1
local attempt=0 out rc
while (( attempt < 5 )); do
out=$(unbuffer timeout 180 aws ssm start-session \
--target "$target" \
--document-name AWS-StartNonInteractiveCommand \
--parameters "file://$tmp" \
--region "$REGION" 2>&1)
rc=$?
# SSM sometimes returns rc=0 with a transport error baked into stdout —
# retry those (EOF, SessionManagerPlugin not found, i/o timeout).
if (( rc == 0 )) && ! echo "$out" | grep -qiE "Cannot perform start session|EOF$|SessionManagerPlugin is not found|ERROR: Unable to|i/o timeout"; then
# Strip SSM session banners and the echoed base64 command line.
echo "$out" | grep -vE '^(Starting session with SessionId:|Exiting session with sessionId:|\s*$)' \
| grep -vE "^(bash -c \"echo [A-Za-z0-9+/=]+ \| base64 -d \| bash\"|echo '[A-Za-z0-9+/=]+'|[A-Za-z0-9+/=]{40,}={0,2})[[:space:]]*\|?[[:space:]]*base64?[[:space:]]*-?d?[[:space:]]*\|?[[:space:]]*bash\"?\$" || true
return 0
fi
if echo "$out" | grep -qiE "ThrottlingException|RequestLimitExceeded|InternalFailure|InternalError|ServiceUnavailable|TooManyUpdates|Cannot perform start session|EOF$|SessionManagerPlugin is not found|i/o timeout"; then
attempt=$((attempt + 1))
sleep $((attempt * 3))
continue
fi
# Non-transient error; surface stderr so callers can diagnose.
echo "$out" >&2
return 1
done
return 1
}
echo ""
echo -e "${CYAN}${BOLD}HyperPod Node Triage — READ-ONLY${NC}"
echo -e "${CYAN} No cluster state will be modified. Each issue line below includes a${NC}"
echo -e "${CYAN} pointer to references/node-diagnostics-detail.md for remediation.${NC}"
section "1. Cluster Identity"
CLUSTER_JSON=$(aws sagemaker describe-cluster \
--cluster-name "$CLUSTER" \
--region "$REGION" \
--cli-read-timeout 30 \
--output json 2>&1) || {
echo -e "${RED}ERROR: Cannot find cluster '$CLUSTER' in region '$REGION'${NC}"
echo ""
echo "Available clusters in $REGION:"
aws sagemaker list-clusters --region "$REGION" \
--query 'ClusterSummaries[*].{Name:ClusterName,Status:ClusterStatus,ARN:ClusterArn}' \
--output table 2>/dev/null || echo " (unable to list)"
exit 1
}
CLUSTER_ARN=$(echo "$CLUSTER_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('ClusterArn',''))" 2>/dev/null || echo "")
CLUSTER_STATUS=$(echo "$CLUSTER_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('ClusterStatus',''))" 2>/dev/null || echo "")
ORCHESTRATOR=$(echo "$CLUSTER_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print('EKS' if 'Eks' in d.get('Orchestrator',{}) else 'Slurm')" 2>/dev/null || echo "Unknown")
NODE_RECOVERY=$(echo "$CLUSTER_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('NodeRecovery','Unknown'))" 2>/dev/null || echo "Unknown")
CLUSTER_ID=$(echo "$CLUSTER_ARN" | cut -d/ -f2 2>/dev/null || echo "")
echo -e " ARN: ${CLUSTER_ARN}"
echo -e " Status: ${BOLD}${CLUSTER_STATUS}${NC}"
echo -e " Orchestrator: ${ORCHESTRATOR}"
echo -e " NodeRecovery: ${NODE_RECOVERY}"
echo -e " ClusterId: ${CLUSTER_ID}"
[[ "$NODE_RECOVERY" == "None" || "$NODE_RECOVERY" == "Unknown" ]] && \
warn "NodeRecovery is '$NODE_RECOVERY' — auto-replacement disabled. Manual intervention required for hardware failures."
section "2. Cluster Events (Root Cause Signals)"
# Fetch multiple pages and merge into a single JSON blob. Cap at 500 events to
# bound memory and runtime on long-lived clusters (each page is up to 100).
fetch_cluster_events() {
local merged='[]' token='' page_json i=0
while (( i < 5 )); do
# Only pass --next-token if the token parses as a non-empty, strictly
# base64/URL-safe string. Sending garbage (e.g. an error message that
# leaked into $token) would cause ValidationException / BadRequest.
if [[ -n "$token" && "$token" =~ ^[a-zA-Z0-9/+]*={0,2}$ ]]; then
page_json=$(aws sagemaker list-cluster-events \
--cluster-name "$CLUSTER" --region "$REGION" \
--max-results 100 --next-token "$token" \
--cli-read-timeout 30 --output json 2>&1) || break
else
page_json=$(aws sagemaker list-cluster-events \
--cluster-name "$CLUSTER" --region "$REGION" \
--max-results 100 \
--cli-read-timeout 30 --output json 2>&1) || break
fi
local combined
combined=$(printf '%s\0%s' "$merged" "$page_json" | python3 -c "
import sys, json
blob = sys.stdin.buffer.read()
try:
a, b = blob.split(b'\0', 1)
merged = json.loads(a)
page = json.loads(b)
except (json.JSONDecodeError, ValueError):
sys.exit(2)
merged.extend(page.get('Events', []))
print(json.dumps(merged))
print(page.get('NextToken','') or '')
" 2>/dev/null) || break
merged=$(printf '%s\n' "$combined" | sed -n '1p')
token=$(printf '%s\n' "$combined" | sed -n '2p')
i=$((i+1))
[[ -z "$token" ]] && break
done
printf '%s' "$merged" | python3 -c "
import sys, json
try:
print(json.dumps({'Events': json.loads(sys.stdin.read())}))
except json.JSONDecodeError:
print('{\"Events\":[]}')
" 2>/dev/null || echo '{"Events":[]}'
}
EVENTS=$(fetch_cluster_events)
if [[ -z "$EVENTS" ]] || echo "$EVENTS" | grep -qE "AccessDenied|not authorized"; then
aws_check_perms "$EVENTS" "sagemaker:ListClusterEvents"
EVENTS='{"Events":[]}'
fi
EVENT_COUNT=$(echo "$EVENTS" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('Events',[])))" 2>/dev/null || echo "0")
if [[ "$EVENT_COUNT" -gt 0 ]]; then
echo -e " Found ${BOLD}${EVENT_COUNT}${NC} cluster events. Recent events:"
echo ""
echo "$EVENTS" | python3 -c "
import sys, json
events = json.load(sys.stdin).get('Events', [])
for e in events[:20]:
ts = e.get('EventTime','?')
msg = e.get('Description','') or ''
grp = e.get('InstanceGroupName','') or ''
rt = e.get('ResourceType','') or ''
tag = ''
low = msg.lower()
if 'EFA health checks did not run' in msg:
tag = ' ← [GO TO SECTION A: EFA/SG FIX]'
elif 'bootstrap failed' in low and 'network' in low:
tag = ' ← [GO TO SECTION A+B: VPC/EKS FIX]'
elif 'Lifecycle scripts' in msg or 'lifecycle script' in low:
tag = ' ← [GO TO SECTION D: LIFECYCLE FIX]'
elif 'hardware failure' in low:
tag = ' ← [GO TO SECTION F: HARDWARE]'
elif 'insufficient capacity' in low or 'sufficient capacity' in low:
tag = ' ← [GO TO SECTION C: CAPACITY]'
elif 'failed to provision' in low:
tag = ' ← [CHECK SECTION C or F]'
elif 'successfully' in low and 'failed' not in low:
tag = ' [OK]'
label = (grp or rt or '?')
print(f' [{label}] {ts}')
print(f' {msg[:120]}{\"...\" if len(msg) > 120 else \"\"}{tag}')
print()
" 2>/dev/null
FAILURE_EVENTS=$(echo "$EVENTS" | python3 -c "
import sys,json
events=json.load(sys.stdin).get('Events',[])
fails=[(e.get('Description','') or '') for e in events if any(k in (e.get('Description','') or '').lower() for k in ['failed','error','timeout','fault','unhealthy'])]
for f in fails[:5]:
print(f)
" 2>/dev/null || echo "")
if echo "$FAILURE_EVENTS" | grep -qi "efa health"; then
add_issue "EFA health check failure → references/node-diagnostics-detail.md § A (EFA / Security Group)" "P0"
fi
if echo "$FAILURE_EVENTS" | grep -qi "network misconfiguration\|bootstrap failed"; then
add_issue "K8s bootstrap network error → references/node-diagnostics-detail.md § A (EFA / Security Group) + § B (VPC / Routing)" "P1"
fi
if echo "$FAILURE_EVENTS" | grep -qi "lifecycle script"; then
add_issue "Lifecycle script failure → references/node-diagnostics-detail.md § D (Lifecycle Scripts)" "P1"
fi
if echo "$FAILURE_EVENTS" | grep -qi "hardware failure"; then
add_issue "Hardware failure detected → references/node-diagnostics-detail.md § F (Hardware / Auto-Repair)" "P1"
fi
if echo "$FAILURE_EVENTS" | grep -qi "insufficient capacity"; then
add_issue "Insufficient capacity → references/node-diagnostics-detail.md § C (Capacity / AZ)" "P1"
fi
else
warn "No cluster events available (may be Slurm cluster or no events yet)"
fi
section "3. Node Health Status"
# Paginate list-cluster-nodes — default page is only 10 nodes, so large clusters
# would otherwise be diagnosed on a tiny sample.
fetch_all_cluster_nodes() {
local merged='[]' token='' page_json combined i=0
local max_pages=200 # 200 × 100 = 20 000 nodes, supports 7k+ clusters
while (( i < max_pages )); do
# Validate token format before sending — avoid BadRequest on garbage.
if [[ -n "$token" && "$token" =~ ^[a-zA-Z0-9/+]*={0,2}$ ]]; then
page_json=$(aws sagemaker list-cluster-nodes \
--cluster-name "$CLUSTER" --region "$REGION" \
--max-results 100 --next-token "$token" \
--cli-read-timeout 30 --output json 2>&1) || break
else
page_json=$(aws sagemaker list-cluster-nodes \
--cluster-name "$CLUSTER" --region "$REGION" \
--max-results 100 \
--cli-read-timeout 30 --output json 2>&1) || break
fi
# Merge via stdin (NUL-delimited) to avoid ARG_MAX truncation at ~500 nodes.
combined=$(printf '%s\0%s' "$merged" "$page_json" | python3 -c "
import sys, json
blob = sys.stdin.buffer.read()
try:
a, b = blob.split(b'\0', 1)
merged = json.loads(a)
page = json.loads(b)
except (json.JSONDecodeError, ValueError):
sys.exit(2)
merged.extend(page.get('ClusterNodeSummaries', []))
print(json.dumps(merged))
print(page.get('NextToken','') or '')
" 2>/dev/null) || break
merged=$(printf '%s\n' "$combined" | sed -n '1p')
token=$(printf '%s\n' "$combined" | sed -n '2p')
i=$((i+1))
[[ -z "$token" ]] && break
done
if (( i == max_pages )) && [[ -n "$token" ]]; then
echo "WARN: list-cluster-nodes truncated at ${max_pages} pages (~$((max_pages*100)) nodes). Diagnostic sample is incomplete for very large clusters." >&2
fi
printf '%s' "$merged" | python3 -c "
import sys, json
try:
print(json.dumps({'ClusterNodeSummaries': json.loads(sys.stdin.read())}))
except json.JSONDecodeError:
print('{\"ClusterNodeSummaries\":[]}')
" 2>/dev/null || echo '{"ClusterNodeSummaries":[]}'
}
NODES_JSON=$(fetch_all_cluster_nodes)
if [[ -z "$NODES_JSON" ]] || echo "$NODES_JSON" | grep -qE "AccessDenied|not authorized"; then
aws_check_perms "$NODES_JSON" "sagemaker:ListClusterNodes"
NODES_JSON='{"ClusterNodeSummaries":[]}'
fi
TOTAL_NODES=$(echo "$NODES_JSON" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('ClusterNodeSummaries',[])))" 2>/dev/null || echo "0")
RUNNING_NODES=$(echo "$NODES_JSON" | python3 -c "import sys,json; print(sum(1 for n in json.load(sys.stdin).get('ClusterNodeSummaries',[]) if n.get('InstanceStatus',{}).get('Status')=='Running'))" 2>/dev/null || echo "0")
BAD_NODES=$(echo "$NODES_JSON" | python3 -c "import sys,json; print(sum(1 for n in json.load(sys.stdin).get('ClusterNodeSummaries',[]) if n.get('InstanceStatus',{}).get('Status') not in ('Running','')))" 2>/dev/null || echo "0")
echo -e " Total: ${TOTAL_NODES} Running: ${GREEN}${RUNNING_NODES}${NC} Problems: ${RED}${BAD_NODES}${NC}"
if [[ "$BAD_NODES" -gt 0 ]]; then
echo ""
echo -e " ${RED}Non-Running nodes:${NC}"
echo "$NODES_JSON" | python3 -c "
import sys,json
nodes=json.load(sys.stdin).get('ClusterNodeSummaries',[])
for n in nodes:
status=n.get('InstanceStatus',{})
st=status.get('Status','?')
if st not in ('Running',''):
iid=n.get('InstanceId','?')
grp=n.get('InstanceGroupName','?')
itype=n.get('InstanceType','?')
msg=status.get('Message','')
print(f' [FAIL] {iid} ({grp} / {itype})')
print(f' Status: {st}')
if msg:
print(f' Message: {msg[:100]}')
print()
" 2>/dev/null
add_issue "$BAD_NODES node(s) not Running → references/node-diagnostics-detail.md § F (Hardware / Auto-Repair)" "P1"
else
ok "All $TOTAL_NODES nodes are Running"
fi
if [[ -n "$TARGET_NODE" ]]; then
echo ""
echo -e " ${BOLD}Targeted node: ${TARGET_NODE}${NC}"
NODE_DETAIL=$(aws sagemaker describe-cluster-node \
--cluster-name "$CLUSTER" \
--node-id "$TARGET_NODE" \
--region "$REGION" \
--cli-read-timeout 30 \
--output json 2>&1 || true)
if echo "$NODE_DETAIL" | grep -qiE "ResourceNotFound|not found|ValidationException"; then
bad "Node '$TARGET_NODE' not found in cluster '$CLUSTER'"
info "Verify the instance ID belongs to this cluster:"
info " aws sagemaker list-cluster-nodes --cluster-name $CLUSTER --region $REGION --query 'ClusterNodeSummaries[*].InstanceId' --output text"
add_issue "Node $TARGET_NODE not found in cluster $CLUSTER → verify --cluster and --node arguments" "P0"
TARGET_NODE="" # clear so downstream SSM probe doesn't retry on nonexistent node
elif echo "$NODE_DETAIL" | grep -qiE "AccessDenied|UnauthorizedOperation"; then
warn "Permission denied: sagemaker:DescribeClusterNode — check IAM policy"
else
echo "$NODE_DETAIL" | python3 -c "
import sys,json
try:
d=json.load(sys.stdin).get('NodeDetails',{})
st=d.get('InstanceStatus',{})
print(f' Status: {st.get(\"Status\",\"?\")}')
print(f' Launch: {d.get(\"LaunchTime\",\"?\")}')
print(f' Message: {st.get(\"Message\",\"\")}')
print(f' Type: {d.get(\"InstanceType\",\"?\")}')
print(f' Group: {d.get(\"InstanceGroupName\",\"?\")}')
except Exception:
pass
" 2>/dev/null
fi
fi
if [[ "$ORCHESTRATOR" == "EKS" ]]; then
section "4. EKS Node Health Labels"
if command -v kubectl &>/dev/null; then
UNHEALTHY_LABELS=$(kubectl get nodes \
-l 'sagemaker.amazonaws.com/node-health-status notin (Schedulable)' \
-o custom-columns='NODE:.metadata.name,HEALTH:.metadata.labels.sagemaker\.amazonaws\.com/node-health-status,FAULT:.metadata.labels.sagemaker\.amazonaws\.com/fault-types,DHC:.metadata.labels.sagemaker\.amazonaws\.com/deep-health-check-status' \
--no-headers 2>/dev/null || echo "")
if [[ -n "$UNHEALTHY_LABELS" ]]; then
bad "Nodes with health issues:"
while IFS= read -r line; do
echo " $line"
if echo "$line" | grep -q "PendingReplacement"; then
add_issue "Node pending replacement (UnschedulablePendingReplacement) → references/node-diagnostics-detail.md § F (Hardware / Auto-Repair)" "P1"
elif echo "$line" | grep -q "PendingReboot"; then
add_issue "Node pending reboot (UnschedulablePendingReboot) → references/node-diagnostics-detail.md § F (Hardware / Auto-Repair)" "P1"
fi
done <<< "$UNHEALTHY_LABELS"
else
ok "All EKS nodes have healthy labels (Schedulable)"
fi
# Check deep health check status. Under `set -o pipefail`, a failed kubectl
# with `| wc -l || echo 0` yields "0\n0". Count safely via a tmp var.
DHC_FAILED_OUT=$(kubectl get nodes \
-l 'sagemaker.amazonaws.com/deep-health-check-status=Failed' \
-o name 2>/dev/null || true)
DHC_FAILED=$(echo -n "$DHC_FAILED_OUT" | grep -c . || true)
[[ -z "$DHC_FAILED" ]] && DHC_FAILED=0
[[ "$DHC_FAILED" -gt 0 ]] && bad "$DHC_FAILED node(s) have deep-health-check-status=Failed → references/node-diagnostics-detail.md § G (GPU/Accelerator) + § F (Hardware / Auto-Repair)"
else
warn "kubectl not available — cannot check EKS node labels (install kubectl to enable this check)"
fi
fi
if [[ "$ORCHESTRATOR" == "EKS" ]] && command -v kubectl &>/dev/null; then
section "4a. EKS CNI & System Pod Health"
CNI_ISSUES=0
# aws-node (VPC CNI plugin) — if this crashes, no pods can get IPs
AWS_NODE_DS=$(kubectl get ds -n kube-system aws-node -o json 2>/dev/null || echo "")
if [[ -n "$AWS_NODE_DS" && "$AWS_NODE_DS" != "" ]]; then
AWS_NODE_STATUS=$(echo "$AWS_NODE_DS" | python3 -c "
import sys, json
ds = json.load(sys.stdin)
desired = ds.get('status',{}).get('desiredNumberScheduled', 0)
ready = ds.get('status',{}).get('numberReady', 0)
unavail = ds.get('status',{}).get('numberUnavailable', 0)
if unavail > 0:
print(f'FAIL:{unavail} of {desired} aws-node pods not ready — pod networking broken on those nodes')
elif ready == desired and desired > 0:
print(f'PASS:aws-node DaemonSet healthy ({ready}/{desired} ready)')
elif desired == 0:
print('WARN:aws-node DaemonSet has 0 desired pods')
else:
print(f'WARN:aws-node DaemonSet {ready}/{desired} ready')
" 2>/dev/null || echo "")
if [[ -n "$AWS_NODE_STATUS" ]]; then
_level="${AWS_NODE_STATUS%%:*}"
_msg="${AWS_NODE_STATUS#*:}"
case "$_level" in
PASS) ok "$_msg" ;;
FAIL) bad "$_msg"
add_issue "aws-node (VPC CNI) pods failing → references/node-diagnostics-detail.md § O (CNI / Pod Networking)" "P0"
CNI_ISSUES=$((CNI_ISSUES + 1))
;;
WARN) warn "$_msg" ;;
esac
fi
CNI_CRASHES=$(kubectl get pods -n kube-system -l k8s-app=aws-node --no-headers 2>/dev/null \
| grep -iE "CrashLoopBackOff|Error|ImagePullBackOff" || true)
if [[ -n "$CNI_CRASHES" ]]; then
bad "aws-node pods in crash state:"
echo "$CNI_CRASHES" | while IFS= read -r line; do echo " $line"; done
add_issue "aws-node CrashLoopBackOff — pod networking broken → references/node-diagnostics-detail.md § O (CNI / Pod Networking)" "P0"
CNI_ISSUES=$((CNI_ISSUES + 1))
CNI_LOGS=$(kubectl logs -n kube-system -l k8s-app=aws-node --tail=20 2>/dev/null | \
grep -iE "error|failed|refused|timeout|fatal|gRPC|ipamd|eni" | tail -5 || true)
if [[ -n "$CNI_LOGS" ]]; then
info "Recent aws-node error logs:"
echo "$CNI_LOGS" | while IFS= read -r line; do info " $line"; done
fi
fi
else
info "aws-node DaemonSet not found in kube-system (may use alternate CNI)"
fi
# kube-proxy — if down, service networking breaks
KP_CRASHES=$(kubectl get pods -n kube-system -l k8s-app=kube-proxy --no-headers 2>/dev/null \
| grep -iE "CrashLoopBackOff|Error|ImagePullBackOff" || true)
if [[ -n "$KP_CRASHES" ]]; then
bad "kube-proxy pods in crash state:"
echo "$KP_CRASHES" | while IFS= read -r line; do echo " $line"; done
add_issue "kube-proxy crash — service networking broken → references/node-diagnostics-detail.md § O (CNI / Pod Networking)" "P0"
CNI_ISSUES=$((CNI_ISSUES + 1))
fi
# CoreDNS — if down, DNS resolution fails (NCCL MASTER_ADDR, service discovery)
COREDNS_CRASHES=$(kubectl get pods -n kube-system -l k8s-app=kube-dns --no-headers 2>/dev/null \
| grep -iE "CrashLoopBackOff|Error|ImagePullBackOff" || true)
if [[ -n "$COREDNS_CRASHES" ]]; then
bad "CoreDNS pods in crash state — DNS resolution will fail:"
echo "$COREDNS_CRASHES" | while IFS= read -r line; do echo " $line"; done
add_issue "CoreDNS crash — DNS broken → references/node-diagnostics-detail.md § O (CNI / Pod Networking)" "P0"
CNI_ISSUES=$((CNI_ISSUES + 1))
fi
[[ "$CNI_ISSUES" -eq 0 ]] && ok "kube-system networking pods healthy (aws-node, kube-proxy, CoreDNS)"
fi
if [[ "$ORCHESTRATOR" == "Slurm" ]]; then
section "4b. Slurm Node States"
if command -v sinfo &>/dev/null; then
SLURM_DOWN=$(sinfo -o "%N %T %30E" --noheader 2>/dev/null | grep -iE "down|drain|fail" || true)
if [[ -n "$SLURM_DOWN" ]]; then
bad "Slurm nodes with issues:"
echo "$SLURM_DOWN" | while IFS= read -r line; do
echo " $line"
done
DOWN_COUNT=$(echo "$SLURM_DOWN" | grep -c .)
add_issue "$DOWN_COUNT Slurm node(s) down/drained → references/node-diagnostics-detail.md § H (Slurm Node Management)" "P1"
else
ok "All Slurm nodes show idle/alloc/mixed state"
fi
STUCK_JOBS=$(squeue -o "%i %j %T %R %N" --noheader 2>/dev/null | grep -iE "COMPLETING|CONFIGURING" || true)
if [[ -n "$STUCK_JOBS" ]]; then
warn "Stuck jobs detected (COMPLETING/CONFIGURING):"
echo "$STUCK_JOBS" | head -5 | while IFS= read -r line; do echo " $line"; done
add_issue "Stuck Slurm jobs → references/node-diagnostics-detail.md § H (Slurm Node Management)" "P1"
fi
else
info "Slurm CLI not available locally — to check Slurm node states, SSM into the head node:"
info " sinfo -o '%N %T %30E'"
info " squeue -o '%i %j %T %R %N'"
info ""
info "Or use SSM to run remotely:"
if [[ -n "$CLUSTER_ID" ]]; then
HEAD_NODE=$(echo "$NODES_JSON" | python3 -c "
import sys,json
nodes=json.load(sys.stdin).get('ClusterNodeSummaries',[])
for n in nodes:
g=n.get('InstanceGroupName','').lower()
if any(x in g for x in ['controller','head','master']):
print(n.get('InstanceId','') + ' ' + n.get('InstanceGroupName',''))
break
else:
for n in nodes:
if n.get('InstanceStatus',{}).get('Status')=='Running':
print(n.get('InstanceId','') + ' ' + n.get('InstanceGroupName',''))
break
" 2>/dev/null || echo "")
if [[ -n "$HEAD_NODE" ]]; then
H_IID=$(echo "$HEAD_NODE" | awk '{print $1}')
H_GRP=$(echo "$HEAD_NODE" | awk '{print $2}')
info " aws ssm start-session --target sagemaker-cluster:${CLUSTER_ID}_${H_GRP}-${H_IID} --region $REGION"
fi
fi
if command -v session-manager-plugin &>/dev/null && [[ -n "$HEAD_NODE" ]]; then
H_IID=$(echo "$HEAD_NODE" | awk '{print $1}')
# Validate instance ID format — defense-in-depth against unexpected input.
if [[ "$H_IID" =~ ^i-[0-9a-f]{8,17}$ ]]; then
info ""
info "Running Slurm checks via SSM on controller ${H_IID}..."
# Unique delimiter prevents false matches if check output happens to contain marker text.
local_nonce=$(date +%s%N 2>/dev/null || echo "$RANDOM")
SLURM_CHECK_SH=$(cat <<EOF
echo SLURM_CHECK_START_${local_nonce}
scontrol show config >/dev/null 2>&1 || echo SLURMCTLD_DOWN_${local_nonce}
echo DOWN_NODES_${local_nonce}
sinfo -o '%20N %10T %30E' --noheader 2>/dev/null | grep -iE 'down|drain|fail' | head -10
echo END_DOWN_${local_nonce}
echo STUCK_COUNT_${local_nonce}
squeue -o '%i %T' --noheader 2>/dev/null | grep -cE 'COMPLETING|CONFIGURING' || echo 0
echo MUNGE_${local_nonce}
systemctl is-active munge 2>/dev/null || echo inactive
echo SLURM_CHECK_END_${local_nonce}
EOF
)
SSM_STDOUT=$(ssm_run_on_node "$H_IID" "$H_GRP" "$SLURM_CHECK_SH" || echo "")
if [[ -z "$SSM_STDOUT" ]] || ! echo "$SSM_STDOUT" | grep -q "SLURM_CHECK_START_${local_nonce}"; then
warn "Slurm SSM probe returned no usable output — controller may be unreachable or SSM agent not responding"
add_issue "Slurm controller SSM probe failed → references/node-diagnostics-detail.md § K (Node Access via SSM) + § H (Slurm Node Management)" "P1"
fi
if echo "$SSM_STDOUT" | grep -q "SLURM_CHECK_START_${local_nonce}"; then
if echo "$SSM_STDOUT" | grep -q "SLURMCTLD_DOWN_${local_nonce}"; then
bad "slurmctld not responding on controller — all Slurm operations blocked"
add_issue "slurmctld down → references/node-diagnostics-detail.md § H (Slurm Node Management)" "P0"
else
ok "slurmctld responding"
fi
SSM_DOWN_LINES=$(echo "$SSM_STDOUT" | sed -n "/^DOWN_NODES_${local_nonce}\$/,/^END_DOWN_${local_nonce}\$/p" | grep -v "^DOWN_NODES_\|^END_DOWN_" | grep -v "^$" || true)
if [[ -n "$SSM_DOWN_LINES" ]]; then
bad "Slurm nodes with issues (via SSM):"
echo "$SSM_DOWN_LINES" | while IFS= read -r line; do info " $line"; done
SSM_DOWN_COUNT=$(echo "$SSM_DOWN_LINES" | grep -c .)
add_issue "$SSM_DOWN_COUNT Slurm node(s) down/drained → references/node-diagnostics-detail.md § H (Slurm Node Management)" "P1"
else
ok "All Slurm nodes healthy (via SSM)"
fi
STUCK_COUNT=$(echo "$SSM_STDOUT" | sed -n "/^STUCK_COUNT_${local_nonce}\$/{n;p;}" | tr -d '[:space:]')
[[ "${STUCK_COUNT:-0}" =~ ^[0-9]+$ ]] && [[ "${STUCK_COUNT:-0}" -gt 0 ]] && \
add_issue "$STUCK_COUNT stuck Slurm jobs → references/node-diagnostics-detail.md § H (Slurm Node Management)" "P1"
if echo "$SSM_STDOUT" | sed -n "/^MUNGE_${local_nonce}\$/{n;p;}" | grep -q inactive; then
bad "munge authentication service inactive on controller"
add_issue "munge service inactive → references/node-diagnostics-detail.md § H (Slurm Node Management)" "P0"
fi
fi
fi
fi
fi
fi
section "5. Cluster VPC Resources"
RESOURCES=$(echo "$CLUSTER_JSON" | python3 -c "
import sys,json
d=json.load(sys.stdin)
vpc=d.get('VpcConfig',{})
sgs=vpc.get('SecurityGroupIds',[])
subnets=vpc.get('Subnets',[])
print('SGs=' + ','.join(sgs))
print('Subnets=' + ','.join(subnets))
" 2>/dev/null || echo "")
CLUSTER_SGS=$(echo "$RESOURCES" | grep "^SGs=" | cut -d= -f2)
CLUSTER_SUBNETS=$(echo "$RESOURCES" | grep "^Subnets=" | cut -d= -f2)
if [[ -n "$CLUSTER_SGS" ]]; then
echo -e " Security Groups: ${BOLD}${CLUSTER_SGS}${NC}"
echo -e " Subnets: ${BOLD}${CLUSTER_SUBNETS}${NC}"
for SG in $(echo "$CLUSTER_SGS" | tr ',' ' '); do
# Nested JMESPath filter `UserIdGroupPairs[?GroupId=='...']` inside an
# already-filtered projection returns empty under AWS CLI even when the
# rule is present — false-flags healthy SGs as a P0. Flatten the array
# and match in bash instead.
_SG_RESULT=$(aws ec2 describe-security-groups \
--group-ids "$SG" --region "$REGION" \
--cli-read-timeout 15 \
--query "SecurityGroups[0].IpPermissionsEgress[?IpProtocol=='-1'].UserIdGroupPairs[].GroupId" \
--output text 2>&1)
if aws_check_perms "$_SG_RESULT" "ec2:DescribeSecurityGroups"; then
info "SG check skipped for $SG (permission denied)"
continue
fi
if echo "$_SG_RESULT" | tr '\t' '\n' | grep -qxF "$SG"; then
ok "SG ${SG} has outbound self-referencing rule (EFA ready)"
else
bad "SG ${SG} missing outbound self-referencing rule → EFA will fail"
add_issue "Missing SG outbound self-ref rule on ${SG} → references/node-diagnostics-detail.md § A (EFA / Security Group)" "P0"
fi
done
if [[ -n "$CLUSTER_SUBNETS" ]]; then
echo ""
# shellcheck disable=SC2046 # intentional word splitting for multiple subnet IDs
IFS=',' read -ra _subnet_arr <<< "$CLUSTER_SUBNETS"
_SUB_RESULT=$(aws ec2 describe-subnets \
--subnet-ids "${_subnet_arr[@]}" \
--region "$REGION" \
--cli-read-timeout 15 \
--query 'Subnets[*].{SubnetId:SubnetId,AZ:AvailabilityZone,FreeIPs:AvailableIpAddressCount}' \
--output table 2>&1)
if ! aws_check_perms "$_SUB_RESULT" "ec2:DescribeSubnets"; then
echo "$_SUB_RESULT"
fi
fi
else
warn "No VpcConfig found in cluster — cluster may not have customer VPC"
fi
section "6. CloudWatch Logs"
if [[ -n "$CLUSTER_ID" ]]; then
CLUSTER_NAME_ONLY=$(echo "$CLUSTER" | awk -F/ '{print $NF}')
LOG_GROUP="/aws/sagemaker/Clusters/${CLUSTER_NAME_ONLY}/${CLUSTER_ID}"
echo -e " Log group: ${LOG_GROUP}"
_LOG_RESULT=$(aws logs describe-log-groups \
--log-group-name-prefix "$LOG_GROUP" \
--region "$REGION" \
--cli-read-timeout 15 \
--query 'logGroups[0].logGroupName' \
--output text 2>&1)
if aws_check_perms "$_LOG_RESULT" "logs:DescribeLogGroups"; then
LOG_EXISTS="None"
else
LOG_EXISTS="$_LOG_RESULT"
fi
if [[ "$LOG_EXISTS" == "None" || -z "$LOG_EXISTS" ]]; then
warn "No CloudWatch log group found — logs may not be configured or cluster is new"
info "Expected: $LOG_GROUP"
else
ok "Log group exists: $LOG_EXISTS"
# Count recent log streams — paginate so the count reflects all streams,
# not just the first 50 (default CloudWatch page size).
STREAM_COUNT=0
_LS_TOKEN=""; _LS_I=0
while (( _LS_I < 20 )); do
# Validate token format before sending — avoid BadRequest on garbage.
if [[ -n "$_LS_TOKEN" && "$_LS_TOKEN" =~ ^[a-zA-Z0-9/+]*={0,2}$ ]]; then
_LS_PAGE=$(aws logs describe-log-streams --log-group-name "$LOG_GROUP" \
--region "$REGION" --cli-read-timeout 15 --limit 50 --next-token "$_LS_TOKEN" \
--output json 2>/dev/null) || break
else
_LS_PAGE=$(aws logs describe-log-streams --log-group-name "$LOG_GROUP" \
--region "$REGION" --cli-read-timeout 15 --limit 50 \
--output json 2>/dev/null) || break
fi
_LS_INC=$(echo "$_LS_PAGE" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('logStreams',[])))" 2>/dev/null || echo 0)
STREAM_COUNT=$((STREAM_COUNT + _LS_INC))
_LS_TOKEN=$(echo "$_LS_PAGE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('nextToken',''))" 2>/dev/null || echo "")
_LS_I=$((_LS_I + 1))
[[ -z "$_LS_TOKEN" ]] && break
done
info "$STREAM_COUNT log stream(s) available"
info "To view: aws logs describe-log-streams --log-group-name \"$LOG_GROUP\" --region $REGION --output table"
fi
fi
section "7. SSM Connectivity"
if command -v session-manager-plugin &>/dev/null; then
# `command -v` only verifies the binary exists — run --version to confirm it
# actually works (permissions, broken install, etc.).
if SSM_VER=$(session-manager-plugin --version 2>/dev/null); then
ok "SSM Session Manager plugin installed (${SSM_VER})"
else
warn "SSM Session Manager plugin installed but --version failed — plugin may be corrupt or missing libs"
add_issue "SSM plugin installed but broken → references/node-diagnostics-detail.md § K (Node Access via SSM)" "P1"
fi
else
warn "SSM Session Manager plugin NOT found"
info "Install session-manager-plugin (see AWS Systems Manager documentation)"
add_issue "SSM plugin missing → references/node-diagnostics-detail.md § K (Node Access via SSM)" "P2"
fi
RUNNING_IDS=$(echo "$NODES_JSON" | python3 -c "
import sys,json
nodes=json.load(sys.stdin).get('ClusterNodeSummaries',[])
ids=[n.get('InstanceId') for n in nodes if n.get('InstanceStatus',{}).get('Status')=='Running']
print(','.join(ids[:3]))
" 2>/dev/null || echo "")
if [[ -n "$RUNNING_IDS" ]]; then
ok "Running nodes available for SSM (examples: ${RUNNING_IDS})"
info "Use hyperpod-ssm skill with cluster ID: ${CLUSTER_ID}"
else
warn "No Running nodes found — SSM access not possible until nodes are healthy"
fi
# 8: On-Node Resource Checks (Memory / Storage / Utilities)
# Runs via SSM on the target node (or first running node) to detect resource
# exhaustion issues that only show up on-node: disk full, /dev/shm too small,
# huge pages misconfigured, OOM signals.
NODE_TO_PROBE="${TARGET_NODE}"
NODE_TO_PROBE_GROUP=""
if [[ -z "$NODE_TO_PROBE" ]]; then
# Prefer GPU / accelerator nodes: a node probe on a CPU-only utility node
# produces empty GPU / EFA sections and the user can't tell whether the
# result is "no hardware" or "hardware is broken." Three-tier fallback
# so the script still returns something on a CPU-only cluster.
NODE_TO_PROBE=$(echo "$NODES_JSON" | python3 -c "
import sys, json
nodes = json.load(sys.stdin).get('ClusterNodeSummaries', [])
GPU_PREFIXES = ('ml.p3', 'ml.p3dn', 'ml.p4d', 'ml.p4de', 'ml.p5', 'ml.p5e',
'ml.p5en', 'ml.p6', 'ml.g4dn', 'ml.g5', 'ml.g6', 'ml.g6e', 'ml.g7e')
NEURON_PREFIXES = ('ml.trn1', 'ml.trn2', 'ml.inf2')
ACCEL_PREFIXES = GPU_PREFIXES + NEURON_PREFIXES
def is_utility(n):
g = (n.get('InstanceGroupName','') or '').lower()
return any(x in g for x in ('controller', 'head', 'master'))
running = [n for n in nodes if n.get('InstanceStatus', {}).get('Status','') == 'Running']
tier1 = [n for n in running if (n.get('InstanceType','') or '').startswith(ACCEL_PREFIXES) and not is_utility(n)]
tier2 = [n for n in running if n not in tier1 and not is_utility(n)]
tier3 = [n for n in running if n not in tier1 and n not in tier2]
for n in tier1 + tier2 + tier3:
print(n.get('InstanceId', ''))
break
" 2>/dev/null || echo "")
fi
if [[ -n "$NODE_TO_PROBE" ]]; then
NODE_TO_PROBE_GROUP=$(echo "$NODES_JSON" | NODE_ID_ENV="$NODE_TO_PROBE" python3 -c "
import sys,json,os
target=os.environ['NODE_ID_ENV']
nodes=json.load(sys.stdin).get('ClusterNodeSummaries',[])
for n in nodes:
if n.get('InstanceId','')==target:
print(n.get('InstanceGroupName',''))
break
" 2>/dev/null || echo "")
fi
if [[ -n "$NODE_TO_PROBE" ]] \
&& [[ "$NODE_TO_PROBE" =~ ^i-[0-9a-f]{8,17}$ ]] \
&& [[ -n "$NODE_TO_PROBE_GROUP" ]] \
&& command -v session-manager-plugin &>/dev/null; then
section "8. On-Node Resource Checks (via SSM)"
info "Probing node: $NODE_TO_PROBE (group: ${NODE_TO_PROBE_GROUP})"
resource_nonce=$(date +%s%N 2>/dev/null || echo "$RANDOM")
RESOURCE_SH=$(cat <<EOF
echo RESOURCE_CHECK_START_${resource_nonce}
echo DISK_ROOT_${resource_nonce}
df -h / 2>/dev/null | tail -1
echo DISK_OPT_${resource_nonce}
df -h /opt/sagemaker 2>/dev/null | tail -1 || echo NOT_MOUNTED
echo DISK_NVME_${resource_nonce}
df -h /opt/dlami/nvme 2>/dev/null | tail -1 || echo NOT_MOUNTED
echo SHM_SIZE_${resource_nonce}
df -h /dev/shm 2>/dev/null | tail -1
echo MEMORY_INFO_${resource_nonce}
free -h | grep Mem
echo HUGEPAGES_${resource_nonce}
cat /proc/meminfo 2>/dev/null | grep -i huge | head -5
echo EFA_HUGE_PAGE_${resource_nonce}
env 2>/dev/null | grep FI_EFA_USE_HUGE_PAGE || echo NOT_SET
echo OOM_RECENT_${resource_nonce}
dmesg 2>/dev/null | grep -iE 'oom|out of memory|cannot allocate' | tail -5 || echo NONE
echo INODE_CHECK_${resource_nonce}
df -i / 2>/dev/null | tail -1
echo TIME_SYNC_${resource_nonce}
chronyc tracking 2>/dev/null | grep -E 'System time|Leap status' || timedatectl status 2>/dev/null | grep -E 'synchronized|NTP service' || echo UNKNOWN
echo SSM_AGENT_${resource_nonce}
systemctl is-active amazon-ssm-agent 2>/dev/null || echo inactive
echo NVME_MOUNTS_${resource_nonce}
lsblk -nr -o NAME,MOUNTPOINT 2>/dev/null | grep -E 'nvme[0-9]+n[0-9]+\$' | head -10 || echo NONE
echo GPU_XID_${resource_nonce}
if command -v nvidia-smi >/dev/null 2>&1; then
_gpu_xid_out=\$(
dmesg 2>/dev/null | grep -E 'NVRM: Xid' | tail -10
nvidia-smi -q 2>/dev/null | awk '
/Uncorrectable/ { if (\$NF ~ /^[0-9]+\$/ && \$NF+0 > 0) print; next }
/Pending Page (Blacklist|Blocklist|Retirement)/ { if (\$NF ~ /^[0-9]+\$/ && \$NF+0 > 0) print; next }
'
)
if [[ -z "\$_gpu_xid_out" ]]; then echo NONE; else echo "\$_gpu_xid_out" | head -20; fi
else
echo NO_NVIDIA_SMI
fi
echo GPU_REMAP_${resource_nonce}
# Row-remap state: 'Pending' rows indicate marginal GPU memory that needs a reset
# to finalize the remap. If remap is reported Failed, the GPU is bad.
# A stuck 'Pending' state across reboots is a known firmware edge case that can
# silently degrade training without NCCL/DCGM flagging it — capture explicitly.
if command -v nvidia-smi >/dev/null 2>&1; then
nvidia-smi --query-remapped-rows=gpu_bus_id,remapped_rows.correctable,remapped_rows.uncorrectable,remapped_rows.pending,remapped_rows.failure \
--format=csv,noheader 2>/dev/null | head -16 || echo UNSUPPORTED
else
echo NO_NVIDIA_SMI
fi
echo GPU_DCGM_${resource_nonce}
# DCGM health summary. Presence of 'Health Monitor Report' + 'PASS'/'Warn'/'Fail'
# tells us DCGM has run recently. Absence is informational, not an error.
# Row-remap errors surface here on drivers where nvidia-smi lags the firmware.
if command -v dcgmi >/dev/null 2>&1; then
dcgmi health --check -j 2>/dev/null | head -40 || dcgmi health --check 2>/dev/null | head -20 || echo DCGM_UNAVAILABLE
else
echo NO_DCGMI
fi
echo GPU_DCGM_LOGS_${resource_nonce}
# DCGM nvvs log presence — SageMaker HyperPod runs DCGM medium/memtest as part
# of deep-health-check. If this log is present the node has been health-checked
# recently; tail captures last run result.
if [ -d /var/log/nvidia-dcgm ] 2>/dev/null; then
find /var/log/nvidia-dcgm -maxdepth 1 -type f -printf '%f\n' 2>/dev/null | head -5
# \$ escapes are required: this heredoc is <<EOF (not <<'EOF'), so unescaped
# shell variables would expand locally. Keep \$ to defer to the remote shell.
NVVS_LATEST=\$(find /var/log/nvidia-dcgm -maxdepth 1 -name 'nvvs*.log' -printf '%T@ %p\n' 2>/dev/null | sort -nr | head -1 | awk '{print \$2}')
if [ -n "\$NVVS_LATEST" ]; then
echo "--- tail of \$NVVS_LATEST ---"
tail -n 5 "\$NVVS_LATEST" 2>/dev/null || true
fi
else
echo NO_DCGM_LOG_DIR
fi
echo KERNEL_PANIC_${resource_nonce}
dmesg 2>/dev/null | grep -iE 'Kernel panic - not syncing|watchdog: BUG|soft lockup|hard lockup|hung_task: blocked|BUG: unable to handle|BUG: kernel NULL|NMI watchdog' | tail -10 || echo NONE
echo CONTAINERD_${resource_nonce}
if command -v systemctl >/dev/null 2>&1; then
systemctl is-active containerd 2>/dev/null || echo inactive
else
echo UNKNOWN
fi
echo RESOURCE_CHECK_END_${resource_nonce}
EOF
)
RES_STDOUT=$(ssm_run_on_node "$NODE_TO_PROBE" "$NODE_TO_PROBE_GROUP" "$RESOURCE_SH" || echo "")
extract_section() {
local start="$1" end="$2"
# grep -v returns 1 when every line is filtered out; under pipefail this
# kills the pipeline even though the EMPTY output is legitimate. Force 0.
{ echo "$RES_STDOUT" | sed -n "/^${start}_${resource_nonce}\$/,/^${end}_${resource_nonce}\$/p" \
| grep -v "^${start}_${resource_nonce}\$\|^${end}_${resource_nonce}\$" || true; }
}
if echo "$RES_STDOUT" | grep -q "RESOURCE_CHECK_START_${resource_nonce}"; then
echo ""
echo -e " ${BOLD}Storage:${NC}"
ROOT_LINE=$(extract_section DISK_ROOT DISK_OPT | head -1)
if [[ -n "$ROOT_LINE" ]]; then
ROOT_USE_PCT=$(echo "$ROOT_LINE" | awk '{print $5}' | tr -d '%')
if [[ "$ROOT_USE_PCT" =~ ^[0-9]+$ ]] && [[ "$ROOT_USE_PCT" -gt 90 ]]; then
bad "Root volume: ${ROOT_USE_PCT}% used — CRITICALLY FULL (100GB fixed, cannot expand)"
add_issue "Root volume ${ROOT_USE_PCT}% full → references/node-diagnostics-detail.md § I (Resource Exhaustion)" "P0"
elif [[ "$ROOT_USE_PCT" =~ ^[0-9]+$ ]] && [[ "$ROOT_USE_PCT" -gt 80 ]]; then
warn "Root volume: ${ROOT_USE_PCT}% used — approaching full"
add_issue "Root volume ${ROOT_USE_PCT}% used → references/node-diagnostics-detail.md § I (Resource Exhaustion)" "P1"
else
ok "Root volume: ${ROOT_USE_PCT:-?}% used"
fi
fi
OPT_LINE=$(extract_section DISK_OPT DISK_NVME | head -1)
if [[ "$OPT_LINE" != "NOT_MOUNTED" && -n "$OPT_LINE" ]]; then
OPT_USE=$(echo "$OPT_LINE" | awk '{print $5}' | tr -d '%')
if [[ "$OPT_USE" =~ ^[0-9]+$ ]] && [[ "$OPT_USE" -gt 90 ]]; then
warn "/opt/sagemaker: ${OPT_USE}% used — secondary EBS nearing full"
add_issue "/opt/sagemaker ${OPT_USE}% full → references/node-diagnostics-detail.md § I (Resource Exhaustion)" "P1"
else
ok "/opt/sagemaker: ${OPT_USE:-?}% used"
fi
fi
NVME_LINE=$(extract_section DISK_NVME SHM_SIZE | head -1)
if [[ "$NVME_LINE" != "NOT_MOUNTED" && -n "$NVME_LINE" ]]; then
ok "NVMe instance store: mounted at /opt/dlami/nvme"
else
# On GPU training instances NVMe is expected — flag if not mounted
INSTANCE_TYPE_LOC=$(echo "$NODES_JSON" | NODE_ID_ENV="$NODE_TO_PROBE" python3 -c "
import sys,json,os
target=os.environ['NODE_ID_ENV']
for n in json.load(sys.stdin).get('ClusterNodeSummaries',[]):
if n.get('InstanceId','')==target:
print(n.get('InstanceType',''))
break
" 2>/dev/null || echo "")
if [[ "$INSTANCE_TYPE_LOC" =~ ^ml\.(p5|p5e|p5en|p4d|p4de|p6|trn1|trn2)\. ]]; then
warn "/opt/dlami/nvme not mounted on $INSTANCE_TYPE_LOC — instance store expected"
add_issue "NVMe instance store not mounted on $NODE_TO_PROBE ($INSTANCE_TYPE_LOC) → references/node-diagnostics-detail.md § I (Resource Exhaustion)" "P1"
fi
fi
INODE_LINE=$(extract_section INODE_CHECK TIME_SYNC | head -1)
if [[ -n "$INODE_LINE" ]]; then
INODE_PCT=$(echo "$INODE_LINE" | awk '{print $5}' | tr -d '%')
if [[ "$INODE_PCT" =~ ^[0-9]+$ ]] && [[ "$INODE_PCT" -gt 90 ]]; then
bad "Inode usage: ${INODE_PCT}% — filesystem running out of inodes"
add_issue "Inode exhaustion ${INODE_PCT}% → references/node-diagnostics-detail.md § I (Resource Exhaustion)" "P1"
fi
fi
echo ""
echo -e " ${BOLD}Memory:${NC}"
MEM_LINE=$(extract_section MEMORY_INFO HUGEPAGES | head -1)
[[ -n "$MEM_LINE" ]] && info "RAM: $MEM_LINE"
SHM_LINE=$(extract_section SHM_SIZE MEMORY_INFO | head -1)
if [[ -n "$SHM_LINE" ]]; then
SHM_SIZE=$(echo "$SHM_LINE" | awk '{print $2}')
SHM_USE_PCT=$(echo "$SHM_LINE" | awk '{print $5}' | tr -d '%')
if [[ "$SHM_USE_PCT" =~ ^[0-9]+$ ]] && [[ "$SHM_USE_PCT" -gt 80 ]]; then
warn "/dev/shm: ${SHM_USE_PCT}% used (size: $SHM_SIZE) — NCCL may fail with 'Bus error'"
add_issue "/dev/shm ${SHM_USE_PCT}% full → references/node-diagnostics-detail.md § I (Resource Exhaustion)" "P1"
else
ok "/dev/shm: ${SHM_USE_PCT:-?}% used (size: ${SHM_SIZE:-?})"
fi
fi
EFA_HP=$(extract_section EFA_HUGE_PAGE OOM_RECENT | head -1)
if [[ "$EFA_HP" == "NOT_SET" ]]; then
HUGEPAGES_TOTAL=$(extract_section HUGEPAGES EFA_HUGE_PAGE | { grep "HugePages_Total" || true; } | awk '{print $2}')
if [[ "${HUGEPAGES_TOTAL:-0}" == "0" ]]; then
warn "FI_EFA_USE_HUGE_PAGE not set and HugePages_Total=0"
add_issue "FI_EFA_USE_HUGE_PAGE not configured → references/node-diagnostics-detail.md § I (Resource Exhaustion)" "P2"
fi
elif echo "$EFA_HP" | grep -q "=0"; then
ok "FI_EFA_USE_HUGE_PAGE=0 (huge pages disabled for EFA — os.fork() safe)"
fi
OOM_LINES=$(extract_section OOM_RECENT INODE_CHECK | { grep -v "^NONE$" || true; } | head -3)
if [[ -n "$OOM_LINES" ]]; then
echo ""
bad "Recent OOM events detected on node:"
echo "$OOM_LINES" | while IFS= read -r line; do info " $line"; done
add_issue "OOM events on node $NODE_TO_PROBE → references/node-diagnostics-detail.md § I (Resource Exhaustion)" "P1"
else
echo ""
ok "No recent OOM events"
fi
# Time sync health — clock drift breaks TLS/SigV4 and Slurm accounting.
TIME_STATUS=$(extract_section TIME_SYNC SSM_AGENT | head -3)
if echo "$TIME_STATUS" | grep -qiE "synchronized: no|Not synchronised|UNKNOWN"; then
warn "Time sync unhealthy — chronyc/timedatectl reports not synchronised"
info "Clock drift breaks TLS/IAM (SigV4) and Slurm accounting"
add_issue "Node $NODE_TO_PROBE time sync not healthy → references/node-diagnostics-detail.md § I (Resource Exhaustion)" "P1"
elif [[ -n "$TIME_STATUS" ]]; then
ok "Time sync healthy"
fi
# SSM agent health — if we got here it's mostly working, but flag if systemd says otherwise.
SSM_AGENT_STATUS=$(extract_section SSM_AGENT NVME_MOUNTS | head -1)
if [[ "$SSM_AGENT_STATUS" == "inactive" ]]; then
warn "amazon-ssm-agent reported inactive — may be restarting or broken"
add_issue "amazon-ssm-agent inactive on $NODE_TO_PROBE → references/node-diagnostics-detail.md § K (Node Access via SSM)" "P1"
fi
# GPU XID / ECC / page-retirement — hardware faults visible via nvidia-smi query.
GPU_XID_LINES=$(extract_section GPU_XID GPU_REMAP | { grep -v "^NONE$" || true; } | { grep -v "^NO_NVIDIA_SMI$" || true; } | head -5)
if [[ -n "$GPU_XID_LINES" ]]; then
echo ""
bad "GPU XID / ECC / page-retirement signals on node $NODE_TO_PROBE:"
echo "$GPU_XID_LINES" | while IFS= read -r line; do info " $line"; done
add_issue "GPU XID / ECC / page-retirement on $NODE_TO_PROBE → references/node-diagnostics-detail.md § G (GPU/Accelerator) + § F (Hardware / Auto-Repair)" "P0"
fi
# GPU row-remapping — marginal GPU memory. Pending rows that never clear
# indicate a firmware edge case where the remap is stuck; Failed rows mean
# the GPU is bad and must be replaced. Silent degrader — NCCL and DCGM's
# default checks can miss this.
GPU_REMAP_LINES=$(extract_section GPU_REMAP GPU_DCGM | { grep -v "^NO_NVIDIA_SMI$" || true; } | { grep -v "^UNSUPPORTED$" || true; })
if [[ -n "$GPU_REMAP_LINES" ]]; then
# Columns (csv,noheader): gpu_bus_id, correctable, uncorrectable, pending, failure
REMAP_PENDING_TOTAL=0
REMAP_FAILED_TOTAL=0
REMAP_UNCORRECT_TOTAL=0
while IFS= read -r line; do
[[ -z "$line" ]] && continue
_p=$(echo "$line" | awk -F, '{gsub(/ /,""); print $4}')
_f=$(echo "$line" | awk -F, '{gsub(/ /,""); print $5}')
_u=$(echo "$line" | awk -F, '{gsub(/ /,""); print $3}')
[[ "$_p" =~ ^[0-9]+$ ]] && REMAP_PENDING_TOTAL=$((REMAP_PENDING_TOTAL + _p))
[[ "$_u" =~ ^[0-9]+$ ]] && REMAP_UNCORRECT_TOTAL=$((REMAP_UNCORRECT_TOTAL + _u))
[[ "$_f" == "Yes" || "$_f" == "1" ]] && REMAP_FAILED_TOTAL=$((REMAP_FAILED_TOTAL + 1))
done <<< "$GPU_REMAP_LINES"
if [[ "$REMAP_FAILED_TOTAL" -gt 0 ]]; then
bad "GPU row-remap FAILED on $REMAP_FAILED_TOTAL device(s) — GPU has exceeded remap capacity"
add_issue "GPU row-remap failure on $NODE_TO_PROBE (bad memory, replace GPU) → references/node-diagnostics-detail.md § G (GPU/Accelerator) + § F (Hardware / Auto-Repair)" "P0"
elif [[ "$REMAP_PENDING_TOTAL" -gt 0 ]]; then
bad "GPU row-remap PENDING — $REMAP_PENDING_TOTAL row(s) awaiting reset"
info " Pending remaps indicate marginal memory that a GPU reset/reboot should finalize."
info " If pending persists across reboots, the firmware may be stuck (known edge case) — escalate."
add_issue "GPU row-remap pending on $NODE_TO_PROBE (reset/reboot to finalize; if stuck, marginal memory) → references/node-diagnostics-detail.md § G (GPU/Accelerator) + § F (Hardware / Auto-Repair)" "P1"
elif [[ "$REMAP_UNCORRECT_TOTAL" -gt 0 ]]; then
warn "GPU has $REMAP_UNCORRECT_TOTAL uncorrectable remapped rows (healthy now, but history of faults)"
fi
fi
GPU_DCGM_LINES=$(extract_section GPU_DCGM GPU_DCGM_LOGS | { grep -v "^NO_DCGMI$" || true; } | { grep -v "^DCGM_UNAVAILABLE$" || true; })
if [[ -n "$GPU_DCGM_LINES" ]]; then
if echo "$GPU_DCGM_LINES" | grep -qiE '"overall_health"\s*:\s*"(Fail|Warn)"|HEALTH_RESULT_FAIL|HEALTH_RESULT_WARN|Health Monitor Report.*(Fail|Warn)'; then
bad "DCGM health check reported Fail/Warn on $NODE_TO_PROBE"
add_issue "DCGM health Fail/Warn on $NODE_TO_PROBE → references/node-diagnostics-detail.md § G (GPU/Accelerator)" "P0"
fi
fi
# DCGM log presence — informational. Confirms deep-health-check history.
GPU_DCGM_LOG_LINES=$(extract_section GPU_DCGM_LOGS KERNEL_PANIC)
if echo "$GPU_DCGM_LOG_LINES" | grep -qi "nvvs"; then
ok "DCGM nvvs logs present on $NODE_TO_PROBE (/var/log/nvidia-dcgm/)"
if echo "$GPU_DCGM_LOG_LINES" | grep -qE "^--- tail"; then
DCGM_TAIL=$(echo "$GPU_DCGM_LOG_LINES" | sed -n '/^--- tail/,$p' | head -20)
if echo "$DCGM_TAIL" | grep -qiE 'FAIL|Error:|row ?remap.*(pending|fail)'; then
warn "DCGM nvvs log tail contains failure/row-remap signals — inspect on node:"
echo "$DCGM_TAIL" | while IFS= read -r line; do info " $line"; done
add_issue "DCGM nvvs log shows failure/row-remap signals on $NODE_TO_PROBE → references/node-diagnostics-detail.md § G (GPU/Accelerator)" "P0"
fi
fi
fi
# Kernel panic / watchdog / hung task signals — indicate node-level instability.
KERNEL_PANIC_LINES=$(extract_section KERNEL_PANIC CONTAINERD | { grep -v "^NONE$" || true; } | head -5)
if [[ -n "$KERNEL_PANIC_LINES" ]]; then
echo ""
bad "Kernel panic / watchdog / hung_task signals on node $NODE_TO_PROBE:"
echo "$KERNEL_PANIC_LINES" | while IFS= read -r line; do info " $line"; done
add_issue "Kernel panic / watchdog on $NODE_TO_PROBE → references/node-diagnostics-detail.md § N (Kernel & System)" "P0"
fi
# containerd health — if the runtime is inactive, every pod on this node fails.
CONTAINERD_STATUS=$(extract_section CONTAINERD RESOURCE_CHECK_END | head -1)
if [[ "$CONTAINERD_STATUS" == "inactive" ]]; then
warn "containerd is inactive on $NODE_TO_PROBE — all pods on this node will fail"
add_issue "containerd inactive on $NODE_TO_PROBE → references/node-diagnostics-detail.md § M (Container Runtime)" "P0"
fi
else
warn "SSM command returned no output — node may not be reachable"
add_issue "Cannot reach node $NODE_TO_PROBE via SSM → references/node-diagnostics-detail.md § K (Node Access via SSM)" "P1"
fi
else
if [[ -z "$NODE_TO_PROBE" ]]; then
info "No running nodes to probe for resource checks"
else
info "SSM plugin not installed — skipping on-node resource checks → references/node-diagnostics-detail.md § K (Node Access via SSM)"
fi
fi
if [[ "$ORCHESTRATOR" == "Slurm" && "$TOTAL_NODES" -gt 0 ]]; then
section "8b. Slurm Node Mapping"
info "Slurm node name → HyperPod instance ID mapping:"
info "(PrivateDnsHostname is not in list-cluster-nodes; use 'describe-cluster-node --node-id <i-...>' to retrieve it for a specific instance.)"
echo ""
echo "$NODES_JSON" | python3 -c "
import sys,json
nodes=json.load(sys.stdin).get('ClusterNodeSummaries',[])
print(f' {\"Instance ID\":<22} {\"Group\":<20} {\"Type\":<22} {\"Status\"}')
print(f' {\"─\"*22} {\"─\"*20} {\"─\"*22} {\"─\"*10}')
for n in nodes[:20]:
iid=n.get('InstanceId','?')
grp=n.get('InstanceGroupName','?')
itype=n.get('InstanceType','?')
st=n.get('InstanceStatus',{}).get('Status','?')
print(f' {iid:<22} {grp:<20} {itype:<22} {st}')
if len(nodes) > 20:
print(f' ... and {len(nodes)-20} more nodes')
" 2>/dev/null
echo ""
info "To get PrivateDnsHostname for a specific instance: aws sagemaker describe-cluster-node --cluster-name $CLUSTER --region $REGION --node-id <i-...> --query 'NodeDetails.PrivateDnsHostname' --output text"
fi
section "9. Triage Summary"
echo ""
if [[ ${#ISSUES_FOUND[@]} -eq 0 ]]; then
echo -e " ${GREEN}${BOLD}No critical issues detected from available signals.${NC}"
echo ""
echo " Next steps:"
echo " • If cluster is still failing: check cluster events above for error details"
echo " • For node-level issues: use hyperpod-ssm skill to inspect nodes directly"
echo " • For EFA issues: bash scripts/check-efa-sg.sh --cluster ${CLUSTER} --region ${REGION}"
else
echo -e " ${RED}${BOLD}Issues found (${#ISSUES_FOUND[@]}):${NC}"
echo ""
for priority in P0 P1 P2; do
has_items=false
for issue in "${ISSUES_FOUND[@]}"; do
if [[ "$issue" == "${priority}|"* ]]; then
if ! "$has_items"; then
case "$priority" in
P0) echo -e " ${RED}${BOLD}[$priority — Fix Immediately]${NC}" ;;
P1) echo -e " ${YELLOW}${BOLD}[$priority — Fix Soon]${NC}" ;;
P2) echo -e " ${BOLD}[$priority — Informational]${NC}" ;;
esac
has_items=true
fi
echo -e " → ${issue#*|}"
fi
done
done
echo ""
echo -e " ${BOLD}Recommended next steps:${NC}"
echo " 1. Address P0 issues first, then P1. Each issue above includes a"
echo " pointer of the form '→ references/node-diagnostics-detail.md § X'."
echo " 2. The hyperpod-node-debugger skill will open the referenced section"
echo " and guide you through the fix with explicit approval."
echo " 3. After fixing, re-run: bash scripts/triage-cluster.sh --cluster ${CLUSTER} --region ${REGION}"
echo " 4. For shell access on nodes, use the hyperpod-ssm skill."
fi
echo ""
echo -e "${BOLD}Cluster: ${CLUSTER} | Region: ${REGION} | Orchestrator: ${ORCHESTRATOR}${NC}"
echo ""
# Exit 1 only on critical (P0/P1) issues so CI / retry loops don't fail on
# P2 informational findings. Fatal prerequisite failures exit 1 earlier at
# argument-validation time.
_critical=0
for _issue in "${ISSUES_FOUND[@]}"; do
case "${_issue%%|*}" in P0|P1) _critical=$((_critical+1)) ;; esac
done
[[ "$_critical" -eq 0 ]] && exit 0 || exit 1
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs a command-line tool, Needs Python, Needs Node.js
Security
What our scanner noticed· Low risk
- Scripts that your agent may run
This skill includes scripts that your agent may run. Read them before you use it.
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- AWS Labs
- Publisher
- AWS Labs (Official)
- Source
- awslabs/agent-plugins · plugins/sagemaker-ai/skills/hyperpod-node-debugger · commit fbefb37
- Licence
- Apache License 2.0 (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- NOTICE (added by AutoGineer from NOTICE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by AWS Labs.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download hyperpod-node-debugger.zip