chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,137 @@
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"clusterId": {
"type": "string",
"metadata": {
"description": "Unique string appended to resource names to isolate resources from different ray clusters."
}
},
"subnet": {
"type": "string",
"metadata": {
"description": "Subnet parameters."
}
},
"msiName": {
"type": "string",
"metadata": {
"description": "Managed service identity."
}
},
"msiResourceGroup": {
"type": "string",
"metadata": {
"description": "Managed service identity resource group."
}
},
"createMsi": {
"type": "bool",
"defaultValue": "true"
},
"roleAssignmentGuid": {
"type": "string",
"metadata": {
"description": "Deterministic resource name for the MSI role assignment."
}
}
},
"variables": {
"contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]",
"location": "[resourceGroup().location]",
"roleAssignmentName": "[concat('ray-', parameters('clusterId'), '-ra')]",
"nsgName": "[concat('ray-', parameters('clusterId'), '-nsg')]",
"nsg": "[resourceId('Microsoft.Network/networkSecurityGroups', variables('nsgName'))]",
"vnetName": "[concat('ray-', parameters('clusterId'), '-vnet')]",
"subnetName": "[concat('ray-', parameters('clusterId'), '-subnet')]"
},
"resources": [
{
"condition": "[parameters('createMsi')]",
"type": "Microsoft.ManagedIdentity/userAssignedIdentities",
"apiVersion": "2024-11-30",
"location": "[variables('location')]",
"name": "[parameters('msiName')]"
},
{
"type": "Microsoft.Authorization/roleAssignments",
"apiVersion": "2022-04-01",
"name": "[parameters('roleAssignmentGuid')]",
"properties": {
"principalId": "[reference(resourceId(parameters('msiResourceGroup'), 'Microsoft.ManagedIdentity/userAssignedIdentities', parameters('msiName')), '2024-11-30').principalId]",
"roleDefinitionId": "[variables('contributor')]",
"scope": "[resourceGroup().id]",
"principalType": "ServicePrincipal",
"description": "[concat('Ray autoscaler cluster ', parameters('clusterId'))]"
},
"dependsOn": [
"[parameters('msiName')]"
]
},
{
"type": "Microsoft.Network/networkSecurityGroups",
"apiVersion": "2024-10-01",
"name": "[variables('nsgName')]",
"location": "[variables('location')]",
"properties": {
"securityRules": [
{
"name": "SSH",
"properties": {
"priority": 1000,
"protocol": "Tcp",
"access": "Allow",
"direction": "Inbound",
"sourceAddressPrefix": "*",
"sourcePortRange": "*",
"destinationAddressPrefix": "*",
"destinationPortRange": "22"
}
}
]
}
},
{
"type": "Microsoft.Network/virtualNetworks",
"apiVersion": "2024-10-01",
"name": "[variables('vnetName')]",
"location": "[variables('location')]",
"properties": {
"addressSpace": {
"addressPrefixes": [
"[parameters('subnet')]"
]
},
"subnets": [
{
"name": "[variables('subnetName')]",
"properties": {
"addressPrefix": "[parameters('subnet')]",
"networkSecurityGroup": {
"id": "[variables('nsg')]"
}
}
}
]
},
"dependsOn": [
"[variables('nsg')]"
]
}
],
"outputs": {
"subnet": {
"type": "string",
"value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('vnetName'), variables('subnetName'))]"
},
"nsg": {
"type": "string",
"value": "[variables('nsg')]"
},
"msi": {
"type": "string",
"value": "[resourceId(parameters('msiResourceGroup'), 'Microsoft.ManagedIdentity/userAssignedIdentities', parameters('msiName'))]"
}
}
}
@@ -0,0 +1,318 @@
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"vmName": {
"type": "string",
"metadata": {
"description": "The name of you Virtual Machine."
}
},
"adminUsername": {
"type": "string",
"metadata": {
"description": "Username for the Virtual Machine."
}
},
"publicKey": {
"type": "securestring",
"metadata": {
"description": "SSH Key for the Virtual Machine"
}
},
"imagePublisher": {
"type": "string",
"metadata": {
"description": "The publisher of the VM image"
},
"defaultValue": ""
},
"imageOffer": {
"type": "string",
"metadata": {
"description": "The offer of the VM image"
},
"defaultValue": ""
},
"imageSku": {
"type": "string",
"metadata": {
"description": "The sku of the VM image"
},
"defaultValue": ""
},
"imageVersion": {
"type": "string",
"metadata": {
"description": "The version of the VM image"
},
"defaultValue": ""
},
"imageId": {
"type": "string",
"metadata": {
"description": "The resource id of the VM image. If provided, it will override the imagePublisher, imageOffer, imageSku and imageVersion parameters."
},
"defaultValue": ""
},
"vmSize": {
"type": "string",
"metadata": {
"description": "The size of the VM"
}
},
"vmTags": {
"type": "object",
"metadata": {
"description": "Tags for the VM"
}
},
"vmCount": {
"type": "int",
"metadata": {
"description": "Number of VMs to deploy"
}
},
"osDiskSize": {
"type": "int",
"defaultValue": 0,
"metadata": {
"description": "Size of the OS disk in GB"
}
},
"provisionPublicIp": {
"type": "bool",
"defaultValue": true,
"metadata": {
"description": "If true creates a public ip"
}
},
"priority": {
"type": "string",
"defaultValue": "Regular",
"metadata": {
"description": "Specifies the priority for the virtual machine."
}
},
"evictionPolicy": {
"type": "string",
"defaultValue": "Delete",
"metadata": {
"description": "Specifies the eviction policy for the virtual machine."
}
},
"billingProfile": {
"type": "object",
"defaultValue": {},
"metadata": {
"description": "Specifies the maximum price to pay for Azure Spot VM."
}
},
"msi": {
"type": "string",
"metadata": {
"description": "Managed service identity resource id."
}
},
"nsg": {
"type": "string",
"metadata": {
"description": "Network security group resource id."
}
},
"subnet": {
"type": "string",
"metadata": {
"descriptions": "Subnet resource id."
}
},
"enableAcceleratedNetworking": {
"type": "bool",
"defaultValue": false,
"metadata": {
"descriptions": "Whether to enable accelerated networking."
}
},
"zones": {
"type": "array",
"defaultValue": [],
"metadata": {
"description": "Availability zones for the virtual machine. If empty, no zones will be specified."
}
}
},
"variables": {
"location": "[resourceGroup().location]",
"useZones": "[greater(length(parameters('zones')), 0)]",
"networkInterfaceNamePrivate": "[concat(parameters('vmName'), '-nic')]",
"networkInterfaceNamePublic": "[concat(parameters('vmName'), '-nic-public')]",
"networkInterfaceName": "[if(parameters('provisionPublicIp'), variables('networkInterfaceNamePublic'), variables('networkInterfaceNamePrivate'))]",
"networkIpConfig": "[guid(resourceGroup().id, parameters('vmName'))]",
"osDiskType": "Standard_LRS",
"publicIpAddressName": "[concat(parameters('vmName'), '-ip')]"
},
"resources": [
{
"type": "Microsoft.Network/networkInterfaces",
"apiVersion": "2024-07-01",
"name": "[concat(variables('networkInterfaceNamePublic'), copyIndex())]",
"location": "[variables('location')]",
"dependsOn": [
"[resourceId('Microsoft.Network/publicIpAddresses/', concat(variables('publicIpAddressName'), copyIndex()))]"
],
"copy": {
"name": "NICPublicCopy",
"count": "[parameters('vmCount')]"
},
"properties": {
"ipConfigurations": [
{
"name": "[variables('networkIpConfig')]",
"properties": {
"subnet": {
"id": "[parameters('subnet')]"
},
"privateIPAllocationMethod": "Dynamic",
"publicIpAddress": {
"id": "[resourceId('Microsoft.Network/publicIPAddresses', concat(variables('publicIPAddressName'), copyIndex()))]"
}
}
}
],
"networkSecurityGroup": {
"id": "[parameters('nsg')]"
},
"enableAcceleratedNetworking": "[parameters('enableAcceleratedNetworking')]"
},
"condition": "[parameters('provisionPublicIp')]"
},
{
"type": "Microsoft.Network/networkInterfaces",
"apiVersion": "2024-07-01",
"name": "[concat(variables('networkInterfaceNamePrivate'), copyIndex())]",
"location": "[variables('location')]",
"copy": {
"name": "NICPrivateCopy",
"count": "[parameters('vmCount')]"
},
"properties": {
"ipConfigurations": [
{
"name": "[variables('networkIpConfig')]",
"properties": {
"subnet": {
"id": "[parameters('subnet')]"
},
"privateIPAllocationMethod": "Dynamic"
}
}
],
"networkSecurityGroup": {
"id": "[parameters('nsg')]"
},
"enableAcceleratedNetworking": "[parameters('enableAcceleratedNetworking')]"
},
"condition": "[not(parameters('provisionPublicIp'))]"
},
{
"type": "Microsoft.Network/publicIpAddresses",
"apiVersion": "2024-07-01",
"name": "[concat(variables('publicIpAddressName'), copyIndex())]",
"location": "[variables('location')]",
"properties": {
"publicIpAllocationMethod": "Static",
"publicIPAddressVersion": "IPv4"
},
"copy": {
"name": "PublicIpCopy",
"count": "[parameters('vmCount')]"
},
"zones": "[if(variables('useZones'), createArray(string(parameters('zones')[mod(copyIndex(), length(parameters('zones')))])), json('null'))]",
"sku": {
"name": "Standard",
"tier": "Regional"
},
"condition": "[parameters('provisionPublicIp')]"
},
{
"type": "Microsoft.Compute/virtualMachines",
"apiVersion": "2024-07-01",
"name": "[concat(parameters('vmName'), copyIndex())]",
"location": "[variables('location')]",
"dependsOn": [
"[resourceId('Microsoft.Network/networkInterfaces/', concat(variables('networkInterfaceName'), copyIndex()))]"
],
"copy": {
"name": "VmCopy",
"count": "[parameters('vmCount')]"
},
"tags": "[parameters('vmTags')]",
"zones": "[if(variables('useZones'), createArray(string(parameters('zones')[mod(copyIndex(), length(parameters('zones')))])), json('null'))]",
"properties": {
"hardwareProfile": {
"vmSize": "[parameters('vmSize')]"
},
"storageProfile": {
"osDisk": {
"createOption": "fromImage",
"diskSizeGB": "[if(equals(parameters('osDiskSize'), 0), json('null'), parameters('osDiskSize'))]",
"managedDisk": {
"storageAccountType": "[variables('osDiskType')]"
}
},
"imageReference": "[if(equals(parameters('imageId'), ''), json(concat('{\"publisher\":\"', parameters('imagePublisher'), '\",\"offer\":\"', parameters('imageOffer'), '\",\"sku\":\"', parameters('imageSku'), '\",\"version\":\"', parameters('imageVersion'), '\"}')), json(concat('{\"id\":\"', parameters('imageId'), '\"}')))]"
},
"networkProfile": {
"networkInterfaces": [
{
"id": "[resourceId('Microsoft.Network/networkInterfaces', concat(variables('networkInterfaceName'), copyIndex()))]"
}
]
},
"osProfile": {
"computerName": "[concat(parameters('vmName'), copyIndex())]",
"adminUsername": "[parameters('adminUsername')]",
"adminPassword": "[parameters('publicKey')]",
"linuxConfiguration": {
"disablePasswordAuthentication": true,
"ssh": {
"publicKeys": [
{
"path": "[concat('/home/', parameters('adminUsername'), '/.ssh/authorized_keys')]",
"keyData": "[parameters('publicKey')]"
}
]
}
}
},
"priority": "[parameters('priority')]",
"evictionPolicy": "[if(equals(parameters('priority'), 'Spot'), parameters('evictionPolicy'), '')]",
"billingProfile": "[parameters('billingProfile')]"
},
"identity": {
"type": "UserAssigned",
"userAssignedIdentities": {
"[parameters('msi')]": {
}
}
}
}
],
"outputs": {
"publicIp": {
"type": "array",
"copy": {
"count": "[parameters('vmCount')]",
"input": "[reference(concat(variables('publicIpAddressName'), copyIndex())).ipAddress]"
},
"condition": "[parameters('provisionPublicIp')]"
},
"privateIp": {
"type": "array",
"copy": {
"count": "[parameters('vmCount')]",
"input": "[reference(concat(variables('networkInterfaceName'), copyIndex())).ipConfigurations[0].properties.privateIPAddress]"
}
}
}
}
@@ -0,0 +1,586 @@
import json
import logging
import os
import random
import time
from hashlib import md5, sha256
from pathlib import Path
from typing import Any, Callable
from uuid import UUID
from azure.common.credentials import get_cli_profile
from azure.core.exceptions import HttpResponseError, ResourceNotFoundError
from azure.identity import AzureCliCredential
from azure.mgmt.resource import ResourceManagementClient
from azure.mgmt.resource.resources.models import DeploymentMode
from ray.autoscaler._private.util import (
generate_rsa_key_pair,
generate_ssh_key_name,
generate_ssh_key_paths,
)
# Built-in Azure Contributor role definition ID used for role assignments.
CONTRIBUTOR_ROLE_DEFINITION_ID = "b24988ac-6180-42a0-ab88-20f7382dd24c"
UNIQUE_ID_LEN = 4
logger = logging.getLogger(__name__)
def get_azure_sdk_function(client: Any, function_name: str) -> Callable:
"""Retrieve a callable function from Azure SDK client object.
Newer versions of the various client SDKs renamed function names to
have a begin_ prefix. This function supports both the old and new
versions of the SDK by first trying the old name and falling back to
the prefixed new name.
"""
func = getattr(
client, function_name, getattr(client, f"begin_{function_name}", None)
)
if func is None:
raise AttributeError(
"'{obj}' object has no {func} or begin_{func} attribute".format(
obj={client.__name__}, func=function_name
)
)
return func
def bootstrap_azure(config):
config = _configure_key_pair(config)
config = _configure_resource_group(config)
return config
def _configure_resource_group(config):
# TODO: look at availability sets
# https://docs.microsoft.com/en-us/azure/virtual-machines/windows/tutorial-availability-sets
subscription_id = config["provider"].get("subscription_id")
if subscription_id is None:
subscription_id = get_cli_profile().get_subscription_id()
resource_client = ResourceManagementClient(AzureCliCredential(), subscription_id)
config["provider"]["subscription_id"] = subscription_id
logger.info("Using subscription id: %s", subscription_id)
assert (
"resource_group" in config["provider"]
), "Provider config must include resource_group field"
resource_group = config["provider"]["resource_group"]
assert (
"location" in config["provider"]
), "Provider config must include location field"
params = {"location": config["provider"]["location"]}
if "tags" in config["provider"]:
params["tags"] = config["provider"]["tags"]
logger.info("Creating/Updating resource group: %s", resource_group)
rg_create_or_update = get_azure_sdk_function(
client=resource_client.resource_groups, function_name="create_or_update"
)
rg_create_or_update(resource_group_name=resource_group, parameters=params)
# load the template file
current_path = Path(__file__).parent
template_path = current_path.joinpath("azure-config-template.json")
with open(template_path, "r") as template_fp:
template = json.load(template_fp)
logger.info("Using cluster name: %s", config["cluster_name"])
# set unique id for resources in this cluster
unique_id = config["provider"].get("unique_id")
if unique_id is None:
hasher = sha256()
hasher.update(config["provider"]["resource_group"].encode("utf-8"))
unique_id = hasher.hexdigest()[:UNIQUE_ID_LEN]
else:
unique_id = str(unique_id)
config["provider"]["unique_id"] = unique_id
logger.info("Using unique id: %s", unique_id)
cluster_id = "{}-{}".format(config["cluster_name"], unique_id)
role_assignment_name = f"ray-{cluster_id}-ra"
role_assignment_guid = _generate_arm_guid(role_assignment_name)
role_assignment_resource_id = (
f"/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers"
f"/Microsoft.Authorization/roleAssignments/{role_assignment_guid}"
)
subnet_mask = config["provider"].get("subnet_mask")
if subnet_mask is None:
# choose a random subnet, skipping most common value of 0
random.seed(unique_id)
subnet_mask = "10.{}.0.0/16".format(random.randint(1, 254))
logger.info("Using subnet mask: %s", subnet_mask)
# Copy over properties from existing subnet.
# Addresses issue (https://github.com/Azure/azure-quickstart-templates/issues/2786)
# where existing subnet properties will get overwritten unless explicitly specified
# during multiple deployments even if vnet/subnet do not change.
# May eventually be fixed by passing empty subnet list if they already exist:
# https://techcommunity.microsoft.com/t5/azure-networking-blog/azure-virtual-network-now-supports-updates-without-subnet/ba-p/4067952
list_by_rg = get_azure_sdk_function(
client=resource_client.resources, function_name="list_by_resource_group"
)
existing_vnets = list(
list_by_rg(
resource_group,
f"substringof('{unique_id}', name) and "
"resourceType eq 'Microsoft.Network/virtualNetworks'",
)
)
if len(existing_vnets) > 0:
vnid = existing_vnets[0].id
get_by_id = get_azure_sdk_function(
client=resource_client.resources, function_name="get_by_id"
)
# Query for supported API versions for Microsoft.Network/virtualNetworks
# because resource_client.DEFAULT_API_VERSION is not always supported.
# (Example: "2024-11-01" is the default at the time of this writing)
# Use "2024-10-01" as a fallback if we can't determine the latest stable version.
vnet_api_version = "2024-10-01"
try:
# Get supported API versions for Microsoft.Network provider
providers = resource_client.providers.get("Microsoft.Network")
vnet_resource_type = next(
(
rt
for rt in providers.resource_types
if rt.resource_type == "virtualNetworks"
),
None,
)
if vnet_resource_type and vnet_resource_type.api_versions:
stable_versions = [
v for v in vnet_resource_type.api_versions if "preview" not in v
]
versions_to_consider = (
stable_versions or vnet_resource_type.api_versions
)
vnet_api_version = sorted(versions_to_consider)[-1]
logger.info(
"Using API version: %s for virtualNetworks", vnet_api_version
)
else:
logger.warning(
"Could not determine supported API versions for virtualNetworks, using fallback version %s",
vnet_api_version,
)
except Exception as e:
logger.warning(
"Failed to query Microsoft.Network provider: %s. Using fallback API version 2024-10-01",
str(e),
)
subnet = get_by_id(vnid, vnet_api_version).properties["subnets"][0]
template_vnet = next(
(
rs
for rs in template["resources"]
if rs["type"] == "Microsoft.Network/virtualNetworks"
),
None,
)
if template_vnet is not None:
template_subnets = template_vnet["properties"].get("subnets")
if template_subnets is not None:
template_subnets[0]["properties"].update(subnet["properties"])
# Get or create an MSI name and resource group.
# Defaults to current resource group if not provided.
use_existing_msi = _is_shared_msi(config["provider"])
msi_resource_group = config["provider"].get("msi_resource_group", resource_group)
msi_name = config["provider"].get("msi_name", f"ray-{cluster_id}-msi")
logger.info(
"Using msi_name: %s from msi_resource_group: %s", msi_name, msi_resource_group
)
existing_principal_id = None
if not use_existing_msi:
# When creating a MSI for managing Azure resources, we first need to clean up
# any role assignments from the previous MSI's principal ID to avoid
# orphaned permissions when the MSI gets recreated with a new principal ID.
# Role assignments cannot be updated, only created/removed, so cleanup is required.
msi_resource_id = (
f"/subscriptions/{subscription_id}/resourceGroups/{msi_resource_group}"
f"/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{msi_name}"
)
try:
get_identity = get_azure_sdk_function(
client=resource_client.resources, function_name="get_by_id"
)
existing_msi = get_identity(msi_resource_id, "2023-01-31")
existing_principal_id = getattr(existing_msi, "properties", {}).get(
"principalId"
)
except ResourceNotFoundError:
existing_principal_id = None
except Exception as exc:
logger.warning(
"Failed to query MSI %s for existing principal: %s",
msi_name,
exc,
)
if existing_principal_id:
logger.info(
"Removing existing role assignments for MSI principal %s before recreation",
existing_principal_id,
)
_delete_role_assignments_for_principal(
resource_client,
resource_group,
existing_principal_id,
)
delete_role_assignment = get_azure_sdk_function(
client=resource_client.resources, function_name="delete_by_id"
)
get_role_assignment = get_azure_sdk_function(
client=resource_client.resources, function_name="get_by_id"
)
role_assignment_known_missing = False
initial_query_failed = False
try:
get_role_assignment(
role_assignment_resource_id,
"2022-04-01",
)
except ResourceNotFoundError:
role_assignment_known_missing = True
logger.debug(
"Role assignment %s not found before MSI creation; skipping deletion",
role_assignment_guid,
)
except Exception as exc:
logger.warning(
"Failed to query role assignment %s before deletion: %s",
role_assignment_guid,
exc,
)
initial_query_failed = True
if not role_assignment_known_missing:
try:
delete_lro = delete_role_assignment(
resource_id=role_assignment_resource_id,
api_version="2022-04-01",
)
if hasattr(delete_lro, "wait"):
delete_lro.wait()
logger.info(
"Deleted existing role assignment %s before recreating MSI",
role_assignment_guid,
)
if initial_query_failed:
logger.debug(
"Retrying verification for role assignment %s",
role_assignment_guid,
)
if not _wait_for_role_assignment_deletion(
get_role_assignment,
role_assignment_resource_id,
role_assignment_guid,
):
logger.warning(
"Role assignment %s not confirmed deleted",
role_assignment_guid,
)
except ResourceNotFoundError:
logger.debug(
"Role assignment %s disappeared before deletion attempt; continuing",
role_assignment_guid,
)
except Exception as e:
logger.warning(
"Failed to delete role assignment %s before MSI creation: %s",
role_assignment_guid,
e,
)
parameters = {
"properties": {
"mode": DeploymentMode.incremental,
"template": template,
"parameters": {
"subnet": {"value": subnet_mask},
"clusterId": {"value": cluster_id},
"msiName": {"value": msi_name},
"msiResourceGroup": {"value": msi_resource_group},
"createMsi": {"value": not use_existing_msi},
"roleAssignmentGuid": {"value": role_assignment_guid},
},
}
}
create_or_update = get_azure_sdk_function(
client=resource_client.deployments, function_name="create_or_update"
)
outputs = (
create_or_update(
resource_group_name=resource_group,
deployment_name="ray-config",
parameters=parameters,
)
.result()
.properties.outputs
)
# append output resource ids to be used with vm creation
config["provider"]["msi"] = outputs["msi"]["value"]
config["provider"]["nsg"] = outputs["nsg"]["value"]
config["provider"]["subnet"] = outputs["subnet"]["value"]
return config
def _configure_key_pair(config):
"""
Configure SSH keypair. Use user specified custom paths, otherwise,
generate Ray-specific keypair in this format: "ray-autoscaler_azure_{region}_{resource_group}_{ssh_user}_{index}"
"""
ssh_user = config["auth"]["ssh_user"]
public_key = None
# Check if user specified custom SSH key paths
user_specified_private_key = "ssh_private_key" in config["auth"]
user_specified_public_key = "ssh_public_key" in config["auth"]
# Validate that the user either specfied both keys or none, but not just one
if user_specified_private_key != user_specified_public_key:
if user_specified_private_key:
missing_key, specified_key = "ssh_public_key", "ssh_private_key"
else:
missing_key, specified_key = "ssh_private_key", "ssh_public_key"
raise ValueError(
f"{specified_key} is specified but {missing_key} is missing. "
"Both SSH key paths must be specified together, or omit both from "
"your config to use auto-generated keys."
)
if user_specified_private_key and user_specified_public_key:
# User specified custom paths
private_key_path = Path(config["auth"]["ssh_private_key"]).expanduser()
public_key_path = Path(config["auth"]["ssh_public_key"]).expanduser()
# Validate that user-specified keys exist
missing_keys = []
if not private_key_path.is_file():
missing_keys.append(f"ssh_private_key: {private_key_path}")
if not public_key_path.is_file():
missing_keys.append(f"ssh_public_key: {public_key_path}")
if missing_keys:
raise ValueError(
"SSH key files from config do not exist: {}. "
"Please create the keys or remove the custom paths from your config "
"to use auto-generated keys.".format(", ".join(missing_keys))
)
logger.info(
"Using specified SSH keys from config: {} and {}".format(
private_key_path, public_key_path
)
)
with open(public_key_path, "r") as f:
public_key = f.read()
else:
# Generate Ray-specific keys
region = config["provider"]["location"]
resource_group = config["provider"]["resource_group"]
# Generate single deterministic key name for this configuration
key_name = generate_ssh_key_name(
"azure", None, region, resource_group, ssh_user
)
public_key_path, private_key_path = generate_ssh_key_paths(key_name)
# Check if this key pair already exists
if os.path.exists(private_key_path) and os.path.exists(public_key_path):
logger.info(
"Using existing Ray-specific SSH keys: {} and {}".format(
private_key_path, public_key_path
)
)
with open(public_key_path, "r") as f:
public_key = f.read()
else:
# Create a key pair since it doesn't exist locally
logger.info(
"Generating new Ray-specific SSH key pair at {} and {}".format(
private_key_path, public_key_path
)
)
os.makedirs(os.path.dirname(private_key_path), exist_ok=True)
public_key, private_key = generate_rsa_key_pair()
with open(
private_key_path,
"w",
opener=lambda path, flags: os.open(path, flags, 0o600),
) as f:
f.write(private_key)
with open(public_key_path, "w") as f:
f.write(public_key)
assert os.path.exists(
private_key_path
), "Private key file {} not found for user {}".format(
private_key_path, ssh_user
)
config["auth"]["ssh_private_key"] = str(private_key_path)
# Remove public key path because bootstrap config must only contain paths that exist on head node
config["auth"].pop("ssh_public_key", None)
for node_type in config["available_node_types"].values():
azure_arm_parameters = node_type["node_config"].setdefault(
"azure_arm_parameters", {}
)
azure_arm_parameters["adminUsername"] = ssh_user
azure_arm_parameters["publicKey"] = public_key
return config
def _delete_role_assignments_for_principal(
resource_client: ResourceManagementClient,
resource_group: str,
principal_id: str,
) -> None:
"""Delete all role assignments in the resource group for the given principal.
Uses the generic ResourceManagementClient so we avoid depending on
azure-mgmt-authorization. All role assignments associated with the
provided principal ID are removed.
"""
if not principal_id:
return
list_by_rg = get_azure_sdk_function(
client=resource_client.resources, function_name="list_by_resource_group"
)
delete_role_assignment = get_azure_sdk_function(
client=resource_client.resources, function_name="delete_by_id"
)
try:
assignments = list(
list_by_rg(
resource_group,
"resourceType eq 'Microsoft.Authorization/roleAssignments'",
)
)
logger.debug(
"Found %d role assignments in resource group %s while cleaning up principal %s",
len(assignments),
resource_group,
principal_id,
)
except HttpResponseError as exc:
logger.warning(
"Failed to enumerate role assignments for resource group %s: %s",
resource_group,
exc,
)
return
for assignment in assignments:
properties = getattr(assignment, "properties", {}) or {}
logger.debug(
"Inspecting role assignment %s with principalId=%s roleDefinitionId=%s",
getattr(assignment, "name", "<unknown>"),
properties.get("principalId"),
properties.get("roleDefinitionId"),
)
if properties.get("principalId") != principal_id:
continue
try:
delete_lro = delete_role_assignment(
resource_id=assignment.id,
api_version="2022-04-01",
)
if hasattr(delete_lro, "wait"):
delete_lro.wait()
logger.info(
"Deleted existing role assignment %s for principal %s",
assignment.name,
principal_id,
)
except ResourceNotFoundError:
logger.debug(
"Role assignment %s not found while processing principal %s",
assignment.name,
principal_id,
)
except Exception as exc:
logger.warning(
"Failed to delete role assignment %s for principal %s: %s",
assignment.name,
principal_id,
exc,
)
def _wait_for_role_assignment_deletion(
get_role_assignment: Callable[..., Any],
resource_id: str,
role_assignment_guid: str,
*,
max_attempts: int = 10,
delay_seconds: int = 2,
) -> bool:
"""Poll until a role assignment disappears after deletion.
Returns True if the assignment is confirmed deleted, False otherwise.
Logs detailed progress to aid troubleshooting when transient errors occur.
"""
for attempt in range(1, max_attempts + 1):
try:
get_role_assignment(resource_id, "2022-04-01")
except ResourceNotFoundError:
return True
except Exception as exc: # noqa: BLE001
logger.debug(
"Attempt %d/%d to verify removal of role assignment %s failed: %s",
attempt,
max_attempts,
role_assignment_guid,
exc,
)
else:
logger.debug(
"Role assignment %s still present after deletion (attempt %d/%d)",
role_assignment_guid,
attempt,
max_attempts,
)
if attempt < max_attempts:
time.sleep(delay_seconds)
return False
def _generate_arm_guid(*values: Any) -> str:
"""Replicates ARM template guid() function for creating deterministic IDs."""
concatenated = "".join(str(v) for v in values)
return str(UUID(md5(concatenated.encode("utf-8")).hexdigest()))
def _is_shared_msi(provider_config: dict) -> bool:
"""Checks if the provider config specifies a shared/pre-existing MSI."""
return (
provider_config.get("msi_name") is not None
and provider_config.get("msi_resource_group") is not None
)
@@ -0,0 +1,952 @@
import json
import logging
import os
import time
from concurrent.futures import Future, ThreadPoolExecutor
from pathlib import Path
from threading import RLock
from typing import Dict, List, Optional
from uuid import uuid4
from azure.common.credentials import get_cli_profile
from azure.core.exceptions import ResourceNotFoundError
from azure.identity import DefaultAzureCredential
from azure.mgmt.compute import ComputeManagementClient
from azure.mgmt.network import NetworkManagementClient
from azure.mgmt.resource import ResourceManagementClient
from azure.mgmt.resource.resources.models import DeploymentMode
from ray._common.usage.usage_lib import get_cloud_from_metadata_requests
from ray.autoscaler._private._azure.config import (
_delete_role_assignments_for_principal,
_generate_arm_guid,
_is_shared_msi,
bootstrap_azure,
get_azure_sdk_function,
)
from ray.autoscaler._private.constants import (
AUTOSCALER_NODE_START_WAIT_S,
AUTOSCALER_NODE_TERMINATE_WAIT_S,
MAX_PARALLEL_SHUTDOWN_WORKERS,
)
from ray.autoscaler.node_provider import NodeProvider
from ray.autoscaler.tags import (
NODE_KIND_HEAD,
TAG_RAY_CLUSTER_NAME,
TAG_RAY_LAUNCH_CONFIG,
TAG_RAY_NODE_KIND,
TAG_RAY_NODE_NAME,
TAG_RAY_USER_NODE_TYPE,
)
VM_NAME_MAX_LEN = 64
UNIQUE_ID_LEN = 4
logger = logging.getLogger(__name__)
azure_logger = logging.getLogger("azure.core.pipeline.policies.http_logging_policy")
azure_logger.setLevel(logging.WARNING)
def synchronized(f):
def wrapper(self, *args, **kwargs):
self.lock.acquire()
try:
return f(self, *args, **kwargs)
finally:
self.lock.release()
return wrapper
class AzureNodeProvider(NodeProvider):
"""Node Provider for Azure
This provider assumes Azure credentials are set by running ``az login``
and the default subscription is configured through ``az account``
or set in the ``provider`` field of the autoscaler configuration.
Nodes may be in one of three states: {pending, running, terminated}. Nodes
appear immediately once started by ``create_node``, and transition
immediately to terminated when ``terminate_node`` is called.
"""
def __init__(self, provider_config, cluster_name):
NodeProvider.__init__(self, provider_config, cluster_name)
subscription_id = provider_config.get("subscription_id")
if subscription_id is None:
# Get subscription from logged in azure profile
# if it isn't provided in the provider_config
# so operations like `get-head-ip` will work
subscription_id = get_cli_profile().get_subscription_id()
logger.info(
"subscription_id not found in provider config, falling back "
f"to subscription_id from the logged in azure profile: {subscription_id}"
)
self.cache_stopped_nodes = provider_config.get("cache_stopped_nodes", True)
# Detect cloud environment to optimize Azure credential chain.
# On non-Azure clouds (AWS, GCP), skip Azure-specific auth methods
# (managed identity, workload identity) to avoid IMDS timeout delays / failures.
detected_cloud = get_cloud_from_metadata_requests()
on_azure = detected_cloud == "azure"
if on_azure:
logger.info(
"Initializing Azure node provider for Azure infrastructure "
"running on Azure cloud environment"
)
else:
logger.info(
f"Initializing Azure node provider for Azure infrastructure "
f"but detected this is running on a '{detected_cloud}' environment. "
f"Skipping Azure-specific authentication methods to avoid timeouts."
)
credential = DefaultAzureCredential(
exclude_shared_token_cache_credential=True,
exclude_managed_identity_credential=not on_azure,
exclude_workload_identity_credential=not on_azure,
)
self.compute_client = ComputeManagementClient(credential, subscription_id)
self.network_client = NetworkManagementClient(credential, subscription_id)
self.resource_client = ResourceManagementClient(credential, subscription_id)
self.lock = RLock()
# cache node objects
self.cached_nodes = {}
# Cache terminating node operations
self.terminating_nodes: dict[str, Future] = {}
self.termination_executor = ThreadPoolExecutor(
max_workers=MAX_PARALLEL_SHUTDOWN_WORKERS
)
@synchronized
def _get_filtered_nodes(self, tag_filters):
# add cluster name filter to only get nodes from this cluster
cluster_tag_filters = {**tag_filters, TAG_RAY_CLUSTER_NAME: self.cluster_name}
def match_tags(tags):
for k, v in cluster_tag_filters.items():
if tags.get(k) != v:
return False
return True
vms = self.compute_client.virtual_machines.list(
resource_group_name=self.provider_config["resource_group"]
)
nodes = [self._extract_metadata(vm) for vm in vms]
self.cached_nodes = {node["name"]: node for node in nodes}
# Update terminating nodes list by removing nodes that
# have finished termination.
self.terminating_nodes = {
k: v for k, v in self.terminating_nodes.items() if not v.done()
}
return {
k: v
for k, v in self.cached_nodes.items()
if v["tags"] is not None and match_tags(v["tags"])
}
def _extract_metadata(self, vm):
# get tags
metadata = {"name": vm.name, "tags": vm.tags, "status": ""}
# get status
resource_group = self.provider_config["resource_group"]
try:
instance = self.compute_client.virtual_machines.instance_view(
resource_group_name=resource_group, vm_name=vm.name
).as_dict()
except ResourceNotFoundError:
return metadata
for status in instance["statuses"]:
# If ProvisioningState is "failed" (e.g.,
# ProvisioningState/failed/RetryableError), we can get a third
# string here, so we need to limit to the first two outputs.
code, state = status["code"].split("/")[:2]
# skip provisioning status
if code == "PowerState":
metadata["status"] = state
break
# get ip data
nic_id = vm.network_profile.network_interfaces[0].id
metadata["nic_name"] = nic_id.split("/")[-1]
nic = self.network_client.network_interfaces.get(
resource_group_name=resource_group,
network_interface_name=metadata["nic_name"],
)
ip_config = nic.ip_configurations[0]
# Get public IP if not using internal IPs or if this is the
# head node and use_external_head_ip is True
if not self.provider_config.get("use_internal_ips", False) or (
self.provider_config.get("use_external_head_ip", False)
and metadata["tags"][TAG_RAY_NODE_KIND] == NODE_KIND_HEAD
):
public_ip_id = ip_config.public_ip_address.id
metadata["public_ip_name"] = public_ip_id.split("/")[-1]
public_ip = self.network_client.public_ip_addresses.get(
resource_group_name=resource_group,
public_ip_address_name=metadata["public_ip_name"],
)
metadata["external_ip"] = public_ip.ip_address
metadata["internal_ip"] = ip_config.private_ip_address
return metadata
def _get_zones_for_vm_size(self, vm_size, location):
"""Get usable availability zones for a given VM size in a specific location."""
try:
# Note: Azure ResourceSKUs API filters don't work reliably(?), so we query all SKUs
# and filter in code. Each SKU object represents one location for the VM size.
skus = self.compute_client.resource_skus.list()
for sku in skus:
if sku.name == vm_size and sku.location_info:
# Each SKU object represents one location, check if it matches our target
for location_info in sku.location_info:
if location_info.location.lower() == location.lower():
zones = location_info.zones if location_info.zones else []
logger.debug(
f"Found {vm_size} in {location} with zones: {zones}"
)
return sorted(zones)
logger.warning(f"No zones found for {vm_size} in {location}")
return [] # No zones available for this VM size
except Exception as e:
logger.warning(
f"Failed to get zones for VM size {vm_size} in location {location}: {str(e)}"
)
return []
def _parse_availability_zones(
self, availability_zone_config: Optional[str]
) -> Optional[List[str]]:
"""Parse availability_zone configuration from comma-separated string format.
Args:
availability_zone_config: Can be:
- String: comma-separated zones like "1,2,3"
- "none": explicitly disable zones
- "auto": let Azure automatically pick zones
- None: no zones specified (defaults to letting Azure pick)
Returns:
List of zone strings, or None if zones explicitly disabled, or [] if auto/unspecified
"""
if availability_zone_config is None:
return [] # Auto - let Azure pick
# Handle string format (AWS-style comma-separated)
if isinstance(availability_zone_config, str):
# Strip whitespace and split by comma
zones = [zone.strip() for zone in availability_zone_config.split(",")]
# Handle special cases (case-insensitive)
if len(zones) == 1:
zone_lower = zones[0].lower()
if zone_lower in ["none", "null"]:
return None # Explicitly disabled
elif zone_lower == "auto":
return [] # Auto - let Azure pick
# Handle empty string or whitespace-only
if not zones or all(not zone for zone in zones):
return [] # Auto - let Azure pick
return zones
# Unsupported format
raise ValueError(
f"availability_zone must be a string, got {type(availability_zone_config).__name__}: {availability_zone_config!r}"
)
def _validate_zones_for_node_pool(self, zones, location, vm_size):
"""Validate that the specified zones are available for the given VM size in the location."""
# Special case: zones=None means explicitly disabled availability zones
if zones is None:
logger.info(
"Zones explicitly disabled with 'none' - will create VM without an availability zone"
)
return None # Special return value to indicate "no zones by choice"
vm_zones = self._get_zones_for_vm_size(vm_size, location)
available_zones = set(vm_zones)
if not available_zones:
logger.warning("No zones available for this VM size and location")
return []
if zones:
requested_zones = {str(z) for z in zones}
intersection = sorted(available_zones.intersection(requested_zones))
return intersection
return sorted(available_zones)
def stopped_nodes(self, tag_filters):
"""Return a list of stopped node ids filtered by the specified tags dict."""
nodes = self._get_filtered_nodes(tag_filters=tag_filters)
return [k for k, v in nodes.items() if v["status"].startswith("deallocat")]
def non_terminated_nodes(self, tag_filters: Dict[str, str]) -> List[str]:
"""Return a list of node ids filtered by the specified tags dict.
This list must not include terminated nodes. For performance reasons,
providers are allowed to cache the result of a call to nodes() to
serve single-node queries (e.g. is_running(node_id)). This means that
nodes() must be called again to refresh results.
Args:
tag_filters: Tags to filter nodes by.
Returns:
List of non-terminated node ids matching the tag filters.
Examples:
>>> from ray.autoscaler.tags import TAG_RAY_NODE_KIND
>>> provider = ... # doctest: +SKIP
>>> provider.non_terminated_nodes( # doctest: +SKIP
... {TAG_RAY_NODE_KIND: "worker"})
["node-1", "node-2"]
"""
nodes = self._get_filtered_nodes(tag_filters=tag_filters)
return [
k
for k, v in nodes.items()
if not v["status"].startswith("deallocat") or k in self.terminating_nodes
]
def is_running(self, node_id):
"""Return whether the specified node is running."""
# always get current status
node = self._get_node(node_id=node_id)
return node["status"] == "running"
def is_terminated(self, node_id):
"""Return whether the specified node is terminated."""
# always get current status
node = self._get_node(node_id=node_id)
return node["status"].startswith("deallocat")
def node_tags(self, node_id):
"""Returns the tags of the given node (string dict)."""
return self._get_cached_node(node_id=node_id)["tags"]
def external_ip(self, node_id):
"""Returns the external ip of the given node."""
ip = (
self._get_cached_node(node_id=node_id)["external_ip"]
or self._get_node(node_id=node_id)["external_ip"]
)
return ip
def internal_ip(self, node_id):
"""Returns the internal ip (Ray ip) of the given node."""
ip = (
self._get_cached_node(node_id=node_id)["internal_ip"]
or self._get_node(node_id=node_id)["internal_ip"]
)
return ip
def create_node(self, node_config, tags, count):
resource_group = self.provider_config["resource_group"]
if self.cache_stopped_nodes:
VALIDITY_TAGS = [
TAG_RAY_CLUSTER_NAME,
TAG_RAY_NODE_KIND,
TAG_RAY_LAUNCH_CONFIG,
TAG_RAY_USER_NODE_TYPE,
]
filters = {tag: tags[tag] for tag in VALIDITY_TAGS if tag in tags}
reuse_nodes = self.stopped_nodes(filters)[:count]
logger.info(
f"Reusing nodes {list(reuse_nodes)}. "
"To disable reuse, set `cache_stopped_nodes: False` "
"under `provider` in the cluster configuration.",
)
start = get_azure_sdk_function(
client=self.compute_client.virtual_machines, function_name="start"
)
for node_id in reuse_nodes:
start(resource_group_name=resource_group, vm_name=node_id).wait()
self.set_node_tags(node_id, tags)
count -= len(reuse_nodes)
if count:
self._create_node(node_config, tags, count)
def _create_node(self, node_config, tags, count):
"""Creates a number of nodes within the namespace."""
resource_group = self.provider_config["resource_group"]
location = self.provider_config["location"]
vm_size = node_config["azure_arm_parameters"]["vmSize"]
# Determine availability zones with precedence: node-level > provider-level
# Check for "availability_zone" field in node config first
node_availability_zone = node_config.get("azure_arm_parameters", {}).get(
"availability_zone"
)
# Then check provider-level "availability_zone"
provider_availability_zone = self.provider_config.get("availability_zone")
requested_zones = []
zone_source = "default"
# Precedence: node availability_zone > provider availability_zone
if node_availability_zone is not None:
requested_zones = self._parse_availability_zones(node_availability_zone)
zone_source = "node config availability_zone"
elif provider_availability_zone is not None:
requested_zones = self._parse_availability_zones(provider_availability_zone)
zone_source = "provider availability_zone"
logger.info(f"Requested zones from {zone_source}: {requested_zones}")
# Get actually available zones for this VM size
available_zones = self._validate_zones_for_node_pool(
requested_zones, location, vm_size
)
# Handle explicit zone disabling
zones_explicitly_disabled = available_zones is None
if requested_zones and not zones_explicitly_disabled and not available_zones:
raise ValueError(
f"No available zones for VM size {vm_size} in {location}. "
f"Requested: {requested_zones}, but none are available for this VM size."
)
# load the template file
current_path = Path(__file__).parent
template_path = current_path.joinpath("azure-vm-template.json")
with open(template_path, "r") as template_fp:
template = json.load(template_fp)
# get the tags
config_tags = node_config.get("tags", {}).copy()
config_tags.update(tags)
config_tags[TAG_RAY_CLUSTER_NAME] = self.cluster_name
deployment_name = "{node}-{unique_id}-{vm_id}".format(
node=config_tags.get(TAG_RAY_NODE_NAME, "node"),
unique_id=self.provider_config["unique_id"],
vm_id=uuid4().hex[:UNIQUE_ID_LEN],
)[:VM_NAME_MAX_LEN]
template_params = node_config["azure_arm_parameters"].copy()
# Remove availability_zone from template params since ARM template expects "zones"
template_params.pop("availability_zone", None)
# Use deployment_name for the vmName template parameter since
# the template will append copyIndex() for each VM that gets created
# to guarantee uniqueness.
template_params["vmName"] = deployment_name
# Provision public IP if not using internal IPs or if this is the
# head node and use_external_head_ip is True
template_params["provisionPublicIp"] = not self.provider_config.get(
"use_internal_ips", False
) or (
self.provider_config.get("use_external_head_ip", False)
and config_tags[TAG_RAY_NODE_KIND] == NODE_KIND_HEAD
)
template_params["vmTags"] = config_tags
template_params["vmCount"] = count
template_params["msi"] = self.provider_config["msi"]
template_params["nsg"] = self.provider_config["nsg"]
template_params["subnet"] = self.provider_config["subnet"]
# Add zone information based on availability and requested zones
if zones_explicitly_disabled:
# User explicitly disabled zones with ["None"]
template_params["zones"] = []
logger.info(
f"Creating {count} VMs with zones explicitly disabled (no availability zone)"
)
elif available_zones:
# Pass the list of available zones to the template
template_params["zones"] = available_zones
logger.info(
f"Creating {count} VMs, distributed across availability zones: {available_zones}"
)
else:
# For non-zonal deployments (no zones available), use empty array
template_params["zones"] = []
logger.info(f"Creating {count} VMs without specific availability zone")
parameters = {
"properties": {
"mode": DeploymentMode.incremental,
"template": template,
"parameters": {
key: {"value": value} for key, value in template_params.items()
},
}
}
# TODO: we could get the private/public ips back directly
create_or_update = get_azure_sdk_function(
client=self.resource_client.deployments,
function_name="create_or_update",
)
create_or_update(
resource_group_name=resource_group,
deployment_name=deployment_name,
parameters=parameters,
).wait(timeout=AUTOSCALER_NODE_START_WAIT_S)
@synchronized
def set_node_tags(self, node_id, tags):
"""Sets the tag values (string dict) for the specified node."""
node_tags = self._get_cached_node(node_id)["tags"]
node_tags.update(tags)
update = get_azure_sdk_function(
client=self.compute_client.virtual_machines, function_name="update"
)
update(
resource_group_name=self.provider_config["resource_group"],
vm_name=node_id,
parameters={"tags": node_tags},
)
self.cached_nodes[node_id]["tags"] = node_tags
def terminate_node(self, node_id):
"""Terminates the specified node. This will delete the VM and
associated resources (NIC, IP, Storage) for the specified node."""
resource_group = self.provider_config["resource_group"]
if self.cache_stopped_nodes:
try:
# stop machine and leave all resources
logger.info(
f"Stopping instance {node_id}"
"(to fully terminate instead, "
"set `cache_stopped_nodes: False` "
"under `provider` in the cluster configuration)"
)
stop = get_azure_sdk_function(
client=self.compute_client.virtual_machines,
function_name="deallocate",
)
stop(resource_group_name=resource_group, vm_name=node_id)
except Exception as e:
logger.warning("Failed to stop VM: {}".format(e))
# If node_id is in terminating nodes dict, it's already terminating
# Otherwise, kick off termination and add it to the dict
elif node_id not in self.terminating_nodes:
self.terminating_nodes[node_id] = self.termination_executor.submit(
self._delete_node_and_resources, resource_group, node_id
)
def _delete_node_and_resources(self, resource_group, node_id):
try:
vm = self.compute_client.virtual_machines.get(
resource_group_name=resource_group, vm_name=node_id
)
except ResourceNotFoundError as e:
# Node no longer exists
logger.warning("Failed to delete VM: {}".format(e))
return
# Gather dependent disks
disks = set()
if vm.storage_profile is not None and vm.storage_profile.data_disks is not None:
for d in vm.storage_profile.data_disks:
if d.name is not None:
disks.add(d.name)
if (
vm.storage_profile is not None
and vm.storage_profile.os_disk is not None
and vm.storage_profile.os_disk.name is not None
):
disks.add(vm.storage_profile.os_disk.name)
# Gather dependent NICs and public IPs
nics = set()
ips = set()
if (
vm.network_profile is not None
and vm.network_profile.network_interfaces is not None
):
for nint in vm.network_profile.network_interfaces:
if nint.id is not None:
nic_name = nint.id.split("/")[-1]
nics.add(nic_name)
# Get public IP if not using internal IPs or if this is the
# head node and use_external_head_ip is True
if not self.provider_config.get("use_internal_ips", False) or (
self.provider_config.get("use_external_head_ip", False)
and vm.tags[TAG_RAY_NODE_KIND] == NODE_KIND_HEAD
):
nic = self.network_client.network_interfaces.get(
resource_group_name=resource_group,
network_interface_name=nic_name,
)
if nic.ip_configurations is not None:
for ipc in nic.ip_configurations:
if ipc.public_ip_address.id is not None:
ips.add(ipc.public_ip_address.id.split("/")[-1])
# Delete VM
st = time.monotonic()
delete = get_azure_sdk_function(
client=self.compute_client.virtual_machines,
function_name="delete",
)
try:
delete(resource_group_name=resource_group, vm_name=node_id).wait(
timeout=AUTOSCALER_NODE_TERMINATE_WAIT_S
)
except Exception as e:
logger.warning("Failed to delete VM: {}".format(e))
# Delete disks (no need to wait for these, but gather the LROs for end)
disk_lros = []
delete = get_azure_sdk_function(
client=self.compute_client.disks, function_name="delete"
)
for d in disks:
try:
disk_lros.append(
delete(
resource_group_name=resource_group,
disk_name=d,
)
)
except Exception as e:
logger.warning("Failed to delete disk: {}".format(e))
# Delete NICs
nic_lros = []
delete = get_azure_sdk_function(
client=self.network_client.network_interfaces, function_name="delete"
)
for n in nics:
try:
nic_lros.append(
delete(
resource_group_name=resource_group,
network_interface_name=n,
)
)
except Exception as e:
logger.warning("Failed to delete NIC: {}".format(e))
while (
not all(nlro.done() for nlro in nic_lros)
and (time.monotonic() - st) < AUTOSCALER_NODE_TERMINATE_WAIT_S
):
time.sleep(0.1)
# Delete Public IPs
delete = get_azure_sdk_function(
client=self.network_client.public_ip_addresses,
function_name="delete",
)
ip_lros = []
for ip in ips:
try:
ip_lros.append(
delete(
resource_group_name=resource_group,
public_ip_address_name=ip,
)
)
except Exception as e:
logger.warning("Failed to delete public IP: {}".format(e))
while (
not all(dlro.done() for dlro in disk_lros)
and (time.monotonic() - st) < AUTOSCALER_NODE_TERMINATE_WAIT_S
):
time.sleep(0.1)
while (
not all(iplro.done() for iplro in ip_lros)
and (time.monotonic() - st) < AUTOSCALER_NODE_TERMINATE_WAIT_S
):
time.sleep(0.1)
def cleanup_cluster_resources(self):
"""Delete shared cluster infrastructure (MSI, NSG, Subnet, VNet)."""
resource_group = self.provider_config["resource_group"]
msi_principal_id = self._cleanup_managed_identity(
resource_group, self.provider_config.get("msi")
)
subnet_id = self.provider_config.get("subnet")
vnet_name = self._cleanup_subnet(resource_group, subnet_id)
nsg_id = self.provider_config.get("nsg")
self._cleanup_nsg(resource_group, nsg_id)
self._cleanup_vnet(resource_group, subnet_id, vnet_name)
self._cleanup_role_assignments(resource_group, msi_principal_id)
self._prune_provider_config_entries()
self._cleanup_config_cache()
@staticmethod
def _get_resource_name_from_id(resource_id: Optional[str]) -> Optional[str]:
if resource_id:
return resource_id.split("/")[-1]
return None
@staticmethod
def _retry_delete(delete_fn, max_retries: int = 5, initial_delay: int = 2):
"""Retry a delete operation with exponential backoff."""
delay = initial_delay
for attempt in range(max_retries):
try:
return delete_fn()
except Exception as exc: # noqa: BLE001
error_msg = str(exc)
if "InUse" in error_msg and attempt < max_retries - 1:
logger.info(
"Resource still in use, retrying in %ss (attempt %s/%s)...",
delay,
attempt + 1,
max_retries,
)
time.sleep(delay)
delay *= 2
else:
raise
def _cleanup_managed_identity(
self, resource_group: str, msi_id: Optional[str]
) -> Optional[str]:
if not msi_id:
return None
msi_name = self._get_resource_name_from_id(msi_id)
if not msi_name:
return None
msi_principal_id: Optional[str] = None
try:
get_identity = get_azure_sdk_function(
client=self.resource_client.resources,
function_name="get_by_id",
)
existing_msi = get_identity(msi_id, "2023-01-31")
msi_principal_id = getattr(existing_msi, "properties", {}).get(
"principalId"
)
except ResourceNotFoundError:
msi_principal_id = None
except Exception as exc: # noqa: BLE001
logger.warning(
"Failed to query MSI %s for principal ID prior to deletion: %s",
msi_name,
exc,
)
if _is_shared_msi(self.provider_config):
return msi_principal_id
try:
logger.info("Deleting Managed Service Identity: %s", msi_name)
delete = get_azure_sdk_function(
client=self.resource_client.resources,
function_name="delete_by_id",
)
delete(resource_id=msi_id, api_version="2023-01-31").wait()
logger.info("Successfully deleted MSI: %s", msi_name)
except ResourceNotFoundError:
logger.info("MSI %s not found, may have been already deleted", msi_name)
except Exception as exc: # noqa: BLE001
logger.warning("Failed to delete MSI %s: %s", msi_name, exc)
return msi_principal_id
def _cleanup_subnet(
self, resource_group: str, subnet_id: Optional[str]
) -> Optional[str]:
if not subnet_id:
return None
subnet_name = self._get_resource_name_from_id(subnet_id)
vnet_name: Optional[str] = None
if subnet_id and "/virtualNetworks/" in subnet_id:
parts = subnet_id.split("/")
vnet_idx = parts.index("virtualNetworks")
if vnet_idx + 1 < len(parts):
vnet_name = parts[vnet_idx + 1]
if not subnet_name or not vnet_name:
return None
try:
logger.info("Deleting Subnet: %s in VNet: %s", subnet_name, vnet_name)
def delete_subnet():
delete = get_azure_sdk_function(
client=self.network_client.subnets, function_name="delete"
)
delete(
resource_group_name=resource_group,
virtual_network_name=vnet_name,
subnet_name=subnet_name,
).wait()
self._retry_delete(delete_subnet)
logger.info("Successfully deleted Subnet: %s", subnet_name)
except ResourceNotFoundError:
logger.info(
"Subnet %s not found, may have been already deleted", subnet_name
)
except Exception as exc: # noqa: BLE001
logger.warning("Failed to delete Subnet %s: %s", subnet_name, exc)
return vnet_name
def _cleanup_nsg(self, resource_group: str, nsg_id: Optional[str]) -> None:
if not nsg_id:
return
nsg_name = self._get_resource_name_from_id(nsg_id)
if not nsg_name:
return
try:
logger.info("Deleting Network Security Group: %s", nsg_name)
def delete_nsg():
delete = get_azure_sdk_function(
client=self.network_client.network_security_groups,
function_name="delete",
)
delete(
resource_group_name=resource_group,
network_security_group_name=nsg_name,
).wait()
self._retry_delete(delete_nsg)
logger.info("Successfully deleted NSG: %s", nsg_name)
except ResourceNotFoundError:
logger.info("NSG %s not found, may have been already deleted", nsg_name)
except Exception as exc: # noqa: BLE001
logger.warning("Failed to delete NSG %s: %s", nsg_name, exc)
def _cleanup_vnet(
self,
resource_group: str,
subnet_id: Optional[str],
vnet_name: Optional[str],
) -> None:
if not subnet_id or not vnet_name:
return
try:
logger.info("Deleting Virtual Network: %s", vnet_name)
def delete_vnet():
delete = get_azure_sdk_function(
client=self.network_client.virtual_networks,
function_name="delete",
)
delete(
resource_group_name=resource_group,
virtual_network_name=vnet_name,
).wait()
self._retry_delete(delete_vnet)
logger.info("Successfully deleted VNet: %s", vnet_name)
except ResourceNotFoundError:
logger.info("VNet %s not found, may have been already deleted", vnet_name)
except Exception as exc: # noqa: BLE001
logger.warning("Failed to delete VNet %s: %s", vnet_name, exc)
def _cleanup_role_assignments(
self, resource_group: str, msi_principal_id: Optional[str]
) -> None:
subscription_id = self.provider_config.get("subscription_id")
unique_id = self.provider_config.get("unique_id")
if not subscription_id or not unique_id:
return
cluster_id = f"{self.cluster_name}-{unique_id}"
role_assignment_name = f"ray-{cluster_id}-ra"
role_assignment_guid = _generate_arm_guid(role_assignment_name)
role_assignment_id = (
f"/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers"
f"/Microsoft.Authorization/roleAssignments/{role_assignment_guid}"
)
if msi_principal_id:
_delete_role_assignments_for_principal(
self.resource_client, resource_group, msi_principal_id
)
delete_role_assignment = get_azure_sdk_function(
client=self.resource_client.resources, function_name="delete_by_id"
)
try:
delete_lro = delete_role_assignment(
resource_id=role_assignment_id,
api_version="2022-04-01",
)
if hasattr(delete_lro, "wait"):
delete_lro.wait()
logger.info(
"Deleted role assignment %s for cluster %s",
role_assignment_guid,
self.cluster_name,
)
except ResourceNotFoundError:
logger.debug(
"Role assignment %s not found during cleanup", role_assignment_guid
)
except Exception as exc: # noqa: BLE001
logger.warning(
"Failed to delete role assignment %s: %s",
role_assignment_guid,
exc,
)
def _prune_provider_config_entries(self) -> None:
for key in ("msi", "nsg", "subnet"):
self.provider_config.pop(key, None)
def _cleanup_config_cache(self) -> None:
cache_path = self.provider_config.get("_config_cache_path")
if not cache_path:
return
try:
if os.path.exists(cache_path):
os.remove(cache_path)
logger.info(
"Deleted cached Ray config at %s after resource cleanup",
cache_path,
)
except Exception as exc: # noqa: BLE001
logger.warning("Failed to delete cached Ray config %s: %s", cache_path, exc)
finally:
self.provider_config.pop("_config_cache_path", None)
def _get_node(self, node_id):
self._get_filtered_nodes({}) # Side effect: updates cache
return self.cached_nodes[node_id]
def _get_cached_node(self, node_id):
return self.cached_nodes.get(node_id) or self._get_node(node_id=node_id)
@staticmethod
def bootstrap_config(cluster_config):
return bootstrap_azure(cluster_config)
@@ -0,0 +1,116 @@
import logging
import os
import stat
from ray.autoscaler._private.aliyun.utils import AcsClient
# instance status
PENDING = "Pending"
RUNNING = "Running"
STARTING = "Starting"
STOPPING = "Stopping"
STOPPED = "Stopped"
logger = logging.getLogger(__name__)
def bootstrap_aliyun(config):
# print(config["provider"])
# create vpc
_get_or_create_vpc(config)
# create security group id
_get_or_create_security_group(config)
# create vswitch
_get_or_create_vswitch(config)
# create key pair
_get_or_import_key_pair(config)
# print(config["provider"])
return config
def _client(config):
return AcsClient(
access_key=config["provider"].get("access_key"),
access_key_secret=config["provider"].get("access_key_secret"),
region_id=config["provider"]["region"],
max_retries=1,
)
def _get_or_create_security_group(config):
cli = _client(config)
security_groups = cli.describe_security_groups(vpc_id=config["provider"]["vpc_id"])
if security_groups is not None and len(security_groups) > 0:
config["provider"]["security_group_id"] = security_groups[0]["SecurityGroupId"]
return config
security_group_id = cli.create_security_group(vpc_id=config["provider"]["vpc_id"])
for rule in config["provider"].get("security_group_rule", {}):
cli.authorize_security_group(
security_group_id=security_group_id,
port_range=rule["port_range"],
source_cidr_ip=rule["source_cidr_ip"],
ip_protocol=rule["ip_protocol"],
)
config["provider"]["security_group_id"] = security_group_id
return
def _get_or_create_vpc(config):
cli = _client(config)
vpcs = cli.describe_vpcs()
if vpcs is not None and len(vpcs) > 0:
config["provider"]["vpc_id"] = vpcs[0].get("VpcId")
return
vpc_id = cli.create_vpc()
if vpc_id is not None:
config["provider"]["vpc_id"] = vpc_id
def _get_or_create_vswitch(config):
cli = _client(config)
vswitches = cli.describe_v_switches(vpc_id=config["provider"]["vpc_id"])
if vswitches is not None and len(vswitches) > 0:
config["provider"]["v_switch_id"] = vswitches[0].get("VSwitchId")
return
v_switch_id = cli.create_v_switch(
vpc_id=config["provider"]["vpc_id"],
zone_id=config["provider"]["zone_id"],
cidr_block=config["provider"]["cidr_block"],
)
if v_switch_id is not None:
config["provider"]["v_switch_id"] = v_switch_id
def _get_or_import_key_pair(config):
cli = _client(config)
key_name = config["provider"].get("key_name", "ray")
key_path = os.path.expanduser("~/.ssh/{}".format(key_name))
keypairs = cli.describe_key_pairs(key_pair_name=key_name)
if keypairs is not None and len(keypairs) > 0:
if "ssh_private_key" not in config["auth"]:
logger.info(
"{} keypair exists, use {} as local ssh key".format(key_name, key_path)
)
config["auth"]["ssh_private_key"] = key_path
else:
if "ssh_private_key" not in config["auth"]:
# create new keypair
resp = cli.create_key_pair(key_pair_name=key_name)
if resp is not None:
with open(key_path, "w+") as f:
f.write(resp.get("PrivateKeyBody"))
os.chmod(key_path, stat.S_IRUSR)
config["auth"]["ssh_private_key"] = key_path
else:
public_key_file = config["auth"]["ssh_private_key"] + ".pub"
# create new keypair, from local file
with open(public_key_file) as f:
public_key = f.readline().strip("\n")
cli.import_key_pair(key_pair_name=key_name, public_key_body=public_key)
return
@@ -0,0 +1,324 @@
import logging
import random
import threading
import time
from collections import defaultdict
from typing import Any, Dict, List, Optional
from ray.autoscaler._private.aliyun.config import (
PENDING,
RUNNING,
STOPPED,
STOPPING,
bootstrap_aliyun,
)
from ray.autoscaler._private.aliyun.utils import AcsClient
from ray.autoscaler._private.cli_logger import cli_logger
from ray.autoscaler._private.constants import BOTO_MAX_RETRIES
from ray.autoscaler._private.log_timer import LogTimer
from ray.autoscaler.node_provider import NodeProvider
from ray.autoscaler.tags import (
TAG_RAY_CLUSTER_NAME,
TAG_RAY_LAUNCH_CONFIG,
TAG_RAY_NODE_KIND,
TAG_RAY_NODE_NAME,
TAG_RAY_NODE_STATUS,
TAG_RAY_USER_NODE_TYPE,
)
logger = logging.getLogger(__name__)
TAG_BATCH_DELAY = 1
STOPPING_NODE_DELAY = 1
class AliyunNodeProvider(NodeProvider):
def __init__(self, provider_config, cluster_name):
NodeProvider.__init__(self, provider_config, cluster_name)
self.cache_stopped_nodes = provider_config.get("cache_stopped_nodes", True)
self.acs = AcsClient(
access_key=provider_config["access_key"],
access_key_secret=provider_config["access_key_secret"],
region_id=provider_config["region"],
max_retries=BOTO_MAX_RETRIES,
)
# Try availability zones round-robin, starting from random offset
self.subnet_idx = random.randint(0, 100)
# Tags that we believe to actually be on the node.
self.tag_cache = {}
# Tags that we will soon upload.
self.tag_cache_pending = defaultdict(dict)
# Number of threads waiting for a batched tag update.
self.batch_thread_count = 0
self.batch_update_done = threading.Event()
self.batch_update_done.set()
self.ready_for_new_batch = threading.Event()
self.ready_for_new_batch.set()
self.tag_cache_lock = threading.Lock()
self.count_lock = threading.Lock()
# Cache of node objects from the last nodes() call. This avoids
# excessive DescribeInstances requests.
self.cached_nodes = {}
def non_terminated_nodes(self, tag_filters: Dict[str, str]) -> List[str]:
tags = [
{
"Key": TAG_RAY_CLUSTER_NAME,
"Value": self.cluster_name,
},
]
for k, v in tag_filters.items():
tags.append(
{
"Key": k,
"Value": v,
}
)
instances = self.acs.describe_instances(tags=tags)
non_terminated_instance = []
for instance in instances:
if instance.get("Status") == RUNNING or instance.get("Status") == PENDING:
non_terminated_instance.append(instance.get("InstanceId"))
self.cached_nodes[instance.get("InstanceId")] = instance
return non_terminated_instance
def is_running(self, node_id: str) -> bool:
instances = self.acs.describe_instances(instance_ids=[node_id])
if instances is not None:
instance = instances[0]
return instance.get("Status") == "Running"
cli_logger.error("Invalid node id: %s", node_id)
return False
def is_terminated(self, node_id: str) -> bool:
instances = self.acs.describe_instances(instance_ids=[node_id])
if instances is not None:
assert len(instances) == 1
instance = instances[0]
return instance.get("Status") == "Stopped"
cli_logger.error("Invalid node id: %s", node_id)
return False
def node_tags(self, node_id: str) -> Dict[str, str]:
instances = self.acs.describe_instances(instance_ids=[node_id])
if instances is not None:
assert len(instances) == 1
instance = instances[0]
if instance.get("Tags") is not None:
node_tags = dict()
for tag in instance.get("Tags").get("Tag"):
node_tags[tag.get("TagKey")] = tag.get("TagValue")
return node_tags
return dict()
def external_ip(self, node_id: str) -> str:
while True:
instances = self.acs.describe_instances(instance_ids=[node_id])
if instances is not None:
assert len(instances)
instance = instances[0]
if (
instance.get("PublicIpAddress") is not None
and instance.get("PublicIpAddress").get("IpAddress") is not None
):
if len(instance.get("PublicIpAddress").get("IpAddress")) > 0:
return instance.get("PublicIpAddress").get("IpAddress")[0]
cli_logger.error("PublicIpAddress attribute is not exist. %s" % instance)
time.sleep(STOPPING_NODE_DELAY)
def internal_ip(self, node_id: str) -> str:
while True:
instances = self.acs.describe_instances(instance_ids=[node_id])
if instances is not None:
assert len(instances) == 1
instance = instances[0]
if (
instance.get("VpcAttributes") is not None
and instance.get("VpcAttributes").get("PrivateIpAddress")
is not None
and len(
instance.get("VpcAttributes")
.get("PrivateIpAddress")
.get("IpAddress")
)
> 0
):
return (
instance.get("VpcAttributes")
.get("PrivateIpAddress")
.get("IpAddress")[0]
)
cli_logger.error("InnerIpAddress attribute is not exist. %s" % instance)
time.sleep(STOPPING_NODE_DELAY)
def set_node_tags(self, node_id: str, tags: Dict[str, str]) -> None:
is_batching_thread = False
with self.tag_cache_lock:
if not self.tag_cache_pending:
is_batching_thread = True
# Wait for threads in the last batch to exit
self.ready_for_new_batch.wait()
self.ready_for_new_batch.clear()
self.batch_update_done.clear()
self.tag_cache_pending[node_id].update(tags)
if is_batching_thread:
time.sleep(TAG_BATCH_DELAY)
with self.tag_cache_lock:
self._update_node_tags()
self.batch_update_done.set()
with self.count_lock:
self.batch_thread_count += 1
self.batch_update_done.wait()
with self.count_lock:
self.batch_thread_count -= 1
if self.batch_thread_count == 0:
self.ready_for_new_batch.set()
def _update_node_tags(self):
batch_updates = defaultdict(list)
for node_id, tags in self.tag_cache_pending.items():
for x in tags.items():
batch_updates[x].append(node_id)
self.tag_cache[node_id] = tags
self.tag_cache_pending = defaultdict(dict)
self._create_tags(batch_updates)
def _create_tags(self, batch_updates):
for (k, v), node_ids in batch_updates.items():
m = "Set tag {}={} on {}".format(k, v, node_ids)
with LogTimer("AliyunNodeProvider: {}".format(m)):
if k == TAG_RAY_NODE_NAME:
k = "Name"
self.acs.tag_resource(node_ids, [{"Key": k, "Value": v}])
def create_node(
self, node_config: Dict[str, Any], tags: Dict[str, str], count: int
) -> Optional[Dict[str, Any]]:
filter_tags = [
{
"Key": TAG_RAY_CLUSTER_NAME,
"Value": self.cluster_name,
},
{"Key": TAG_RAY_NODE_KIND, "Value": tags[TAG_RAY_NODE_KIND]},
{"Key": TAG_RAY_USER_NODE_TYPE, "Value": tags[TAG_RAY_USER_NODE_TYPE]},
{"Key": TAG_RAY_LAUNCH_CONFIG, "Value": tags[TAG_RAY_LAUNCH_CONFIG]},
{"Key": TAG_RAY_NODE_NAME, "Value": tags[TAG_RAY_NODE_NAME]},
]
reused_nodes_dict = {}
if self.cache_stopped_nodes:
reuse_nodes_candidate = self.acs.describe_instances(tags=filter_tags)
if reuse_nodes_candidate:
with cli_logger.group("Stopping instances to reuse"):
reuse_node_ids = []
for node in reuse_nodes_candidate:
node_id = node.get("InstanceId")
status = node.get("Status")
if status != STOPPING and status != STOPPED:
continue
if status == STOPPING:
# wait for node stopped
while (
self.acs.describe_instances(instance_ids=[node_id])[
0
].get("Status")
== STOPPING
):
logging.info("wait for %s stop" % node_id)
time.sleep(STOPPING_NODE_DELAY)
# logger.info("reuse %s" % node_id)
reuse_node_ids.append(node_id)
reused_nodes_dict[node.get("InstanceId")] = node
self.acs.start_instance(node_id)
self.tag_cache[node_id] = node.get("Tags")
self.set_node_tags(node_id, tags)
if len(reuse_node_ids) == count:
break
count -= len(reuse_node_ids)
created_nodes_dict = {}
if count > 0:
filter_tags.append(
{"Key": TAG_RAY_NODE_STATUS, "Value": tags[TAG_RAY_NODE_STATUS]}
)
instance_id_sets = self.acs.run_instances(
instance_type=node_config["InstanceType"],
image_id=node_config["ImageId"],
tags=filter_tags,
amount=count,
vswitch_id=self.provider_config["v_switch_id"],
security_group_id=self.provider_config["security_group_id"],
key_pair_name=self.provider_config["key_name"],
)
instances = self.acs.describe_instances(instance_ids=instance_id_sets)
if instances is not None:
for instance in instances:
created_nodes_dict[instance.get("InstanceId")] = instance
all_created_nodes = reused_nodes_dict
all_created_nodes.update(created_nodes_dict)
return all_created_nodes
def terminate_node(self, node_id: str) -> None:
logger.info("terminate node: %s" % node_id)
if self.cache_stopped_nodes:
logger.info(
"Stopping instance {} (to terminate instead, "
"set `cache_stopped_nodes: False` "
"under `provider` in the cluster configuration)"
).format(node_id)
self.acs.stop_instance(node_id)
else:
self.acs.delete_instance(node_id)
def terminate_nodes(self, node_ids: List[str]) -> None:
if not node_ids:
return
if self.cache_stopped_nodes:
logger.info(
"Stopping instances {} (to terminate instead, "
"set `cache_stopped_nodes: False` "
"under `provider` in the cluster configuration)".format(node_ids)
)
self.acs.stop_instances(node_ids)
else:
self.acs.delete_instances(node_ids)
def _get_node(self, node_id):
"""Refresh and get info for this node, updating the cache."""
self.non_terminated_nodes({}) # Side effect: updates cache
if node_id in self.cached_nodes:
return self.cached_nodes[node_id]
# Node not in {pending, running} -- retry with a point query. This
# usually means the node was recently preempted or terminated.
matches = self.acs.describe_instances(instance_ids=[node_id])
assert len(matches) == 1, "Invalid instance id {}".format(node_id)
return matches[0]
def _get_cached_node(self, node_id):
"""Return node info from cache if possible, otherwise fetches it."""
if node_id in self.cached_nodes:
return self.cached_nodes[node_id]
return self._get_node(node_id)
@staticmethod
def bootstrap_config(cluster_config):
return bootstrap_aliyun(cluster_config)
@@ -0,0 +1,521 @@
import json
import logging
from typing import List, Optional
from aliyunsdkcore import client
from aliyunsdkcore.acs_exception.exceptions import ClientException, ServerException
from aliyunsdkecs.request.v20140526.AllocatePublicIpAddressRequest import (
AllocatePublicIpAddressRequest,
)
from aliyunsdkecs.request.v20140526.AuthorizeSecurityGroupRequest import (
AuthorizeSecurityGroupRequest,
)
from aliyunsdkecs.request.v20140526.CreateInstanceRequest import CreateInstanceRequest
from aliyunsdkecs.request.v20140526.CreateKeyPairRequest import CreateKeyPairRequest
from aliyunsdkecs.request.v20140526.CreateSecurityGroupRequest import (
CreateSecurityGroupRequest,
)
from aliyunsdkecs.request.v20140526.CreateVpcRequest import CreateVpcRequest
from aliyunsdkecs.request.v20140526.CreateVSwitchRequest import CreateVSwitchRequest
from aliyunsdkecs.request.v20140526.DeleteInstanceRequest import DeleteInstanceRequest
from aliyunsdkecs.request.v20140526.DeleteInstancesRequest import DeleteInstancesRequest
from aliyunsdkecs.request.v20140526.DeleteKeyPairsRequest import DeleteKeyPairsRequest
from aliyunsdkecs.request.v20140526.DescribeInstancesRequest import (
DescribeInstancesRequest,
)
from aliyunsdkecs.request.v20140526.DescribeKeyPairsRequest import (
DescribeKeyPairsRequest,
)
from aliyunsdkecs.request.v20140526.DescribeSecurityGroupsRequest import (
DescribeSecurityGroupsRequest,
)
from aliyunsdkecs.request.v20140526.DescribeVpcsRequest import DescribeVpcsRequest
from aliyunsdkecs.request.v20140526.DescribeVSwitchesRequest import (
DescribeVSwitchesRequest,
)
from aliyunsdkecs.request.v20140526.ImportKeyPairRequest import ImportKeyPairRequest
from aliyunsdkecs.request.v20140526.RunInstancesRequest import RunInstancesRequest
from aliyunsdkecs.request.v20140526.StartInstanceRequest import StartInstanceRequest
from aliyunsdkecs.request.v20140526.StopInstanceRequest import StopInstanceRequest
from aliyunsdkecs.request.v20140526.StopInstancesRequest import StopInstancesRequest
from aliyunsdkecs.request.v20140526.TagResourcesRequest import TagResourcesRequest
class AcsClient:
"""
A wrapper around Aliyun SDK. We use this wrapper in aliyun node provider.
Parameters:
access_key: The AccessKey ID of your aliyun account.
access_key_secret: The AccessKey secret of your aliyun account.
region_id: A region is a geographic area where a data center resides.
Region_id is the ID of region (e.g., cn-hangzhou,
us-west-1, etc.)
max_retries: The maximum number of retries each connection.
"""
def __init__(
self,
access_key: str,
access_key_secret: str,
region_id: str,
max_retries: int,
):
self.cli = client.AcsClient(
ak=access_key,
secret=access_key_secret,
max_retry_time=max_retries,
region_id=region_id,
)
def describe_instances(
self,
tags: Optional[List[dict]] = None,
instance_ids: Optional[List[str]] = None,
) -> Optional[list]:
"""Query the details of one or more Elastic Compute Service (ECS) instances.
Args:
tags: The tags of the instance.
instance_ids: The IDs of ECS instances.
Returns:
ECS instance list.
"""
request = DescribeInstancesRequest()
if tags is not None:
request.set_Tags(tags)
if instance_ids is not None:
request.set_InstanceIds(instance_ids)
response = self._send_request(request)
if response is not None:
instance_list = response.get("Instances").get("Instance")
return instance_list
return None
def create_instance(
self,
instance_type: str,
image_id: str,
tags: List[dict],
key_pair_name: str,
optimized: str = "optimized",
instance_charge_type: str = "PostPaid",
spot_strategy: str = "SpotWithPriceLimit",
internet_charge_type: str = "PayByTraffic",
internet_max_bandwidth_out: int = 5,
) -> Optional[str]:
"""Create a subscription or pay-as-you-go ECS instance.
Args:
instance_type: The instance type of the ECS.
image_id: The ID of the image used to create the instance.
tags: The tags of the instance.
key_pair_name: The name of the key pair to be bound to
the instance.
optimized: Specifies whether the instance is I/O optimized.
instance_charge_type: The billing method of the instance.
Default value: PostPaid.
spot_strategy: The preemption policy for the pay-as-you-go
instance.
internet_charge_type: The billing method for network usage.
Default value: PayByTraffic.
internet_max_bandwidth_out: The maximum inbound public
bandwidth. Unit: Mbit/s.
Returns:
The created instance ID.
"""
request = CreateInstanceRequest()
request.set_InstanceType(instance_type)
request.set_ImageId(image_id)
request.set_IoOptimized(optimized)
request.set_InstanceChargeType(instance_charge_type)
request.set_SpotStrategy(spot_strategy)
request.set_InternetChargeType(internet_charge_type)
request.set_InternetMaxBandwidthOut(internet_max_bandwidth_out)
request.set_KeyPairName(key_pair_name)
request.set_Tags(tags)
response = self._send_request(request)
if response is not None:
instance_id = response.get("InstanceId")
logging.info("instance %s created task submit successfully.", instance_id)
return instance_id
logging.error("instance created failed.")
return None
def run_instances(
self,
instance_type: str,
image_id: str,
tags: List[dict],
security_group_id: str,
vswitch_id: str,
key_pair_name: str,
amount: int = 1,
optimized: str = "optimized",
instance_charge_type: str = "PostPaid",
spot_strategy: str = "SpotWithPriceLimit",
internet_charge_type: str = "PayByTraffic",
internet_max_bandwidth_out: int = 1,
) -> Optional[List[str]]:
"""Create one or more pay-as-you-go or subscription ECS instances.
Args:
instance_type: The instance type of the ECS.
image_id: The ID of the image used to create the instance.
tags: The tags of the instance.
security_group_id: The ID of the security group to which to
assign the instance. Instances in the same security group
can communicate with each other.
vswitch_id: The ID of the vSwitch to which to connect
the instance.
key_pair_name: The name of the key pair to be bound to
the instance.
amount: The number of instances that you want to create.
optimized: Specifies whether the instance is I/O optimized.
instance_charge_type: The billing method of the instance.
Default value: PostPaid.
spot_strategy: The preemption policy for the pay-as-you-go
instance.
internet_charge_type: The billing method for network usage.
Default value: PayByTraffic.
internet_max_bandwidth_out: The maximum inbound public
bandwidth. Unit: Mbit/s.
Returns:
The created instance IDs.
"""
request = RunInstancesRequest()
request.set_InstanceType(instance_type)
request.set_ImageId(image_id)
request.set_IoOptimized(optimized)
request.set_InstanceChargeType(instance_charge_type)
request.set_SpotStrategy(spot_strategy)
request.set_InternetChargeType(internet_charge_type)
request.set_InternetMaxBandwidthOut(internet_max_bandwidth_out)
request.set_Tags(tags)
request.set_Amount(amount)
request.set_SecurityGroupId(security_group_id)
request.set_VSwitchId(vswitch_id)
request.set_KeyPairName(key_pair_name)
response = self._send_request(request)
if response is not None:
instance_ids = response.get("InstanceIdSets").get("InstanceIdSet")
return instance_ids
logging.error("instance created failed.")
return None
def create_security_group(self, vpc_id: str) -> Optional[str]:
"""Create a security group.
Args:
vpc_id: The ID of the VPC in which to create the security group.
Returns:
The created security group ID.
"""
request = CreateSecurityGroupRequest()
request.set_VpcId(vpc_id)
response = self._send_request(request)
if response is not None:
security_group_id = response.get("SecurityGroupId")
return security_group_id
return None
def describe_security_groups(
self,
vpc_id: Optional[str] = None,
tags: Optional[List[dict]] = None,
) -> Optional[list]:
"""Query basic information of security groups.
Args:
vpc_id: The ID of the VPC to which the security group belongs.
tags: The tags of the security group.
Returns:
Security group list.
"""
request = DescribeSecurityGroupsRequest()
if vpc_id is not None:
request.set_VpcId(vpc_id)
if tags is not None:
request.set_Tags(tags)
response = self._send_request(request)
if response is not None:
security_groups = response.get("SecurityGroups").get("SecurityGroup")
return security_groups
logging.error("describe security group failed.")
return None
def authorize_security_group(
self,
ip_protocol: str,
port_range: str,
security_group_id: str,
source_cidr_ip: str,
) -> None:
"""Create an inbound security group rule.
Args:
ip_protocol: The transport layer protocol.
port_range: The range of destination ports relevant to
the transport layer protocol.
security_group_id: The ID of the destination security group.
source_cidr_ip: The range of source IPv4 addresses.
CIDR blocks and IPv4 addresses are supported.
"""
request = AuthorizeSecurityGroupRequest()
request.set_IpProtocol(ip_protocol)
request.set_PortRange(port_range)
request.set_SecurityGroupId(security_group_id)
request.set_SourceCidrIp(source_cidr_ip)
self._send_request(request)
def create_v_switch(
self, vpc_id: str, zone_id: str, cidr_block: str
) -> Optional[str]:
"""Create vSwitches to divide the VPC into one or more subnets.
Args:
vpc_id: The ID of the VPC to which the VSwitch belongs.
zone_id: The ID of the zone to which the target VSwitch belongs.
cidr_block: The CIDR block of the VSwitch.
Returns:
The created VSwitch ID, or None if the request failed.
"""
request = CreateVSwitchRequest()
request.set_ZoneId(zone_id)
request.set_VpcId(vpc_id)
request.set_CidrBlock(cidr_block)
response = self._send_request(request)
if response is not None:
return response.get("VSwitchId")
else:
logging.error("create_v_switch vpc_id %s failed.", vpc_id)
return None
def create_vpc(self) -> Optional[str]:
"""Create a virtual private cloud (VPC).
Returns:
The created VPC ID.
"""
request = CreateVpcRequest()
response = self._send_request(request)
if response is not None:
return response.get("VpcId")
return None
def describe_vpcs(self) -> Optional[list]:
"""Query one or more VPCs in a region.
Returns:
VPC list.
"""
request = DescribeVpcsRequest()
response = self._send_request(request)
if response is not None:
return response.get("Vpcs").get("Vpc")
return None
def tag_resource(
self,
resource_ids: List[str],
tags: List[dict],
resource_type: str = "instance",
) -> None:
"""Create and bind tags to specified ECS resources.
Args:
resource_ids: The IDs of N resources.
tags: The tags of the resource.
resource_type: The type of the resource.
"""
request = TagResourcesRequest()
request.set_Tags(tags)
request.set_ResourceType(resource_type)
request.set_ResourceIds(resource_ids)
response = self._send_request(request)
if response is not None:
logging.info("instance %s create tag successfully.", resource_ids)
else:
logging.error("instance %s create tag failed.", resource_ids)
def start_instance(self, instance_id: str) -> None:
"""Start an ECS instance.
Args:
instance_id: The ECS instance ID.
"""
request = StartInstanceRequest()
request.set_InstanceId(instance_id)
response = self._send_request(request)
if response is not None:
logging.info("instance %s start successfully.", instance_id)
else:
logging.error("instance %s start failed.", instance_id)
def stop_instance(self, instance_id: str, force_stop: bool = False) -> None:
"""Stop an ECS instance that is in the Running state.
Args:
instance_id: The ECS instance ID.
force_stop: Specifies whether to forcibly stop the instance.
"""
request = StopInstanceRequest()
request.set_InstanceId(instance_id)
request.set_ForceStop(force_stop)
logging.info("Stop %s command submit successfully.", instance_id)
self._send_request(request)
def stop_instances(
self, instance_ids: List[str], stopped_mode: str = "StopCharging"
) -> None:
"""Stop one or more ECS instances that are in the Running state.
Args:
instance_ids: The IDs of instances.
stopped_mode: Specifies whether billing for the instance
continues after the instance is stopped.
"""
request = StopInstancesRequest()
request.set_InstanceIds(instance_ids)
request.set_StoppedMode(stopped_mode)
response = self._send_request(request)
if response is None:
logging.error("stop_instances failed")
def delete_instance(self, instance_id: str) -> None:
"""Release a pay-as-you-go instance or an expired subscription instance.
Args:
instance_id: The ID of the instance that you want to release.
"""
request = DeleteInstanceRequest()
request.set_InstanceId(instance_id)
request.set_Force(True)
logging.info("Delete %s command submit successfully", instance_id)
self._send_request(request)
def delete_instances(self, instance_ids: List[str]) -> None:
"""Release one or more pay-as-you-go or expired subscription instances.
Args:
instance_ids: The IDs of instances that you want to release.
"""
request = DeleteInstancesRequest()
request.set_Force(True)
request.set_InstanceIds(instance_ids)
self._send_request(request)
def allocate_public_address(self, instance_id: str) -> Optional[str]:
"""Assign a public IP address to an ECS instance.
Args:
instance_id: The ID of the instance to which you want to
assign a public IP address.
Returns:
The assigned IP address.
"""
request = AllocatePublicIpAddressRequest()
request.set_InstanceId(instance_id)
response = self._send_request(request)
if response is not None:
return response.get("IpAddress")
def create_key_pair(self, key_pair_name: str) -> Optional[dict]:
"""Create an SSH key pair.
Args:
key_pair_name: The name of the key pair.
Returns:
The created keypair data.
"""
request = CreateKeyPairRequest()
request.set_KeyPairName(key_pair_name)
response = self._send_request(request)
if response is not None:
logging.info("Create Key Pair %s Successfully", response.get("KeyPairId"))
return response
else:
logging.error("Create Key Pair Failed")
return None
def import_key_pair(self, key_pair_name: str, public_key_body: str) -> None:
"""Import the public key of an RSA-encrypted key pair.
Args:
key_pair_name: The name of the key pair.
public_key_body: The public key of the key pair.
"""
request = ImportKeyPairRequest()
request.set_KeyPairName(key_pair_name)
request.set_PublicKeyBody(public_key_body)
self._send_request(request)
def delete_key_pairs(self, key_pair_names: List[str]) -> None:
"""Delete one or more SSH key pairs.
Args:
key_pair_names: The names of the key pairs.
"""
request = DeleteKeyPairsRequest()
request.set_KeyPairNames(key_pair_names)
self._send_request(request)
def describe_key_pairs(self, key_pair_name: Optional[str] = None) -> Optional[list]:
"""Query one or more key pairs.
Args:
key_pair_name: The name of the key pair.
Returns:
Key pair list, or None if the request failed.
"""
request = DescribeKeyPairsRequest()
if key_pair_name is not None:
request.set_KeyPairName(key_pair_name)
response = self._send_request(request)
if response is not None:
return response.get("KeyPairs").get("KeyPair")
else:
return None
def describe_v_switches(self, vpc_id: Optional[str] = None) -> Optional[list]:
"""Query one or more VSwitches.
Args:
vpc_id: The ID of the VPC to which the VSwitch belongs.
Returns:
VSwitch list.
"""
request = DescribeVSwitchesRequest()
if vpc_id is not None:
request.set_VpcId(vpc_id)
response = self._send_request(request)
if response is not None:
return response.get("VSwitches").get("VSwitch")
else:
logging.error("Describe VSwitches Failed.")
return None
def _send_request(self, request):
"""send open api request"""
request.set_accept_format("json")
try:
response_str = self.cli.do_action_with_exception(request)
response_detail = json.loads(response_str)
return response_detail
except (ClientException, ServerException) as e:
logging.error(request.get_action_name())
logging.error(e)
return None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,821 @@
import copy
import hashlib
import json
import logging
import os
import time
from enum import Enum
from typing import Any, Callable, Dict, List, Union
import botocore
from ray.autoscaler._private.aws.utils import client_cache, resource_cache
from ray.autoscaler.tags import NODE_KIND_HEAD, TAG_RAY_CLUSTER_NAME, TAG_RAY_NODE_KIND
logger = logging.getLogger(__name__)
RAY = "ray-autoscaler"
CLOUDWATCH_RAY_INSTANCE_PROFILE = RAY + "-cloudwatch-v1"
CLOUDWATCH_RAY_IAM_ROLE = RAY + "-cloudwatch-v1"
CLOUDWATCH_AGENT_INSTALLED_AMI_TAG = "T6Iq2faj"
CLOUDWATCH_AGENT_INSTALLED_TAG = "cloudwatch-agent-installed"
CLOUDWATCH_CONFIG_HASH_TAG_BASE = "cloudwatch-config-hash"
class CloudwatchConfigType(str, Enum):
AGENT = "agent"
DASHBOARD = "dashboard"
ALARM = "alarm"
class CloudwatchHelper:
def __init__(
self, provider_config: Dict[str, Any], node_id: str, cluster_name: str
) -> None:
self.node_id = node_id
self.cluster_name = cluster_name
self.provider_config = provider_config
region = provider_config["region"]
self.ec2_resource = resource_cache("ec2", region)
self.ec2_client = self.ec2_resource.meta.client
self.ssm_client = client_cache("ssm", region)
cloudwatch_resource = resource_cache("cloudwatch", region)
self.cloudwatch_client = cloudwatch_resource.meta.client
self.CLOUDWATCH_CONFIG_TYPE_TO_CONFIG_VARIABLE_REPLACE_FUNC: Dict[
str, Callable
] = {
CloudwatchConfigType.AGENT.value: self._replace_cwa_config_vars,
CloudwatchConfigType.DASHBOARD.value: self._replace_dashboard_config_vars,
CloudwatchConfigType.ALARM.value: self._load_config_file,
}
self.CLOUDWATCH_CONFIG_TYPE_TO_UPDATE_FUNC_HEAD_NODE: Dict[str, Callable] = {
CloudwatchConfigType.AGENT.value: self._restart_cloudwatch_agent,
CloudwatchConfigType.DASHBOARD.value: self._put_cloudwatch_dashboard,
CloudwatchConfigType.ALARM.value: self._put_cloudwatch_alarm,
}
self.CLOUDWATCH_CONFIG_TYPE_TO_UPDATE_FUNC_WORKER_NODE: Dict[str, Callable] = {
CloudwatchConfigType.AGENT.value: self._restart_cloudwatch_agent,
CloudwatchConfigType.ALARM.value: self._put_cloudwatch_alarm,
}
def update_from_config(self, is_head_node: bool) -> None:
"""Discovers and applies CloudWatch config updates as required.
Args:
is_head_node: whether this node is the head node.
"""
for config_type in CloudwatchConfigType:
if CloudwatchHelper.cloudwatch_config_exists(
self.provider_config, config_type.value
):
self._update_cloudwatch_config(config_type.value, is_head_node)
def _ec2_health_check_waiter(self, node_id: str) -> None:
# wait for all EC2 instance checks to complete
try:
logger.info(
"Waiting for EC2 instance health checks to complete before "
"configuring Unified Cloudwatch Agent. This may take a few "
"minutes..."
)
waiter = self.ec2_client.get_waiter("instance_status_ok")
waiter.wait(InstanceIds=[node_id])
except botocore.exceptions.WaiterError as e:
logger.error(
"Failed while waiting for EC2 instance checks to complete: {}".format(
e.message
)
)
raise e
def _update_cloudwatch_config(self, config_type: str, is_head_node: bool) -> None:
"""
check whether update operations are needed in
cloudwatch related configs
"""
cwa_installed = self._setup_cwa()
param_name = self._get_ssm_param_name(config_type)
if cwa_installed:
if is_head_node:
cw_config_ssm = self._set_cloudwatch_ssm_config_param(
param_name, config_type
)
cur_cw_config_hash = self._sha256_hash_file(config_type)
ssm_cw_config_hash = self._sha256_hash_json(cw_config_ssm)
# check if user updated cloudwatch related config files.
# if so, perform corresponding actions.
if cur_cw_config_hash != ssm_cw_config_hash:
logger.info(
"Cloudwatch {} config file has changed.".format(config_type)
)
self._upload_config_to_ssm_and_set_hash_tag(config_type)
self.CLOUDWATCH_CONFIG_TYPE_TO_UPDATE_FUNC_HEAD_NODE.get(
config_type
)()
else:
head_node_hash = self._get_head_node_config_hash(config_type)
cur_node_hash = self._get_cur_node_config_hash(config_type)
if head_node_hash != cur_node_hash:
logger.info(
"Cloudwatch {} config file has changed.".format(config_type)
)
update_func = (
self.CLOUDWATCH_CONFIG_TYPE_TO_UPDATE_FUNC_WORKER_NODE.get(
config_type
)
)
if update_func:
update_func()
self._update_cloudwatch_hash_tag_value(
self.node_id, head_node_hash, config_type
)
def _put_cloudwatch_dashboard(self) -> Dict[str, Any]:
"""put dashboard to cloudwatch console"""
cloudwatch_config = self.provider_config["cloudwatch"]
dashboard_config = cloudwatch_config.get("dashboard", {})
dashboard_name_cluster = dashboard_config.get("name", self.cluster_name)
dashboard_name = self.cluster_name + "-" + dashboard_name_cluster
widgets = self._replace_dashboard_config_vars(
CloudwatchConfigType.DASHBOARD.value
)
response = self.cloudwatch_client.put_dashboard(
DashboardName=dashboard_name, DashboardBody=json.dumps({"widgets": widgets})
)
issue_count = len(response.get("DashboardValidationMessages", []))
if issue_count > 0:
for issue in response.get("DashboardValidationMessages"):
logging.error(
"Error in dashboard config: {} - {}".format(
issue["Message"], issue["DataPath"]
)
)
raise Exception(
"Errors in dashboard configuration: {} issues raised".format(
issue_count
)
)
else:
logger.info("Successfully put dashboard to CloudWatch console")
return response
def _put_cloudwatch_alarm(self) -> None:
"""put CloudWatch metric alarms read from config"""
param_name = self._get_ssm_param_name(CloudwatchConfigType.ALARM.value)
data = json.loads(self._get_ssm_param(param_name))
for item in data:
item_out = copy.deepcopy(item)
self._replace_all_config_variables(
item_out,
self.node_id,
self.cluster_name,
self.provider_config["region"],
)
self.cloudwatch_client.put_metric_alarm(**item_out)
logger.info("Successfully put alarms to CloudWatch console")
def _send_command_to_node(
self, document_name: str, parameters: Dict[str, List[str]], node_id: str
) -> Dict[str, Any]:
"""send SSM command to the given nodes"""
logger.debug(
"Sending SSM command to {} node(s). Document name: {}. "
"Parameters: {}.".format(node_id, document_name, parameters)
)
response = self.ssm_client.send_command(
InstanceIds=[node_id],
DocumentName=document_name,
Parameters=parameters,
MaxConcurrency="1",
MaxErrors="0",
)
return response
def _ssm_command_waiter(
self,
document_name: str,
parameters: Dict[str, List[str]],
node_id: str,
retry_failed: bool = True,
) -> Dict[str, Any]:
"""wait for SSM command to complete on all cluster nodes"""
# This waiter differs from the built-in SSM.Waiter by
# optimistically waiting for the command invocation to
# exist instead of failing immediately, and by resubmitting
# any failed command until all retry attempts are exhausted
# by default.
response = self._send_command_to_node(document_name, parameters, node_id)
command_id = response["Command"]["CommandId"]
cloudwatch_config = self.provider_config["cloudwatch"]
agent_retryer_config = cloudwatch_config.get(
CloudwatchConfigType.AGENT.value
).get("retryer", {})
max_attempts = agent_retryer_config.get("max_attempts", 120)
delay_seconds = agent_retryer_config.get("delay_seconds", 30)
num_attempts = 0
cmd_invocation_res = {}
while True:
num_attempts += 1
logger.debug(
"Listing SSM command ID {} invocations on node {}".format(
command_id, node_id
)
)
response = self.ssm_client.list_command_invocations(
CommandId=command_id,
InstanceId=node_id,
)
cmd_invocations = response["CommandInvocations"]
if not cmd_invocations:
logger.debug(
"SSM Command ID {} invocation does not exist. If "
"the command was just started, it may take a "
"few seconds to register.".format(command_id)
)
else:
if len(cmd_invocations) > 1:
logger.warning(
"Expected to find 1 SSM command invocation with "
"ID {} on node {} but found {}: {}".format(
command_id,
node_id,
len(cmd_invocations),
cmd_invocations,
)
)
cmd_invocation = cmd_invocations[0]
if cmd_invocation["Status"] == "Success":
logger.debug(
"SSM Command ID {} completed successfully.".format(command_id)
)
cmd_invocation_res[node_id] = True
break
if num_attempts >= max_attempts:
logger.error(
"Max attempts for command {} exceeded on node {}".format(
command_id, node_id
)
)
raise botocore.exceptions.WaiterError(
name="ssm_waiter",
reason="Max attempts exceeded",
last_response=cmd_invocation,
)
if cmd_invocation["Status"] == "Failed":
logger.debug(f"SSM Command ID {command_id} failed.")
if retry_failed:
logger.debug(f"Retrying in {delay_seconds} seconds.")
response = self._send_command_to_node(
document_name, parameters, node_id
)
command_id = response["Command"]["CommandId"]
logger.debug(
"Sent SSM command ID {} to node {}".format(
command_id, node_id
)
)
else:
logger.debug(f"Ignoring Command ID {command_id} failure.")
cmd_invocation_res[node_id] = False
break
time.sleep(delay_seconds)
return cmd_invocation_res
def _replace_config_variables(
self, string: str, node_id: str, cluster_name: str, region: str
) -> str:
"""
replace known config variable occurrences in the input string
does not replace variables with undefined or empty strings
"""
if node_id:
string = string.replace("{instance_id}", node_id)
if cluster_name:
string = string.replace("{cluster_name}", cluster_name)
if region:
string = string.replace("{region}", region)
return string
def _replace_all_config_variables(
self,
collection: Union[Dict[str, Any], str],
node_id: str,
cluster_name: str,
region: str,
) -> Union[str, Dict[str, Any]]:
"""
Replace known config variable occurrences in the input collection.
The input collection must be either a dict or list.
Returns a tuple consisting of the output collection and the number of
modified strings in the collection (which is not necessarily equal to
the number of variables replaced).
"""
for key in collection:
if type(collection) is dict:
value = collection.get(key)
index_key = key
elif type(collection) is list:
value = key
index_key = collection.index(key)
else:
raise ValueError(
f"Can't replace CloudWatch config variables "
f"in unsupported collection type: {type(collection)}."
f"Please check your CloudWatch JSON config files."
)
if type(value) is str:
collection[index_key] = self._replace_config_variables(
value, node_id, cluster_name, region
)
elif type(value) is dict or type(value) is list:
collection[index_key] = self._replace_all_config_variables(
value, node_id, cluster_name, region
)
return collection
def _load_config_file(self, config_type: str) -> Dict[str, Any]:
"""load JSON config file"""
cloudwatch_config = self.provider_config["cloudwatch"]
json_config_file_section = cloudwatch_config.get(config_type, {})
json_config_file_path = json_config_file_section.get("config", {})
json_config_path = os.path.abspath(json_config_file_path)
with open(json_config_path) as f:
data = json.load(f)
return data
def _set_cloudwatch_ssm_config_param(
self, parameter_name: str, config_type: str
) -> str:
"""
get cloudwatch config for the given param and config type from SSM
if it exists, put it in the SSM param store if not
"""
try:
parameter_value = self._get_ssm_param(parameter_name)
except botocore.exceptions.ClientError as e:
if e.response["Error"]["Code"] == "ParameterNotFound":
logger.info(
"Cloudwatch {} config file is not found "
"at SSM parameter store. "
"Checking for Unified CloudWatch Agent installation".format(
config_type
)
)
return self._get_default_empty_config_file_hash()
else:
logger.info(
"Failed to fetch Unified CloudWatch Agent config from SSM "
"parameter store."
)
logger.error(e)
raise e
return parameter_value
def _get_default_empty_config_file_hash(self):
default_cw_config = "{}"
parameter_value = self._sha256_hash_json(default_cw_config)
return parameter_value
def _get_ssm_param(self, parameter_name: str) -> str:
"""
get the SSM parameter value associated with the given parameter name
"""
response = self.ssm_client.get_parameter(Name=parameter_name)
logger.info("Successfully fetch ssm parameter: {}".format(parameter_name))
res = response.get("Parameter", {})
cwa_parameter = res.get("Value", {})
return cwa_parameter
def _sha256_hash_json(self, value: str) -> str:
"""calculate the json string sha256 hash"""
sha256_hash = hashlib.new("sha256")
binary_value = value.encode("utf-8")
sha256_hash.update(binary_value)
sha256_res = sha256_hash.hexdigest()
return sha256_res
def _sha256_hash_file(self, config_type: str) -> str:
"""calculate the config file sha256 hash"""
config = self.CLOUDWATCH_CONFIG_TYPE_TO_CONFIG_VARIABLE_REPLACE_FUNC.get(
config_type
)(config_type)
value = json.dumps(config)
sha256_res = self._sha256_hash_json(value)
return sha256_res
def _upload_config_to_ssm_and_set_hash_tag(self, config_type: str):
data = self.CLOUDWATCH_CONFIG_TYPE_TO_CONFIG_VARIABLE_REPLACE_FUNC.get(
config_type
)(config_type)
sha256_hash_value = self._sha256_hash_file(config_type)
self._upload_config_to_ssm(data, config_type)
self._update_cloudwatch_hash_tag_value(
self.node_id, sha256_hash_value, config_type
)
def _add_cwa_installed_tag(self, node_id: str) -> None:
self.ec2_client.create_tags(
Resources=[node_id],
Tags=[{"Key": CLOUDWATCH_AGENT_INSTALLED_TAG, "Value": "True"}],
)
logger.info(
"Successfully add Unified CloudWatch Agent installed "
"tag on {}".format(node_id)
)
def _update_cloudwatch_hash_tag_value(
self, node_id: str, sha256_hash_value: str, config_type: str
):
hash_key_value = "-".join([CLOUDWATCH_CONFIG_HASH_TAG_BASE, config_type])
self.ec2_client.create_tags(
Resources=[node_id],
Tags=[{"Key": hash_key_value, "Value": sha256_hash_value}],
)
logger.info(
"Successfully update cloudwatch {} hash tag on {}".format(
config_type, node_id
)
)
def _get_ssm_param_name(self, config_type: str) -> str:
"""return the parameter name for cloudwatch configs"""
ssm_config_param_name = "AmazonCloudWatch-" + "ray_{}_config_{}".format(
config_type, self.cluster_name
)
return ssm_config_param_name
def _put_ssm_param(self, parameter: Dict[str, Any], parameter_name: str) -> None:
"""upload cloudwatch config to the SSM parameter store"""
self.ssm_client.put_parameter(
Name=parameter_name,
Type="String",
Value=json.dumps(parameter),
Overwrite=True,
Tier="Intelligent-Tiering",
)
def _upload_config_to_ssm(self, param: Dict[str, Any], config_type: str):
param_name = self._get_ssm_param_name(config_type)
self._put_ssm_param(param, param_name)
def _replace_cwa_config_vars(self, config_type: str) -> Dict[str, Any]:
"""
replace {instance_id}, {region}, {cluster_name}
variable occurrences in Unified Cloudwatch Agent config file
"""
cwa_config = self._load_config_file(config_type)
self._replace_all_config_variables(
cwa_config,
self.node_id,
self.cluster_name,
self.provider_config["region"],
)
return cwa_config
def _replace_dashboard_config_vars(self, config_type: str) -> List[str]:
"""
replace known variable occurrences in CloudWatch Dashboard config file
"""
data = self._load_config_file(config_type)
widgets = []
for item in data:
item_out = self._replace_all_config_variables(
item,
self.node_id,
self.cluster_name,
self.provider_config["region"],
)
widgets.append(item_out)
return widgets
def _replace_alarm_config_vars(self, config_type: str) -> List[str]:
"""
replace {instance_id}, {region}, {cluster_name}
variable occurrences in cloudwatch alarm config file
"""
data = self._load_config_file(config_type)
param_data = []
for item in data:
item_out = copy.deepcopy(item)
self._replace_all_config_variables(
item_out,
self.node_id,
self.cluster_name,
self.provider_config["region"],
)
param_data.append(item_out)
return param_data
def _restart_cloudwatch_agent(self) -> None:
"""restart Unified CloudWatch Agent"""
cwa_param_name = self._get_ssm_param_name(CloudwatchConfigType.AGENT.value)
logger.info(
"Restarting Unified CloudWatch Agent package on node {}.".format(
self.node_id
)
)
self._stop_cloudwatch_agent()
self._start_cloudwatch_agent(cwa_param_name)
def _stop_cloudwatch_agent(self) -> None:
"""stop Unified CloudWatch Agent"""
logger.info(
"Stopping Unified CloudWatch Agent package on node {}.".format(self.node_id)
)
parameters_stop_cwa = {
"action": ["stop"],
"mode": ["ec2"],
}
# don't retry failed stop commands
# (there's not always an agent to stop)
self._ssm_command_waiter(
"AmazonCloudWatch-ManageAgent",
parameters_stop_cwa,
self.node_id,
False,
)
logger.info("Unified CloudWatch Agent stopped on node {}.".format(self.node_id))
def _start_cloudwatch_agent(self, cwa_param_name: str) -> None:
"""start Unified CloudWatch Agent"""
logger.info(
"Starting Unified CloudWatch Agent package on node {}.".format(self.node_id)
)
parameters_start_cwa = {
"action": ["configure"],
"mode": ["ec2"],
"optionalConfigurationSource": ["ssm"],
"optionalConfigurationLocation": [cwa_param_name],
"optionalRestart": ["yes"],
}
self._ssm_command_waiter(
"AmazonCloudWatch-ManageAgent", parameters_start_cwa, self.node_id
)
logger.info(
"Unified CloudWatch Agent started successfully on node {}.".format(
self.node_id
)
)
def _setup_cwa(self) -> bool:
cwa_installed = self._check_cwa_installed_ec2_tag()
if cwa_installed == "False":
res_cwa_installed = self._ensure_cwa_installed_ssm(self.node_id)
return res_cwa_installed
else:
return True
def _get_head_node_config_hash(self, config_type: str) -> str:
hash_key_value = "-".join([CLOUDWATCH_CONFIG_HASH_TAG_BASE, config_type])
filters = copy.deepcopy(
self._get_current_cluster_session_nodes(self.cluster_name)
)
filters.append(
{
"Name": "tag:{}".format(TAG_RAY_NODE_KIND),
"Values": [NODE_KIND_HEAD],
}
)
try:
instance = list(self.ec2_resource.instances.filter(Filters=filters))
assert len(instance) == 1, "More than 1 head node found!"
for tag in instance[0].tags:
if tag["Key"] == hash_key_value:
return tag["Value"]
except botocore.exceptions.ClientError as e:
logger.warning(
"{} Error caught when getting value of {} tag on head node".format(
e.response["Error"], hash_key_value
)
)
def _get_cur_node_config_hash(self, config_type: str) -> str:
hash_key_value = "-".join([CLOUDWATCH_CONFIG_HASH_TAG_BASE, config_type])
try:
response = self.ec2_client.describe_instances(InstanceIds=[self.node_id])
reservations = response["Reservations"]
message = "More than 1 response received from describing current node"
assert len(reservations) == 1, message
instances = reservations[0]["Instances"]
assert len(reservations) == 1, message
tags = instances[0]["Tags"]
hash_value = self._get_default_empty_config_file_hash()
for tag in tags:
if tag["Key"] == hash_key_value:
logger.info(
"Successfully get cloudwatch {} hash tag value from "
"node {}".format(config_type, self.node_id)
)
hash_value = tag["Value"]
return hash_value
except botocore.exceptions.ClientError as e:
logger.warning(
"{} Error caught when getting hash tag {} tag".format(
e.response["Error"], hash_key_value
)
)
def _ensure_cwa_installed_ssm(self, node_id: str) -> bool:
"""
Check if Unified Cloudwatch Agent is installed via ssm run command.
If not, notify user to use an AMI with
the Unified CloudWatch Agent installed.
"""
logger.info(
"Checking Unified Cloudwatch Agent status on node {}".format(node_id)
)
parameters_status_cwa = {
"action": ["status"],
"mode": ["ec2"],
}
self._ec2_health_check_waiter(node_id)
cmd_invocation_res = self._ssm_command_waiter(
"AmazonCloudWatch-ManageAgent", parameters_status_cwa, node_id, False
)
cwa_installed = cmd_invocation_res.get(node_id, False)
if not cwa_installed:
logger.warning(
"Unified CloudWatch Agent not installed on {}. "
"Ray logs, metrics not picked up. "
"Please use an AMI with Unified CloudWatch Agent installed.".format(
node_id
)
)
return False
else:
return True
def _get_current_cluster_session_nodes(self, cluster_name: str) -> List[dict]:
filters = [
{
"Name": "instance-state-name",
"Values": ["pending", "running"],
},
{
"Name": "tag:{}".format(TAG_RAY_CLUSTER_NAME),
"Values": [cluster_name],
},
]
return filters
def _check_cwa_installed_ec2_tag(self) -> List[str]:
"""
Filtering all nodes to get nodes
without Unified CloudWatch Agent installed
"""
try:
response = self.ec2_client.describe_instances(InstanceIds=[self.node_id])
reservations = response["Reservations"]
message = "More than 1 response received from describing current node"
assert len(reservations) == 1, message
instances = reservations[0]["Instances"]
assert len(instances) == 1, message
tags = instances[0]["Tags"]
cwa_installed = str(False)
for tag in tags:
if tag["Key"] == CLOUDWATCH_AGENT_INSTALLED_TAG:
logger.info(
"Unified CloudWatch Agent is installed on "
"node {}".format(self.node_id)
)
cwa_installed = tag["Value"]
return cwa_installed
except botocore.exceptions.ClientError as e:
logger.warning(
"{} Error caught when getting Unified CloudWatch Agent "
"status based on {} tag".format(
e.response["Error"], CLOUDWATCH_AGENT_INSTALLED_TAG
)
)
@staticmethod
def resolve_instance_profile_name(
config: Dict[str, Any], default_instance_profile_name: str
) -> str:
"""Get default cloudwatch instance profile name.
Args:
config: provider section of cluster config file.
default_instance_profile_name: default ray instance profile name.
Returns:
default cloudwatch instance profile name if cloudwatch config file
exists.
default ray instance profile name if cloudwatch config file
doesn't exist.
"""
cwa_cfg_exists = CloudwatchHelper.cloudwatch_config_exists(
config, CloudwatchConfigType.AGENT.value
)
return (
CLOUDWATCH_RAY_INSTANCE_PROFILE
if cwa_cfg_exists
else default_instance_profile_name
)
@staticmethod
def resolve_iam_role_name(
config: Dict[str, Any], default_iam_role_name: str
) -> str:
"""Get default cloudwatch iam role name.
Args:
config: provider section of cluster config file.
default_iam_role_name: default ray iam role name.
Returns:
default cloudwatch iam role name if cloudwatch config file exists.
default ray iam role name if cloudwatch config file doesn't exist.
"""
cwa_cfg_exists = CloudwatchHelper.cloudwatch_config_exists(
config, CloudwatchConfigType.AGENT.value
)
return CLOUDWATCH_RAY_IAM_ROLE if cwa_cfg_exists else default_iam_role_name
@staticmethod
def resolve_policy_arns(
config: Dict[str, Any], iam: Any, default_policy_arns: List[str]
) -> List[str]:
"""Attach necessary AWS policies for CloudWatch related operations.
Args:
config: provider section of cluster config file.
iam: AWS iam resource.
default_policy_arns: List of default ray AWS policies.
Returns:
list of policy arns including additional policies for CloudWatch
related operations if cloudwatch agent config is specifed in
cluster config file.
"""
cwa_cfg_exists = CloudwatchHelper.cloudwatch_config_exists(
config, CloudwatchConfigType.AGENT.value
)
if cwa_cfg_exists:
cloudwatch_managed_policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ssm:SendCommand",
"ssm:ListCommandInvocations",
"iam:PassRole",
],
"Resource": "*",
}
],
}
iam_client = iam.meta.client
iam_client.create_policy(
PolicyName="CloudwatchManagedPolicies",
PolicyDocument=json.dumps(cloudwatch_managed_policy),
)
sts_client = client_cache("sts", config["region"])
account_id = sts_client.get_caller_identity().get("Account")
managed_policy_arn = (
"arn:aws:iam::{}:policy/CloudwatchManagedPolicies".format(account_id)
)
policy_waiter = iam_client.get_waiter("policy_exists")
policy_waiter.wait(
PolicyArn=managed_policy_arn,
WaiterConfig={"Delay": 2, "MaxAttempts": 200},
)
new_policy_arns = copy.copy(default_policy_arns)
new_policy_arns.extend(
[
"arn:aws:iam::aws:policy/CloudWatchAgentAdminPolicy",
"arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
managed_policy_arn,
]
)
return new_policy_arns
else:
return default_policy_arns
@staticmethod
def cloudwatch_config_exists(config: Dict[str, Any], config_type: str) -> bool:
"""Check if CloudWatch configuration was specified by the user
in their cluster config file.
Specifically, this function checks if a CloudWatch config file is
specified by the user in their cluster config file.
Args:
config: provider section of cluster config file.
config_type: type of CloudWatch config file.
Returns:
True if config file is specified by user.
False if config file is not specified.
"""
cfg = config.get("cloudwatch", {}).get(config_type, {}).get("config")
return bool(cfg)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,715 @@
import copy
import logging
import sys
import threading
import time
from collections import OrderedDict, defaultdict
from typing import Any, Dict, List
import botocore
from boto3.resources.base import ServiceResource
import ray
import ray._private.ray_constants as ray_constants
from ray.autoscaler._private.aws.cloudwatch.cloudwatch_helper import (
CLOUDWATCH_AGENT_INSTALLED_AMI_TAG,
CLOUDWATCH_AGENT_INSTALLED_TAG,
CloudwatchHelper,
)
from ray.autoscaler._private.aws.config import bootstrap_aws
from ray.autoscaler._private.aws.utils import (
boto_exception_handler,
client_cache,
resource_cache,
)
from ray.autoscaler._private.cli_logger import cf, cli_logger
from ray.autoscaler._private.constants import BOTO_CREATE_MAX_RETRIES, BOTO_MAX_RETRIES
from ray.autoscaler._private.log_timer import LogTimer
from ray.autoscaler.node_launch_exception import NodeLaunchException
from ray.autoscaler.node_provider import NodeProvider
from ray.autoscaler.tags import (
TAG_RAY_CLUSTER_NAME,
TAG_RAY_LAUNCH_CONFIG,
TAG_RAY_NODE_KIND,
TAG_RAY_NODE_NAME,
TAG_RAY_USER_NODE_TYPE,
)
logger = logging.getLogger(__name__)
TAG_BATCH_DELAY = 1
LIST_RETRY_DELAY_SEC = 1
def to_aws_format(tags):
"""Convert the Ray node name tag to the AWS-specific 'Name' tag."""
if TAG_RAY_NODE_NAME in tags:
tags["Name"] = tags[TAG_RAY_NODE_NAME]
del tags[TAG_RAY_NODE_NAME]
return tags
def from_aws_format(tags):
"""Convert the AWS-specific 'Name' tag to the Ray node name tag."""
if "Name" in tags:
tags[TAG_RAY_NODE_NAME] = tags["Name"]
del tags["Name"]
return tags
def make_ec2_resource(region, max_retries, aws_credentials=None) -> ServiceResource:
"""Make client, retrying requests up to `max_retries`."""
aws_credentials = aws_credentials or {}
return resource_cache("ec2", region, max_retries, **aws_credentials)
def list_ec2_instances(
region: str, aws_credentials: Dict[str, Any] = None
) -> List[Dict[str, Any]]:
"""Get all instance-types/resources available in the user's AWS region.
Args:
region: the region of the AWS provider. e.g., "us-west-2".
aws_credentials: AWS credentials to use for the boto3 client.
Returns:
A list of instances. An example of one element in the list:
{'InstanceType': 'm5a.xlarge', 'ProcessorInfo':
{'SupportedArchitectures': ['x86_64'], 'SustainedClockSpeedInGhz':
2.5},'VCpuInfo': {'DefaultVCpus': 4, 'DefaultCores': 2,
'DefaultThreadsPerCore': 2, 'ValidCores': [2],
'ValidThreadsPerCore': [1, 2]}, 'MemoryInfo': {'SizeInMiB': 16384},
...}
"""
final_instance_types = []
aws_credentials = aws_credentials or {}
ec2 = client_cache("ec2", region, BOTO_MAX_RETRIES, **aws_credentials)
instance_types = ec2.describe_instance_types()
final_instance_types.extend(copy.deepcopy(instance_types["InstanceTypes"]))
while "NextToken" in instance_types:
instance_types = ec2.describe_instance_types(
NextToken=instance_types["NextToken"]
)
final_instance_types.extend(copy.deepcopy(instance_types["InstanceTypes"]))
return final_instance_types
class AWSNodeProvider(NodeProvider):
max_terminate_nodes = 1000
def __init__(self, provider_config, cluster_name):
NodeProvider.__init__(self, provider_config, cluster_name)
self.cache_stopped_nodes = provider_config.get("cache_stopped_nodes", True)
aws_credentials = provider_config.get("aws_credentials")
self.ec2 = make_ec2_resource(
region=provider_config["region"],
max_retries=BOTO_MAX_RETRIES,
aws_credentials=aws_credentials,
)
self.ec2_fail_fast = make_ec2_resource(
region=provider_config["region"],
max_retries=0,
aws_credentials=aws_credentials,
)
# Tags that we believe to actually be on EC2.
self.tag_cache = {}
# Tags that we will soon upload.
self.tag_cache_pending = defaultdict(dict)
# Number of threads waiting for a batched tag update.
self.batch_thread_count = 0
self.batch_update_done = threading.Event()
self.batch_update_done.set()
self.ready_for_new_batch = threading.Event()
self.ready_for_new_batch.set()
self.tag_cache_lock = threading.Lock()
self.count_lock = threading.Lock()
# Prevent concurrent create_node calls to get the same stopped/stopping node to reuse.
self._reuse_node_lock = threading.Lock()
# Cache of node objects from the last nodes() call. This avoids
# excessive DescribeInstances requests.
self.cached_nodes = {}
def non_terminated_nodes(self, tag_filters):
# Note that these filters are acceptable because they are set on
# node initialization, and so can never be sitting in the cache.
tag_filters = to_aws_format(tag_filters)
filters = [
{
"Name": "instance-state-name",
"Values": ["pending", "running"],
},
{
"Name": "tag:{}".format(TAG_RAY_CLUSTER_NAME),
"Values": [self.cluster_name],
},
]
for k, v in tag_filters.items():
filters.append(
{
"Name": "tag:{}".format(k),
"Values": [v],
}
)
with boto_exception_handler("Failed to fetch running instances from AWS."):
nodes = list(self.ec2.instances.filter(Filters=filters))
# Populate the tag cache with initial information if necessary
for node in nodes:
if node.id in self.tag_cache:
continue
self.tag_cache[node.id] = from_aws_format(
{x["Key"]: x["Value"] for x in node.tags}
)
self.cached_nodes = {node.id: node for node in nodes}
return [node.id for node in nodes]
def is_running(self, node_id):
node = self._get_cached_node(node_id)
return node.state["Name"] == "running"
def is_terminated(self, node_id):
node = self._get_cached_node(node_id)
state = node.state["Name"]
return state not in ["running", "pending"]
def node_tags(self, node_id):
with self.tag_cache_lock:
d1 = self.tag_cache[node_id]
d2 = self.tag_cache_pending.get(node_id, {})
return dict(d1, **d2)
def external_ip(self, node_id):
node = self._get_cached_node(node_id)
if node.public_ip_address is None:
node = self._get_node(node_id)
return node.public_ip_address
def internal_ip(self, node_id):
node = self._get_cached_node(node_id)
if node.private_ip_address is None:
node = self._get_node(node_id)
return node.private_ip_address
def set_node_tags(self, node_id, tags):
is_batching_thread = False
with self.tag_cache_lock:
if not self.tag_cache_pending:
is_batching_thread = True
# Wait for threads in the last batch to exit
self.ready_for_new_batch.wait()
self.ready_for_new_batch.clear()
self.batch_update_done.clear()
self.tag_cache_pending[node_id].update(tags)
if is_batching_thread:
time.sleep(TAG_BATCH_DELAY)
with self.tag_cache_lock:
self._update_node_tags()
self.batch_update_done.set()
with self.count_lock:
self.batch_thread_count += 1
self.batch_update_done.wait()
with self.count_lock:
self.batch_thread_count -= 1
if self.batch_thread_count == 0:
self.ready_for_new_batch.set()
def _update_node_tags(self):
batch_updates = defaultdict(list)
for node_id, tags in self.tag_cache_pending.items():
for x in tags.items():
batch_updates[x].append(node_id)
self.tag_cache[node_id].update(tags)
self.tag_cache_pending = defaultdict(dict)
self._create_tags(batch_updates)
def _create_tags(self, batch_updates):
for (k, v), node_ids in batch_updates.items():
m = "Set tag {}={} on {}".format(k, v, node_ids)
with LogTimer("AWSNodeProvider: {}".format(m)):
if k == TAG_RAY_NODE_NAME:
k = "Name"
self.ec2.meta.client.create_tags(
Resources=node_ids,
Tags=[{"Key": k, "Value": v}],
)
def create_node(self, node_config, tags, count) -> Dict[str, Any]:
"""Creates instances.
Returns dict mapping instance id to ec2.Instance object for the created
instances.
"""
# sort tags by key to support deterministic unit test stubbing
tags = OrderedDict(sorted(copy.deepcopy(tags).items()))
reused_nodes_dict = {}
# Try to reuse previously stopped nodes with compatible configs
if self.cache_stopped_nodes:
# TODO(ekl) this is breaking the abstraction boundary a little by
# peeking into the tag set.
filters = [
{
"Name": "instance-state-name",
"Values": ["stopped", "stopping"],
},
{
"Name": "tag:{}".format(TAG_RAY_CLUSTER_NAME),
"Values": [self.cluster_name],
},
{
"Name": "tag:{}".format(TAG_RAY_NODE_KIND),
"Values": [tags[TAG_RAY_NODE_KIND]],
},
{
"Name": "tag:{}".format(TAG_RAY_LAUNCH_CONFIG),
"Values": [tags[TAG_RAY_LAUNCH_CONFIG]],
},
]
# This tag may not always be present.
if TAG_RAY_USER_NODE_TYPE in tags:
filters.append(
{
"Name": "tag:{}".format(TAG_RAY_USER_NODE_TYPE),
"Values": [tags[TAG_RAY_USER_NODE_TYPE]],
}
)
with self._reuse_node_lock:
reuse_nodes = list(self.ec2.instances.filter(Filters=filters))[:count]
reuse_node_ids = [n.id for n in reuse_nodes]
reused_nodes_dict = {n.id: n for n in reuse_nodes}
if reuse_nodes:
cli_logger.print(
# todo: handle plural vs singular?
"Reusing nodes {}. "
"To disable reuse, set `cache_stopped_nodes: False` "
"under `provider` in the cluster configuration.",
cli_logger.render_list(reuse_node_ids),
)
# todo: timed?
with cli_logger.group("Stopping instances to reuse"):
for node in reuse_nodes:
self.tag_cache[node.id] = from_aws_format(
{x["Key"]: x["Value"] for x in node.tags}
)
if node.state["Name"] == "stopping":
cli_logger.print(
"Waiting for instance {} to stop", node.id
)
node.wait_until_stopped()
self.ec2.meta.client.start_instances(InstanceIds=reuse_node_ids)
for node_id in reuse_node_ids:
self.set_node_tags(node_id, tags)
count -= len(reuse_node_ids)
created_nodes_dict = {}
if count:
created_nodes_dict = self._create_node(node_config, tags, count)
all_created_nodes = reused_nodes_dict
all_created_nodes.update(created_nodes_dict)
return all_created_nodes
@staticmethod
def _merge_tag_specs(
tag_specs: List[Dict[str, Any]], user_tag_specs: List[Dict[str, Any]]
) -> None:
"""
Merges user-provided node config tag specifications into a base
list of node provider tag specifications. The base list of
node provider tag specs is modified in-place.
This allows users to add tags and override values of existing
tags with their own, and only applies to the resource type
"instance". All other resource types are appended to the list of
tag specs.
Args:
tag_specs: base node provider tag specs
user_tag_specs: user's node config tag specs
"""
for user_tag_spec in user_tag_specs:
if user_tag_spec["ResourceType"] == "instance":
for user_tag in user_tag_spec["Tags"]:
exists = False
for tag in tag_specs[0]["Tags"]:
if user_tag["Key"] == tag["Key"]:
exists = True
tag["Value"] = user_tag["Value"]
break
if not exists:
tag_specs[0]["Tags"] += [user_tag]
else:
tag_specs += [user_tag_spec]
def _create_node(self, node_config, tags, count):
created_nodes_dict = {}
tags = to_aws_format(tags)
conf = node_config.copy()
tag_pairs = [
{
"Key": TAG_RAY_CLUSTER_NAME,
"Value": self.cluster_name,
}
]
for k, v in tags.items():
tag_pairs.append(
{
"Key": k,
"Value": v,
}
)
if CloudwatchHelper.cloudwatch_config_exists(self.provider_config, "agent"):
cwa_installed = self._check_ami_cwa_installation(node_config)
if cwa_installed:
tag_pairs.extend(
[
{
"Key": CLOUDWATCH_AGENT_INSTALLED_TAG,
"Value": "True",
}
]
)
tag_specs = [
{
"ResourceType": "instance",
"Tags": tag_pairs,
}
]
user_tag_specs = conf.get("TagSpecifications", [])
AWSNodeProvider._merge_tag_specs(tag_specs, user_tag_specs)
# SubnetIds is not a real config key: we must resolve to a
# single SubnetId before invoking the AWS API.
subnet_ids = conf.pop("SubnetIds")
# update config with min/max node counts and tag specs
conf.update({"MinCount": 1, "MaxCount": count, "TagSpecifications": tag_specs})
# Try to always launch in the first listed subnet.
subnet_idx = 0
cli_logger_tags = {}
# NOTE: This ensures that we try ALL availability zones before
# throwing an error.
max_tries = max(BOTO_CREATE_MAX_RETRIES, len(subnet_ids))
for attempt in range(1, max_tries + 1):
try:
if "NetworkInterfaces" in conf:
net_ifs = conf["NetworkInterfaces"]
# remove security group IDs previously copied from network
# interfaces (create_instances call fails otherwise)
conf.pop("SecurityGroupIds", None)
cli_logger_tags["network_interfaces"] = str(net_ifs)
else:
subnet_id = subnet_ids[subnet_idx % len(subnet_ids)]
conf["SubnetId"] = subnet_id
cli_logger_tags["subnet_id"] = subnet_id
created = self.ec2_fail_fast.create_instances(**conf)
created_nodes_dict = {n.id: n for n in created}
# todo: timed?
# todo: handle plurality?
with cli_logger.group(
"Launched {} nodes", count, _tags=cli_logger_tags
):
for instance in created:
# NOTE(maximsmol): This is needed for mocking
# boto3 for tests. This is likely a bug in moto
# but AWS docs don't seem to say.
# You can patch moto/ec2/responses/instances.py
# to fix this (add <stateReason> to EC2_RUN_INSTANCES)
# The correct value is technically
# {"code": "0", "Message": "pending"}
state_reason = "pending"
if instance.state_reason:
state_reason = (
instance.state_reason["Message"] or state_reason
)
cli_logger.print(
"Launched instance {}",
instance.instance_id,
_tags=dict(
state=instance.state["Name"],
info=state_reason,
),
)
break
except botocore.exceptions.ClientError as exc:
# Launch failure may be due to instance type availability in
# the given AZ
subnet_idx += 1
if attempt == max_tries:
try:
exc = NodeLaunchException(
category=exc.response["Error"]["Code"],
description=exc.response["Error"]["Message"],
src_exc_info=sys.exc_info(),
)
except Exception:
# In theory, all ClientError's we expect to get should
# have these fields, but just in case we can't parse
# it, it's fine, just throw the original error.
logger.warning("Couldn't parse exception.", exc)
pass
cli_logger.abort(
"Failed to launch instances. Max attempts exceeded.",
exc=exc,
)
else:
cli_logger.warning(
"create_instances: Attempt failed with {}, retrying.", exc
)
return created_nodes_dict
def terminate_node(self, node_id):
node = self._get_cached_node(node_id)
if self.cache_stopped_nodes:
if node.spot_instance_request_id:
cli_logger.print(
"Terminating instance {} "
+ cf.dimmed("(cannot stop spot instances, only terminate)"),
node_id,
) # todo: show node name?
node.terminate()
else:
cli_logger.print(
"Stopping instance {} "
+ cf.dimmed(
"(to terminate instead, "
"set `cache_stopped_nodes: False` "
"under `provider` in the cluster configuration)"
),
node_id,
) # todo: show node name?
node.stop()
else:
node.terminate()
# TODO (Alex): We are leaking the tag cache here. Naively, we would
# want to just remove the cache entry here, but terminating can be
# asyncrhonous or error, which would result in a use after free error.
# If this leak becomes bad, we can garbage collect the tag cache when
# the node cache is updated.
def _check_ami_cwa_installation(self, config):
response = self.ec2.meta.client.describe_images(ImageIds=[config["ImageId"]])
cwa_installed = False
images = response.get("Images")
if images:
assert len(images) == 1, (
f"Expected to find only 1 AMI with the given ID, "
f"but found {len(images)}."
)
image_name = images[0].get("Name", "")
if CLOUDWATCH_AGENT_INSTALLED_AMI_TAG in image_name:
cwa_installed = True
return cwa_installed
def terminate_nodes(self, node_ids):
if not node_ids:
return
terminate_instances_func = self.ec2.meta.client.terminate_instances
stop_instances_func = self.ec2.meta.client.stop_instances
# In some cases, this function stops some nodes, but terminates others.
# Each of these requires a different EC2 API call. So, we use the
# "nodes_to_terminate" dict below to keep track of exactly which API
# call will be used to stop/terminate which set of nodes. The key is
# the function to use, and the value is the list of nodes to terminate
# with that function.
nodes_to_terminate = {terminate_instances_func: [], stop_instances_func: []}
if self.cache_stopped_nodes:
spot_ids = []
on_demand_ids = []
for node_id in node_ids:
if self._get_cached_node(node_id).spot_instance_request_id:
spot_ids += [node_id]
else:
on_demand_ids += [node_id]
if on_demand_ids:
# todo: show node names?
cli_logger.print(
"Stopping instances {} "
+ cf.dimmed(
"(to terminate instead, "
"set `cache_stopped_nodes: False` "
"under `provider` in the cluster configuration)"
),
cli_logger.render_list(on_demand_ids),
)
if spot_ids:
cli_logger.print(
"Terminating instances {} "
+ cf.dimmed("(cannot stop spot instances, only terminate)"),
cli_logger.render_list(spot_ids),
)
nodes_to_terminate[stop_instances_func] = on_demand_ids
nodes_to_terminate[terminate_instances_func] = spot_ids
else:
nodes_to_terminate[terminate_instances_func] = node_ids
max_terminate_nodes = (
self.max_terminate_nodes
if self.max_terminate_nodes is not None
else len(node_ids)
)
for terminate_func, nodes in nodes_to_terminate.items():
for start in range(0, len(nodes), max_terminate_nodes):
terminate_func(InstanceIds=nodes[start : start + max_terminate_nodes])
def _get_node(self, node_id):
"""Refresh and get info for this node, updating the cache."""
self.non_terminated_nodes({}) # Side effect: updates cache
if node_id in self.cached_nodes:
return self.cached_nodes[node_id]
# Node not in {pending, running} -- retry with a point query. This
# usually means the node was recently preempted or terminated.
# The EC2 API is eventually consistent. This means that an instance
# might not be immediately visible. So we need to retry the query a few times.
# See: https://docs.aws.amazon.com/ec2/latest/devguide/eventual-consistency.html
# and https://github.com/ray-project/ray/issues/51861
for attempts in range(max(BOTO_MAX_RETRIES, 1)): # at least try once.
matches = list(self.ec2.instances.filter(InstanceIds=[node_id]))
if len(matches) == 1:
return matches[0]
cli_logger.warning(
"Attempt to fetch EC2 instances that have instance ID {}. Got {} matching EC2 instances. Will retry after {} second. This is retry number {}, and the maximum number of retries is {}.",
node_id,
len(matches),
LIST_RETRY_DELAY_SEC,
attempts + 1,
BOTO_MAX_RETRIES,
)
time.sleep(LIST_RETRY_DELAY_SEC)
raise AssertionError("Invalid instance id {}".format(node_id))
def _get_cached_node(self, node_id):
"""Return node info from cache if possible, otherwise fetches it."""
if node_id in self.cached_nodes:
return self.cached_nodes[node_id]
return self._get_node(node_id)
@staticmethod
def bootstrap_config(cluster_config):
return bootstrap_aws(cluster_config)
@staticmethod
def fillout_available_node_types_resources(
cluster_config: Dict[str, Any]
) -> Dict[str, Any]:
"""Fills out missing "resources" field for available_node_types."""
if "available_node_types" not in cluster_config:
return cluster_config
cluster_config = copy.deepcopy(cluster_config)
instances_list = list_ec2_instances(
cluster_config["provider"]["region"],
cluster_config["provider"].get("aws_credentials"),
)
instances_dict = {
instance["InstanceType"]: instance for instance in instances_list
}
available_node_types = cluster_config["available_node_types"]
head_node_type = cluster_config["head_node_type"]
for node_type in available_node_types:
instance_type = available_node_types[node_type]["node_config"][
"InstanceType"
]
if instance_type in instances_dict:
cpus = instances_dict[instance_type]["VCpuInfo"]["DefaultVCpus"]
autodetected_resources = {"CPU": cpus}
if node_type != head_node_type:
# we only autodetect worker node type memory resource
memory_total = instances_dict[instance_type]["MemoryInfo"][
"SizeInMiB"
]
memory_total = int(memory_total) * 1024 * 1024
prop = 1 - ray_constants.DEFAULT_OBJECT_STORE_MEMORY_PROPORTION
memory_resources = int(memory_total * prop)
autodetected_resources["memory"] = memory_resources
for (
accelerator_manager
) in ray._private.accelerators.get_all_accelerator_managers():
num_accelerators = (
accelerator_manager.get_ec2_instance_num_accelerators(
instance_type, instances_dict
)
)
accelerator_type = (
accelerator_manager.get_ec2_instance_accelerator_type(
instance_type, instances_dict
)
)
if num_accelerators:
autodetected_resources[
accelerator_manager.get_resource_name()
] = num_accelerators
if accelerator_type:
autodetected_resources[
f"accelerator_type:{accelerator_type}"
] = 1
autodetected_resources.update(
available_node_types[node_type].get("resources", {})
)
if autodetected_resources != available_node_types[node_type].get(
"resources", {}
):
available_node_types[node_type][
"resources"
] = autodetected_resources
logger.debug(
"Updating the resources of {} to {}.".format(
node_type, autodetected_resources
)
)
else:
raise ValueError(
"Instance type "
+ instance_type
+ " is not available in AWS region: "
+ cluster_config["provider"]["region"]
+ "."
)
return cluster_config
+181
View File
@@ -0,0 +1,181 @@
from collections import defaultdict
from functools import lru_cache
import boto3
from boto3.exceptions import ResourceNotExistsError
from boto3.resources.base import ServiceResource
from botocore.client import BaseClient
from botocore.config import Config
from ray.autoscaler._private.cli_logger import cf, cli_logger
from ray.autoscaler._private.constants import BOTO_MAX_RETRIES
class LazyDefaultDict(defaultdict):
"""
LazyDefaultDict(default_factory[, ...]) --> dict with default factory
The default factory is call with the key argument to produce
a new value when a key is not present, in __getitem__ only.
A LazyDefaultDict compares equal to a dict with the same items.
All remaining arguments are treated the same as if they were
passed to the dict constructor, including keyword arguments.
"""
def __missing__(self, key):
"""
__missing__(key) # Called by __getitem__ for missing key; pseudo-code:
if self.default_factory is None: raise KeyError((key,))
self[key] = value = self.default_factory(key)
return value
"""
self[key] = self.default_factory(key)
return self[key]
def handle_boto_error(exc, msg, *args, **kwargs):
error_code = None
error_info = None
# todo: not sure if these exceptions always have response
if hasattr(exc, "response"):
error_info = exc.response.get("Error", None)
if error_info is not None:
error_code = error_info.get("Code", None)
generic_message_args = [
"{}\nError code: {}",
msg.format(*args, **kwargs),
cf.bold(error_code),
]
# apparently
# ExpiredTokenException
# ExpiredToken
# RequestExpired
# are all the same pretty much
credentials_expiration_codes = [
"ExpiredTokenException",
"ExpiredToken",
"RequestExpired",
]
if error_code in credentials_expiration_codes:
# "An error occurred (ExpiredToken) when calling the
# GetInstanceProfile operation: The security token
# included in the request is expired"
# "An error occurred (RequestExpired) when calling the
# DescribeKeyPairs operation: Request has expired."
token_command = (
"aws sts get-session-token "
"--serial-number arn:aws:iam::"
+ cf.underlined("ROOT_ACCOUNT_ID")
+ ":mfa/"
+ cf.underlined("AWS_USERNAME")
+ " --token-code "
+ cf.underlined("TWO_FACTOR_AUTH_CODE")
)
secret_key_var = (
"export AWS_SECRET_ACCESS_KEY = "
+ cf.underlined("REPLACE_ME")
+ " # found at Credentials.SecretAccessKey"
)
session_token_var = (
"export AWS_SESSION_TOKEN = "
+ cf.underlined("REPLACE_ME")
+ " # found at Credentials.SessionToken"
)
access_key_id_var = (
"export AWS_ACCESS_KEY_ID = "
+ cf.underlined("REPLACE_ME")
+ " # found at Credentials.AccessKeyId"
)
# fixme: replace with a Github URL that points
# to our repo
aws_session_script_url = (
"https://gist.github.com/maximsmol/a0284e1d97b25d417bd9ae02e5f450cf"
)
cli_logger.verbose_error(*generic_message_args)
cli_logger.verbose(vars(exc))
cli_logger.panic("Your AWS session has expired.")
cli_logger.newline()
cli_logger.panic("You can request a new one using")
cli_logger.panic(cf.bold(token_command))
cli_logger.panic("then expose it to Ray by setting")
cli_logger.panic(cf.bold(secret_key_var))
cli_logger.panic(cf.bold(session_token_var))
cli_logger.panic(cf.bold(access_key_id_var))
cli_logger.newline()
cli_logger.panic("You can find a script that automates this at:")
cli_logger.panic(cf.underlined(aws_session_script_url))
# Do not re-raise the exception here because it looks awful
# and we already print all the info in verbose
cli_logger.abort()
# todo: any other errors that we should catch separately?
cli_logger.panic(*generic_message_args)
cli_logger.newline()
with cli_logger.verbatim_error_ctx("Boto3 error:"):
cli_logger.verbose("{}", str(vars(exc)))
cli_logger.panic("{}", str(exc))
cli_logger.abort()
def boto_exception_handler(msg, *args, **kwargs):
# todo: implement timer
class ExceptionHandlerContextManager:
def __enter__(self):
pass
def __exit__(self, type, value, tb):
import botocore
if type is botocore.exceptions.ClientError:
handle_boto_error(value, msg, *args, **kwargs)
return ExceptionHandlerContextManager()
@lru_cache()
def resource_cache(
name, region, max_retries=BOTO_MAX_RETRIES, **kwargs
) -> ServiceResource:
cli_logger.verbose(
"Creating AWS resource `{}` in `{}`", cf.bold(name), cf.bold(region)
)
kwargs.setdefault(
"config",
Config(retries={"max_attempts": max_retries}),
)
return boto3.resource(
name,
region,
**kwargs,
)
@lru_cache()
def client_cache(name, region, max_retries=BOTO_MAX_RETRIES, **kwargs) -> BaseClient:
try:
# try to re-use a client from the resource cache first
return resource_cache(name, region, max_retries, **kwargs).meta.client
except ResourceNotExistsError:
# fall back for clients without an associated resource
cli_logger.verbose(
"Creating AWS client `{}` in `{}`", cf.bold(name), cf.bold(region)
)
kwargs.setdefault(
"config",
Config(retries={"max_attempts": max_retries}),
)
return boto3.client(
name,
region,
**kwargs,
)
@@ -0,0 +1,814 @@
"""Logger implementing the Command Line Interface.
A replacement for the standard Python `logging` API
designed for implementing a better CLI UX for the cluster launcher.
Supports color, bold text, italics, underlines, etc.
(depending on TTY features)
as well as indentation and other structured output.
"""
import inspect
import logging
import os
import sys
import time
from contextlib import contextmanager
from functools import wraps
from typing import Any, Callable, Dict, List, Optional, Tuple
import click
import colorama
# Import ray first to use the bundled colorama
import ray # noqa: F401
if sys.platform == "win32":
import msvcrt
else:
import select
class _ColorfulMock:
def __init__(self):
# do not do any color work
self.identity = lambda x: x
self.colorful = self
self.colormode = None
self.NO_COLORS = None
self.ANSI_8_COLORS = None
def disable(self):
pass
@contextmanager
def with_style(self, x):
class IdentityClass:
def __getattr__(self, name):
return lambda y: y
yield IdentityClass()
def __getattr__(self, name):
if name == "with_style":
return self.with_style
return self.identity
try:
import colorful as _cf
from colorful.core import ColorfulString
_cf.use_8_ansi_colors()
except ModuleNotFoundError:
# We mock Colorful to restrict the colors used for consistency
# anyway, so we also allow for not having colorful at all.
# If the Ray Core dependency on colorful is ever removed,
# the CliLogger code will still work.
class ColorfulString:
pass
_cf = _ColorfulMock()
# We want to only allow specific formatting
# to prevent people from accidentally making bad looking color schemes.
#
# This is especially important since most will look bad on either light
# or dark themes.
class _ColorfulProxy:
_proxy_allowlist = [
"disable",
"reset",
"bold",
"italic",
"underlined",
# used instead of `gray` as `dimmed` adapts to
# both light and dark themes
"dimmed",
"dodgerBlue", # group
"limeGreen", # success
"red", # error
"orange", # warning
"skyBlue", # label
"magenta", # syntax highlighting key words and symbols
"yellow", # syntax highlighting strings
]
def __getattr__(self, name):
res = getattr(_cf, name)
if callable(res) and name not in _ColorfulProxy._proxy_allowlist:
raise ValueError(
"Usage of the colorful method '" + name + "' is forbidden "
"by the proxy to keep a consistent color scheme. "
"Check `cli_logger.py` for allowed methods"
)
return res
cf = _ColorfulProxy()
colorama.init(strip=False)
def _external_caller_info():
"""Get the info from the caller frame.
Used to override the logging function and line number with the correct
ones. See the comment on _patched_makeRecord for more info.
"""
frame = inspect.currentframe()
caller = frame
levels = 0
while caller.f_code.co_filename == __file__:
caller = caller.f_back
levels += 1
return {
"lineno": caller.f_lineno,
"filename": os.path.basename(caller.f_code.co_filename),
}
def _format_msg(
msg: str,
*args: Any,
no_format: bool = None,
_tags: Dict[str, Any] = None,
_numbered: Tuple[str, int, int] = None,
**kwargs: Any,
):
"""Formats a message for printing.
Renders `msg` using the built-in `str.format` and the passed-in
`*args` and `**kwargs`.
Args:
msg: The message to format.
*args: `.format` arguments for `msg`.
no_format: If `no_format` is `True`, `.format` will not be called on
the message. Useful if the output is user-provided or may otherwise
contain an unexpected formatting string (e.g. "{}").
_tags: key-value pairs to display at the end of the message in
square brackets. If a tag is set to `True`, it is printed without
the value, the presence of the tag treated as a "flag".
E.g. `_format_msg("hello", _tags=dict(from=mom, signed=True))`
`hello [from=Mom, signed]`
_numbered: `(brackets, i, n)` The `brackets` string is composed of
two "bracket" characters, `i` is the index, `n` is the total.
The string `{i}/{n}` surrounded by the "brackets" is
prepended to the message. This is used to number steps in a
procedure, with different brackets specifying different major tasks.
E.g. `_format_msg("hello", _numbered=("[]", 0, 5))`
`[0/5] hello`
**kwargs: `.format` keyword arguments for `msg`.
Returns:
The formatted message.
"""
if isinstance(msg, str) or isinstance(msg, ColorfulString):
tags_str = ""
if _tags is not None:
tags_list = []
for k, v in _tags.items():
if v is True:
tags_list += [k]
continue
if v is False:
continue
tags_list += [k + "=" + v]
if tags_list:
tags_str = cf.reset(cf.dimmed(" [{}]".format(", ".join(tags_list))))
numbering_str = ""
if _numbered is not None:
chars, i, n = _numbered
numbering_str = cf.dimmed(chars[0] + str(i) + "/" + str(n) + chars[1]) + " "
if no_format:
# todo: throw if given args/kwargs?
return numbering_str + msg + tags_str
return numbering_str + msg.format(*args, **kwargs) + tags_str
if kwargs:
raise ValueError("We do not support printing kwargs yet.")
res = [msg, *args]
res = [str(x) for x in res]
return ", ".join(res)
# TODO: come up with a plan to unify logging.
# formatter = logging.Formatter(
# # TODO(maximsmol): figure out the required log level padding
# # width automatically
# fmt="[{asctime}] {levelname:6} {message}",
# datefmt="%x %X",
# # We want alignment on our level names
# style="{")
def _isatty():
"""More robust check for interactive terminal/tty."""
try:
# https://stackoverflow.com/questions/6108330/
# checking-for-interactive-shell-in-a-python-script
return sys.__stdin__.isatty()
except Exception:
# sometimes this can fail due to closed output
# either way, no-tty is generally safe fallback.
return False
class _CliLogger:
"""Singleton class for CLI logging.
Without calling 'cli_logger.configure', the CLILogger will default
to 'record' style logging.
Attributes:
color_mode: Can be "true", "false", or "auto". Enables or disables
`colorful`. If `color_mode` is "auto", is set to `not stdout.isatty()`
indent_level: The current indentation level. All messages will
be indented by prepending `" " * indent_level`
_verbosity: Output verbosity. Low verbosity will disable
`verbose` and `very_verbose` messages.
"""
color_mode: str
# color_mode: Union[Literal["auto"], Literal["false"], Literal["true"]]
indent_level: int
interactive: bool
VALID_LOG_STYLES = ("auto", "record", "pretty")
_autodetected_cf_colormode: int
def __init__(self):
self.indent_level = 0
self._verbosity = 0
self._verbosity_overriden = False
self._color_mode = "auto"
self._log_style = "record"
self.pretty = False
self.interactive = False
# store whatever colorful has detected for future use if
# the color ouput is toggled (colorful detects # of supported colors,
# so it has some non-trivial logic to determine this)
self._autodetected_cf_colormode = cf.colorful.colormode
self.set_format()
def set_format(self, format_tmpl=None):
if not format_tmpl:
from ray.autoscaler._private.constants import LOGGER_FORMAT
format_tmpl = LOGGER_FORMAT
self._formatter = logging.Formatter(format_tmpl)
def configure(self, log_style=None, color_mode=None, verbosity=None):
"""Configures the logger according to values."""
if log_style is not None:
self._set_log_style(log_style)
if color_mode is not None:
self._set_color_mode(color_mode)
if verbosity is not None:
self._set_verbosity(verbosity)
self.detect_colors()
@property
def log_style(self):
return self._log_style
def _set_log_style(self, x):
"""Configures interactivity and formatting."""
self._log_style = x.lower()
self.interactive = _isatty()
if self._log_style == "auto":
self.pretty = _isatty()
elif self._log_style == "record":
self.pretty = False
self._set_color_mode("false")
elif self._log_style == "pretty":
self.pretty = True
@property
def color_mode(self):
return self._color_mode
def _set_color_mode(self, x):
self._color_mode = x.lower()
self.detect_colors()
@property
def verbosity(self):
if self._verbosity_overriden:
return self._verbosity
elif not self.pretty:
return 999
return self._verbosity
def _set_verbosity(self, x):
self._verbosity = x
self._verbosity_overriden = True
def detect_colors(self):
"""Update color output settings.
Parse the `color_mode` string and optionally disable or force-enable
color output
(8-color ANSI if no terminal detected to be safe) in colorful.
"""
if self.color_mode == "true":
if self._autodetected_cf_colormode != cf.NO_COLORS:
cf.colormode = self._autodetected_cf_colormode
else:
cf.colormode = cf.ANSI_8_COLORS
return
if self.color_mode == "false":
cf.disable()
return
if self.color_mode == "auto":
# colorful autodetects tty settings
return
raise ValueError("Invalid log color setting: " + self.color_mode)
def newline(self):
"""Print a line feed."""
self.print("")
def _print(
self,
msg: str,
_level_str: str = "INFO",
_linefeed: bool = True,
end: str = None,
) -> None:
"""Proxy for printing messages.
Args:
msg: Message to print.
_level_str: Log level label used by the non-pretty formatter.
_linefeed: If `_linefeed` is `False` no linefeed is printed at
the end of the message.
end: String appended after the last value, passed to print().
"""
if self.pretty:
rendered_message = " " * self.indent_level + msg
else:
if msg.strip() == "":
return
caller_info = _external_caller_info()
record = logging.LogRecord(
name="cli",
# We override the level name later
# TODO(maximsmol): give approximate level #s to our log levels
level=0,
# The user-facing logs do not need this information anyway
# and it would be very tedious to extract since _print
# can be at varying depths in the call stack
# TODO(maximsmol): do it anyway to be extra
pathname=caller_info["filename"],
lineno=caller_info["lineno"],
msg=msg,
args={},
# No exception
exc_info=None,
)
record.levelname = _level_str
rendered_message = self._formatter.format(record)
# We aren't using standard python logging convention, so we hardcode
# the log levels for now.
if _level_str in ["WARNING", "ERROR", "PANIC"]:
stream = sys.stderr
else:
stream = sys.stdout
if not _linefeed:
stream.write(rendered_message)
stream.flush()
return
kwargs = {"end": end}
print(rendered_message, file=stream, **kwargs)
def indented(self):
"""Context manager that starts an indented block of output."""
cli_logger = self
class IndentedContextManager:
def __enter__(self):
cli_logger.indent_level += 1
def __exit__(self, type, value, tb):
cli_logger.indent_level -= 1
return IndentedContextManager()
def group(self, msg: str, *args: Any, **kwargs: Any):
"""Print a group title in a special color and start an indented block.
For arguments, see `_format_msg`.
"""
self.print(cf.dodgerBlue(msg), *args, **kwargs)
return self.indented()
def verbatim_error_ctx(self, msg: str, *args: Any, **kwargs: Any):
"""Context manager for printing multi-line error messages.
Displays a start sequence "!!! {optional message}"
and a matching end sequence "!!!".
The string "!!!" can be used as a "tombstone" for searching.
For arguments, see `_format_msg`.
"""
cli_logger = self
class VerbatimErorContextManager:
def __enter__(self):
cli_logger.error(cf.bold("!!! ") + "{}", msg, *args, **kwargs)
def __exit__(self, type, value, tb):
cli_logger.error(cf.bold("!!!"))
return VerbatimErorContextManager()
def labeled_value(self, key: str, msg: str, *args: Any, **kwargs: Any):
"""Displays a key-value pair with special formatting.
Args:
key: Label that is prepended to the message.
msg: Message to print after the label. See `_format_msg`.
*args: Additional positional args forwarded to `_format_msg`.
**kwargs: Additional keyword args forwarded to `_format_msg`.
"""
self._print(cf.skyBlue(key) + ": " + _format_msg(cf.bold(msg), *args, **kwargs))
def verbose(self, msg: str, *args: Any, **kwargs: Any):
"""Prints a message if verbosity is not 0.
For arguments, see `_format_msg`.
"""
if self.verbosity > 0:
self.print(msg, *args, _level_str="VINFO", **kwargs)
def verbose_warning(self, msg, *args, **kwargs):
"""Prints a formatted warning if verbosity is not 0.
For arguments, see `_format_msg`.
"""
if self.verbosity > 0:
self._warning(msg, *args, _level_str="VWARN", **kwargs)
def verbose_error(self, msg: str, *args: Any, **kwargs: Any):
"""Logs an error if verbosity is not 0.
For arguments, see `_format_msg`.
"""
if self.verbosity > 0:
self._error(msg, *args, _level_str="VERR", **kwargs)
def very_verbose(self, msg: str, *args: Any, **kwargs: Any):
"""Prints if verbosity is > 1.
For arguments, see `_format_msg`.
"""
if self.verbosity > 1:
self.print(msg, *args, _level_str="VVINFO", **kwargs)
def success(self, msg: str, *args: Any, **kwargs: Any):
"""Prints a formatted success message.
For arguments, see `_format_msg`.
"""
self.print(cf.limeGreen(msg), *args, _level_str="SUCC", **kwargs)
def _warning(self, msg: str, *args: Any, _level_str: str = None, **kwargs: Any):
"""Prints a formatted warning message.
For arguments, see `_format_msg`.
"""
if _level_str is None:
raise ValueError("Log level not set.")
self.print(cf.orange(msg), *args, _level_str=_level_str, **kwargs)
def warning(self, *args, **kwargs):
self._warning(*args, _level_str="WARN", **kwargs)
def _error(self, msg: str, *args: Any, _level_str: str = None, **kwargs: Any):
"""Prints a formatted error message.
For arguments, see `_format_msg`.
"""
if _level_str is None:
raise ValueError("Log level not set.")
self.print(cf.red(msg), *args, _level_str=_level_str, **kwargs)
def error(self, *args, **kwargs):
self._error(*args, _level_str="ERR", **kwargs)
def panic(self, *args, **kwargs):
self._error(*args, _level_str="PANIC", **kwargs)
# Fine to expose _level_str here, since this is a general log function.
def print(
self,
msg: str,
*args: Any,
_level_str: str = "INFO",
end: str = None,
**kwargs: Any,
):
"""Prints a message.
For arguments, see `_format_msg`.
"""
self._print(_format_msg(msg, *args, **kwargs), _level_str=_level_str, end=end)
def info(self, msg: str, no_format=True, *args, **kwargs):
self.print(msg, no_format=no_format, *args, **kwargs)
def abort(
self, msg: Optional[str] = None, *args: Any, exc: Any = None, **kwargs: Any
):
"""Prints an error and aborts execution.
Print an error and throw an exception to terminate the program
(the exception will not print a message).
"""
if msg is not None:
self._error(msg, *args, _level_str="PANIC", **kwargs)
if exc is not None:
raise exc
exc_cls = click.ClickException
if self.pretty:
exc_cls = SilentClickException
if msg is None:
msg = "Exiting due to cli_logger.abort()"
raise exc_cls(msg)
def doassert(self, val: bool, msg: str, *args: Any, **kwargs: Any):
"""Handle assertion without throwing a scary exception.
Args:
val: Value to check.
msg: Message to print on assertion failure. See `_format_msg`.
*args: Additional positional args forwarded to `_format_msg`.
**kwargs: Additional keyword args forwarded to `_format_msg`.
"""
if not val:
exc = None
if not self.pretty:
exc = AssertionError()
# TODO(maximsmol): rework asserts so that we get the expression
# that triggered the assert
# to do this, install a global try-catch
# for AssertionError and raise them normally
self.abort(msg, *args, exc=exc, **kwargs)
def render_list(self, xs: List[str], separator: str = cf.reset(", ")):
"""Render a list of bolded values using a non-bolded separator."""
return separator.join([str(cf.bold(x)) for x in xs])
def confirm(
self,
yes: bool,
msg: str,
*args: Any,
_abort: bool = False,
_default: bool = False,
_timeout_s: Optional[float] = None,
**kwargs: Any,
):
"""Display a confirmation dialog.
Valid answers are "y/yes/true/1" and "n/no/false/0".
Args:
yes: If `yes` is `True` the dialog will default to "yes"
and continue without waiting for user input.
msg: The prompt message to display. See `_format_msg`.
*args: Additional positional args forwarded to `_format_msg`.
_abort: If `_abort` is `True`, "no" means aborting the program.
_default: The default action to take if the user just presses
enter with no input.
_timeout_s: If user has no input within _timeout_s seconds,
the default action is taken. None means no timeout.
**kwargs: Additional keyword args forwarded to `_format_msg`.
Returns:
True if the user confirmed (or `yes` was set); False otherwise.
"""
should_abort = _abort
default = _default
if not self.interactive and not yes:
# no formatting around --yes here since this is non-interactive
self.error(
"This command requires user confirmation. "
"When running non-interactively, supply --yes to skip."
)
raise ValueError("Non-interactive confirm without --yes.")
if default:
yn_str = "Y/n"
else:
yn_str = "y/N"
confirm_str = cf.underlined("Confirm [" + yn_str + "]:") + " "
rendered_message = _format_msg(msg, *args, **kwargs)
# the rendered message ends with ascii coding
if rendered_message and not msg.endswith("\n"):
rendered_message += " "
msg_len = len(rendered_message.split("\n")[-1])
complete_str = rendered_message + confirm_str
if yes:
self._print(complete_str + "y " + cf.dimmed("[automatic, due to --yes]"))
return True
self._print(complete_str, _linefeed=False)
res = None
yes_answers = ["y", "yes", "true", "1"]
no_answers = ["n", "no", "false", "0"]
try:
while True:
if _timeout_s is None:
ans = sys.stdin.readline()
elif sys.platform == "win32":
# Windows doesn't support select
start_time = time.time()
ans = ""
while True:
if (time.time() - start_time) >= _timeout_s:
self.newline()
ans = "\n"
break
elif msvcrt.kbhit():
ch = msvcrt.getwch()
if ch in ("\n", "\r"):
self.newline()
ans = ans + "\n"
break
elif ch == "\b":
if ans:
ans = ans[:-1]
# Emulate backspace erasing
print("\b \b", end="", flush=True)
else:
ans = ans + ch
print(ch, end="", flush=True)
else:
time.sleep(0.1)
else:
ready, _, _ = select.select([sys.stdin], [], [], _timeout_s)
if not ready:
self.newline()
ans = "\n"
else:
ans = sys.stdin.readline()
ans = ans.lower()
if ans == "\n":
res = default
break
ans = ans.strip()
if ans in yes_answers:
res = True
break
if ans in no_answers:
res = False
break
indent = " " * msg_len
self.error(
"{}Invalid answer: {}. Expected {} or {}",
indent,
cf.bold(ans.strip()),
self.render_list(yes_answers, "/"),
self.render_list(no_answers, "/"),
)
self._print(indent + confirm_str, _linefeed=False)
except KeyboardInterrupt:
self.newline()
res = default
if not res and should_abort:
# todo: make sure we tell the user if they
# need to do cleanup
self._print("Exiting...")
raise SilentClickException(
"Exiting due to the response to confirm(should_abort=True)."
)
return res
def prompt(self, msg: str, *args, **kwargs):
"""Prompt the user for some text input.
Args:
msg: The message to display to the user before the prompt.
*args: Additional positional args forwarded to `_format_msg`.
**kwargs: Additional keyword args forwarded to `_format_msg`.
Returns:
The string entered by the user.
"""
complete_str = cf.underlined(msg)
rendered_message = _format_msg(complete_str, *args, **kwargs)
# the rendered message ends with ascii coding
if rendered_message and not msg.endswith("\n"):
rendered_message += " "
self._print(rendered_message, linefeed=False)
res = ""
try:
ans = sys.stdin.readline()
ans = ans.lower()
res = ans.strip()
except KeyboardInterrupt:
self.newline()
return res
def flush(self):
sys.stdout.flush()
sys.stderr.flush()
class SilentClickException(click.ClickException):
"""`ClickException` that does not print a message.
Some of our tooling relies on catching ClickException in particular.
However the default prints a message, which is undesirable since we expect
our code to log errors manually using `cli_logger.error()` to allow for
colors and other formatting.
"""
def __init__(self, message: str):
super(SilentClickException, self).__init__(message)
def show(self, file=None):
pass
cli_logger = _CliLogger()
CLICK_LOGGING_OPTIONS = [
click.option(
"--log-style",
required=False,
type=click.Choice(cli_logger.VALID_LOG_STYLES, case_sensitive=False),
default="auto",
help=(
"If 'pretty', outputs with formatting and color. If 'record', "
"outputs record-style without formatting. "
"'auto' defaults to 'pretty', and disables pretty logging "
"if stdin is *not* a TTY."
),
),
click.option(
"--log-color",
required=False,
type=click.Choice(["auto", "false", "true"], case_sensitive=False),
default="auto",
help=("Use color logging. Auto enables color logging if stdout is a TTY."),
),
click.option("-v", "--verbose", default=None, count=True),
]
def add_click_logging_options(f: Callable) -> Callable:
for option in reversed(CLICK_LOGGING_OPTIONS):
f = option(f)
@wraps(f)
def wrapper(*args, log_style=None, log_color=None, verbose=None, **kwargs):
cli_logger.configure(log_style, log_color, verbose)
return f(*args, **kwargs)
return wrapper
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python
# This is an executable script that runs an example of every single CliLogger
# function for demonstration purposes. Primarily useful for tuning color and
# other formatting.
from ray.autoscaler._private.cli_logger import cf, cli_logger
cli_logger.configure(log_style="auto", verbosity=999)
cli_logger.print(cf.bold("Bold ") + cf.italic("Italic ") + cf.underlined("Underlined"))
cli_logger.labeled_value("Label", "value")
cli_logger.print("List: {}", cli_logger.render_list([1, 2, 3]))
cli_logger.newline()
cli_logger.very_verbose("Very verbose")
cli_logger.verbose("Verbose")
cli_logger.verbose_warning("Verbose warning")
cli_logger.verbose_error("Verbose error")
cli_logger.print("Info")
cli_logger.success("Success")
cli_logger.warning("Warning")
cli_logger.error("Error")
cli_logger.newline()
try:
cli_logger.abort("Abort")
except Exception:
pass
try:
cli_logger.doassert(False, "Assert")
except Exception:
pass
cli_logger.newline()
cli_logger.confirm(True, "example")
cli_logger.newline()
with cli_logger.indented():
cli_logger.print("Indented")
with cli_logger.group("Group"):
cli_logger.print("Group contents")
with cli_logger.verbatim_error_ctx("Verbtaim error"):
cli_logger.print("Error contents")
@@ -0,0 +1,45 @@
"""Lightweight CLI output helpers for cluster launcher context notes.
These functions produce informational output that disambiguates head-node
commands from local-machine commands. They have NO dependency on the ``ray``
C++ runtime and accept ``cli_logger`` / ``cf`` as explicit parameters so they
can be tested in isolation.
"""
def print_next_steps_context_note(cli_logger, cf):
"""Print a dimmed note at the top of the 'Next steps' block.
Informs the user that the commands below are intended for the head node
or for machines within the cluster network.
"""
cli_logger.print(cf.dimmed("Note: The following commands are intended for use on"))
cli_logger.print(cf.dimmed("the head node or within the cluster network."))
cli_logger.newline()
def print_head_node_context_separator(cli_logger, cf):
"""Print a visual separator and context note after head-node output.
Used by ``ray up`` to separate the streamed ``ray start`` output from
the local-machine commands that follow.
"""
cli_logger.print(cf.dimmed("-" * 60))
cli_logger.print(
cf.dimmed("Note: The output above is from the head node (via `ray start`).")
)
cli_logger.print(
cf.dimmed(" Commands shown in 'Next steps' may only work from the head node")
)
cli_logger.print(cf.dimmed(" or from within the cluster network."))
cli_logger.newline()
def print_head_node_context_separator_if_needed(ray_start_commands, cli_logger, cf):
"""Print the head-node separator when ``ray start`` output was shown."""
if ray_start_commands:
print_head_node_context_separator(cli_logger, cf)
# Group heading used by ``ray up`` for the local commands section.
USEFUL_COMMANDS_HEADING = "Useful commands for your local machine:"
@@ -0,0 +1,652 @@
import os
import re
import subprocess
import sys
import tarfile
import tempfile
import threading
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from typing import Any, Iterator, List, Optional, Sequence, Tuple
import yaml
import ray # noqa: F401
from ray.autoscaler._private.cli_logger import cli_logger
from ray.autoscaler._private.providers import _get_node_provider
from ray.autoscaler.tags import NODE_KIND_HEAD, NODE_KIND_WORKER, TAG_RAY_NODE_KIND
# Import psutil after ray so the packaged version is used.
import psutil
MAX_PARALLEL_SSH_WORKERS = 8
DEFAULT_SSH_USER = "ubuntu"
DEFAULT_SSH_KEYS = ["~/ray_bootstrap_key.pem", "~/.ssh/ray-autoscaler_2_us-west-2.pem"]
class CommandFailed(RuntimeError):
pass
class LocalCommandFailed(CommandFailed):
pass
class RemoteCommandFailed(CommandFailed):
pass
class GetParameters:
def __init__(
self,
logs: bool = True,
debug_state: bool = True,
pip: bool = True,
processes: bool = True,
processes_verbose: bool = True,
processes_list: Optional[List[Tuple[str, bool]]] = None,
):
self.logs = logs
self.debug_state = debug_state
self.pip = pip
self.processes = processes
self.processes_verbose = processes_verbose
self.processes_list = processes_list
class Node:
"""Node (as in "machine")"""
def __init__(
self,
host: str,
ssh_user: str = "ubuntu",
ssh_key: str = "~/ray_bootstrap_key.pem",
docker_container: Optional[str] = None,
is_head: bool = False,
):
self.host = host
self.ssh_user = ssh_user
self.ssh_key = ssh_key
self.docker_container = docker_container
self.is_head = is_head
class Archive:
"""Archive object to collect and compress files into a single file.
Objects of this class can be passed around to different data collection
functions. These functions can use the :meth:`subdir` method to add
files to a sub directory of the archive.
"""
def __init__(self, file: Optional[str] = None):
self.file = file or tempfile.mkstemp(prefix="ray_logs_", suffix=".tar.gz")[1]
self.tar = None
self._lock = threading.Lock()
@property
def is_open(self):
return bool(self.tar)
def open(self):
self.tar = tarfile.open(self.file, "w:gz")
def close(self):
self.tar.close()
self.tar = None
def __enter__(self):
self.open()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
@contextmanager
def subdir(self, subdir: str, root: Optional[str] = "/") -> Iterator[Any]:
"""Open a context to add files to the archive.
Example:
.. code-block:: python
with Archive("file.tar.gz") as archive:
with archive.subdir("logfiles", root="/tmp/logs") as sd:
# Will be added as `logfiles/nested/file.txt`
sd.add("/tmp/logs/nested/file.txt")
Args:
subdir: Subdir to which to add files to. Calling the
``add(path)`` command will place files into the ``subdir``
directory of the archive.
root: Root path. Files without an explicit ``arcname``
will be named relatively to this path.
Yields:
Any: A context object that can be used to add files to the archive.
"""
root = os.path.abspath(root)
class _Context:
@staticmethod
def add(path: str, arcname: Optional[str] = None):
path = os.path.abspath(path)
arcname = arcname or os.path.join(subdir, os.path.relpath(path, root))
self._lock.acquire()
self.tar.add(path, arcname=arcname)
self._lock.release()
yield _Context()
###
# Functions to gather logs and information on the local node
###
def get_local_ray_logs(
archive: Archive,
exclude: Optional[Sequence[str]] = None,
session_log_dir: str = "/tmp/ray/session_latest",
) -> Archive:
"""Copy local log files into an archive.
Args:
archive: Archive object to add log files to.
exclude: Sequence of regex patterns. Files that match
any of these patterns will not be included in the archive.
session_log_dir: Path to the Ray session files. Defaults to
``/tmp/ray/session_latest``
Returns:
Open archive object.
"""
if not archive.is_open:
archive.open()
exclude = exclude or []
session_log_dir = os.path.join(os.path.expanduser(session_log_dir), "logs")
with archive.subdir("logs", root=session_log_dir) as sd:
for root, dirs, files in os.walk(session_log_dir):
for file in files:
file_path = os.path.join(root, file)
rel_path = os.path.relpath(file_path, start=session_log_dir)
# Skip file if it matches any pattern in `exclude`
if any(re.match(pattern, rel_path) for pattern in exclude):
continue
sd.add(file_path)
return archive
def get_local_debug_state(
archive: Archive, session_dir: str = "/tmp/ray/session_latest"
) -> Archive:
"""Copy local log files into an archive.
Args:
archive: Archive object to add log files to.
session_dir: Path to the Ray session files. Defaults to
``/tmp/ray/session_latest``
Returns:
Open archive object.
"""
if not archive.is_open:
archive.open()
session_dir = os.path.expanduser(session_dir)
debug_state_file = os.path.join(session_dir, "logs/debug_state.txt")
if not os.path.exists(debug_state_file):
raise LocalCommandFailed("No `debug_state.txt` file found.")
with archive.subdir("", root=session_dir) as sd:
sd.add(debug_state_file)
return archive
def get_local_pip_packages(archive: Archive):
"""Get currently installed pip packages and write into an archive.
Args:
archive: Archive object to add meta files to.
Returns:
Open archive object.
"""
if not archive.is_open:
archive.open()
try:
from pip._internal.operations import freeze
except ImportError: # pip < 10.0
from pip.operations import freeze
with tempfile.NamedTemporaryFile("wt") as fp:
for line in freeze.freeze():
fp.writelines([line, "\n"])
fp.flush()
with archive.subdir("") as sd:
sd.add(fp.name, "pip_packages.txt")
return archive
def get_local_ray_processes(
archive: Archive,
processes: Optional[List[Tuple[str, bool]]] = None,
verbose: bool = False,
):
"""Get the status of all the relevant ray processes.
Args:
archive: Archive object to add process info files to.
processes: List of processes to get information on. The first
element of the tuple is a string to filter by, and the second
element is a boolean indicating if we should filter by command
name (True) or command line including parameters (False)
verbose: If True, show entire executable command line.
If False, show just the first term.
Returns:
Open archive object.
"""
if not processes:
# local import to avoid circular dependencies
from ray.autoscaler._private.constants import RAY_PROCESSES
processes = RAY_PROCESSES
process_infos = []
for process in psutil.process_iter(["pid", "name", "cmdline", "status"]):
try:
with process.oneshot():
cmdline = " ".join(process.cmdline())
process_infos.append(
(
{
"executable": cmdline
if verbose
else cmdline.split("--", 1)[0][:-1],
"name": process.name(),
"pid": process.pid,
"status": process.status(),
},
process.cmdline(),
)
)
except Exception as exc:
raise LocalCommandFailed(exc) from exc
relevant_processes = {}
for process_dict, cmdline in process_infos:
for keyword, filter_by_cmd in processes:
if filter_by_cmd:
corpus = process_dict["name"]
else:
corpus = subprocess.list2cmdline(cmdline)
if keyword in corpus and process_dict["pid"] not in relevant_processes:
relevant_processes[process_dict["pid"]] = process_dict
with tempfile.NamedTemporaryFile("wt") as fp:
for line in relevant_processes.values():
fp.writelines([yaml.dump(line), "\n"])
fp.flush()
with archive.subdir("meta") as sd:
sd.add(fp.name, "process_info.txt")
return archive
def get_all_local_data(archive: Archive, parameters: GetParameters):
"""Get all local data.
Gets:
- The Ray logs of the latest session
- The currently installed pip packages
Args:
archive: Archive object to add meta files to.
parameters: Parameters (settings) for getting data.
Returns:
Open archive object.
"""
if not archive.is_open:
archive.open()
if parameters.logs:
try:
get_local_ray_logs(archive=archive)
except LocalCommandFailed as exc:
cli_logger.error(exc)
if parameters.debug_state:
try:
get_local_debug_state(archive=archive)
except LocalCommandFailed as exc:
cli_logger.error(exc)
if parameters.pip:
try:
get_local_pip_packages(archive=archive)
except LocalCommandFailed as exc:
cli_logger.error(exc)
if parameters.processes:
try:
get_local_ray_processes(
archive=archive,
processes=parameters.processes_list,
verbose=parameters.processes_verbose,
)
except LocalCommandFailed as exc:
cli_logger.error(exc)
return archive
###
# Functions to invoke remote scripts and gather data from remote nodes
###
def _wrap(items: List[str], quotes="'"):
return f"{quotes}{' '.join(items)}{quotes}"
def create_and_get_archive_from_remote_node(
remote_node: Node, parameters: GetParameters, script_path: str = "ray"
) -> Optional[str]:
"""Create an archive containing logs on a remote node and transfer.
This will call ``ray local-dump --stream`` on the remote
node. The resulting file will be saved locally in a temporary file and
returned.
Args:
remote_node: Remote node to gather archive from.
parameters: Parameters (settings) for getting data.
script_path: Path to this script on the remote node.
Returns:
Path to a temporary file containing the node's collected data.
"""
cmd = [
"ssh",
"-o StrictHostKeyChecking=no",
"-o UserKnownHostsFile=/dev/null",
"-o LogLevel=ERROR",
"-i",
remote_node.ssh_key,
f"{remote_node.ssh_user}@{remote_node.host}",
]
if remote_node.docker_container:
cmd += [
"docker",
"exec",
remote_node.docker_container,
]
collect_cmd = [script_path, "local-dump", "--stream"]
collect_cmd += ["--logs"] if parameters.logs else ["--no-logs"]
collect_cmd += ["--debug-state"] if parameters.debug_state else ["--no-debug-state"]
collect_cmd += ["--pip"] if parameters.pip else ["--no-pip"]
collect_cmd += ["--processes"] if parameters.processes else ["--no-processes"]
if parameters.processes:
collect_cmd += (
["--processes-verbose"]
if parameters.processes_verbose
else ["--no-proccesses-verbose"]
)
cmd += ["/bin/bash", "-c", _wrap(collect_cmd, quotes='"')]
cat = "node" if not remote_node.is_head else "head"
cli_logger.print(f"Collecting data from remote node: {remote_node.host}")
tmp = tempfile.mkstemp(prefix=f"ray_{cat}_{remote_node.host}_", suffix=".tar.gz")[1]
with open(tmp, "wb") as fp:
try:
subprocess.check_call(cmd, stdout=fp, stderr=sys.stderr)
except subprocess.CalledProcessError as exc:
raise RemoteCommandFailed(
f"Gathering logs from remote node failed: {' '.join(cmd)}"
) from exc
return tmp
def create_and_add_remote_data_to_local_archive(
archive: Archive, remote_node: Node, parameters: GetParameters
):
"""Create and get data from remote node and add to local archive.
Args:
archive: Archive object to add remote data to.
remote_node: Remote node to gather archive from.
parameters: Parameters (settings) for getting data.
Returns:
Open archive object.
"""
tmp = create_and_get_archive_from_remote_node(remote_node, parameters)
if not archive.is_open:
archive.open()
cat = "node" if not remote_node.is_head else "head"
with archive.subdir("", root=os.path.dirname(tmp)) as sd:
sd.add(tmp, arcname=f"ray_{cat}_{remote_node.host}.tar.gz")
return archive
def create_and_add_local_data_to_local_archive(
archive: Archive, parameters: GetParameters
):
"""Create and get data from this node and add to archive.
Args:
archive: Archive object to add remote data to.
parameters: Parameters (settings) for getting data.
Returns:
Open archive object.
"""
with Archive() as local_data_archive:
get_all_local_data(local_data_archive, parameters)
if not archive.is_open:
archive.open()
with archive.subdir("", root=os.path.dirname(local_data_archive.file)) as sd:
sd.add(local_data_archive.file, arcname="local_node.tar.gz")
os.remove(local_data_archive.file)
return archive
def create_archive_for_remote_nodes(
archive: Archive, remote_nodes: Sequence[Node], parameters: GetParameters
):
"""Create an archive combining data from the remote nodes.
This will parallelize calls to get data from remote nodes.
Args:
archive: Archive object to add remote data to.
remote_nodes: Sequence of remote nodes.
parameters: Parameters (settings) for getting data.
Returns:
Open archive object.
"""
if not archive.is_open:
archive.open()
with ThreadPoolExecutor(max_workers=MAX_PARALLEL_SSH_WORKERS) as executor:
for remote_node in remote_nodes:
executor.submit(
create_and_add_remote_data_to_local_archive,
archive=archive,
remote_node=remote_node,
parameters=parameters,
)
return archive
def create_archive_for_local_and_remote_nodes(
archive: Archive, remote_nodes: Sequence[Node], parameters: GetParameters
):
"""Create an archive combining data from the local and remote nodes.
This will parallelize calls to get data from remote nodes.
Args:
archive: Archive object to add data to.
remote_nodes: Sequence of remote nodes.
parameters: Parameters (settings) for getting data.
Returns:
Open archive object.
"""
if not archive.is_open:
archive.open()
try:
create_and_add_local_data_to_local_archive(archive, parameters)
except CommandFailed as exc:
cli_logger.error(exc)
create_archive_for_remote_nodes(archive, remote_nodes, parameters)
cli_logger.print(
f"Collected data from local node and {len(remote_nodes)} " f"remote nodes."
)
return archive
###
# Ray cluster info
###
def get_info_from_ray_cluster_config(
cluster_config: str,
) -> Tuple[List[str], str, str, Optional[str], Optional[str]]:
"""Get information from Ray cluster config.
Return list of host IPs, ssh user, ssh key file, and optional docker
container.
Args:
cluster_config: Path to ray cluster config.
Returns:
Tuple of list of host IPs, ssh user name, ssh key file path,
optional docker container name, optional cluster name.
"""
from ray.autoscaler._private.commands import _bootstrap_config
cli_logger.print(
f"Retrieving cluster information from ray cluster file: " f"{cluster_config}"
)
cluster_config = os.path.expanduser(cluster_config)
config = yaml.safe_load(open(cluster_config).read())
config = _bootstrap_config(config, no_config_cache=True)
provider = _get_node_provider(config["provider"], config["cluster_name"])
head_nodes = provider.non_terminated_nodes({TAG_RAY_NODE_KIND: NODE_KIND_HEAD})
worker_nodes = provider.non_terminated_nodes({TAG_RAY_NODE_KIND: NODE_KIND_WORKER})
hosts = [provider.external_ip(node) for node in head_nodes + worker_nodes]
ssh_user = config["auth"]["ssh_user"]
ssh_key = config["auth"]["ssh_private_key"]
docker = None
docker_config = config.get("docker", None)
if docker_config:
docker = docker_config.get("container_name", None)
cluster_name = config.get("cluster_name", None)
return hosts, ssh_user, ssh_key, docker, cluster_name
def _info_from_params(
cluster: Optional[str] = None,
host: Optional[str] = None,
ssh_user: Optional[str] = None,
ssh_key: Optional[str] = None,
docker: Optional[str] = None,
):
"""Parse command line arguments.
Note: This returns a list of hosts, not a comma separated string!
"""
if not host and not cluster:
bootstrap_config = os.path.expanduser("~/ray_bootstrap_config.yaml")
if os.path.exists(bootstrap_config):
cluster = bootstrap_config
cli_logger.warning(
f"Detected cluster config file at {cluster}. "
f"If this is incorrect, specify with "
f"`ray cluster-dump <config>`"
)
elif cluster:
cluster = os.path.expanduser(cluster)
cluster_name = None
if cluster:
h, u, k, d, cluster_name = get_info_from_ray_cluster_config(cluster)
ssh_user = ssh_user or u
ssh_key = ssh_key or k
docker = docker or d
hosts = host.split(",") if host else h
if not hosts:
raise LocalCommandFailed(
f"Invalid cluster file or cluster has no running nodes: " f"{cluster}"
)
elif host:
hosts = host.split(",")
else:
raise LocalCommandFailed(
"You need to either specify a `<cluster_config>` or `--host`."
)
if not ssh_user:
ssh_user = DEFAULT_SSH_USER
cli_logger.warning(
f"Using default SSH user `{ssh_user}`. "
f"If this is incorrect, specify with `--ssh-user <user>`"
)
if not ssh_key:
for cand_key in DEFAULT_SSH_KEYS:
cand_key_file = os.path.expanduser(cand_key)
if os.path.exists(cand_key_file):
ssh_key = cand_key_file
cli_logger.warning(
f"Auto detected SSH key file: {ssh_key}. "
f"If this is incorrect, specify with `--ssh-key <key>`"
)
break
return cluster, hosts, ssh_user, ssh_key, docker, cluster_name
@@ -0,0 +1,966 @@
import hashlib
import json
import logging
import os
import subprocess
import sys
import time
from getpass import getuser
from shlex import quote
from typing import Dict, List, Optional, Tuple
import click
from ray._private.ray_constants import DEFAULT_OBJECT_STORE_MAX_MEMORY_BYTES
from ray.autoscaler._private.cli_logger import cf, cli_logger
from ray.autoscaler._private.constants import (
AUTOSCALER_NODE_SSH_INTERVAL_S,
AUTOSCALER_NODE_START_WAIT_S,
DEFAULT_OBJECT_STORE_MEMORY_PROPORTION,
)
from ray.autoscaler._private.docker import (
check_bind_mounts_cmd,
check_docker_image,
check_docker_running_cmd,
docker_start_cmds,
with_docker_exec,
)
from ray.autoscaler._private.log_timer import LogTimer
from ray.autoscaler._private.subprocess_output_util import (
ProcessRunnerError,
is_output_redirected,
run_cmd_redirected,
)
from ray.autoscaler.command_runner import CommandRunnerInterface
logger = logging.getLogger(__name__)
# How long to wait for a node to start, in seconds
HASH_MAX_LENGTH = 10
KUBECTL_RSYNC = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "_kubernetes/kubectl-rsync.sh"
)
MAX_HOME_RETRIES = 3
HOME_RETRY_DELAY_S = 5
_config = {"use_login_shells": True, "silent_rsync": True}
def is_rsync_silent():
return _config["silent_rsync"]
def set_rsync_silent(val):
"""Choose whether to silence rsync output.
Most commands will want to list rsync'd files themselves rather than
print the default rsync spew.
"""
_config["silent_rsync"] = val
def is_using_login_shells():
return _config["use_login_shells"]
def set_using_login_shells(val: bool):
"""Choose between login and non-interactive shells.
Non-interactive shells have the benefit of receiving less output from
subcommands (since progress bars and TTY control codes are not printed).
Sometimes this can be significant since e.g. `pip install` prints
hundreds of progress bar lines when downloading.
Login shells have the benefit of working very close to how a proper bash
session does, regarding how scripts execute and how the environment is
setup. This is also how all commands were ran in the past. The only reason
to use login shells over non-interactive shells is if you need some weird
and non-robust tool to work.
Args:
val: If true, login shells will be used to run all commands.
"""
_config["use_login_shells"] = val
def _with_environment_variables(cmd: str, environment_variables: Dict[str, object]):
"""Prepend environment variables to a shell command.
Args:
cmd: The base command.
environment_variables: The set of environment variables. If an
environment variable value is a dict, it will automatically be
converted to a one line yaml string.
Returns:
The base command prefixed with `export` statements for each variable.
"""
as_strings = []
for key, val in environment_variables.items():
val = json.dumps(val, separators=(",", ":"))
s = "export {}={};".format(key, quote(val))
as_strings.append(s)
all_vars = "".join(as_strings)
return all_vars + cmd
def _with_interactive(cmd):
force_interactive = (
f"source ~/.bashrc; "
f"export OMP_NUM_THREADS=1 PYTHONWARNINGS=ignore && ({cmd})"
)
return ["bash", "--login", "-c", "-i", quote(force_interactive)]
class SSHOptions:
def __init__(self, ssh_key, control_path=None, **kwargs):
self.ssh_key = ssh_key
self.arg_dict = {
# Supresses initial fingerprint verification.
"StrictHostKeyChecking": "no",
# SSH IP and fingerprint pairs no longer added to known_hosts.
# This is to remove a "REMOTE HOST IDENTIFICATION HAS CHANGED"
# warning if a new node has the same IP as a previously
# deleted node, because the fingerprints will not match in
# that case.
"UserKnownHostsFile": os.devnull,
# Try fewer extraneous key pairs.
"IdentitiesOnly": "yes",
# Abort if port forwarding fails (instead of just printing to
# stderr).
"ExitOnForwardFailure": "yes",
# Quickly kill the connection if network connection breaks (as
# opposed to hanging/blocking).
"ServerAliveInterval": 5,
"ServerAliveCountMax": 3,
}
if control_path:
if sys.platform == "win32":
# Don't set any control path options on Windows
pass
else:
self.arg_dict.update(
{
"ControlMaster": "auto",
"ControlPath": "{}/%C".format(control_path),
"ControlPersist": "10s",
}
)
self.arg_dict.update(kwargs)
def to_ssh_options_list(self, *, timeout=60):
self.arg_dict["ConnectTimeout"] = "{}s".format(timeout)
ssh_key_option = ["-i", self.ssh_key] if self.ssh_key else []
return ssh_key_option + [
x
for y in (
["-o", "{}={}".format(k, v)]
for k, v in self.arg_dict.items()
if v is not None
)
for x in y
]
class SSHCommandRunner(CommandRunnerInterface):
def __init__(
self,
log_prefix,
node_id,
provider,
auth_config,
cluster_name,
process_runner,
use_internal_ip,
):
ssh_control_hash = hashlib.sha256(cluster_name.encode()).hexdigest()
ssh_user_hash = hashlib.sha256(getuser().encode()).hexdigest()
if sys.platform == "win32":
# Disable SSH control paths on Windows - currently using it cause socket errors
ssh_control_path = None
else:
ssh_control_path = "/tmp/ray_ssh_{}/{}".format(
ssh_user_hash[:HASH_MAX_LENGTH], ssh_control_hash[:HASH_MAX_LENGTH]
)
self.cluster_name = cluster_name
self.log_prefix = log_prefix
self.process_runner = process_runner
self.node_id = node_id
self.use_internal_ip = use_internal_ip
self.provider = provider
self.ssh_private_key = auth_config.get("ssh_private_key")
self.ssh_user = auth_config["ssh_user"]
self.ssh_control_path = ssh_control_path
self.ssh_ip = None
self.ssh_proxy_command = auth_config.get("ssh_proxy_command", None)
self.ssh_options = SSHOptions(
self.ssh_private_key,
self.ssh_control_path,
ProxyCommand=self.ssh_proxy_command,
)
def _get_node_ip(self):
if self.use_internal_ip:
return self.provider.internal_ip(self.node_id)
else:
return self.provider.external_ip(self.node_id)
def _wait_for_ip(self, deadline):
# if we have IP do not print waiting info
ip = self._get_node_ip()
if ip is not None:
cli_logger.labeled_value("Fetched IP", ip)
return ip
interval = AUTOSCALER_NODE_SSH_INTERVAL_S
with cli_logger.group("Waiting for IP"):
while time.time() < deadline and not self.provider.is_terminated(
self.node_id
):
ip = self._get_node_ip()
if ip is not None:
cli_logger.labeled_value("Received", ip)
return ip
cli_logger.print(
"Not yet available, retrying in {} seconds", cf.bold(str(interval))
)
time.sleep(interval)
return None
def _set_ssh_ip_if_required(self):
if self.ssh_ip is not None:
return
# We assume that this never changes.
# I think that's reasonable.
deadline = time.time() + AUTOSCALER_NODE_START_WAIT_S
with LogTimer(self.log_prefix + "Got IP"):
ip = self._wait_for_ip(deadline)
cli_logger.doassert(ip is not None, "Could not get node IP.") # todo: msg
assert ip is not None, "Unable to find IP of node"
self.ssh_ip = ip
# This should run before any SSH commands and therefore ensure that
# the ControlPath directory exists, allowing SSH to maintain
# persistent sessions later on.
if self.ssh_control_path is not None:
try:
os.makedirs(self.ssh_control_path, mode=0o700, exist_ok=True)
except OSError as e:
cli_logger.warning("{}", str(e)) # todo: msg
def _run_helper(
self,
final_cmd: List[str],
with_output: bool = False,
exit_on_fail: bool = False,
silent: bool = False,
):
"""Run a command that was already setup with SSH and `bash` settings.
Args:
final_cmd: Full command to run. Should include SSH options and
other processing that we do.
with_output: If `with_output` is `True`, command stdout will be
captured and returned.
exit_on_fail: If `exit_on_fail` is `True`, the process will exit
if the command fails (exits with a code other than 0).
silent: If true, the command output will be silenced.
Returns:
Captured stdout bytes when `with_output` is True, otherwise the
return value of ``run_cmd_redirected`` (typically the return code).
Raises:
ProcessRunnerError: If using new log style and disabled
login shells.
click.ClickException: If using login shells.
"""
try:
# For now, if the output is needed we just skip the new logic.
# In the future we could update the new logic to support
# capturing output, but it is probably not needed.
if not with_output:
return run_cmd_redirected(
final_cmd,
process_runner=self.process_runner,
silent=silent,
use_login_shells=is_using_login_shells(),
)
else:
return self.process_runner.check_output(final_cmd)
except subprocess.CalledProcessError as e:
joined_cmd = " ".join(final_cmd)
if not is_using_login_shells():
raise ProcessRunnerError(
"Command failed",
"ssh_command_failed",
code=e.returncode,
command=joined_cmd,
)
if exit_on_fail:
raise click.ClickException(
"Command failed:\n\n {}\n".format(joined_cmd)
) from None
else:
fail_msg = "SSH command failed."
if is_output_redirected():
fail_msg += " See above for the output from the failure."
raise click.ClickException(fail_msg) from None
finally:
# Do our best to flush output to terminal.
# See https://github.com/ray-project/ray/pull/19473.
sys.stdout.flush()
sys.stderr.flush()
def run(
self,
cmd: Optional[str] = None,
timeout: int = 120,
exit_on_fail: bool = False,
port_forward: Optional[List[Tuple[int, int]]] = None,
with_output: bool = False,
environment_variables: Optional[Dict[str, object]] = None,
run_env: str = "auto", # Unused argument.
ssh_options_override_ssh_key: str = "",
shutdown_after_run: bool = False,
silent: bool = False,
) -> str:
if shutdown_after_run:
cmd += "; sudo shutdown -h now"
if ssh_options_override_ssh_key:
if self.ssh_proxy_command:
ssh_options = SSHOptions(
ssh_options_override_ssh_key, ProxyCommand=self.ssh_proxy_command
)
else:
ssh_options = SSHOptions(ssh_options_override_ssh_key)
else:
ssh_options = self.ssh_options
assert isinstance(
ssh_options, SSHOptions
), "ssh_options must be of type SSHOptions, got {}".format(type(ssh_options))
self._set_ssh_ip_if_required()
if is_using_login_shells():
ssh = ["ssh", "-tt"]
else:
ssh = ["ssh"]
if port_forward:
with cli_logger.group("Forwarding ports"):
if not isinstance(port_forward, list):
port_forward = [port_forward]
for local, remote in port_forward:
cli_logger.verbose(
"Forwarding port {} to port {} on localhost.",
cf.bold(local),
cf.bold(remote),
) # todo: msg
ssh += ["-L", "{}:localhost:{}".format(local, remote)]
final_cmd = (
ssh
+ ssh_options.to_ssh_options_list(timeout=timeout)
+ ["{}@{}".format(self.ssh_user, self.ssh_ip)]
)
if cmd:
if environment_variables:
cmd = _with_environment_variables(cmd, environment_variables)
if is_using_login_shells():
final_cmd += _with_interactive(cmd)
else:
final_cmd += [cmd]
else:
# We do this because `-o ControlMaster` causes the `-N` flag to
# still create an interactive shell in some ssh versions.
final_cmd.append("while true; do sleep 86400; done")
cli_logger.verbose("Running `{}`", cf.bold(cmd))
with cli_logger.indented():
cli_logger.very_verbose(
"Full command is `{}`", cf.bold(" ".join(final_cmd))
)
if cli_logger.verbosity > 0:
with cli_logger.indented():
return self._run_helper(
final_cmd, with_output, exit_on_fail, silent=silent
)
else:
return self._run_helper(final_cmd, with_output, exit_on_fail, silent=silent)
def _create_rsync_filter_args(self, options):
rsync_excludes = options.get("rsync_exclude") or []
rsync_filters = options.get("rsync_filter") or []
exclude_args = [
["--exclude", rsync_exclude] for rsync_exclude in rsync_excludes
]
filter_args = [
["--filter", "dir-merge,- {}".format(rsync_filter)]
for rsync_filter in rsync_filters
]
# Combine and flatten the two lists
return [arg for args_list in exclude_args + filter_args for arg in args_list]
def run_rsync_up(self, source, target, options=None):
self._set_ssh_ip_if_required()
options = options or {}
# on windows use scp -r instead of rsync
if sys.platform == "win32":
# Use scp as fallback for Windows
command = ["scp", "-r"]
command += self.ssh_options.to_ssh_options_list(timeout=120)
command += [source, "{}@{}:{}".format(self.ssh_user, self.ssh_ip, target)]
else:
command = ["rsync"]
command += [
"--rsh",
subprocess.list2cmdline(
["ssh"] + self.ssh_options.to_ssh_options_list(timeout=120)
),
]
command += ["-avz"]
command += self._create_rsync_filter_args(options=options)
command += [source, "{}@{}:{}".format(self.ssh_user, self.ssh_ip, target)]
cli_logger.verbose("Running `{}`", cf.bold(" ".join(command)))
self._run_helper(command, silent=is_rsync_silent())
def run_rsync_down(self, source, target, options=None):
self._set_ssh_ip_if_required()
# on Windows use scp -r instead of rsync
if sys.platform == "win32":
# Use scp as fallback for Windows
command = ["scp", "-r"]
command += self.ssh_options.to_ssh_options_list(timeout=120)
command += ["{}@{}:{}".format(self.ssh_user, self.ssh_ip, source), target]
else:
command = ["rsync"]
command += [
"--rsh",
subprocess.list2cmdline(
["ssh"] + self.ssh_options.to_ssh_options_list(timeout=120)
),
]
command += ["-avz"]
command += self._create_rsync_filter_args(options=options)
command += ["{}@{}:{}".format(self.ssh_user, self.ssh_ip, source), target]
cli_logger.verbose("Running `{}`", cf.bold(" ".join(command)))
self._run_helper(command, silent=is_rsync_silent())
def remote_shell_command_str(self):
if self.ssh_private_key:
return "ssh -o IdentitiesOnly=yes -i {} {}@{}\n".format(
self.ssh_private_key, self.ssh_user, self.ssh_ip
)
else:
return "ssh -o IdentitiesOnly=yes {}@{}\n".format(
self.ssh_user, self.ssh_ip
)
class DockerCommandRunner(CommandRunnerInterface):
def __init__(self, docker_config, **common_args):
self.ssh_command_runner = SSHCommandRunner(**common_args)
self.container_name = docker_config["container_name"]
self.docker_config = docker_config
self.home_dir = None
self.initialized = False
# Optionally use 'podman' instead of 'docker'
use_podman = docker_config.get("use_podman", False)
self.docker_cmd = "podman" if use_podman else "docker"
def run(
self,
cmd: Optional[str] = None,
timeout: int = 120,
exit_on_fail: bool = False,
port_forward: Optional[List[Tuple[int, int]]] = None,
with_output: bool = False,
environment_variables: Optional[Dict[str, object]] = None,
run_env: str = "auto",
ssh_options_override_ssh_key: str = "",
shutdown_after_run: bool = False,
) -> str:
if run_env == "auto":
run_env = (
"host"
if (not bool(cmd) or cmd.find(self.docker_cmd) == 0)
else self.docker_cmd
)
if environment_variables:
cmd = _with_environment_variables(cmd, environment_variables)
if run_env == self.docker_cmd:
cmd = self._docker_expand_user(cmd, any_char=True)
if is_using_login_shells():
cmd = " ".join(_with_interactive(cmd))
cmd = with_docker_exec(
[cmd],
container_name=self.container_name,
with_interactive=is_using_login_shells(),
docker_cmd=self.docker_cmd,
)[0]
if shutdown_after_run:
# sudo shutdown should run after `with_docker_exec` command above
cmd += "; sudo shutdown -h now"
# Do not pass shutdown_after_run argument to ssh_command_runner.run()
# since it is handled above.
return self.ssh_command_runner.run(
cmd,
timeout=timeout,
exit_on_fail=exit_on_fail,
port_forward=port_forward,
with_output=with_output,
ssh_options_override_ssh_key=ssh_options_override_ssh_key,
)
def run_rsync_up(self, source, target, options=None):
options = options or {}
host_destination = os.path.join(
self._get_docker_host_mount_location(self.ssh_command_runner.cluster_name),
target.lstrip("/"),
)
host_mount_location = os.path.dirname(host_destination.rstrip("/"))
if sys.platform == "win32":
# fix paths if running on Windows
source = source.replace("\\", "/")
host_mount_location = host_mount_location.replace("\\", "/")
host_destination = host_destination.replace("\\", "/")
self.ssh_command_runner.run(
f"mkdir -p {host_mount_location} && chown -R "
f"{self.ssh_command_runner.ssh_user} {host_mount_location}",
silent=is_rsync_silent(),
)
self.ssh_command_runner.run_rsync_up(source, host_destination, options=options)
if self._check_container_status() and not options.get(
"docker_mount_if_possible", False
):
if os.path.isdir(source):
# Adding a "." means that docker copies the *contents*
# Without it, docker copies the source *into* the target
host_destination += "/."
# This path may not exist inside the container. This ensures
# that the path is created!
prefix = with_docker_exec(
[
"mkdir -p {}".format(
os.path.dirname(self._docker_expand_user(target))
)
],
container_name=self.container_name,
with_interactive=is_using_login_shells(),
docker_cmd=self.docker_cmd,
)[0]
self.ssh_command_runner.run(
"{} && rsync -e '{} exec -i' -avz {} {}:{}".format(
prefix,
self.docker_cmd,
host_destination,
self.container_name,
self._docker_expand_user(target),
),
silent=is_rsync_silent(),
)
def run_rsync_down(self, source, target, options=None):
options = options or {}
host_source = os.path.join(
self._get_docker_host_mount_location(self.ssh_command_runner.cluster_name),
source.lstrip("/"),
)
host_mount_location = os.path.dirname(host_source.rstrip("/"))
# Convert Windows paths to Unix-style for remote commands
host_mount_location_unix = host_mount_location.replace("\\", "/")
self.ssh_command_runner.run(
f"mkdir -p {host_mount_location_unix} && chown -R "
f"{self.ssh_command_runner.ssh_user} {host_mount_location_unix}",
silent=is_rsync_silent(),
)
if source[-1] == "/":
source += "."
# Adding a "." means that docker copies the *contents*
# Without it, docker copies the source *into* the target
if not options.get("docker_mount_if_possible", False):
# NOTE: `--delete` is okay here because the container is the source
# of truth.
self.ssh_command_runner.run(
"rsync -e '{} exec -i' -avz --delete {}:{} {}".format(
self.docker_cmd,
self.container_name,
self._docker_expand_user(source),
host_source.replace(
"\\", "/"
), # Convert Windows paths to Unix-style for rsync
),
silent=is_rsync_silent(),
)
self.ssh_command_runner.run_rsync_down(host_source, target, options=options)
def remote_shell_command_str(self):
inner_str = (
self.ssh_command_runner.remote_shell_command_str()
.replace("ssh", "ssh -tt", 1)
.strip("\n")
)
return inner_str + " {} exec -it {} /bin/bash\n".format(
self.docker_cmd, self.container_name
)
def _check_docker_installed(self):
no_exist = "NoExist"
output = self.ssh_command_runner.run(
f"command -v {self.docker_cmd} || echo '{no_exist}'", with_output=True
)
cleaned_output = output.decode().strip()
if no_exist in cleaned_output or "docker" not in cleaned_output:
if self.docker_cmd == "docker":
install_commands = [
"curl -fsSL https://get.docker.com -o get-docker.sh",
"sudo sh get-docker.sh",
"sudo usermod -aG docker $USER",
"sudo systemctl restart docker -f",
]
else:
install_commands = [
"sudo apt-get update",
"sudo apt-get -y install podman",
]
logger.error(
f"{self.docker_cmd.capitalize()} not installed. You can "
f"install {self.docker_cmd.capitalize()} by adding the "
"following commands to 'initialization_commands':\n"
+ "\n".join(install_commands)
)
def _check_container_status(self):
if self.initialized:
return True
output = (
self.ssh_command_runner.run(
check_docker_running_cmd(self.container_name, self.docker_cmd),
with_output=True,
)
.decode("utf-8")
.strip()
)
# Checks for the false positive where "true" is in the container name
return "true" in output.lower() and "no such object" not in output.lower()
def _docker_expand_user(self, string, any_char=False):
user_pos = string.find("~")
if user_pos > -1:
if self.home_dir is None:
self.home_dir = (
self.ssh_command_runner.run(
f"{self.docker_cmd} exec {self.container_name} "
"printenv HOME",
with_output=True,
)
.decode("utf-8")
.strip()
)
if any_char:
return string.replace("~/", self.home_dir + "/")
elif not any_char and user_pos == 0:
return string.replace("~", self.home_dir, 1)
return string
def _check_if_container_restart_is_needed(
self, image: str, cleaned_bind_mounts: Dict[str, str]
) -> bool:
re_init_required = False
running_image = (
self.run(
check_docker_image(self.container_name, self.docker_cmd),
with_output=True,
run_env="host",
)
.decode("utf-8")
.strip()
)
if running_image != image:
cli_logger.error(
"A container with name {} is running image {} instead "
+ "of {} (which was provided in the YAML)",
self.container_name,
running_image,
image,
)
mounts = (
self.run(
check_bind_mounts_cmd(self.container_name, self.docker_cmd),
with_output=True,
run_env="host",
)
.decode("utf-8")
.strip()
)
try:
active_mounts = json.loads(mounts)
active_remote_mounts = {
mnt["Destination"].strip("/") for mnt in active_mounts
}
# Ignore ray bootstrap files.
requested_remote_mounts = {
self._docker_expand_user(remote).strip("/")
for remote in cleaned_bind_mounts.keys()
}
unfulfilled_mounts = requested_remote_mounts - active_remote_mounts
if unfulfilled_mounts:
re_init_required = True
cli_logger.warning(
"This Docker Container is already running. "
"Restarting the Docker container on "
"this node to pick up the following file_mounts {}",
unfulfilled_mounts,
)
except json.JSONDecodeError:
cli_logger.verbose(
"Unable to check if file_mounts specified in the YAML "
"differ from those on the running container."
)
return re_init_required
def run_init(
self, *, as_head: bool, file_mounts: Dict[str, str], sync_run_yet: bool
):
BOOTSTRAP_MOUNTS = ["~/ray_bootstrap_config.yaml", "~/ray_bootstrap_key.pem"]
specific_image = self.docker_config.get(
f"{'head' if as_head else 'worker'}_image", self.docker_config.get("image")
)
self._check_docker_installed()
if self.docker_config.get("pull_before_run", True):
assert specific_image, (
"Image must be included in config if " + "pull_before_run is specified"
)
self.run(
"{} pull {}".format(self.docker_cmd, specific_image), run_env="host"
)
else:
self.run(
f"{self.docker_cmd} image inspect {specific_image} "
"1> /dev/null 2>&1 || "
f"{self.docker_cmd} pull {specific_image}"
)
# Bootstrap files cannot be bind mounted because docker opens the
# underlying inode. When the file is switched, docker becomes outdated.
cleaned_bind_mounts = file_mounts.copy()
for mnt in BOOTSTRAP_MOUNTS:
cleaned_bind_mounts.pop(mnt, None)
docker_run_executed = False
container_running = self._check_container_status()
requires_re_init = False
if container_running:
requires_re_init = self._check_if_container_restart_is_needed(
specific_image, cleaned_bind_mounts
)
if requires_re_init:
docker_stop_cmd = f"{self.docker_cmd} stop {self.container_name}"
logger.info("Executing Docker command: %s", docker_stop_cmd)
self.run(docker_stop_cmd, run_env="host")
if (not container_running) or requires_re_init:
if not sync_run_yet:
# Do not start the actual image as we need to run file_sync
# first to ensure that all folders are created with the
# correct ownership. Docker will create the folders with
# `root` as the owner.
return True
# Get home directory
image_env = (
self.ssh_command_runner.run(
f"{self.docker_cmd} "
+ "inspect -f '{{json .Config.Env}}' "
+ specific_image,
with_output=True,
)
.decode()
.strip()
)
home_directory = "/root"
try:
for env_var in json.loads(image_env):
if env_var.startswith("HOME="):
home_directory = env_var.split("HOME=")[1]
break
except json.JSONDecodeError as e:
cli_logger.error(
"Unable to deserialize `image_env` to Python object. "
f"The `image_env` is:\n{image_env}"
)
raise e
user_docker_run_options = self.docker_config.get(
"run_options", []
) + self.docker_config.get(
f"{'head' if as_head else 'worker'}_run_options", []
)
start_command = docker_start_cmds(
self.ssh_command_runner.ssh_user,
specific_image,
cleaned_bind_mounts,
self.container_name,
self._configure_runtime(
self._auto_configure_shm(user_docker_run_options)
),
self.ssh_command_runner.cluster_name,
home_directory,
self.docker_cmd,
)
self.run(start_command, run_env="host")
docker_run_executed = True
# Explicitly copy in ray bootstrap files.
for mount in BOOTSTRAP_MOUNTS:
if mount in file_mounts:
if not sync_run_yet:
# NOTE(ilr) This rsync is needed because when starting from
# a stopped instance, /tmp may be deleted and `run_init`
# is called before the first `file_sync` happens
self.run_rsync_up(file_mounts[mount], mount)
self.ssh_command_runner.run(
"rsync -e '{cmd} exec -i' -avz {src} {container}:{dst}".format(
cmd=self.docker_cmd,
src=os.path.join(
self._get_docker_host_mount_location(
self.ssh_command_runner.cluster_name
),
mount,
).replace(
"\\", "/"
), # Convert Windows paths to Unix-style for rsync
container=self.container_name,
dst=self._docker_expand_user(mount),
)
)
try:
# Check if the current user has read permission.
# If they do not, try to change ownership!
self.run(
f"cat {mount} >/dev/null 2>&1 || "
f"sudo chown $(id -u):$(id -g) {mount}"
)
except Exception:
lsl_string = (
self.run(f"ls -l {mount}", with_output=True)
.decode("utf-8")
.strip()
)
# The string is of format <Permission> <Links>
# <Owner> <Group> <Size> <Date> <Name>
permissions = lsl_string.split(" ")[0]
owner = lsl_string.split(" ")[2]
group = lsl_string.split(" ")[3]
current_user = (
self.run("whoami", with_output=True).decode("utf-8").strip()
)
cli_logger.warning(
f"File ({mount}) is owned by user:{owner} and group:"
f"{group} with permissions ({permissions}). The "
f"current user ({current_user}) does not have "
"permission to read these files, and Ray may not be "
"able to autoscale. This can be resolved by "
"installing `sudo` in your container, or adding a "
f"command like 'chown {current_user} {mount}' to "
"your `setup_commands`."
)
self.initialized = True
return docker_run_executed
def _configure_runtime(self, run_options: List[str]) -> List[str]:
if self.docker_config.get("disable_automatic_runtime_detection"):
return run_options
runtime_output = (
self.ssh_command_runner.run(
f"{self.docker_cmd} " + "info -f '{{.Runtimes}}' ", with_output=True
)
.decode()
.strip()
)
if "nvidia-container-runtime" in runtime_output:
try:
self.ssh_command_runner.run("nvidia-smi", with_output=False)
return run_options + ["--runtime=nvidia"]
except Exception as e:
logger.warning(
"NVIDIA Container Runtime is present, but no GPUs found."
)
logger.debug(f"nvidia-smi error: {e}")
return run_options
return run_options
def _auto_configure_shm(self, run_options: List[str]) -> List[str]:
if self.docker_config.get("disable_shm_size_detection"):
return run_options
for run_opt in run_options:
if "--shm-size" in run_opt:
logger.info(
"Bypassing automatic SHM-Detection because of "
f"`run_option`: {run_opt}"
)
return run_options
try:
shm_output = (
self.ssh_command_runner.run(
"cat /proc/meminfo || true", with_output=True
)
.decode()
.strip()
)
available_memory = int(
[ln for ln in shm_output.split("\n") if "MemAvailable" in ln][
0
].split()[1]
)
available_memory_bytes = available_memory * 1024
# Overestimate SHM size by 10%
shm_size = min(
(available_memory_bytes * DEFAULT_OBJECT_STORE_MEMORY_PROPORTION * 1.1),
DEFAULT_OBJECT_STORE_MAX_MEMORY_BYTES,
)
return run_options + [f"--shm-size='{shm_size}b'"]
except Exception as e:
logger.warning(f"Received error while trying to auto-compute SHM size {e}")
return run_options
def _get_docker_host_mount_location(self, cluster_name: str) -> str:
"""Return the docker host mount directory location."""
# Imported here due to circular dependency in imports.
from ray.autoscaler.sdk import get_docker_host_mount_location
return get_docker_host_mount_location(cluster_name)
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
import os
import sys
from ray._private.ray_constants import ( # noqa F401
AGENT_PROCESS_TYPE_DASHBOARD_AGENT,
AGENT_PROCESS_TYPE_RUNTIME_ENV_AGENT,
AUTOSCALER_RESOURCE_REQUEST_CHANNEL,
DEFAULT_OBJECT_STORE_MEMORY_PROPORTION,
LABELS_ENVIRONMENT_VARIABLE,
LOGGER_FORMAT,
RESOURCES_ENVIRONMENT_VARIABLE,
)
def env_integer(key, default):
if key in os.environ:
val = os.environ[key]
if val == "inf":
return sys.maxsize
else:
return int(val)
return default
# Whether autoscaler cluster status logging is enabled. Set to 0 disable.
AUTOSCALER_STATUS_LOG = env_integer("RAY_ENABLE_CLUSTER_STATUS_LOG", 1)
# The name of the environment variable for plugging in a utilization scorer.
AUTOSCALER_UTILIZATION_SCORER_KEY = "RAY_AUTOSCALER_UTILIZATION_SCORER"
# Whether to avoid launching GPU nodes for CPU only tasks.
AUTOSCALER_CONSERVE_GPU_NODES = env_integer("AUTOSCALER_CONSERVE_GPU_NODES", 1)
# How long to wait for a node to start and terminate, in seconds.
AUTOSCALER_NODE_START_WAIT_S = env_integer("AUTOSCALER_NODE_START_WAIT_S", 900)
AUTOSCALER_NODE_TERMINATE_WAIT_S = env_integer("AUTOSCALER_NODE_TERMINATE_WAIT_S", 900)
# Interval at which to check if node SSH became available.
AUTOSCALER_NODE_SSH_INTERVAL_S = env_integer("AUTOSCALER_NODE_SSH_INTERVAL_S", 5)
# Abort autoscaling if more than this number of errors are encountered. This
# is a safety feature to prevent e.g. runaway node launches.
AUTOSCALER_MAX_NUM_FAILURES = env_integer("AUTOSCALER_MAX_NUM_FAILURES", 5)
# The maximum number of nodes to launch in a single request.
# Multiple requests may be made for this batch size, up to
# the limit of AUTOSCALER_MAX_CONCURRENT_LAUNCHES.
AUTOSCALER_MAX_LAUNCH_BATCH = env_integer("AUTOSCALER_MAX_LAUNCH_BATCH", 5)
# Max number of nodes to launch at a time.
AUTOSCALER_MAX_CONCURRENT_LAUNCHES = env_integer(
"AUTOSCALER_MAX_CONCURRENT_LAUNCHES", 10
)
# Default upscaling speed for the autoscaler. This specifies how many nodes
# to request at a time, where the desired number to upscale is
# min(1, upscaling_speed * current_num_nodes)
# e.g. 1.0 means to request enough nodes to double
# the cluster size in each round of requests.
# When the upscaling speed is 0.0, the autoscaler will request 1 node.
DEFAULT_UPSCALING_SPEED = 0.0
# Interval at which to perform autoscaling updates.
AUTOSCALER_UPDATE_INTERVAL_S = env_integer("AUTOSCALER_UPDATE_INTERVAL_S", 5)
# The autoscaler will attempt to restart Ray on nodes it hasn't heard from
# in more than this interval.
AUTOSCALER_HEARTBEAT_TIMEOUT_S = env_integer("AUTOSCALER_HEARTBEAT_TIMEOUT_S", 30)
# The maximum number of nodes (including failed nodes) that the autoscaler will
# track for logging purposes.
AUTOSCALER_MAX_NODES_TRACKED = 1500
AUTOSCALER_MAX_FAILURES_DISPLAYED = 20
AUTOSCALER_NODE_AVAILABILITY_MAX_STALENESS_S = env_integer(
"AUTOSCALER_NODE_AVAILABILITY_MAX_STALENESS_S", 30 * 60
)
# The window during which the recoverable cloud resource availability score linearly recovers from 0.0 to 1.0.
RAY_AUTOSCALER_AVAILABILITY_RECOVERY_S = env_integer(
"RAY_AUTOSCALER_AVAILABILITY_RECOVERY_S", 600
)
AUTOSCALER_REPORT_PER_NODE_STATUS = (
env_integer("AUTOSCALER_REPORT_PER_NODE_STATUS", 1) == 1
)
# The maximum allowed resource demand vector size to guarantee the resource
# demand scheduler bin packing algorithm takes a reasonable amount of time
# to run.
AUTOSCALER_MAX_RESOURCE_DEMAND_VECTOR_SIZE = env_integer(
"AUTOSCALER_MAX_RESOURCE_DEMAND_VECTOR_SIZE", 1000
)
# Port that autoscaler prometheus metrics will be exported to
AUTOSCALER_METRIC_PORT = env_integer("AUTOSCALER_METRIC_PORT", 44217)
# The minimum number of nodes to launch concurrently.
AUTOSCALER_UPSCALING_INITIAL_NUM_NODES = 5
# Max number of retries to AWS (default is 5, time increases exponentially)
BOTO_MAX_RETRIES = env_integer("BOTO_MAX_RETRIES", 12)
# Max number of retries to create an EC2 node (retry different subnet)
BOTO_CREATE_MAX_RETRIES = env_integer("BOTO_CREATE_MAX_RETRIES", 5)
# ray home path in the container image
RAY_HOME = "/home/ray"
# The order of this list matters! `scripts.py` kills the ray processes in order of this
# list. Think twice when you add to this list.
# Invariants:
# RAYLET must be the first in the list.
# GCS SERVER must be the last in the list.
RAY_PROCESSES = [
# The first element is the substring to filter.
# The second element, if True, is to filter ps results by command name
# (only the first 15 charactors of the executable name on Linux);
# if False, is to filter ps results by command with all its arguments.
# See STANDARD FORMAT SPECIFIERS section of
# http://man7.org/linux/man-pages/man1/ps.1.html
# about comm and args. This can help avoid killing non-ray processes.
# Format:
# Keyword to filter, filter by command (True)/filter by args (False)
["raylet", True],
["plasma_store", True],
["monitor.py", False],
["ray.util.client.server", False],
["default_worker.py", False], # Python worker.
["setup_worker.py", False], # Python environment setup worker.
# For mac osx, setproctitle doesn't change the process name returned
# by psutil but only cmdline.
[
"ray::",
sys.platform != "darwin",
], # Python worker. TODO(mehrdadn): Fix for Windows
["io.ray.runtime.runner.worker.DefaultWorker", False], # Java worker.
["log_monitor.py", False],
[AGENT_PROCESS_TYPE_DASHBOARD_AGENT, False],
[os.path.join("dashboard", "dashboard.py"), False],
[AGENT_PROCESS_TYPE_RUNTIME_ENV_AGENT, False],
# On Windows, setproctitle does not change the process name or command line
# visible to psutil, so the "ray::DashboardAgent" / "ray::RuntimeEnvAgent"
# keywords above will never match. Add fallback entries that match the
# actual script paths in the command line. See
# https://github.com/ray-project/ray/issues/61452
*(
[
[os.path.join("dashboard", "agent.py"), False],
[os.path.join("runtime_env", "agent", "main.py"), False],
]
if sys.platform == "win32"
else []
),
["ray_process_reaper.py", False],
["gcs_server", True],
]
# Max Concurrent SSH Calls to stop Docker
MAX_PARALLEL_SHUTDOWN_WORKERS = env_integer("MAX_PARALLEL_SHUTDOWN_WORKERS", 50)
DISABLE_NODE_UPDATERS_KEY = "disable_node_updaters"
DISABLE_LAUNCH_CONFIG_CHECK_KEY = "disable_launch_config_check"
FOREGROUND_NODE_LAUNCH_KEY = "foreground_node_launch"
WORKER_LIVENESS_CHECK_KEY = "worker_liveness_check"
+130
View File
@@ -0,0 +1,130 @@
from pathlib import Path
from typing import Any, Dict
from ray.autoscaler._private.cli_logger import cli_logger
try: # py3
from shlex import quote
except ImportError: # py2
from pipes import quote
def _check_docker_file_mounts(file_mounts: Dict[str, str]) -> None:
"""Checks if files are passed as file_mounts. This is a problem for Docker
based clusters because when a file is bind-mounted in Docker, updates to
the file on the host do not always propagate to the container. Using
directories is recommended.
"""
for remote, local in file_mounts.items():
if Path(local).is_file():
cli_logger.warning(
f"File Mount: ({remote}:{local}) refers to a file.\n To ensure"
" this mount updates properly, please use a directory."
)
def validate_docker_config(config: Dict[str, Any]) -> None:
"""Checks whether the Docker configuration is valid."""
if "docker" not in config:
return
_check_docker_file_mounts(config.get("file_mounts", {}))
docker_image = config["docker"].get("image")
cname = config["docker"].get("container_name")
head_docker_image = config["docker"].get("head_image", docker_image)
worker_docker_image = config["docker"].get("worker_image", docker_image)
image_present = docker_image or (head_docker_image and worker_docker_image)
if (not cname) and (not image_present):
return
else:
assert cname and image_present, "Must provide a container & image name"
return None
def with_docker_exec(
cmds, container_name, docker_cmd, env_vars=None, with_interactive=False
):
assert docker_cmd, "Must provide docker command"
env_str = ""
if env_vars:
env_str = " ".join(["-e {env}=${env}".format(env=env) for env in env_vars])
return [
"{docker_cmd} exec {interactive} {env} {container} /bin/bash -c {cmd} ".format(
docker_cmd=docker_cmd,
interactive="-it" if with_interactive else "",
env=env_str,
container=container_name,
cmd=quote(cmd),
)
for cmd in cmds
]
def _check_helper(cname, template, docker_cmd):
return " ".join(
[docker_cmd, "inspect", "-f", "'{{" + template + "}}'", cname, "||", "true"]
)
def check_docker_running_cmd(cname, docker_cmd):
return _check_helper(cname, ".State.Running", docker_cmd)
def check_bind_mounts_cmd(cname, docker_cmd):
return _check_helper(cname, "json .Mounts", docker_cmd)
def check_docker_image(cname, docker_cmd):
return _check_helper(cname, ".Config.Image", docker_cmd)
def docker_start_cmds(
user,
image,
mount_dict,
container_name,
user_options,
cluster_name,
home_directory,
docker_cmd,
):
# Imported here due to circular dependency.
from ray.autoscaler.sdk import get_docker_host_mount_location
docker_mount_prefix = get_docker_host_mount_location(cluster_name)
mount = {f"{docker_mount_prefix}/{dst}": dst for dst in mount_dict}
mount_flags = " ".join(
[
"-v {src}:{dest}".format(src=k, dest=v.replace("~/", home_directory + "/"))
for k, v in mount.items()
]
)
# for click, used in ray cli
env_vars = {"LC_ALL": "C.UTF-8", "LANG": "C.UTF-8"}
env_flags = " ".join(
["-e {name}={val}".format(name=k, val=v) for k, v in env_vars.items()]
)
user_options_str = " ".join(user_options)
docker_run = [
docker_cmd,
"run",
"--rm",
"--name {}".format(container_name),
"-d",
"-it",
mount_flags,
env_flags,
user_options_str,
"--net=host",
image,
"bash",
]
return " ".join(docker_run)
@@ -0,0 +1,75 @@
import time
from threading import RLock
from typing import Any, Callable, Dict, List
class EventSummarizer:
"""Utility that aggregates related log messages to reduce log spam."""
def __init__(self):
self.events_by_key: Dict[str, int] = {}
# Messages to send in next summary batch.
self.messages_to_send: List[str] = []
# Tracks TTL of messages. A message will not be re-sent once it is
# added here, until its TTL expires.
self.throttled_messages: Dict[str, float] = {}
# Event summarizer is used by the main thread and
# by node launcher child threads.
self.lock = RLock()
def add(
self, template: str, *, quantity: Any, aggregate: Callable[[Any, Any], Any]
) -> None:
"""Add a log message, which will be combined by template.
Args:
template: Format string with one placeholder for quantity.
quantity: Quantity to aggregate.
aggregate: Aggregation function used to combine the
quantities. The result is inserted into the template to
produce the final log message.
"""
with self.lock:
# Enforce proper sentence structure.
if not template.endswith("."):
template += "."
if template in self.events_by_key:
self.events_by_key[template] = aggregate(
self.events_by_key[template], quantity
)
else:
self.events_by_key[template] = quantity
def add_once_per_interval(self, message: str, key: str, interval_s: int):
"""Add a log message, which is throttled once per interval by a key.
Args:
message: The message to log.
key: The key to use to deduplicate the message.
interval_s: Throttling interval in seconds.
"""
with self.lock:
if key not in self.throttled_messages:
self.throttled_messages[key] = time.time() + interval_s
self.messages_to_send.append(message)
def summary(self) -> List[str]:
"""Generate the aggregated log summary of all added events."""
with self.lock:
out = []
for template, quantity in self.events_by_key.items():
out.append(template.format(quantity))
out.extend(self.messages_to_send)
return out
def clear(self) -> None:
"""Clear the events added."""
with self.lock:
self.events_by_key.clear()
self.messages_to_send.clear()
# Expire any messages that have reached their TTL. This allows them
# to be sent again.
for k, t in list(self.throttled_messages.items()):
if time.time() > t:
del self.throttled_messages[k]
@@ -0,0 +1,106 @@
from enum import Enum, auto
from typing import Any, Callable, Dict, List, Optional, Union
from ray.autoscaler._private.cli_logger import cli_logger
class CreateClusterEvent(Enum):
"""Events to track in ray.autoscaler.sdk.create_or_update_cluster.
Attributes:
up_started : Invoked at the beginning of create_or_update_cluster.
ssh_keypair_downloaded : Invoked when the ssh keypair is downloaded.
cluster_booting_started : Invoked when when the cluster booting starts.
acquiring_new_head_node : Invoked before the head node is acquired.
head_node_acquired : Invoked after the head node is acquired.
ssh_control_acquired : Invoked when the node is being updated.
run_initialization_cmd : Invoked before all initialization
commands are called and again before each initialization command.
run_setup_cmd : Invoked before all setup commands are
called and again before each setup command.
start_ray_runtime : Invoked before ray start commands are run.
start_ray_runtime_completed : Invoked after ray start commands
are run.
cluster_booting_completed : Invoked after cluster booting
is completed.
"""
up_started = auto()
ssh_keypair_downloaded = auto()
cluster_booting_started = auto()
acquiring_new_head_node = auto()
head_node_acquired = auto()
ssh_control_acquired = auto()
run_initialization_cmd = auto()
run_setup_cmd = auto()
start_ray_runtime = auto()
start_ray_runtime_completed = auto()
cluster_booting_completed = auto()
class _EventSystem:
"""Event system that handles storing and calling callbacks for events.
Attributes:
callback_map (Dict[str, List[Callable]]) : Stores list of callbacks
for events when registered.
"""
def __init__(self):
self.callback_map = {}
def add_callback_handler(
self,
event: str,
callback: Union[Callable[[Dict], None], List[Callable[[Dict], None]]],
):
"""Stores callback handler for event.
Args:
event: Event that callback should be called on. See
CreateClusterEvent for details on the events available to be
registered against.
callback: Callable object that is invoked
when specified event occurs.
"""
if event not in CreateClusterEvent.__members__.values():
cli_logger.warning(
f"{event} is not currently tracked, and this"
" callback will not be invoked."
)
self.callback_map.setdefault(event, []).extend(
[callback] if type(callback) is not list else callback
)
def execute_callback(
self, event: CreateClusterEvent, event_data: Optional[Dict[str, Any]] = None
):
"""Executes all callbacks for event.
Args:
event: Event that is invoked. See CreateClusterEvent
for details on the available events.
event_data: Argument that is passed to each
callable object stored for this particular event.
"""
if event_data is None:
event_data = {}
event_data["event_name"] = event
if event in self.callback_map:
for callback in self.callback_map[event]:
callback(event_data)
def clear_callbacks_for_event(self, event: str):
"""Clears stored callable objects for event.
Args:
event: Event that has callable objects stored in map.
See CreateClusterEvent for details on the available events.
"""
if event in self.callback_map:
del self.callback_map[event]
global_event_system = _EventSystem()
@@ -0,0 +1,91 @@
import os
import subprocess
from typing import Dict, List, Tuple
from ray.autoscaler._private.docker import with_docker_exec
from ray.autoscaler.command_runner import CommandRunnerInterface
class FakeDockerCommandRunner(CommandRunnerInterface):
"""Command runner for the fke docker multinode cluster.
This command runner uses ``docker exec`` and ``docker cp`` to
run commands and copy files, respectively.
The regular ``DockerCommandRunner`` is made for use in SSH settings
where Docker runs on a remote hose. In contrast, this command runner
does not wrap the docker commands in ssh calls.
"""
def __init__(self, docker_config, **common_args):
self.container_name = docker_config["container_name"]
self.docker_config = docker_config
self.home_dir = None
self.initialized = False
# Optionally use 'podman' instead of 'docker'
use_podman = docker_config.get("use_podman", False)
self.docker_cmd = "podman" if use_podman else "docker"
def _run_shell(self, cmd: str, timeout: int = 120) -> str:
return subprocess.check_output(
cmd, shell=True, timeout=timeout, encoding="utf-8"
)
def run(
self,
cmd: str = None,
timeout: int = 120,
exit_on_fail: bool = False,
port_forward: List[Tuple[int, int]] = None,
with_output: bool = False,
environment_variables: Dict[str, object] = None,
run_env: str = "auto",
ssh_options_override_ssh_key: str = "",
shutdown_after_run: bool = False,
) -> str:
prefix = with_docker_exec(
[cmd],
container_name=self.container_name,
with_interactive=False,
docker_cmd=self.docker_cmd,
)[0]
return self._run_shell(prefix)
def run_init(
self, *, as_head: bool, file_mounts: Dict[str, str], sync_run_yet: bool
):
pass
def remote_shell_command_str(self):
return "{} exec -it {} bash".format(self.docker_cmd, self.container_name)
def run_rsync_down(self, source, target, options=None):
docker_dir = os.path.dirname(self._docker_expand_user(source))
self._run_shell(f"docker cp {self.container_name}:{docker_dir} {target}")
def run_rsync_up(self, source, target, options=None):
docker_dir = os.path.dirname(self._docker_expand_user(target))
self.run(cmd=f"mkdir -p {docker_dir}")
self._run_shell(f"docker cp {source} {self.container_name}:{docker_dir}")
def _docker_expand_user(self, string, any_char=False):
user_pos = string.find("~")
if user_pos > -1:
if self.home_dir is None:
self.home_dir = self._run_shell(
with_docker_exec(
["printenv HOME"],
container_name=self.container_name,
docker_cmd=self.docker_cmd,
)
).strip()
if any_char:
return string.replace("~/", self.home_dir + "/")
elif not any_char and user_pos == 0:
return string.replace("~", self.home_dir, 1)
return string
@@ -0,0 +1,246 @@
"""Fake multinode docker monitoring script.
This script is the "docker compose server" for the fake_multinode
provider using Docker compose. It should be started before running
`RAY_FAKE_CLUSTER=1 ray up <cluster_config>`.
This script reads the volume directory from a supplied fake multinode
docker cluster config file.
It then waits until a docker-compose.yaml file is created in the same
directory, which is done by the `ray up` command.
It then watches for changes in the docker-compose.yaml file and runs
`docker compose up` whenever changes are detected. This will start docker
containers as requested by the autoscaler.
Generally, the docker-compose.yaml will be mounted in the head node of the
cluster, which will then continue to change it according to the autoscaler
requirements.
Additionally, this script monitors the docker container status using
`docker status` and writes it into a `status.json`. This information is
again used by the autoscaler to determine if any nodes have died.
"""
import argparse
import json
import os
import shutil
import subprocess
import time
from typing import Any, Dict, List, Optional
import yaml
def _read_yaml(path: str):
with open(path, "rt") as f:
return yaml.safe_load(f)
def _update_docker_compose(
docker_compose_path: str, project_name: str, status: Optional[Dict[str, Any]]
) -> bool:
docker_compose_config = _read_yaml(docker_compose_path)
if not docker_compose_config:
print("Docker compose currently empty")
return False
cmd = ["up", "-d"]
if status and len(status) > 0:
cmd += ["--no-recreate"]
shutdown = False
if not docker_compose_config["services"]:
# If no more nodes, run `down` instead of `up`
print("Shutting down nodes")
cmd = ["down"]
shutdown = True
try:
subprocess.check_call(
["docker", "compose", "-f", docker_compose_path, "-p", project_name]
+ cmd
+ [
"--remove-orphans",
]
)
except Exception as e:
print(f"Ran into error when updating docker compose: {e}")
# Ignore error
return shutdown
def _get_ip(
project_name: str,
container_name: str,
override_network: Optional[str] = None,
retry_times: int = 3,
) -> Optional[str]:
network = override_network or f"{project_name}_ray_local"
cmd = [
"docker",
"inspect",
"-f",
'"{{ .NetworkSettings.Networks' f".{network}.IPAddress" ' }}"',
f"{container_name}",
]
for i in range(retry_times):
try:
ip_address = subprocess.check_output(cmd, encoding="utf-8")
except Exception:
time.sleep(1)
else:
return ip_address.strip().strip('"').strip('\\"')
return None
def _update_docker_status(
docker_compose_path: str, project_name: str, docker_status_path: str
):
data_str = ""
try:
data_str = (
subprocess.check_output(
[
"docker",
"compose",
"-f",
docker_compose_path,
"-p",
project_name,
"ps",
"--format",
"json",
]
)
.decode("utf-8")
.strip()
.split("\n")
)
data: List[Dict[str, str]] = []
for line in data_str:
line = line.strip()
if line:
data.append(json.loads(line))
except Exception as e:
print(f"Ran into error when fetching status: {e}")
print(f"docker compose ps output: {data_str}")
return None
status = {}
for container in data:
node_id = container["Service"]
container_name = container["Name"]
if container["State"] == "running":
ip = _get_ip(project_name, container_name)
else:
ip = ""
container["IP"] = ip
status[node_id] = container
with open(docker_status_path, "wt") as f:
json.dump(status, f)
return status
def monitor_docker(
docker_compose_path: str,
status_path: str,
project_name: str,
update_interval: float = 1.0,
):
while not os.path.exists(docker_compose_path):
# Wait until cluster is created
time.sleep(0.5)
print("Docker compose config detected, starting status monitoring")
# Make sure this is always writeable from inside the containers
os.chmod(docker_compose_path, 0o777)
docker_config = {"force_update": True}
# Force update
next_update = time.monotonic() - 1.0
shutdown = False
status = None
# Loop:
# If the config changed, update cluster.
# Every `update_interval` seconds, update docker status.
while not shutdown:
new_docker_config = _read_yaml(docker_compose_path)
if new_docker_config != docker_config:
# Update cluster
shutdown = _update_docker_compose(docker_compose_path, project_name, status)
# Force status update
next_update = time.monotonic() - 1.0
if time.monotonic() > next_update:
# Update docker status
status = _update_docker_status(
docker_compose_path, project_name, status_path
)
next_update = time.monotonic() + update_interval
docker_config = new_docker_config
time.sleep(0.1)
print("Cluster shut down, terminating monitoring script.")
def start_monitor(config_file: str):
cluster_config = _read_yaml(config_file)
provider_config = cluster_config["provider"]
assert provider_config["type"] == "fake_multinode_docker", (
f"The docker monitor only works with providers of type "
f"`fake_multinode_docker`, got `{provider_config['type']}`"
)
project_name = provider_config["project_name"]
volume_dir = provider_config["shared_volume_dir"]
os.makedirs(volume_dir, mode=0o755, exist_ok=True)
# Create bootstrap config
bootstrap_config_path = os.path.join(volume_dir, "bootstrap_config.yaml")
shutil.copy(config_file, bootstrap_config_path)
# These two files usually don't exist, yet
docker_compose_config_path = os.path.join(volume_dir, "docker-compose.yaml")
docker_status_path = os.path.join(volume_dir, "status.json")
if os.path.exists(docker_compose_config_path):
# We wait until this file exists, so remove it if it exists
# from a previous run.
os.remove(docker_compose_config_path)
if os.path.exists(docker_status_path):
os.remove(docker_status_path)
# Create empty file so it can be mounted
with open(docker_status_path, "wt") as f:
f.write("{}")
print(
f"Starting monitor process. Please start Ray cluster with:\n"
f" RAY_FAKE_CLUSTER=1 ray up {config_file}"
)
monitor_docker(docker_compose_config_path, docker_status_path, project_name)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"config_file",
help="Path to cluster config file containing a fake docker "
"cluster configuration.",
)
args = parser.parse_args()
start_monitor(args.config_file)
@@ -0,0 +1,59 @@
# Example command to start a cluster with this config:
#
# RAY_FAKE_CLUSTER=1 ray start --autoscaling-config=example.yaml --head --block
#
# Alternatively, you can programmatically create a fake autoscaling cluster
# using ray.cluster_utils.AutoscalingCluster.
cluster_name: fake_multinode
max_workers: 8
provider:
type: fake_multinode
# This must be true since the nodes share the same ip!
use_node_id_as_ip: True
disable_node_updaters: True
disable_launch_config_check: True
available_node_types:
ray.head.default:
# You must set this manually to your "head" node resources!! The head
# node is launched via `ray start` and hence the autoscaler cannot
# configure its resources. The resources specified for its node type
# must line up with what Ray detects/is configured with on start.
resources:
CPU: 8 # <-- set this to num CPUs used/detected in `ray start`
GPU: 0 # <-- set this to num GPUs used/detected in `ray start`
node_config: {}
max_workers: 0
ray.worker.cpu:
resources:
CPU: 1
object_store_memory: 1000000000
node_config: {}
min_workers: 0
max_workers: 4
ray.worker.gpu:
resources:
CPU: 4
GPU: 1
object_store_memory: 1000000000
node_config: {}
min_workers: 0
max_workers: 2
head_node_type: ray.head.default
upscaling_speed: 1.0
idle_timeout_minutes: 0.1
#
# !!! Configurations below are not supported in fake cluster mode !!!
#
auth: {}
docker: {}
initialization_commands: []
setup_commands: []
head_setup_commands: []
worker_setup_commands: []
head_start_ray_commands: []
worker_start_ray_commands: []
file_mounts: {}
cluster_synced_files: []
file_mounts_sync_continuously: false
rsync_exclude: []
rsync_filter: []
@@ -0,0 +1,85 @@
# This is an example config file to start a local
# multi-node cluster using Docker compose.
# It requires the ``docker compose`` plugin to be installed:
# https://docs.docker.com/compose/cli-command/#installing-compose-v2
# The resulting cluster will consist of docker containers
# scheduled via docker compose. These containers behave just like
# regular Ray nodes, have their own IPs, and can SSH into each other.
# They are mostly used to test multi-node setups and autoscaling on
# a single node.
# Example command to start a cluster with this config:
#
# python docker_monitor.py example_docker.yaml &
# RAY_FAKE_DOCKER=1 ray up -y example_docker.yaml
cluster_name: fake_docker
max_workers: 8
provider:
type: fake_multinode_docker
disable_launch_config_check: True
disable_node_updaters: True
# Docker-compose config
project_name: fake_docker
image: rayproject/ray:nightly
shared_volume_dir: /tmp/fake_docker
# For now, this has to be set here separately again:
head_resources:
CPU: 4
GPU: 0
auth:
ssh_user: ubuntu
available_node_types:
ray.head.default:
# You must set this manually to your "head" node resources!! The head
# node is launched via `ray start` and hence the autoscaler cannot
# configure its resources. The resources specified for its node type
# must line up with what Ray detects/is configured with on start.
resources:
CPU: 4
GPU: 0
node_config: {}
max_workers: 0
ray.worker.cpu:
resources:
CPU: 2
object_store_memory: 1000000000
node_config: {}
min_workers: 1
max_workers: 4
ray.worker.gpu:
resources:
CPU: 4
GPU: 1
object_store_memory: 1000000000
node_config: {}
min_workers: 1
max_workers: 2
head_node_type: ray.head.default
upscaling_speed: 1.0
idle_timeout_minutes: 0.1
# The start commands currently don't work - docker doesn't seem to like docker exec
# and Ray only works when including it in the docker-compose command
head_start_ray_commands: []
worker_start_ray_commands: []
# The docker config is currently not propagated to the node provider config.
# Thus, docker-specific configuration is expected to go into the provider part
# as demonstrated above.
docker: {}
#
# !!! Configurations below are not supported in fake cluster mode !!!
#
initialization_commands: []
setup_commands: []
head_setup_commands: []
worker_setup_commands: []
file_mounts: {}
cluster_synced_files: []
file_mounts_sync_continuously: false
rsync_exclude: []
rsync_filter: []
@@ -0,0 +1,730 @@
import copy
import json
import logging
import os
import subprocess
import sys
import time
from threading import RLock
from types import ModuleType
from typing import Any, Dict, Optional
import yaml
import ray
import ray._private.ray_constants as ray_constants
from ray._common.network_utils import build_address
from ray.autoscaler._private.fake_multi_node.command_runner import (
FakeDockerCommandRunner,
)
from ray.autoscaler.command_runner import CommandRunnerInterface
from ray.autoscaler.node_provider import NodeProvider
from ray.autoscaler.tags import (
NODE_KIND_HEAD,
NODE_KIND_WORKER,
STATUS_UP_TO_DATE,
TAG_RAY_NODE_KIND,
TAG_RAY_NODE_NAME,
TAG_RAY_NODE_STATUS,
TAG_RAY_USER_NODE_TYPE,
)
logger = logging.getLogger(__name__)
# We generate the node ids deterministically in the fake node provider, so that
# we can associate launched nodes with their resource reports. IDs increment
# starting with fffff*00000 for the head node, fffff*00001, etc. for workers.
FAKE_HEAD_NODE_ID = "fffffffffffffffffffffffffffffffffffffffffffffffffff00000"
FAKE_HEAD_NODE_TYPE = "ray.head.default"
FAKE_DOCKER_DEFAULT_GCS_PORT = 16379
FAKE_DOCKER_DEFAULT_OBJECT_MANAGER_PORT = 18076
FAKE_DOCKER_DEFAULT_CLIENT_PORT = 10002
DOCKER_COMPOSE_SKELETON = {
"services": {},
"networks": {"ray_local": {}},
}
DOCKER_NODE_SKELETON = {
"networks": ["ray_local"],
"mem_limit": "3000m",
"mem_reservation": "3000m",
"shm_size": "1200m",
"volumes": [],
}
DOCKER_HEAD_CMD = (
'bash -c "'
"sudo mkdir -p {volume_dir} && "
"sudo chmod 777 {volume_dir} && "
"touch {volume_dir}/.in_docker && "
"sudo chown -R ray:users /cluster/node && "
"sudo chmod -R 777 /cluster/node && "
"sudo chown -R ray:users /cluster/shared && "
"sudo chmod -R 777 /cluster/shared && "
"sudo chmod 700 ~/.ssh && "
"sudo chmod 600 ~/.ssh/authorized_keys && "
"sudo chmod 600 ~/ray_bootstrap_key.pem && "
"sudo chown ray:users "
"~/.ssh ~/.ssh/authorized_keys ~/ray_bootstrap_key.pem && "
"{ensure_ssh} && "
"sleep 1 && "
"RAY_FAKE_CLUSTER=1 ray start --head "
"--autoscaling-config=~/ray_bootstrap_config.yaml "
"--object-manager-port=8076 "
"--num-cpus {num_cpus} "
"--num-gpus {num_gpus} "
# "--resources='{resources}' "
'--block"'
)
DOCKER_WORKER_CMD = (
'bash -c "'
"sudo mkdir -p {volume_dir} && "
"sudo chmod 777 {volume_dir} && "
"touch {volume_dir}/.in_docker && "
"sudo chown -R ray:users /cluster/node && "
"sudo chmod -R 777 /cluster/node && "
"sudo chmod 700 ~/.ssh && "
"sudo chmod 600 ~/.ssh/authorized_keys && "
"sudo chown ray:users ~/.ssh ~/.ssh/authorized_keys && "
"{ensure_ssh} && "
"sleep 1 && "
f"ray start --address={FAKE_HEAD_NODE_ID}:6379 "
"--object-manager-port=8076 "
"--num-cpus {num_cpus} "
"--num-gpus {num_gpus} "
# "--resources='{resources}' "
'--block"'
)
def host_dir(container_dir: str):
"""Replace local dir with potentially different host dir.
E.g. in docker-in-docker environments, the host dir might be
different to the mounted directory in the container.
This method will do a simple global replace to adjust the paths.
"""
ray_tempdir = os.environ.get("RAY_TEMPDIR", None)
ray_hostdir = os.environ.get("RAY_HOSTDIR", None)
if not ray_tempdir or not ray_hostdir:
return container_dir
return container_dir.replace(ray_tempdir, ray_hostdir)
def create_node_spec(
head: bool,
docker_image: str,
mounted_cluster_dir: str,
mounted_node_dir: str,
num_cpus: int = 2,
num_gpus: int = 0,
resources: Optional[Dict] = None,
env_vars: Optional[Dict] = None,
host_gcs_port: int = 16379,
host_object_manager_port: int = 18076,
host_client_port: int = 10002,
volume_dir: Optional[str] = None,
node_state_path: Optional[str] = None,
docker_status_path: Optional[str] = None,
docker_compose_path: Optional[str] = None,
bootstrap_config_path: Optional[str] = None,
private_key_path: Optional[str] = None,
public_key_path: Optional[str] = None,
):
node_spec = copy.deepcopy(DOCKER_NODE_SKELETON)
node_spec["image"] = docker_image
bootstrap_cfg_path_on_container = "/home/ray/ray_bootstrap_config.yaml"
bootstrap_key_path_on_container = "/home/ray/ray_bootstrap_key.pem"
resources = resources or {}
ensure_ssh = (
(
"((sudo apt update && sudo apt install -y openssh-server && "
"sudo service ssh start) || true)"
)
if not bool(int(os.environ.get("RAY_HAS_SSH", "0") or "0"))
else "sudo service ssh start"
)
cmd_kwargs = dict(
ensure_ssh=ensure_ssh,
num_cpus=num_cpus,
num_gpus=num_gpus,
resources=json.dumps(resources, indent=None),
volume_dir=volume_dir,
autoscaling_config=bootstrap_cfg_path_on_container,
)
env_vars = env_vars or {}
# Set to "auto" to mount current autoscaler directory to nodes for dev
fake_cluster_dev_dir = os.environ.get("FAKE_CLUSTER_DEV", "")
if fake_cluster_dev_dir:
if fake_cluster_dev_dir == "auto":
local_ray_dir = os.path.dirname(ray.__file__)
else:
local_ray_dir = fake_cluster_dev_dir
os.environ["FAKE_CLUSTER_DEV"] = local_ray_dir
mj = sys.version_info.major
mi = sys.version_info.minor
fake_modules_str = os.environ.get("FAKE_CLUSTER_DEV_MODULES", "autoscaler")
fake_modules = fake_modules_str.split(",")
docker_ray_dir = f"/home/ray/anaconda3/lib/python{mj}.{mi}/site-packages/ray"
node_spec["volumes"] += [
f"{local_ray_dir}/{module}:{docker_ray_dir}/{module}:ro"
for module in fake_modules
]
env_vars["FAKE_CLUSTER_DEV"] = local_ray_dir
env_vars["FAKE_CLUSTER_DEV_MODULES"] = fake_modules_str
os.environ["FAKE_CLUSTER_DEV_MODULES"] = fake_modules_str
if head:
node_spec["command"] = DOCKER_HEAD_CMD.format(**cmd_kwargs)
# Expose ports so we can connect to the cluster from outside
node_spec["ports"] = [
f"{host_gcs_port}:{ray_constants.DEFAULT_PORT}",
f"{host_object_manager_port}:8076",
f"{host_client_port}:10001",
]
# Mount status and config files for the head node
node_spec["volumes"] += [
f"{host_dir(node_state_path)}:{node_state_path}",
f"{host_dir(docker_status_path)}:{docker_status_path}",
f"{host_dir(docker_compose_path)}:{docker_compose_path}",
f"{host_dir(bootstrap_config_path)}:" f"{bootstrap_cfg_path_on_container}",
f"{host_dir(private_key_path)}:{bootstrap_key_path_on_container}",
]
# Create file if it does not exist on local filesystem
for filename in [node_state_path, docker_status_path, bootstrap_config_path]:
if not os.path.exists(filename):
with open(filename, "wt") as f:
f.write("{}")
else:
node_spec["command"] = DOCKER_WORKER_CMD.format(**cmd_kwargs)
node_spec["depends_on"] = [FAKE_HEAD_NODE_ID]
# Mount shared directories and ssh access keys
node_spec["volumes"] += [
f"{host_dir(mounted_cluster_dir)}:/cluster/shared",
f"{host_dir(mounted_node_dir)}:/cluster/node",
f"{host_dir(public_key_path)}:/home/ray/.ssh/authorized_keys",
]
# Pass these environment variables (to the head node)
# These variables are propagated by the `docker compose` command.
env_vars.setdefault("RAY_HAS_SSH", os.environ.get("RAY_HAS_SSH", ""))
env_vars.setdefault("RAY_TEMPDIR", os.environ.get("RAY_TEMPDIR", ""))
env_vars.setdefault("RAY_HOSTDIR", os.environ.get("RAY_HOSTDIR", ""))
node_spec["environment"] = [f"{k}={v}" for k, v in env_vars.items()]
return node_spec
class FakeMultiNodeProvider(NodeProvider):
"""A node provider that implements multi-node on a single machine.
This is used for laptop mode testing of autoscaling functionality."""
def __init__(
self,
provider_config: Dict[str, Any],
cluster_name: str,
):
"""Initialize the fake multi-node provider.
Args:
provider_config: Configuration for the provider.
cluster_name: Name of the cluster.
"""
NodeProvider.__init__(self, provider_config, cluster_name)
self.lock = RLock()
if "RAY_FAKE_CLUSTER" not in os.environ:
raise RuntimeError(
"FakeMultiNodeProvider requires ray to be started with "
"RAY_FAKE_CLUSTER=1 ray start ..."
)
# GCS address to use for the cluster
self._gcs_address = provider_config.get("gcs_address", None)
# Head node id
self._head_node_id = provider_config.get("head_node_id", FAKE_HEAD_NODE_ID)
# Whether to launch multiple nodes at once, or one by one regardless of
# the count (default)
self._launch_multiple = provider_config.get("launch_multiple", False)
# These are injected errors for testing purposes. If not None,
# these will be raised on `create_node_with_resources_and_labels`` and
# `terminate_node``, respectively.
self._creation_error = None
self._termination_errors = None
self._nodes = {
self._head_node_id: {
"tags": {
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
TAG_RAY_USER_NODE_TYPE: FAKE_HEAD_NODE_TYPE,
TAG_RAY_NODE_NAME: self._head_node_id,
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
}
},
}
self._next_node_id = 0
def _next_hex_node_id(self):
self._next_node_id += 1
base = "fffffffffffffffffffffffffffffffffffffffffffffffffff"
return base + str(self._next_node_id).zfill(5)
def non_terminated_nodes(self, tag_filters):
with self.lock:
nodes = []
for node_id in self._nodes:
tags = self.node_tags(node_id)
ok = True
for k, v in tag_filters.items():
if tags.get(k) != v:
ok = False
if ok:
nodes.append(node_id)
return nodes
def is_running(self, node_id):
with self.lock:
return node_id in self._nodes
def is_terminated(self, node_id):
with self.lock:
return node_id not in self._nodes
def node_tags(self, node_id):
with self.lock:
return self._nodes[node_id]["tags"]
def _get_ip(self, node_id: str) -> Optional[str]:
return node_id
def external_ip(self, node_id):
return self._get_ip(node_id)
def internal_ip(self, node_id):
return self._get_ip(node_id)
def set_node_tags(self, node_id, tags):
raise AssertionError("Readonly node provider cannot be updated")
def create_node_with_resources_and_labels(
self, node_config, tags, count, resources, labels
):
if self._creation_error:
raise self._creation_error
if self._launch_multiple:
for _ in range(count):
self._create_node_with_resources_and_labels(
node_config, tags, count, resources, labels
)
else:
self._create_node_with_resources_and_labels(
node_config, tags, count, resources, labels
)
def _create_node_with_resources_and_labels(
self, node_config, tags, count, resources, labels
):
# This function calls `pop`. To avoid side effects, we make a
# copy of `resources`.
resources = copy.deepcopy(resources)
with self.lock:
node_type = tags[TAG_RAY_USER_NODE_TYPE]
next_id = self._next_hex_node_id()
ray_params = ray._private.parameter.RayParams(
min_worker_port=0,
max_worker_port=0,
dashboard_port=None,
dashboard_agent_listen_port=0,
num_cpus=resources.pop("CPU", 0),
num_gpus=resources.pop("GPU", 0),
object_store_memory=resources.pop("object_store_memory", None),
resources=resources,
labels=labels,
redis_address=build_address(
ray._private.services.get_node_ip_address(), 6379
)
if not self._gcs_address
else self._gcs_address,
gcs_address=build_address(
ray._private.services.get_node_ip_address(), 6379
)
if not self._gcs_address
else self._gcs_address,
env_vars={
"RAY_OVERRIDE_NODE_ID_FOR_TESTING": next_id,
"RAY_CLOUD_INSTANCE_ID": next_id,
"RAY_NODE_TYPE_NAME": node_type,
ray_constants.RESOURCES_ENVIRONMENT_VARIABLE: json.dumps(resources),
ray_constants.LABELS_ENVIRONMENT_VARIABLE: json.dumps(labels),
},
)
node = ray._private.node.Node(
ray_params, head=False, shutdown_at_exit=False, spawn_reaper=False
)
all_tags = {
TAG_RAY_NODE_KIND: NODE_KIND_WORKER,
TAG_RAY_USER_NODE_TYPE: node_type,
TAG_RAY_NODE_NAME: next_id,
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
}
all_tags.update(tags)
self._nodes[next_id] = {
"tags": all_tags,
"node": node,
}
def terminate_node(self, node_id):
with self.lock:
if self._termination_errors:
raise self._termination_errors
try:
node = self._nodes.pop(node_id)
except Exception as e:
raise e
self._terminate_node(node)
def _terminate_node(self, node):
node["node"].kill_all_processes(check_alive=False, allow_graceful=True)
@staticmethod
def bootstrap_config(cluster_config):
return cluster_config
############################
# Test only methods
############################
def _test_set_creation_error(self, e: Exception):
"""Set an error that will be raised on
create_node_with_resources_and_labels."""
self._creation_error = e
def _test_add_termination_errors(self, e: Exception):
"""Set an error that will be raised on terminate_node."""
self._termination_errors = e
class FakeMultiNodeDockerProvider(FakeMultiNodeProvider):
"""A node provider that implements multi-node on a single machine.
This is used for laptop mode testing of multi node functionality
where each node has their own FS and IP."""
def __init__(self, provider_config, cluster_name):
super(FakeMultiNodeDockerProvider, self).__init__(provider_config, cluster_name)
fake_head = copy.deepcopy(self._nodes)
self._project_name = self.provider_config["project_name"]
self._docker_image = self.provider_config["image"]
self._host_gcs_port = self.provider_config.get(
"host_gcs_port", FAKE_DOCKER_DEFAULT_GCS_PORT
)
self._host_object_manager_port = self.provider_config.get(
"host_object_manager_port", FAKE_DOCKER_DEFAULT_OBJECT_MANAGER_PORT
)
self._host_client_port = self.provider_config.get(
"host_client_port", FAKE_DOCKER_DEFAULT_CLIENT_PORT
)
self._head_resources = self.provider_config["head_resources"]
# subdirs:
# - ./shared (shared filesystem)
# - ./nodes/<node_id> (node-specific mounted filesystem)
self._volume_dir = self.provider_config["shared_volume_dir"]
self._mounted_cluster_dir = os.path.join(self._volume_dir, "shared")
if not self.in_docker_container:
# Only needed on host
os.makedirs(self._mounted_cluster_dir, mode=0o755, exist_ok=True)
self._boostrap_config_path = os.path.join(
self._volume_dir, "bootstrap_config.yaml"
)
self._private_key_path = os.path.join(self._volume_dir, "bootstrap_key.pem")
self._public_key_path = os.path.join(self._volume_dir, "bootstrap_key.pem.pub")
if not self.in_docker_container:
# Create private key
if not os.path.exists(self._private_key_path):
subprocess.check_call(
f'ssh-keygen -b 2048 -t rsa -q -N "" '
f"-f {self._private_key_path}",
shell=True,
)
# Create public key
if not os.path.exists(self._public_key_path):
subprocess.check_call(
f"ssh-keygen -y "
f"-f {self._private_key_path} "
f"> {self._public_key_path}",
shell=True,
)
self._docker_compose_config_path = os.path.join(
self._volume_dir, "docker-compose.yaml"
)
self._docker_compose_config = None
self._node_state_path = os.path.join(self._volume_dir, "nodes.json")
self._docker_status_path = os.path.join(self._volume_dir, "status.json")
self._load_node_state()
if FAKE_HEAD_NODE_ID not in self._nodes:
# Reset
self._nodes = copy.deepcopy(fake_head)
self._nodes[FAKE_HEAD_NODE_ID][
"node_spec"
] = self._create_node_spec_with_resources(
head=True, node_id=FAKE_HEAD_NODE_ID, resources=self._head_resources
)
self._possibly_terminated_nodes = dict()
self._cleanup_interval = provider_config.get("cleanup_interval", 9.5)
self._docker_status = {}
self._update_docker_compose_config()
self._update_docker_status()
self._save_node_state()
@property
def in_docker_container(self):
return os.path.exists(os.path.join(self._volume_dir, ".in_docker"))
def _create_node_spec_with_resources(
self, head: bool, node_id: str, resources: Dict[str, Any]
):
resources = resources.copy()
# Create shared directory
node_dir = os.path.join(self._volume_dir, "nodes", node_id)
os.makedirs(node_dir, mode=0o777, exist_ok=True)
resource_str = json.dumps(resources, indent=None)
return create_node_spec(
head=head,
docker_image=self._docker_image,
mounted_cluster_dir=self._mounted_cluster_dir,
mounted_node_dir=node_dir,
num_cpus=resources.pop("CPU", 0),
num_gpus=resources.pop("GPU", 0),
host_gcs_port=self._host_gcs_port,
host_object_manager_port=self._host_object_manager_port,
host_client_port=self._host_client_port,
resources=resources,
env_vars={
"RAY_OVERRIDE_NODE_ID_FOR_TESTING": node_id,
ray_constants.RESOURCES_ENVIRONMENT_VARIABLE: resource_str,
**self.provider_config.get("env_vars", {}),
},
volume_dir=self._volume_dir,
node_state_path=self._node_state_path,
docker_status_path=self._docker_status_path,
docker_compose_path=self._docker_compose_config_path,
bootstrap_config_path=self._boostrap_config_path,
public_key_path=self._public_key_path,
private_key_path=self._private_key_path,
)
def _load_node_state(self) -> bool:
if not os.path.exists(self._node_state_path):
return False
try:
with open(self._node_state_path, "rt") as f:
nodes = json.load(f)
except Exception:
return False
if not nodes:
return False
self._nodes = nodes
return True
def _save_node_state(self):
with open(self._node_state_path, "wt") as f:
json.dump(self._nodes, f)
# Make sure this is always writeable from inside the containers
if not self.in_docker_container:
# Only chmod from the outer container
os.chmod(self._node_state_path, 0o777)
def _update_docker_compose_config(self):
config = copy.deepcopy(DOCKER_COMPOSE_SKELETON)
config["services"] = {}
for node_id, node in self._nodes.items():
config["services"][node_id] = node["node_spec"]
with open(self._docker_compose_config_path, "wt") as f:
yaml.safe_dump(config, f)
def _update_docker_status(self):
if not os.path.exists(self._docker_status_path):
return
with open(self._docker_status_path, "rt") as f:
self._docker_status = json.load(f)
def _update_nodes(self):
for node_id in list(self._nodes):
if not self._is_docker_running(node_id):
self._possibly_terminated_nodes.setdefault(node_id, time.monotonic())
else:
self._possibly_terminated_nodes.pop(node_id, None)
self._cleanup_nodes()
def _cleanup_nodes(self):
for node_id, timestamp in list(self._possibly_terminated_nodes.items()):
if time.monotonic() > timestamp + self._cleanup_interval:
if not self._is_docker_running(node_id):
self._nodes.pop(node_id, None)
self._possibly_terminated_nodes.pop(node_id, None)
self._save_node_state()
def _container_name(self, node_id):
node_status = self._docker_status.get(node_id, {})
timeout = time.monotonic() + 60
while not node_status:
if time.monotonic() > timeout:
raise RuntimeError(f"Container for {node_id} never became available.")
time.sleep(1)
self._update_docker_status()
node_status = self._docker_status.get(node_id, {})
return node_status["Name"]
def _is_docker_running(self, node_id):
self._update_docker_status()
return self._docker_status.get(node_id, {}).get("State", None) == "running"
def non_terminated_nodes(self, tag_filters):
self._update_nodes()
return super(FakeMultiNodeDockerProvider, self).non_terminated_nodes(
tag_filters
)
def is_running(self, node_id):
with self.lock:
self._update_nodes()
return node_id in self._nodes and self._is_docker_running(node_id)
def is_terminated(self, node_id):
with self.lock:
self._update_nodes()
return node_id not in self._nodes and not self._is_docker_running(node_id)
def get_command_runner(
self,
log_prefix: str,
node_id: str,
auth_config: Dict[str, Any],
cluster_name: str,
process_runner: ModuleType,
use_internal_ip: bool,
docker_config: Optional[Dict[str, Any]] = None,
) -> CommandRunnerInterface:
if self.in_docker_container:
return super(FakeMultiNodeProvider, self).get_command_runner(
log_prefix,
node_id,
auth_config,
cluster_name,
process_runner,
use_internal_ip,
)
# Else, host command runner:
common_args = {
"log_prefix": log_prefix,
"node_id": node_id,
"provider": self,
"auth_config": auth_config,
"cluster_name": cluster_name,
"process_runner": process_runner,
"use_internal_ip": use_internal_ip,
}
docker_config["container_name"] = self._container_name(node_id)
docker_config["image"] = self._docker_image
return FakeDockerCommandRunner(docker_config, **common_args)
def _get_ip(self, node_id: str) -> Optional[str]:
for i in range(3):
self._update_docker_status()
ip = self._docker_status.get(node_id, {}).get("IP", None)
if ip:
return ip
time.sleep(3)
return None
def set_node_tags(self, node_id, tags):
assert node_id in self._nodes
self._nodes[node_id]["tags"].update(tags)
def create_node_with_resources_and_labels(
self, node_config, tags, count, resources, labels
):
with self.lock:
is_head = tags[TAG_RAY_NODE_KIND] == NODE_KIND_HEAD
if is_head:
next_id = FAKE_HEAD_NODE_ID
else:
next_id = self._next_hex_node_id()
self._nodes[next_id] = {
"tags": tags,
"node_spec": self._create_node_spec_with_resources(
head=is_head, node_id=next_id, resources=resources
),
}
self._update_docker_compose_config()
self._save_node_state()
def create_node(
self, node_config: Dict[str, Any], tags: Dict[str, str], count: int
) -> Optional[Dict[str, Any]]:
resources = self._head_resources
return self.create_node_with_resources_and_labels(
node_config, tags, count, resources, {}
)
def _terminate_node(self, node):
self._update_docker_compose_config()
self._save_node_state()
@staticmethod
def bootstrap_config(cluster_config):
return cluster_config
@@ -0,0 +1,399 @@
import json
import logging
import os
import random
import shutil
import subprocess
import sys
import tempfile
import threading
import time
from typing import Any, Dict, Optional
import yaml
import ray
from ray._common.network_utils import build_address
from ray._private.dict import deep_update
from ray.autoscaler._private.fake_multi_node.node_provider import (
FAKE_DOCKER_DEFAULT_CLIENT_PORT,
FAKE_DOCKER_DEFAULT_GCS_PORT,
)
from ray.util.queue import Empty, Queue
logger = logging.getLogger(__name__)
DEFAULT_DOCKER_IMAGE = "rayproject/ray:nightly-py{major}{minor}-cpu"
class ResourcesNotReadyError(RuntimeError):
pass
class DockerCluster:
"""Docker cluster wrapper.
Creates a directory for starting a fake multinode docker cluster.
Includes APIs to update the cluster config as needed in tests,
and to start and connect to the cluster.
"""
def __init__(self, config: Optional[Dict[str, Any]] = None):
self._base_config_file = os.path.join(
os.path.dirname(__file__), "example_docker.yaml"
)
self._tempdir = None
self._config_file = None
self._nodes_file = None
self._nodes = {}
self._status_file = None
self._status = {}
self._partial_config = config
self._cluster_config = None
self._docker_image = None
self._monitor_script = os.path.join(
os.path.dirname(__file__), "docker_monitor.py"
)
self._monitor_process = None
self._execution_thread = None
self._execution_event = threading.Event()
self._execution_queue = None
@property
def config_file(self):
return self._config_file
@property
def cluster_config(self):
return self._cluster_config
@property
def cluster_dir(self):
return self._tempdir
@property
def gcs_port(self):
return self._cluster_config.get("provider", {}).get(
"host_gcs_port", FAKE_DOCKER_DEFAULT_GCS_PORT
)
@property
def client_port(self):
return self._cluster_config.get("provider", {}).get(
"host_client_port", FAKE_DOCKER_DEFAULT_CLIENT_PORT
)
def connect(self, client: bool = True, timeout: int = 120, **init_kwargs):
"""Connect to the docker-compose Ray cluster.
Assumes the cluster is at RAY_TESTHOST (defaults to
``127.0.0.1``).
Args:
client: If True, uses Ray client to connect to the
cluster. If False, uses GCS to connect to the cluster.
timeout: Connection timeout in seconds.
**init_kwargs: kwargs to pass to ``ray.init()``.
"""
host = os.environ.get("RAY_TESTHOST", "127.0.0.1")
if client:
port = self.client_port
address = f"ray://{build_address(host, port)}"
else:
port = self.gcs_port
address = build_address(host, port)
timeout_at = time.monotonic() + timeout
while time.monotonic() < timeout_at:
try:
ray.init(address, **init_kwargs)
self.wait_for_resources({"CPU": 1})
except ResourcesNotReadyError:
time.sleep(1)
continue
else:
break
try:
ray.cluster_resources()
except Exception as e:
raise RuntimeError(f"Timed out connecting to Ray: {e}")
def remote_execution_api(self) -> "RemoteAPI":
"""Create an object to control cluster state from within the cluster."""
self._execution_queue = Queue(actor_options={"num_cpus": 0})
stop_event = self._execution_event
def entrypoint():
while not stop_event.is_set():
try:
cmd, kwargs = self._execution_queue.get(timeout=1)
except Empty:
continue
if cmd == "kill_node":
self.kill_node(**kwargs)
self._execution_thread = threading.Thread(target=entrypoint)
self._execution_thread.start()
return RemoteAPI(self._execution_queue)
@staticmethod
def wait_for_resources(resources: Dict[str, float], timeout: int = 60):
"""Wait until Ray cluster resources are available
Args:
resources: Minimum resources needed before
this function returns.
timeout: Timeout in seconds.
"""
timeout = time.monotonic() + timeout
available = ray.cluster_resources()
while any(available.get(k, 0.0) < v for k, v in resources.items()):
if time.monotonic() > timeout:
raise ResourcesNotReadyError(
f"Timed out waiting for resources: {resources}"
)
time.sleep(1)
available = ray.cluster_resources()
def update_config(self, config: Optional[Dict[str, Any]] = None):
"""Update autoscaling config.
Does a deep update of the base config with a new configuration.
This can change autoscaling behavior.
Args:
config: Partial config to update current
config with.
"""
assert self._tempdir, "Call setup() first"
config = config or {}
if config:
self._partial_config = config
if not config.get("provider", {}).get("image"):
# No image specified, trying to parse from buildkite
docker_image = os.environ.get("RAY_DOCKER_IMAGE", None)
if not docker_image:
# If still no docker image, use one according to Python version
mj = sys.version_info.major
mi = sys.version_info.minor
docker_image = DEFAULT_DOCKER_IMAGE.format(major=mj, minor=mi)
self._docker_image = docker_image
with open(self._base_config_file, "rt") as f:
cluster_config = yaml.safe_load(f)
if self._partial_config:
deep_update(cluster_config, self._partial_config, new_keys_allowed=True)
if self._docker_image:
cluster_config["provider"]["image"] = self._docker_image
cluster_config["provider"]["shared_volume_dir"] = self._tempdir
self._cluster_config = cluster_config
with open(self._config_file, "wt") as f:
yaml.safe_dump(self._cluster_config, f)
logging.info(f"Updated cluster config to: {self._cluster_config}")
def maybe_pull_image(self):
if self._docker_image:
try:
images_str = subprocess.check_output(
f"docker image inspect {self._docker_image}", shell=True
)
images = json.loads(images_str)
except Exception as e:
logger.error(f"Error inspecting image {self._docker_image}: {e}")
return
if not images:
try:
subprocess.check_call(
f"docker pull {self._docker_image}", shell=True
)
except Exception as e:
logger.error(f"Error pulling image {self._docker_image}: {e}")
def setup(self):
"""Setup docker compose cluster environment.
Creates the temporary directory, writes the initial config file,
and pulls the docker image, if required.
"""
self._tempdir = tempfile.mkdtemp(dir=os.environ.get("RAY_TEMPDIR", None))
os.chmod(self._tempdir, 0o777)
self._config_file = os.path.join(self._tempdir, "cluster.yaml")
self._nodes_file = os.path.join(self._tempdir, "nodes.json")
self._status_file = os.path.join(self._tempdir, "status.json")
self.update_config()
self.maybe_pull_image()
def teardown(self, keep_dir: bool = False):
"""Tear down docker compose cluster environment.
Args:
keep_dir: If True, cluster directory
will not be removed after termination.
"""
if not keep_dir:
shutil.rmtree(self._tempdir)
self._tempdir = None
self._config_file = None
def _start_monitor(self):
self._monitor_process = subprocess.Popen(
[sys.executable, self._monitor_script, self.config_file]
)
time.sleep(2)
def _stop_monitor(self):
if self._monitor_process:
self._monitor_process.wait(timeout=30)
if self._monitor_process.poll() is None:
self._monitor_process.terminate()
self._monitor_process = None
def start(self):
"""Start docker compose cluster.
Starts the monitor process and runs ``ray up``.
"""
self._start_monitor()
subprocess.check_call(
f"RAY_FAKE_CLUSTER=1 ray up -y {self.config_file}", shell=True
)
def stop(self):
"""Stop docker compose cluster.
Runs ``ray down`` and stops the monitor process.
"""
if ray.is_initialized:
ray.shutdown()
subprocess.check_call(
f"RAY_FAKE_CLUSTER=1 ray down -y {self.config_file}", shell=True
)
self._stop_monitor()
self._execution_event.set()
def _update_nodes(self):
with open(self._nodes_file, "rt") as f:
self._nodes = json.load(f)
def _update_status(self):
with open(self._status_file, "rt") as f:
self._status = json.load(f)
def _get_node(
self,
node_id: Optional[str] = None,
num: Optional[int] = None,
rand: Optional[str] = None,
) -> str:
self._update_nodes()
if node_id:
assert (
not num and not rand
), "Only provide either `node_id`, `num`, or `random`."
elif num:
assert (
not node_id and not rand
), "Only provide either `node_id`, `num`, or `random`."
base = "fffffffffffffffffffffffffffffffffffffffffffffffffff"
node_id = base + str(num).zfill(5)
elif rand:
assert (
not node_id and not num
), "Only provide either `node_id`, `num`, or `random`."
assert rand in [
"worker",
"any",
], "`random` must be one of ['worker', 'any']"
choices = list(self._nodes.keys())
if rand == "worker":
choices.remove(
"fffffffffffffffffffffffffffffffffffffffffffffffffff00000"
)
# Else: any
node_id = random.choice(choices)
assert node_id in self._nodes, f"Node with ID {node_id} is not in active nodes."
return node_id
def _get_docker_container(self, node_id: str) -> Optional[str]:
self._update_status()
node_status = self._status.get(node_id)
if not node_status:
return None
return node_status["Name"]
def kill_node(
self,
node_id: Optional[str] = None,
num: Optional[int] = None,
rand: Optional[str] = None,
):
"""Kill node.
If ``node_id`` is given, kill that node.
If ``num`` is given, construct node_id from this number, and kill
that node.
If ``rand`` is given (as either ``worker`` or ``any``), kill a random
node.
"""
node_id = self._get_node(node_id=node_id, num=num, rand=rand)
container = self._get_docker_container(node_id=node_id)
subprocess.check_call(f"docker kill {container}", shell=True)
class RemoteAPI:
"""Remote API to control cluster state from within cluster tasks.
This API uses a Ray queue to interact with an execution thread on the
host machine that will execute commands passed to the queue.
Instances of this class can be serialized and passed to Ray remote actors
to interact with cluster state (but they can also be used outside actors).
The API subset is limited to specific commands.
Args:
queue: Ray queue to push command instructions to.
"""
def __init__(self, queue: Queue):
self._queue = queue
def kill_node(
self,
node_id: Optional[str] = None,
num: Optional[int] = None,
rand: Optional[str] = None,
):
self._queue.put(("kill_node", dict(node_id=node_id, num=num, rand=rand)))
@@ -0,0 +1,874 @@
import copy
import json
import logging
import os
import re
import time
from functools import partial, reduce
import google_auth_httplib2
import googleapiclient
import httplib2
from google.oauth2 import service_account
from google.oauth2.credentials import Credentials as OAuthCredentials
from googleapiclient import discovery, errors
from ray._private.accelerators import TPUAcceleratorManager, tpu
from ray.autoscaler._private.gcp.node import MAX_POLLS, POLL_INTERVAL, GCPNodeType
from ray.autoscaler._private.util import (
check_legacy_fields,
generate_rsa_key_pair,
generate_ssh_key_name,
generate_ssh_key_paths,
)
logger = logging.getLogger(__name__)
VERSION = "v1"
TPU_VERSION = "v2alpha" # change once v2 is stable
RAY = "ray-autoscaler"
DEFAULT_SERVICE_ACCOUNT_ID = RAY + "-sa-" + VERSION
SERVICE_ACCOUNT_EMAIL_TEMPLATE = "{account_id}@{project_id}.iam.gserviceaccount.com"
DEFAULT_SERVICE_ACCOUNT_CONFIG = {
"displayName": "Ray Autoscaler Service Account ({})".format(VERSION),
}
# Those roles will be always added.
# NOTE: `serviceAccountUser` allows the head node to create workers with
# a serviceAccount. `roleViewer` allows the head node to run bootstrap_gcp.
DEFAULT_SERVICE_ACCOUNT_ROLES = [
"roles/storage.objectAdmin",
"roles/compute.admin",
"roles/iam.serviceAccountUser",
"roles/iam.roleViewer",
]
# Those roles will only be added if there are TPU nodes defined in config.
TPU_SERVICE_ACCOUNT_ROLES = ["roles/tpu.admin"]
# If there are TPU nodes in config, this field will be set
# to True in config["provider"].
HAS_TPU_PROVIDER_FIELD = "_has_tpus"
# NOTE: iam.serviceAccountUser allows the Head Node to create worker nodes
# with ServiceAccounts.
def tpu_accelerator_config_to_type(accelerator_config: dict) -> str:
"""Convert a provided accelerator_config to accelerator_type.
Args:
accelerator_config: A dictionary defining the spec of a
TPU accelerator. The dictionary should consist of
the keys 'type', indicating the TPU chip type, and
'topology', indicating the topology of the TPU.
Returns:
A string, accelerator_type, e.g. "v4-8".
"""
generation = accelerator_config["type"].lower()
topology = accelerator_config["topology"]
# Reduce e.g. "2x2x2" to 8
chip_dimensions = [int(chip_count) for chip_count in topology.split("x")]
num_chips = reduce(lambda x, y: x * y, chip_dimensions)
# V5LitePod is rendered as "V5LITE_POD" in accelerator configuration but
# accelerator type uses a format like "v5litepod-{cores}", so we need
# to manually convert the string here.
if generation == "v5lite_pod":
generation = "v5litepod"
num_cores = tpu.get_tpu_cores_per_chip(generation) * num_chips
return f"{generation}-{num_cores}"
def _validate_tpu_config(node: dict):
"""Validate the provided node with TPU support.
If the config is malformed, users will run into an error but this function
will raise the error at config parsing time. This only tests very simple assertions.
Raises: `ValueError` in case the input is malformed.
"""
if "acceleratorType" in node and "acceleratorConfig" in node:
raise ValueError(
"For TPU usage, acceleratorType and acceleratorConfig "
"cannot both be set."
)
if "acceleratorType" in node:
accelerator_type = node["acceleratorType"]
if not TPUAcceleratorManager.is_valid_tpu_accelerator_type(accelerator_type):
raise ValueError(
"`acceleratorType` should match v(generation)-(cores/chips). "
f"Got {accelerator_type}."
)
else: # "acceleratorConfig" in node
accelerator_config = node["acceleratorConfig"]
if "type" not in accelerator_config or "topology" not in accelerator_config:
raise ValueError(
"acceleratorConfig expects 'type' and 'topology'. "
f"Got {accelerator_config}"
)
generation = node["acceleratorConfig"]["type"]
topology = node["acceleratorConfig"]["topology"]
generation_pattern = re.compile(r"^V\d+[a-zA-Z]*$")
topology_pattern = re.compile(r"^\d+x\d+(x\d+)?$")
if generation != "V5LITE_POD" and not generation_pattern.match(generation):
raise ValueError(f"type should match V(generation). Got {generation}.")
if generation == "V2" or generation == "V3":
raise ValueError(
f"acceleratorConfig is not supported on V2/V3 TPUs. Got {generation}."
)
if not topology_pattern.match(topology):
raise ValueError(
f"topology should be of form axbxc or axb. Got {topology}."
)
def _get_num_tpu_chips(node: dict) -> int:
chips = 0
if "acceleratorType" in node:
accelerator_type = node["acceleratorType"]
# `acceleratorType` is typically v{generation}-{cores}
cores = int(accelerator_type.split("-")[1])
chips = cores / tpu.get_tpu_cores_per_chip(accelerator_type)
if "acceleratorConfig" in node:
topology = node["acceleratorConfig"]["topology"]
# `topology` is typically {chips}x{chips}x{chips}
# Multiply all dimensions together to get total number of chips
chips = 1
for dim in topology.split("x"):
chips *= int(dim)
return chips
def _is_single_host_tpu(node: dict) -> bool:
accelerator_type = ""
if "acceleratorType" in node:
accelerator_type = node["acceleratorType"]
else:
accelerator_type = tpu_accelerator_config_to_type(node["acceleratorConfig"])
return _get_num_tpu_chips(node) <= tpu.get_num_tpu_visible_chips_per_host(
accelerator_type
)
def get_node_type(node: dict) -> GCPNodeType:
"""Returns node type based on the keys in ``node``.
This is a very simple check. If we have a ``machineType`` key,
this is a Compute instance. If we don't have a ``machineType`` key,
but we have ``acceleratorType``, this is a TPU. Otherwise, it's
invalid and an exception is raised.
This works for both node configs and API returned nodes.
"""
if (
"machineType" not in node
and "acceleratorType" not in node
and "acceleratorConfig" not in node
):
raise ValueError(
"Invalid node. For a Compute instance, 'machineType' is required."
"For a TPU instance, 'acceleratorType' OR 'acceleratorConfig' and "
f"no 'machineType' is required. Got {list(node)}."
)
if "machineType" not in node and (
"acceleratorType" in node or "acceleratorConfig" in node
):
_validate_tpu_config(node)
if not _is_single_host_tpu(node):
# Remove once proper autoscaling support is added.
logger.warning(
"TPU pod detected. Note that while the cluster launcher can create "
"multiple TPU pods, proper autoscaling will not work as expected, "
"as all hosts in a TPU pod need to execute the same program. "
"Proceed with caution."
)
return GCPNodeType.TPU
return GCPNodeType.COMPUTE
def wait_for_crm_operation(operation, crm):
"""Poll for cloud resource manager operation until finished."""
logger.info(
"wait_for_crm_operation: "
"Waiting for operation {} to finish...".format(operation)
)
for _ in range(MAX_POLLS):
result = crm.operations().get(name=operation["name"]).execute()
if "error" in result:
raise Exception(result["error"])
if "done" in result and result["done"]:
logger.info("wait_for_crm_operation: Operation done.")
break
time.sleep(POLL_INTERVAL)
return result
def wait_for_compute_global_operation(project_name, operation, compute):
"""Poll for global compute operation until finished."""
logger.info(
"wait_for_compute_global_operation: "
"Waiting for operation {} to finish...".format(operation["name"])
)
for _ in range(MAX_POLLS):
result = (
compute.globalOperations()
.get(
project=project_name,
operation=operation["name"],
)
.execute()
)
if "error" in result:
raise Exception(result["error"])
if result["status"] == "DONE":
logger.info("wait_for_compute_global_operation: Operation done.")
break
time.sleep(POLL_INTERVAL)
return result
def _has_tpus_in_node_configs(config: dict) -> bool:
"""Check if any nodes in config are TPUs."""
node_configs = [
node_type["node_config"]
for node_type in config["available_node_types"].values()
]
return any(get_node_type(node) == GCPNodeType.TPU for node in node_configs)
def _is_head_node_a_tpu(config: dict) -> bool:
"""Check if the head node is a TPU."""
node_configs = {
node_id: node_type["node_config"]
for node_id, node_type in config["available_node_types"].items()
}
return get_node_type(node_configs[config["head_node_type"]]) == GCPNodeType.TPU
def build_request(http, *args, **kwargs):
new_http = google_auth_httplib2.AuthorizedHttp(
http.credentials, http=httplib2.Http()
)
return googleapiclient.http.HttpRequest(new_http, *args, **kwargs)
def _create_crm(gcp_credentials=None):
return discovery.build(
"cloudresourcemanager",
"v1",
credentials=gcp_credentials,
requestBuilder=build_request,
cache_discovery=False,
)
def _create_iam(gcp_credentials=None):
return discovery.build(
"iam",
"v1",
credentials=gcp_credentials,
requestBuilder=build_request,
cache_discovery=False,
)
def _create_compute(gcp_credentials=None):
return discovery.build(
"compute",
"v1",
credentials=gcp_credentials,
requestBuilder=build_request,
cache_discovery=False,
)
def _create_tpu(gcp_credentials=None):
return discovery.build(
"tpu",
TPU_VERSION,
credentials=gcp_credentials,
requestBuilder=build_request,
cache_discovery=False,
discoveryServiceUrl="https://tpu.googleapis.com/$discovery/rest",
)
def construct_clients_from_provider_config(provider_config):
"""
Attempt to fetch and parse the JSON GCP credentials from the provider
config yaml file.
tpu resource (the last element of the tuple) will be None if
`_has_tpus` in provider config is not set or False.
"""
gcp_credentials = provider_config.get("gcp_credentials")
if gcp_credentials is None:
logger.debug(
"gcp_credentials not found in cluster yaml file. "
"Falling back to GOOGLE_APPLICATION_CREDENTIALS "
"environment variable."
)
tpu_resource = (
_create_tpu()
if provider_config.get(HAS_TPU_PROVIDER_FIELD, False)
else None
)
# If gcp_credentials is None, then discovery.build will search for
# credentials in the local environment.
return _create_crm(), _create_iam(), _create_compute(), tpu_resource
assert (
"type" in gcp_credentials
), "gcp_credentials cluster yaml field missing 'type' field."
assert (
"credentials" in gcp_credentials
), "gcp_credentials cluster yaml field missing 'credentials' field."
cred_type = gcp_credentials["type"]
credentials_field = gcp_credentials["credentials"]
if cred_type == "service_account":
# If parsing the gcp_credentials failed, then the user likely made a
# mistake in copying the credentials into the config yaml.
try:
service_account_info = json.loads(credentials_field)
except json.decoder.JSONDecodeError:
raise RuntimeError(
"gcp_credentials found in cluster yaml file but "
"formatted improperly."
)
credentials = service_account.Credentials.from_service_account_info(
service_account_info
)
elif cred_type == "credentials_token":
# Otherwise the credentials type must be credentials_token.
credentials = OAuthCredentials(credentials_field)
tpu_resource = (
_create_tpu(credentials)
if provider_config.get(HAS_TPU_PROVIDER_FIELD, False)
else None
)
return (
_create_crm(credentials),
_create_iam(credentials),
_create_compute(credentials),
tpu_resource,
)
def bootstrap_gcp(config):
config = copy.deepcopy(config)
check_legacy_fields(config)
# Used internally to store head IAM role.
config["head_node"] = {}
# Check if we have any TPUs defined, and if so,
# insert that information into the provider config
if _has_tpus_in_node_configs(config):
config["provider"][HAS_TPU_PROVIDER_FIELD] = True
crm, iam, compute, tpu = construct_clients_from_provider_config(config["provider"])
config = _configure_project(config, crm)
config = _configure_iam_role(config, crm, iam)
config = _configure_key_pair(config, compute)
config = _configure_subnet(config, compute)
return config
def _configure_project(config, crm):
"""Setup a Google Cloud Platform Project.
Google Compute Platform organizes all the resources, such as storage
buckets, users, and instances under projects. This is different from
aws ec2 where everything is global.
"""
config = copy.deepcopy(config)
project_id = config["provider"].get("project_id")
assert config["provider"]["project_id"] is not None, (
"'project_id' must be set in the 'provider' section of the autoscaler"
" config. Notice that the project id must be globally unique."
)
project = _get_project(project_id, crm)
if project is None:
# Project not found, try creating it
_create_project(project_id, crm)
project = _get_project(project_id, crm)
assert project is not None, "Failed to create project"
assert (
project["lifecycleState"] == "ACTIVE"
), "Project status needs to be ACTIVE, got {}".format(project["lifecycleState"])
config["provider"]["project_id"] = project["projectId"]
return config
def _configure_iam_role(config, crm, iam):
"""Setup a gcp service account with IAM roles.
Creates a gcp service acconut and binds IAM roles which allow it to control
control storage/compute services. Specifically, the head node needs to have
an IAM role that allows it to create further gce instances and store items
in google cloud storage.
TODO: Allow the name/id of the service account to be configured
"""
config = copy.deepcopy(config)
email = SERVICE_ACCOUNT_EMAIL_TEMPLATE.format(
account_id=DEFAULT_SERVICE_ACCOUNT_ID,
project_id=config["provider"]["project_id"],
)
service_account = _get_service_account(email, config, iam)
if service_account is None:
logger.info(
"_configure_iam_role: "
"Creating new service account {}".format(DEFAULT_SERVICE_ACCOUNT_ID)
)
service_account = _create_service_account(
DEFAULT_SERVICE_ACCOUNT_ID, DEFAULT_SERVICE_ACCOUNT_CONFIG, config, iam
)
assert service_account is not None, "Failed to create service account"
if config["provider"].get(HAS_TPU_PROVIDER_FIELD, False):
roles = DEFAULT_SERVICE_ACCOUNT_ROLES + TPU_SERVICE_ACCOUNT_ROLES
else:
roles = DEFAULT_SERVICE_ACCOUNT_ROLES
_add_iam_policy_binding(service_account, roles, crm)
config["head_node"]["serviceAccounts"] = [
{
"email": service_account["email"],
# NOTE: The amount of access is determined by the scope + IAM
# role of the service account. Even if the cloud-platform scope
# gives (scope) access to the whole cloud-platform, the service
# account is limited by the IAM rights specified below.
"scopes": ["https://www.googleapis.com/auth/cloud-platform"],
}
]
return config
def _configure_key_pair(config, compute):
"""Configure SSH access, using an existing key pair if possible.
Creates a project-wide ssh key that can be used to access all the instances
unless explicitly prohibited by instance config.
The ssh-keys created by ray are of format:
[USERNAME]:ssh-rsa [KEY_VALUE] [USERNAME]
where:
[USERNAME] is the user for the SSH key, specified in the config.
[KEY_VALUE] is the public SSH key value.
"""
config = copy.deepcopy(config)
if "ssh_private_key" in config["auth"]:
return config
ssh_user = config["auth"]["ssh_user"]
project = compute.projects().get(project=config["provider"]["project_id"]).execute()
# Key pairs associated with project meta data. The key pairs are general,
# and not just ssh keys.
ssh_keys_str = next(
(
item
for item in project["commonInstanceMetadata"].get("items", [])
if item["key"] == "ssh-keys"
),
{},
).get("value", "")
ssh_keys = ssh_keys_str.split("\n") if ssh_keys_str else []
# Try a few times to get or create a good key pair.
key_found = False
for i in range(10):
key_name = generate_ssh_key_name(
"gcp",
i,
config["provider"]["region"],
config["provider"]["project_id"],
ssh_user,
)
public_key_path, private_key_path = generate_ssh_key_paths(key_name)
for ssh_key in ssh_keys:
key_parts = ssh_key.split(" ")
if len(key_parts) != 3:
continue
if key_parts[2] == ssh_user and os.path.exists(private_key_path):
# Found a key
key_found = True
break
# Writing the new ssh key to the filesystem fails if the ~/.ssh
# directory doesn't already exist.
os.makedirs(os.path.expanduser("~/.ssh"), exist_ok=True)
# Create a key since it doesn't exist locally or in GCP
if not key_found and not os.path.exists(private_key_path):
logger.info(
"_configure_key_pair: Creating new key pair {}".format(key_name)
)
public_key, private_key = generate_rsa_key_pair()
for attempt in range(MAX_POLLS):
try:
_create_project_ssh_key_pair(project, public_key, ssh_user, compute)
break
except errors.HttpError as e:
if e.resp.status != 412 or attempt == MAX_POLLS - 1:
raise
logger.warning(
"GCP project metadata update conflict for %s (%s); retrying",
config["provider"]["project_id"],
e,
)
time.sleep(POLL_INTERVAL)
project = (
compute.projects()
.get(project=config["provider"]["project_id"])
.execute()
)
# Create the directory if it doesn't exists
private_key_dir = os.path.dirname(private_key_path)
os.makedirs(private_key_dir, exist_ok=True)
# We need to make sure to _create_ the file with the right
# permissions. In order to do that we need to change the default
# os.open behavior to include the mode we want.
with open(
private_key_path,
"w",
opener=partial(os.open, mode=0o600),
) as f:
f.write(private_key)
with open(public_key_path, "w") as f:
f.write(public_key)
key_found = True
break
if key_found:
break
assert key_found, "SSH keypair for user {} not found for {}".format(
ssh_user, private_key_path
)
assert os.path.exists(
private_key_path
), "Private key file {} not found for user {}".format(private_key_path, ssh_user)
logger.info(
"_configure_key_pair: "
"Private key not specified in config, using"
"{}".format(private_key_path)
)
config["auth"]["ssh_private_key"] = private_key_path
return config
def _configure_subnet(config, compute):
"""Pick a reasonable subnet if not specified by the config."""
config = copy.deepcopy(config)
node_configs = [
node_type["node_config"]
for node_type in config["available_node_types"].values()
]
# Rationale: avoid subnet lookup if the network is already
# completely manually configured
# networkInterfaces is compute, networkConfig is TPU
if all(
"networkInterfaces" in node_config or "networkConfig" in node_config
for node_config in node_configs
):
return config
subnets = _list_subnets(config, compute)
if not subnets:
raise NotImplementedError("Should be able to create subnet.")
# TODO: make sure that we have usable subnet. Maybe call
# compute.subnetworks().listUsable? For some reason it didn't
# work out-of-the-box
default_subnet = subnets[0]
default_interfaces = [
{
"subnetwork": default_subnet["selfLink"],
"accessConfigs": [
{
"name": "External NAT",
"type": "ONE_TO_ONE_NAT",
}
],
}
]
for node_config in node_configs:
# The not applicable key will be removed during node creation
# compute
if "networkInterfaces" not in node_config:
node_config["networkInterfaces"] = copy.deepcopy(default_interfaces)
# TPU
if "networkConfig" not in node_config:
node_config["networkConfig"] = copy.deepcopy(default_interfaces)[0]
node_config["networkConfig"].pop("accessConfigs")
return config
def _list_subnets(config, compute):
response = (
compute.subnetworks()
.list(
project=config["provider"]["project_id"],
region=config["provider"]["region"],
)
.execute()
)
return response["items"]
def _get_subnet(config, subnet_id, compute):
subnet = (
compute.subnetworks()
.get(
project=config["provider"]["project_id"],
region=config["provider"]["region"],
subnetwork=subnet_id,
)
.execute()
)
return subnet
def _get_project(project_id, crm):
try:
project = crm.projects().get(projectId=project_id).execute()
except errors.HttpError as e:
if e.resp.status != 403:
raise
project = None
return project
def _create_project(project_id, crm):
operation = (
crm.projects()
.create(body={"projectId": project_id, "name": project_id})
.execute()
)
result = wait_for_crm_operation(operation, crm)
return result
def _get_service_account(account, config, iam):
project_id = config["provider"]["project_id"]
full_name = "projects/{project_id}/serviceAccounts/{account}".format(
project_id=project_id, account=account
)
try:
service_account = iam.projects().serviceAccounts().get(name=full_name).execute()
except errors.HttpError as e:
if e.resp.status != 404:
raise
service_account = None
return service_account
def _create_service_account(account_id, account_config, config, iam):
project_id = config["provider"]["project_id"]
service_account = (
iam.projects()
.serviceAccounts()
.create(
name="projects/{project_id}".format(project_id=project_id),
body={
"accountId": account_id,
"serviceAccount": account_config,
},
)
.execute()
)
return service_account
def _add_iam_policy_binding(service_account, roles, crm):
"""Add new IAM roles for the service account."""
project_id = service_account["projectId"]
email = service_account["email"]
member_id = "serviceAccount:" + email
policy = (
crm.projects()
.getIamPolicy(
resource=project_id, body={"options": {"requestedPolicyVersion": 3}}
)
.execute()
)
already_configured = True
for role in roles:
role_exists = False
for binding in policy["bindings"]:
if binding["role"] == role:
if member_id not in binding["members"]:
binding["members"].append(member_id)
already_configured = False
role_exists = True
if not role_exists:
already_configured = False
policy["bindings"].append(
{
"members": [member_id],
"role": role,
}
)
if already_configured:
# In some managed environments, an admin needs to grant the
# roles, so only call setIamPolicy if needed.
return
result = (
crm.projects()
.setIamPolicy(
resource=project_id,
body={
"policy": policy,
},
)
.execute()
)
return result
def _create_project_ssh_key_pair(project, public_key, ssh_user, compute):
"""Inserts an ssh-key into project commonInstanceMetadata"""
key_parts = public_key.split(" ")
# Sanity checks to make sure that the generated key matches expectation
assert len(key_parts) == 2, key_parts
assert key_parts[0] == "ssh-rsa", key_parts
new_ssh_meta = "{ssh_user}:ssh-rsa {key_value} {ssh_user}".format(
ssh_user=ssh_user, key_value=key_parts[1]
)
common_instance_metadata = project["commonInstanceMetadata"]
items = common_instance_metadata.get("items", [])
ssh_keys_i = next(
(i for i, item in enumerate(items) if item["key"] == "ssh-keys"), None
)
if ssh_keys_i is None:
items.append({"key": "ssh-keys", "value": new_ssh_meta})
else:
ssh_keys = items[ssh_keys_i]
ssh_keys["value"] += "\n" + new_ssh_meta
items[ssh_keys_i] = ssh_keys
common_instance_metadata["items"] = items
try:
operation = (
compute.projects()
.setCommonInstanceMetadata(
project=project["name"], body=common_instance_metadata
)
.execute()
)
response = wait_for_compute_global_operation(
project["name"], operation, compute
)
except errors.HttpError as e: # Only trim when explicitly opted in.
if (
e.resp.status != 413
or ssh_keys_i is None
or os.environ.get("RAY_TRIM_AUTOSCALER_SSH_KEYS") != "1"
):
raise
logger.warning(
"GCP project metadata size limit reached for %s (%s); "
"removing half of ssh keys and retrying",
project["name"],
e,
)
ssh_keys_value = items[ssh_keys_i].get("value", "")
ssh_keys = [key for key in ssh_keys_value.splitlines() if key.strip()]
trim_start = ( # If our new key is the only one, this preserves it.
len(ssh_keys) // 2
)
items[ssh_keys_i]["value"] = "\n".join(ssh_keys[trim_start:])
common_instance_metadata["items"] = items
operation = (
compute.projects()
.setCommonInstanceMetadata(
project=project["name"], body=common_instance_metadata
)
.execute()
)
response = wait_for_compute_global_operation(
project["name"], operation, compute
)
return response
+856
View File
@@ -0,0 +1,856 @@
"""Abstractions around GCP resources and nodes.
The logic has been abstracted away here to allow for different GCP resources
(API endpoints), which can differ widely, making it impossible to use
the same logic for everything.
Classes inheriting from ``GCPResource`` represent different GCP resources -
API endpoints that allow for nodes to be created, removed, listed and
otherwise managed. Those classes contain methods abstracting GCP REST API
calls.
Each resource has a corresponding node type, represented by a
class inheriting from ``GCPNode``. Those classes are essentially dicts
with some extra methods. The instances of those classes will be created
from API responses.
The ``GCPNodeType`` enum is a lightweight way to classify nodes.
Currently, Compute and TPU resources & nodes are supported.
In order to add support for new resources, create classes inheriting from
``GCPResource`` and ``GCPNode``, update the ``GCPNodeType`` enum,
update the ``_generate_node_name`` method and finally update the
node provider.
"""
import abc
import logging
import re
import time
from collections import UserDict
from copy import deepcopy
from enum import Enum
from functools import wraps
from typing import Any, Dict, List, Optional, Tuple, Union
from uuid import uuid4
import httplib2
from google_auth_httplib2 import AuthorizedHttp
from googleapiclient.discovery import Resource
from googleapiclient.errors import HttpError
from ray.autoscaler.tags import TAG_RAY_CLUSTER_NAME, TAG_RAY_NODE_NAME
logger = logging.getLogger(__name__)
INSTANCE_NAME_MAX_LEN = 64
INSTANCE_NAME_UUID_LEN = 8
MAX_POLLS = 12
# TPUs take a long while to respond, so we increase the MAX_POLLS
# considerably - this probably could be smaller
# TPU deletion uses MAX_POLLS
MAX_POLLS_TPU = MAX_POLLS * 8
POLL_INTERVAL = 5
def _retry_on_exception(
exception: Union[Exception, Tuple[Exception]],
regex: Optional[str] = None,
max_retries: int = MAX_POLLS,
retry_interval_s: int = POLL_INTERVAL,
):
"""Retry a function call n-times for as long as it throws an exception."""
def dec(func):
@wraps(func)
def wrapper(*args, **kwargs):
def try_catch_exc():
try:
value = func(*args, **kwargs)
return value
except Exception as e:
if not isinstance(e, exception) or (
regex and not re.search(regex, str(e))
):
raise e
return e
for _ in range(max_retries):
ret = try_catch_exc()
if not isinstance(ret, Exception):
break
time.sleep(retry_interval_s)
if isinstance(ret, Exception):
raise ret
return ret
return wrapper
return dec
def _generate_node_name(labels: dict, node_suffix: str) -> str:
"""Generate node name from labels and suffix.
This is required so that the correct resource can be selected
when the only information autoscaler has is the name of the node.
The suffix is expected to be one of 'compute' or 'tpu'
(as in ``GCPNodeType``).
"""
name_label = labels[TAG_RAY_NODE_NAME]
assert len(name_label) <= (INSTANCE_NAME_MAX_LEN - INSTANCE_NAME_UUID_LEN - 1), (
name_label,
len(name_label),
)
return f"{name_label}-{uuid4().hex[:INSTANCE_NAME_UUID_LEN]}-{node_suffix}"
class GCPNodeType(Enum):
"""Enum for GCP node types (compute & tpu)"""
COMPUTE = "compute"
TPU = "tpu"
@staticmethod
def from_gcp_node(node: "GCPNode"):
"""Return GCPNodeType based on ``node``'s class"""
if isinstance(node, GCPTPUNode):
return GCPNodeType.TPU
if isinstance(node, GCPComputeNode):
return GCPNodeType.COMPUTE
raise TypeError(f"Wrong GCPNode type {type(node)}.")
@staticmethod
def name_to_type(name: str):
"""Provided a node name, determine the type.
This expects the name to be in format '[NAME]-[UUID]-[TYPE]',
where [TYPE] is either 'compute' or 'tpu'.
"""
return GCPNodeType(name.split("-")[-1])
class GCPNode(UserDict, metaclass=abc.ABCMeta):
"""Abstraction around compute and tpu nodes"""
NON_TERMINATED_STATUSES = None
RUNNING_STATUSES = None
STATUS_FIELD = None
def __init__(self, base_dict: dict, resource: "GCPResource", **kwargs) -> None:
super().__init__(base_dict, **kwargs)
self.resource = resource
assert isinstance(self.resource, GCPResource)
def is_running(self) -> bool:
return self.get(self.STATUS_FIELD) in self.RUNNING_STATUSES
def is_terminated(self) -> bool:
return self.get(self.STATUS_FIELD) not in self.NON_TERMINATED_STATUSES
@abc.abstractmethod
def get_labels(self) -> dict:
return
@abc.abstractmethod
def get_external_ip(self) -> str:
return
@abc.abstractmethod
def get_internal_ip(self) -> str:
return
def __repr__(self) -> str:
return f"<{self.__class__.__name__}: {self.get('name')}>"
class GCPComputeNode(GCPNode):
"""Abstraction around compute nodes"""
# https://cloud.google.com/compute/docs/instances/instance-life-cycle
NON_TERMINATED_STATUSES = {"PROVISIONING", "STAGING", "RUNNING"}
TERMINATED_STATUSES = {"TERMINATED", "SUSPENDED"}
RUNNING_STATUSES = {"RUNNING"}
STATUS_FIELD = "status"
def get_labels(self) -> dict:
return self.get("labels", {})
def get_external_ip(self) -> str:
return (
self.get("networkInterfaces", [{}])[0]
.get("accessConfigs", [{}])[0]
.get("natIP", None)
)
def get_internal_ip(self) -> str:
return self.get("networkInterfaces", [{}])[0].get("networkIP")
class GCPTPUNode(GCPNode):
"""Abstraction around tpu nodes"""
# https://cloud.google.com/tpu/docs/reference/rest/v2alpha1/projects.locations.nodes#State
NON_TERMINATED_STATUSES = {"CREATING", "STARTING", "RESTARTING", "READY"}
RUNNING_STATUSES = {"READY"}
STATUS_FIELD = "state"
def get_labels(self) -> dict:
return self.get("labels", {})
@property
def num_workers(self) -> int:
return len(self.get("networkEndpoints", [{}]))
def get_external_ips(self) -> List[str]:
return self.get("networkEndpoints", [{}])
def get_external_ip(self, worker_index: int = 0) -> str:
return (
self.get_external_ips()[worker_index]
.get("accessConfig", {})
.get("externalIp", None)
)
def get_internal_ips(self) -> List[str]:
return self.get("networkEndpoints", [{}])
def get_internal_ip(self, worker_index: int = 0) -> str:
return self.get_internal_ips()[worker_index].get("ipAddress", None)
class GCPResource(metaclass=abc.ABCMeta):
"""Abstraction around compute and TPU resources"""
def __init__(
self,
resource: Resource,
project_id: str,
availability_zone: str,
cluster_name: str,
) -> None:
self.resource = resource
self.project_id = project_id
self.availability_zone = availability_zone
self.cluster_name = cluster_name
@abc.abstractmethod
def get_new_authorized_http(self, http: AuthorizedHttp) -> AuthorizedHttp:
"""Generate a new AuthorizedHttp object with the given credentials."""
return
@abc.abstractmethod
def wait_for_operation(
self,
operation: dict,
max_polls: int = MAX_POLLS,
poll_interval: int = POLL_INTERVAL,
) -> dict:
"""Waits a preset amount of time for operation to complete."""
return None
@abc.abstractmethod
def list_instances(
self,
label_filters: Optional[dict] = None,
is_terminated: bool = False,
) -> List["GCPNode"]:
"""Returns a filtered list of all instances.
The filter removes all terminated instances and, if ``label_filters``
are provided, all instances which labels are not matching the
ones provided.
"""
return
@abc.abstractmethod
def get_instance(self, node_id: str) -> "GCPNode":
"""Returns a single instance."""
return
@abc.abstractmethod
def set_labels(
self, node: GCPNode, labels: dict, wait_for_operation: bool = True
) -> dict:
"""Sets labels on an instance and returns result.
Completely replaces the labels dictionary."""
return
@abc.abstractmethod
def create_instance(
self, base_config: dict, labels: dict, wait_for_operation: bool = True
) -> Tuple[dict, str]:
"""Creates a single instance and returns result.
Returns a tuple of (result, node_name).
"""
return
def create_instances(
self,
base_config: dict,
labels: dict,
count: int,
wait_for_operation: bool = True,
) -> List[Tuple[dict, str]]:
"""Creates multiple instances and returns result.
Returns a list of tuples of (result, node_name).
"""
operations = [
self.create_instance(base_config, labels, wait_for_operation=False)
for i in range(count)
]
if wait_for_operation:
results = [
(self.wait_for_operation(operation), node_name)
for operation, node_name in operations
]
else:
results = operations
return results
@abc.abstractmethod
def delete_instance(self, node_id: str, wait_for_operation: bool = True) -> dict:
"""Deletes an instance and returns result."""
return
@abc.abstractmethod
def stop_instance(self, node_id: str, wait_for_operation: bool = True) -> dict:
"""Deletes an instance and returns result."""
return
@abc.abstractmethod
def start_instance(self, node_id: str, wait_for_operation: bool = True) -> dict:
"""Starts a single instance and returns result."""
return
class GCPCompute(GCPResource):
"""Abstraction around GCP compute resource"""
def get_new_authorized_http(self, http: AuthorizedHttp) -> AuthorizedHttp:
"""Generate a new AuthorizedHttp object with the given credentials."""
new_http = AuthorizedHttp(http.credentials, http=httplib2.Http())
return new_http
def wait_for_operation(
self,
operation: dict,
max_polls: int = MAX_POLLS,
poll_interval: int = POLL_INTERVAL,
) -> dict:
"""Poll for compute zone operation until finished."""
logger.info(
"wait_for_compute_zone_operation: "
f"Waiting for operation {operation['name']} to finish..."
)
for _ in range(max_polls):
result = (
self.resource.zoneOperations()
.get(
project=self.project_id,
operation=operation["name"],
zone=self.availability_zone,
)
.execute(http=self.get_new_authorized_http(self.resource._http))
)
if "error" in result:
raise Exception(result["error"])
if result["status"] == "DONE":
logger.info(
"wait_for_compute_zone_operation: "
f"Operation {operation['name']} finished."
)
break
time.sleep(poll_interval)
return result
def list_instances(
self,
label_filters: Optional[dict] = None,
is_terminated: bool = False,
) -> List[GCPComputeNode]:
label_filters = label_filters or {}
if label_filters:
label_filter_expr = (
"("
+ " AND ".join(
[
"(labels.{key} = {value})".format(key=key, value=value)
for key, value in label_filters.items()
]
)
+ ")"
)
else:
label_filter_expr = ""
statuses = (
GCPComputeNode.TERMINATED_STATUSES
if is_terminated
else GCPComputeNode.NON_TERMINATED_STATUSES
)
instance_state_filter_expr = (
"("
+ " OR ".join(
["(status = {status})".format(status=status) for status in statuses]
)
+ ")"
)
cluster_name_filter_expr = "(labels.{key} = {value})".format(
key=TAG_RAY_CLUSTER_NAME, value=self.cluster_name
)
# TPU VMs spawn accompanying Compute Instances that must be filtered out,
# else this results in duplicated nodes.
tpu_negation_filter_expr = "(NOT labels.{label}:*)".format(label="tpu_cores")
not_empty_filters = [
f
for f in [
label_filter_expr,
instance_state_filter_expr,
cluster_name_filter_expr,
tpu_negation_filter_expr,
]
if f
]
filter_expr = " AND ".join(not_empty_filters)
response = (
self.resource.instances()
.list(
project=self.project_id,
zone=self.availability_zone,
filter=filter_expr,
)
.execute(http=self.get_new_authorized_http(self.resource._http))
)
instances = response.get("items", [])
return [GCPComputeNode(i, self) for i in instances]
def get_instance(self, node_id: str) -> GCPComputeNode:
instance = (
self.resource.instances()
.get(
project=self.project_id,
zone=self.availability_zone,
instance=node_id,
)
.execute()
)
return GCPComputeNode(instance, self)
def set_labels(
self, node: GCPComputeNode, labels: dict, wait_for_operation: bool = True
) -> dict:
body = {
"labels": dict(node["labels"], **labels),
"labelFingerprint": node["labelFingerprint"],
}
node_id = node["name"]
operation = (
self.resource.instances()
.setLabels(
project=self.project_id,
zone=self.availability_zone,
instance=node_id,
body=body,
)
.execute(http=self.get_new_authorized_http(self.resource._http))
)
if wait_for_operation:
result = self.wait_for_operation(operation)
else:
result = operation
return result
def _convert_resources_to_urls(
self, configuration_dict: Dict[str, Any]
) -> Dict[str, Any]:
"""Ensures that resources are in their full URL form.
GCP expects machineType and acceleratorType to be a full URL (e.g.
`zones/us-west1/machineTypes/n1-standard-2`) instead of just the
type (`n1-standard-2`)
Args:
configuration_dict: Dict of options that will be passed to GCP
Returns:
Input dictionary, but with possibly expanding `machineType` and
`acceleratorType`.
"""
configuration_dict = deepcopy(configuration_dict)
existing_machine_type = configuration_dict["machineType"]
if not re.search(".*/machineTypes/.*", existing_machine_type):
configuration_dict[
"machineType"
] = "zones/{zone}/machineTypes/{machine_type}".format(
zone=self.availability_zone,
machine_type=configuration_dict["machineType"],
)
for accelerator in configuration_dict.get("guestAccelerators", []):
gpu_type = accelerator["acceleratorType"]
if not re.search(".*/acceleratorTypes/.*", gpu_type):
accelerator[
"acceleratorType"
] = "projects/{project}/zones/{zone}/acceleratorTypes/{accelerator}".format( # noqa: E501
project=self.project_id,
zone=self.availability_zone,
accelerator=gpu_type,
)
return configuration_dict
def create_instance(
self, base_config: dict, labels: dict, wait_for_operation: bool = True
) -> Tuple[dict, str]:
config = self._convert_resources_to_urls(base_config)
# removing TPU-specific default key set in config.py
config.pop("networkConfig", None)
name = _generate_node_name(labels, GCPNodeType.COMPUTE.value)
labels = dict(config.get("labels", {}), **labels)
config.update(
{
"labels": dict(labels, **{TAG_RAY_CLUSTER_NAME: self.cluster_name}),
"name": name,
}
)
# Allow Google Compute Engine instance templates.
#
# Config example:
#
# ...
# node_config:
# sourceInstanceTemplate: global/instanceTemplates/worker-16
# machineType: e2-standard-16
# ...
#
# node_config parameters override matching template parameters, if any.
#
# https://cloud.google.com/compute/docs/instance-templates
# https://cloud.google.com/compute/docs/reference/rest/v1/instances/insert
source_instance_template = config.pop("sourceInstanceTemplate", None)
operation = (
self.resource.instances()
.insert(
project=self.project_id,
zone=self.availability_zone,
sourceInstanceTemplate=source_instance_template,
body=config,
)
.execute(http=self.get_new_authorized_http(self.resource._http))
)
if wait_for_operation:
result = self.wait_for_operation(operation)
else:
result = operation
return result, name
def delete_instance(self, node_id: str, wait_for_operation: bool = True) -> dict:
operation = (
self.resource.instances()
.delete(
project=self.project_id,
zone=self.availability_zone,
instance=node_id,
)
.execute()
)
if wait_for_operation:
result = self.wait_for_operation(operation)
else:
result = operation
return result
def stop_instance(self, node_id: str, wait_for_operation: bool = True) -> dict:
operation = (
self.resource.instances()
.stop(
project=self.project_id,
zone=self.availability_zone,
instance=node_id,
)
.execute()
)
if wait_for_operation:
result = self.wait_for_operation(operation)
else:
result = operation
return result
def start_instance(self, node_id: str, wait_for_operation: bool = True) -> dict:
operation = (
self.resource.instances()
.start(
project=self.project_id,
zone=self.availability_zone,
instance=node_id,
)
.execute(http=self.get_new_authorized_http(self.resource._http))
)
if wait_for_operation:
result = self.wait_for_operation(operation)
else:
result = operation
return result
class GCPTPU(GCPResource):
"""Abstraction around GCP TPU resource"""
# node names already contain the path, but this is required for `parent`
# arguments
@property
def path(self):
return f"projects/{self.project_id}/locations/{self.availability_zone}"
def get_new_authorized_http(self, http: AuthorizedHttp) -> AuthorizedHttp:
"""Generate a new AuthorizedHttp object with the given credentials."""
new_http = AuthorizedHttp(http.credentials, http=httplib2.Http())
return new_http
def wait_for_operation(
self,
operation: dict,
max_polls: int = MAX_POLLS_TPU,
poll_interval: int = POLL_INTERVAL,
) -> dict:
"""Poll for TPU operation until finished."""
logger.info(
"wait_for_tpu_operation: "
f"Waiting for operation {operation['name']} to finish..."
)
for _ in range(max_polls):
result = (
self.resource.projects()
.locations()
.operations()
.get(name=f"{operation['name']}")
.execute(http=self.get_new_authorized_http(self.resource._http))
)
if "error" in result:
raise Exception(result["error"])
if "response" in result:
logger.info(
"wait_for_tpu_operation: "
f"Operation {operation['name']} finished."
)
break
time.sleep(poll_interval)
return result
def list_instances(
self,
label_filters: Optional[dict] = None,
is_terminated: bool = False,
) -> List[GCPTPUNode]:
response = (
self.resource.projects()
.locations()
.nodes()
.list(parent=self.path)
.execute(http=self.get_new_authorized_http(self.resource._http))
)
instances = response.get("nodes", [])
instances = [GCPTPUNode(i, self) for i in instances]
# filter_expr cannot be passed directly to API
# so we need to filter the results ourselves
# same logic as in GCPCompute.list_instances
label_filters = label_filters or {}
label_filters[TAG_RAY_CLUSTER_NAME] = self.cluster_name
def filter_instance(instance: GCPTPUNode) -> bool:
if instance.is_terminated():
return False
labels = instance.get_labels()
if label_filters:
for key, value in label_filters.items():
if key not in labels:
return False
if value != labels[key]:
return False
return True
instances = list(filter(filter_instance, instances))
return instances
def get_instance(self, node_id: str) -> GCPTPUNode:
instance = (
self.resource.projects()
.locations()
.nodes()
.get(name=node_id)
.execute(http=self.get_new_authorized_http(self.resource._http))
)
return GCPTPUNode(instance, self)
# this sometimes fails without a clear reason, so we retry it
# MAX_POLLS times
@_retry_on_exception(HttpError, "unable to queue the operation")
def set_labels(
self, node: GCPTPUNode, labels: dict, wait_for_operation: bool = True
) -> dict:
body = {
"labels": dict(node["labels"], **labels),
}
update_mask = "labels"
operation = (
self.resource.projects()
.locations()
.nodes()
.patch(
name=node["name"],
updateMask=update_mask,
body=body,
)
.execute(http=self.get_new_authorized_http(self.resource._http))
)
if wait_for_operation:
result = self.wait_for_operation(operation)
else:
result = operation
return result
def create_instance(
self, base_config: dict, labels: dict, wait_for_operation: bool = True
) -> Tuple[dict, str]:
config = base_config.copy()
# removing Compute-specific default key set in config.py
config.pop("networkInterfaces", None)
name = _generate_node_name(labels, GCPNodeType.TPU.value)
labels = dict(config.get("labels", {}), **labels)
config.update(
{
"labels": dict(labels, **{TAG_RAY_CLUSTER_NAME: self.cluster_name}),
}
)
if "networkConfig" not in config:
config["networkConfig"] = {}
if "enableExternalIps" not in config["networkConfig"]:
# this is required for SSH to work, per google documentation
# https://cloud.google.com/tpu/docs/users-guide-tpu-vm#create-curl
config["networkConfig"]["enableExternalIps"] = True
# replace serviceAccounts with serviceAccount, and scopes with scope
# this is necessary for the head node to work
# see here: https://tpu.googleapis.com/$discovery/rest?version=v2alpha1
if "serviceAccounts" in config:
config["serviceAccount"] = config.pop("serviceAccounts")[0]
config["serviceAccount"]["scope"] = config["serviceAccount"].pop("scopes")
operation = (
self.resource.projects()
.locations()
.nodes()
.create(
parent=self.path,
body=config,
nodeId=name,
)
.execute(http=self.get_new_authorized_http(self.resource._http))
)
if wait_for_operation:
result = self.wait_for_operation(operation)
else:
result = operation
return result, name
def delete_instance(self, node_id: str, wait_for_operation: bool = True) -> dict:
operation = (
self.resource.projects()
.locations()
.nodes()
.delete(name=node_id)
.execute(http=self.get_new_authorized_http(self.resource._http))
)
# No need to increase MAX_POLLS for deletion
if wait_for_operation:
result = self.wait_for_operation(operation, max_polls=MAX_POLLS)
else:
result = operation
return result
def stop_instance(self, node_id: str, wait_for_operation: bool = True) -> dict:
operation = (
self.resource.projects()
.locations()
.nodes()
.stop(name=node_id)
.execute(http=self.get_new_authorized_http(self.resource._http))
)
if wait_for_operation:
result = self.wait_for_operation(operation, max_polls=MAX_POLLS)
else:
result = operation
return result
def start_instance(self, node_id: str, wait_for_operation: bool = True) -> dict:
operation = (
self.resource.projects()
.locations()
.nodes()
.start(name=node_id)
.execute(http=self.get_new_authorized_http(self.resource._http))
)
if wait_for_operation:
result = self.wait_for_operation(operation, max_polls=MAX_POLLS)
else:
result = operation
return result
@@ -0,0 +1,350 @@
import copy
import logging
import time
from functools import wraps
from threading import RLock
from types import ModuleType
from typing import Any, Dict, List, Optional, Tuple
import googleapiclient
from ray.autoscaler._private.gcp.config import (
bootstrap_gcp,
construct_clients_from_provider_config,
get_node_type,
tpu_accelerator_config_to_type,
)
# The logic has been abstracted away here to allow for different GCP resources
# (API endpoints), which can differ widely, making it impossible to use
# the same logic for everything.
from ray.autoscaler._private.gcp.node import (
GCPTPU, # noqa
GCPCompute,
GCPNode,
GCPNodeType,
GCPResource,
)
from ray.autoscaler._private.gcp.tpu_command_runner import TPUCommandRunner
from ray.autoscaler.command_runner import CommandRunnerInterface
from ray.autoscaler.node_provider import NodeProvider
logger = logging.getLogger(__name__)
def _retry(method, max_tries=5, backoff_s=1):
"""Retry decorator for methods of GCPNodeProvider.
Upon catching BrokenPipeError, API clients are rebuilt and
decorated methods are retried.
Work-around for https://github.com/ray-project/ray/issues/16072.
Based on https://github.com/kubeflow/pipelines/pull/5250/files.
"""
@wraps(method)
def method_with_retries(self, *args, **kwargs):
try_count = 0
while try_count < max_tries:
try:
return method(self, *args, **kwargs)
except BrokenPipeError:
logger.warning("Caught a BrokenPipeError. Retrying.")
try_count += 1
if try_count < max_tries:
self._construct_clients()
time.sleep(backoff_s)
else:
raise
return method_with_retries
class GCPNodeProvider(NodeProvider):
def __init__(self, provider_config: dict, cluster_name: str):
NodeProvider.__init__(self, provider_config, cluster_name)
self.lock = RLock()
self._construct_clients()
self.cache_stopped_nodes = provider_config.get("cache_stopped_nodes", False)
# Cache of node objects from the last nodes() call. This avoids
# excessive DescribeInstances requests.
self.cached_nodes: Dict[str, GCPNode] = {}
def _construct_clients(self):
_, _, compute, tpu = construct_clients_from_provider_config(
self.provider_config
)
# Dict of different resources provided by GCP.
# At this moment - Compute and TPUs
self.resources: Dict[GCPNodeType, GCPResource] = {}
# Compute is always required
self.resources[GCPNodeType.COMPUTE] = GCPCompute(
compute,
self.provider_config["project_id"],
self.provider_config["availability_zone"],
self.cluster_name,
)
# if there are no TPU nodes defined in config, tpu will be None.
if tpu is not None:
self.resources[GCPNodeType.TPU] = GCPTPU(
tpu,
self.provider_config["project_id"],
self.provider_config["availability_zone"],
self.cluster_name,
)
def _get_resource_depending_on_node_name(self, node_name: str) -> GCPResource:
"""Return the resource responsible for the node, based on node_name.
This expects the name to be in format '[NAME]-[UUID]-[TYPE]',
where [TYPE] is either 'compute' or 'tpu' (see ``GCPNodeType``).
"""
return self.resources[GCPNodeType.name_to_type(node_name)]
@_retry
def non_terminated_nodes(self, tag_filters: dict):
with self.lock:
instances = []
for resource in self.resources.values():
node_instances = resource.list_instances(tag_filters)
instances += node_instances
# Note: All the operations use "name" as the unique instance id
self.cached_nodes = {i["name"]: i for i in instances}
return [i["name"] for i in instances]
def is_running(self, node_id: str):
with self.lock:
node = self._get_cached_node(node_id)
return node.is_running()
def is_terminated(self, node_id: str):
with self.lock:
node = self._get_cached_node(node_id)
return node.is_terminated()
def node_tags(self, node_id: str):
with self.lock:
node = self._get_cached_node(node_id)
return node.get_labels()
@_retry
def set_node_tags(self, node_id: str, tags: dict):
with self.lock:
labels = tags
node = self._get_node(node_id)
resource = self._get_resource_depending_on_node_name(node_id)
result = resource.set_labels(node=node, labels=labels)
return result
def external_ip(self, node_id: str):
with self.lock:
node = self._get_cached_node(node_id)
ip = node.get_external_ip()
if ip is None:
node = self._get_node(node_id)
ip = node.get_external_ip()
return ip
def internal_ip(self, node_id: str):
with self.lock:
node = self._get_cached_node(node_id)
ip = node.get_internal_ip()
if ip is None:
node = self._get_node(node_id)
ip = node.get_internal_ip()
return ip
@_retry
def create_node(self, base_config: dict, tags: dict, count: int) -> Dict[str, dict]:
"""Creates instances.
Returns dict mapping instance id to each create operation result for the created
instances.
"""
with self.lock:
labels = tags # gcp uses "labels" instead of aws "tags"
node_type = get_node_type(base_config)
resource = self.resources[node_type]
all_nodes = {}
if self.cache_stopped_nodes:
filters = {
"ray-node-name": labels["ray-node-name"],
"ray-node-type": labels["ray-node-type"],
"ray-user-node-type": labels["ray-user-node-type"],
}
reuse_nodes = resource.list_instances(filters, True)[:count]
if reuse_nodes:
reused_nodes_dict = {
n["name"]: resource.start_instance(n["name"])
for n in reuse_nodes
}
all_nodes.update(reused_nodes_dict)
count -= len(reuse_nodes)
if count > 0:
results: List[Tuple[dict, str]] = resource.create_instances(
base_config, labels, count
)
created_nodes_dict = {
instance_id: result for result, instance_id in results
}
all_nodes.update(created_nodes_dict)
return all_nodes
def _thread_unsafe_terminate_node(self, node_id: str):
# Assumes the global lock is held for the duration of this operation.
# The lock may be held by a different thread if in `terminate_nodes()` case.
logger.info("NodeProvider: {}: Terminating node".format(node_id))
resource = self._get_resource_depending_on_node_name(node_id)
try:
result = resource.delete_instance(
node_id=node_id,
)
except googleapiclient.errors.HttpError as http_error:
if http_error.resp.status == 404:
logger.warning(
f"Tried to delete the node with id {node_id} "
"but it was already gone."
)
result = None
else:
raise http_error from None
return result
@_retry
def terminate_node(self, node_id: str):
with self.lock:
resource = self._get_resource_depending_on_node_name(node_id)
try:
if self.cache_stopped_nodes:
node = self._get_cached_node(node_id)
if node.is_running():
result = resource.stop_instance(node_id=node_id)
else:
result = None
else:
result = resource.delete_instance(
node_id=node_id,
)
except googleapiclient.errors.HttpError as http_error:
if http_error.resp.status == 404:
logger.warning(
f"Tried to delete the node with id {node_id} "
"but it was already gone."
)
else:
raise http_error from None
return result
@_retry
def _get_node(self, node_id: str) -> GCPNode:
self.non_terminated_nodes({}) # Side effect: updates cache
with self.lock:
if node_id in self.cached_nodes:
return self.cached_nodes[node_id]
resource = self._get_resource_depending_on_node_name(node_id)
instance = resource.get_instance(node_id=node_id)
return instance
def _get_cached_node(self, node_id: str) -> GCPNode:
if node_id in self.cached_nodes:
return self.cached_nodes[node_id]
return self._get_node(node_id)
@staticmethod
def bootstrap_config(cluster_config):
return bootstrap_gcp(cluster_config)
@staticmethod
def fillout_available_node_types_resources(
cluster_config: Dict[str, Any]
) -> Dict[str, Any]:
"""Fill out TPU resources to the cluster config.
To enable TPU pod autoscaling, we provide the TPU accelerator
type as a resource that only exists on worker 0 of the pod slice.
For instance, a v4-16 should have the resource labels:
worker 0: resources = {"TPU": 4, "TPU-v4-16-head": 1}
worker 1: resources = {"TPU": 4}
For the autoscaler to correctly process the demands of
creating a new TPU pod, then the autoscaler must know what
a TPU pod is in the form of the TPU accelerator resource.
Therefore we fill out TPU pods appropriately by providing the
expected resource which we can deduce from the cluster config.
"""
if "available_node_types" not in cluster_config:
return cluster_config
cluster_config = copy.deepcopy(cluster_config)
available_node_types = cluster_config["available_node_types"]
for node_type in available_node_types:
node_config = available_node_types[node_type]["node_config"]
if get_node_type(node_config) == GCPNodeType.TPU:
autodetected_resources = {}
accelerator_type = ""
if "acceleratorType" in node_config:
accelerator_type = node_config["acceleratorType"]
elif "acceleratorConfig" in node_config:
accelerator_type = tpu_accelerator_config_to_type(
node_config["acceleratorConfig"]
)
if not accelerator_type:
continue
autodetected_resources[f"TPU-{accelerator_type}-head"] = 1
available_node_types[node_type]["resources"].update(
autodetected_resources
)
return cluster_config
def get_command_runner(
self,
log_prefix: str,
node_id: str,
auth_config: Dict[str, Any],
cluster_name: str,
process_runner: ModuleType,
use_internal_ip: bool,
docker_config: Optional[Dict[str, Any]] = None,
) -> CommandRunnerInterface:
"""Returns a TPU command runner as applicable."""
resource = self._get_resource_depending_on_node_name(node_id)
instance = resource.get_instance(node_id)
common_args = {
"docker_config": docker_config,
"log_prefix": log_prefix,
"node_id": node_id,
"auth_config": auth_config,
"cluster_name": cluster_name,
"process_runner": process_runner,
"use_internal_ip": use_internal_ip,
}
if (
GCPNodeType.TPU in self.resources
and resource == self.resources[GCPNodeType.TPU]
):
return TPUCommandRunner(instance=instance, provider=self, **common_args)
else:
return super().get_command_runner(**common_args)
@@ -0,0 +1,329 @@
"""Command runners specific to TPU VM pods.
TPU VM pods may contain multiple hosts, each including attached TPU chips and
associated internal/external IP addresses.
To support TPU VM pods, we represent entire TPU pods as "Ray Nodes", meaning
that TPU pods will need to run the operations specified in `CommandRunnerInterface`
N times, where N denotes the number of hosts that comprise a TPU pod.
To maintain feature completeness, we simply wrap the existing `SSHCommandRunner` and
`DockerCommandRunner` and run them as batched calls.
"""
import copy
from concurrent.futures import ThreadPoolExecutor
from types import ModuleType
from typing import Any, Dict, Optional
from ray._private import ray_constants
from ray.autoscaler._private.command_runner import DockerCommandRunner, SSHCommandRunner
from ray.autoscaler._private.gcp.node import GCPTPUNode
from ray.autoscaler.command_runner import CommandRunnerInterface
from ray.autoscaler.node_provider import NodeProvider
class TPUVMSSHCommandRunner(SSHCommandRunner):
"""An SSH command runner with overwritten IP address calls."""
def __init__(
self,
internal_ip: str,
external_ip: str,
worker_id: int,
accelerator_type: str,
*args,
**kwargs,
):
self._internal_ip = internal_ip
self._external_ip = external_ip
self._worker_id = worker_id
self._accelerator_type = accelerator_type
super().__init__(*args, **kwargs)
def _get_node_ip(self) -> str:
if self.use_internal_ip:
return self._internal_ip
else:
return self._external_ip
def run(
self,
cmd,
timeout=120,
exit_on_fail=False,
port_forward=None,
with_output=False,
environment_variables: Dict[str, object] = None,
run_env="auto", # Unused argument.
ssh_options_override_ssh_key="",
shutdown_after_run=False,
) -> str:
"""Override the SSH run for TPU VM pods.
Main functionality here we need to inject is to intercept the resources
provided by the node_provider TPU node type fillout.
node_provider will provide a resource "TPU-{TPU_POD_TYPE}-head" which:
1) allows application developers to target worker 0 of an arbitary TPU pod, and
2) signals to the autoscaler how to address the demand for more TPU pods.
Without this intercept, then all workers of a TPU pod will have the
"TPU-{TPU_POD_TYPE}-head" resource which will violate functionality (1)
above.
"""
if environment_variables:
environment_variables = _maybe_remove_head_resource(
environment_variables, self._worker_id, self._accelerator_type
)
return super().run(
cmd=cmd,
timeout=timeout,
exit_on_fail=exit_on_fail,
port_forward=port_forward,
with_output=with_output,
environment_variables=environment_variables,
run_env=run_env,
ssh_options_override_ssh_key=ssh_options_override_ssh_key,
shutdown_after_run=shutdown_after_run,
)
class TPUVMDockerCommandRunner(DockerCommandRunner):
"""A Docker command runner with overwritten IP addresses."""
def __init__(
self,
docker_config: Dict[str, Any],
internal_ip: str,
external_ip: str,
worker_id: int,
accelerator_type: str,
**common_args,
):
super().__init__(docker_config=docker_config, **common_args)
self._worker_id = worker_id
self._accelerator_type = accelerator_type
self.ssh_command_runner = TPUVMSSHCommandRunner(
internal_ip=internal_ip,
external_ip=external_ip,
worker_id=worker_id,
accelerator_type=accelerator_type,
**common_args,
)
def run(
self,
cmd,
timeout=120,
exit_on_fail=False,
port_forward=None,
with_output=False,
environment_variables: Optional[Dict[str, object]] = None,
run_env="auto",
ssh_options_override_ssh_key="",
shutdown_after_run=False,
):
if environment_variables:
environment_variables = _maybe_remove_head_resource(
environment_variables, self._worker_id, self._accelerator_type
)
return super().run(
cmd,
timeout,
exit_on_fail,
port_forward,
with_output,
environment_variables,
run_env,
ssh_options_override_ssh_key,
shutdown_after_run,
)
class TPUCommandRunner(CommandRunnerInterface):
"""A TPU pod command runner."""
def __init__(
self,
instance: GCPTPUNode,
log_prefix: str,
node_id: str,
auth_config: Dict[str, Any],
provider: NodeProvider,
cluster_name: str,
process_runner: ModuleType,
use_internal_ip: bool,
docker_config: Optional[Dict[str, Any]] = None,
):
def create_command_runner(
worker_id: int, accelerator_type: str, internal_ip: str, external_ip: str
) -> CommandRunnerInterface:
"""Returns the correct base command runner."""
common_args = {
"internal_ip": internal_ip,
"external_ip": external_ip,
"worker_id": worker_id,
"accelerator_type": accelerator_type,
"log_prefix": "[tpu_worker_{}] ".format(worker_id) + log_prefix,
"node_id": node_id,
"provider": provider,
"auth_config": auth_config,
"cluster_name": cluster_name,
"process_runner": process_runner,
"use_internal_ip": use_internal_ip,
}
if docker_config and docker_config["container_name"] != "":
return TPUVMDockerCommandRunner(
docker_config=docker_config, **common_args
)
else:
return TPUVMSSHCommandRunner(**common_args)
self._command_runners = []
self._num_workers = instance.num_workers
for i in range(self._num_workers):
self._command_runners.append(
create_command_runner(
worker_id=i,
accelerator_type=instance.get("acceleratorType"),
internal_ip=instance.get_internal_ip(i),
external_ip=instance.get_external_ip(i),
)
)
@property
def num_connections(self) -> int:
"""Return the number of active connections allowed at a time.
We occasionally see issues where too many concurrent connections may lead to
failed SSH connections when there are too many TPU hosts.
We utilize this property to cap the maximum number of active connections
at a time until a proper fix is found.
"""
num_max_concurrent_active_connections = ray_constants.env_integer(
ray_constants.RAY_TPU_MAX_CONCURRENT_CONNECTIONS_ENV_VAR, default=16
)
return min(self._num_workers, num_max_concurrent_active_connections)
def run(
self,
cmd,
timeout=120,
exit_on_fail=False,
port_forward=None,
with_output=False,
environment_variables: Dict[str, object] = None,
run_env="auto", # Unused argument.
ssh_options_override_ssh_key="",
shutdown_after_run=False,
) -> str:
with ThreadPoolExecutor(self.num_connections) as executor:
results = executor.map(
lambda i: self._command_runners[i].run(
cmd=cmd,
timeout=timeout,
exit_on_fail=exit_on_fail,
port_forward=port_forward,
with_output=with_output,
environment_variables=copy.deepcopy(environment_variables),
run_env=run_env,
ssh_options_override_ssh_key=ssh_options_override_ssh_key,
shutdown_after_run=shutdown_after_run,
),
range(self._num_workers),
)
# Note: the `run` abstract function may return a string representing
# representing command output, but this result is rarely used - especially
# if the node is a worker (which a TPU pod is).
# We return only the results from worker 0 which may not always be expected.
return list(results)[0]
def run_rsync_up(self, *args, **kwargs) -> None:
with ThreadPoolExecutor(self.num_connections) as executor:
executor.map(
lambda i: self._command_runners[i].run_rsync_up(*args, **kwargs),
range(self._num_workers),
)
def run_rsync_down(self, *args: Any, **kwargs: Any) -> None:
"""Rsync files down from the cluster node.
Args:
*args: Forwarded to each per-worker ``run_rsync_down`` call.
Includes the (remote) source path and (local) target path.
**kwargs: Forwarded to each per-worker ``run_rsync_down`` call.
"""
with ThreadPoolExecutor(self.num_connections) as executor:
executor.map(
lambda i: self._command_runners[i].run_rsync_down(*args, **kwargs),
range(self._num_workers),
)
def remote_shell_command_str(self) -> str:
"""Return the command the user can use to open a shell."""
# Note: this function is rarely used if the node is a worker.
# We return only the results from worker 0 which may not always be expected.
return self._command_runners[0].remote_shell_command_str()
def run_init(self, *args: Any, **kwargs: Any) -> Optional[bool]:
"""Used to run extra initialization commands.
Args:
*args: Forwarded to each per-worker ``run_init`` call. Includes
``as_head``, ``file_mounts``, and ``sync_run_yet``.
**kwargs: Forwarded to each per-worker ``run_init`` call.
Returns:
Whether initialization is necessary on any worker.
"""
with ThreadPoolExecutor(self.num_connections) as executor:
results = executor.map(
lambda i: self._command_runners[i].run_init(*args, **kwargs),
range(self._num_workers),
)
# Note: the `run_init` abstract function may return a bool representing
# whether initialization is necessary, but this result is rarely used -
# especially if the node is a worker (which a TPU pod is).
# Here we return whether any workers require initialization, which may not be
# the expected result.
return any(results)
def _maybe_remove_head_resource(
environment_variables: Dict[str, Any], worker_id: int, accelerator_type: str
):
"""
node_provider will provide a resource "TPU-{TPU_POD_TYPE}-head" which:
1) allows application developers to target worker 0 of an arbitary TPU pod, and
2) signals to the autoscaler how to address the demand for more TPU pods.
Without this intercept, then all workers of a TPU pod will have the
"TPU-{TPU_POD_TYPE}-head" resource which will violate functionality (1)
above.
"""
resources = environment_variables.get(
ray_constants.RESOURCES_ENVIRONMENT_VARIABLE, None
)
if resources:
# For TPU pod support, we need to ensure that the
# tpu pod resource type only propagates to worker 0.
if worker_id != 0:
tpu_pod_resource_type = f"TPU-{accelerator_type}-head"
if tpu_pod_resource_type in resources:
resources = copy.copy(resources)
resources.pop(tpu_pod_resource_type, None)
environment_variables = {
**environment_variables,
ray_constants.RESOURCES_ENVIRONMENT_VARIABLE: resources,
}
return environment_variables
@@ -0,0 +1,555 @@
import decimal
import json
import logging
import time
from itertools import chain
from typing import Any, Dict, Optional
import requests
from ray._private.label_utils import (
validate_node_label_syntax,
)
from ray.autoscaler._private.constants import (
DISABLE_LAUNCH_CONFIG_CHECK_KEY,
DISABLE_NODE_UPDATERS_KEY,
FOREGROUND_NODE_LAUNCH_KEY,
WORKER_LIVENESS_CHECK_KEY,
)
from ray.autoscaler._private.kuberay import node_provider, utils
from ray.autoscaler._private.util import validate_config
from ray.util.debug import log_once
logger = logging.getLogger(__name__)
AUTOSCALER_OPTIONS_KEY = "autoscalerOptions"
IDLE_SECONDS_KEY = "idleTimeoutSeconds"
UPSCALING_KEY = "upscalingMode"
UPSCALING_VALUE_AGGRESSIVE = "Aggressive"
UPSCALING_VALUE_DEFAULT = "Default"
UPSCALING_VALUE_CONSERVATIVE = "Conservative"
MAX_RAYCLUSTER_FETCH_TRIES = 5
RAYCLUSTER_FETCH_RETRY_S = 5
GKE_TPU_TOPOLOGY_LABEL = "cloud.google.com/gke-tpu-topology"
GKE_TPU_ACCELERATOR_LABEL = "cloud.google.com/gke-tpu-accelerator"
# Logical group name for the KubeRay head group.
# Used as the name of the "head node type" by the autoscaler.
_HEAD_GROUP_NAME = "headgroup"
class AutoscalingConfigProducer:
"""Produces an autoscaling config by reading data from the RayCluster CR.
Used to fetch the autoscaling config at the beginning of each autoscaler iteration.
In the context of Ray deployment on Kubernetes, the autoscaling config is an
internal interface.
The autoscaling config carries the strict subset of RayCluster CR data required by
the autoscaler to make scaling decisions; in particular, the autoscaling config does
not carry pod configuration data.
This class is the only public object in this file.
"""
def __init__(self, ray_cluster_name, ray_cluster_namespace):
self.kubernetes_api_client = node_provider.KubernetesHttpApiClient(
namespace=ray_cluster_namespace
)
self._ray_cr_path = f"rayclusters/{ray_cluster_name}"
def __call__(self):
ray_cr = self._fetch_ray_cr_from_k8s_with_retries()
autoscaling_config = _derive_autoscaling_config_from_ray_cr(ray_cr)
return autoscaling_config
def _fetch_ray_cr_from_k8s_with_retries(self) -> Dict[str, Any]:
"""Fetch the RayCluster CR by querying the K8s API server.
Retry on HTTPError for robustness, in particular to protect autoscaler
initialization.
"""
for i in range(1, MAX_RAYCLUSTER_FETCH_TRIES + 1):
try:
return self.kubernetes_api_client.get(self._ray_cr_path)
except requests.HTTPError as e:
if i < MAX_RAYCLUSTER_FETCH_TRIES:
logger.exception(
"Failed to fetch RayCluster CR from K8s. Retrying."
)
time.sleep(RAYCLUSTER_FETCH_RETRY_S)
else:
raise e from None
# This branch is inaccessible. Raise to satisfy mypy.
raise AssertionError
def _derive_autoscaling_config_from_ray_cr(ray_cr: Dict[str, Any]) -> Dict[str, Any]:
provider_config = _generate_provider_config(ray_cr["metadata"]["namespace"])
available_node_types = _generate_available_node_types_from_ray_cr_spec(
ray_cr["spec"]
)
# The autoscaler expects a global max workers field. We set it to the sum of
# node type max workers.
global_max_workers = sum(
node_type["max_workers"] for node_type in available_node_types.values()
)
# Legacy autoscaling fields carry no information but are required for compatibility.
legacy_autoscaling_fields = _generate_legacy_autoscaling_config_fields()
# Process autoscaler options.
autoscaler_options = ray_cr["spec"].get(AUTOSCALER_OPTIONS_KEY, {})
if IDLE_SECONDS_KEY in autoscaler_options:
idle_timeout_minutes = autoscaler_options[IDLE_SECONDS_KEY] / 60.0
else:
idle_timeout_minutes = 1.0
if autoscaler_options.get(UPSCALING_KEY) == UPSCALING_VALUE_CONSERVATIVE:
upscaling_speed = 1 # Rate-limit upscaling if "Conservative" is set by user.
# This elif is redudant but included for clarity.
elif autoscaler_options.get(UPSCALING_KEY) == UPSCALING_VALUE_DEFAULT:
upscaling_speed = 1000 # i.e. big, no rate-limiting by default
# This elif is redudant but included for clarity.
elif autoscaler_options.get(UPSCALING_KEY) == UPSCALING_VALUE_AGGRESSIVE:
upscaling_speed = 1000
else:
upscaling_speed = 1000
autoscaling_config = {
"provider": provider_config,
"cluster_name": ray_cr["metadata"]["name"],
"head_node_type": _HEAD_GROUP_NAME,
"available_node_types": available_node_types,
"max_workers": global_max_workers,
# Should consider exposing `idleTimeoutMinutes` in the RayCluster CRD,
# under an `autoscaling` field.
"idle_timeout_minutes": idle_timeout_minutes,
# Should consider exposing `upscalingSpeed` in the RayCluster CRD,
# under an `autoscaling` field.
"upscaling_speed": upscaling_speed,
**legacy_autoscaling_fields,
}
# Make sure the config is readable by the autoscaler.
validate_config(autoscaling_config)
return autoscaling_config
def _generate_provider_config(ray_cluster_namespace: str) -> Dict[str, Any]:
"""Generates the `provider` field of the autoscaling config, which carries data
required to instantiate the KubeRay node provider.
"""
return {
"type": "kuberay",
"namespace": ray_cluster_namespace,
DISABLE_NODE_UPDATERS_KEY: True,
DISABLE_LAUNCH_CONFIG_CHECK_KEY: True,
FOREGROUND_NODE_LAUNCH_KEY: True,
WORKER_LIVENESS_CHECK_KEY: False,
}
def _generate_legacy_autoscaling_config_fields() -> Dict[str, Any]:
"""Generates legacy autoscaling config fields required for compatibiliy."""
return {
"file_mounts": {},
"cluster_synced_files": [],
"file_mounts_sync_continuously": False,
"initialization_commands": [],
"setup_commands": [],
"head_setup_commands": [],
"worker_setup_commands": [],
"head_start_ray_commands": [],
"worker_start_ray_commands": [],
"auth": {},
}
def _generate_available_node_types_from_ray_cr_spec(
ray_cr_spec: Dict[str, Any],
) -> Dict[str, Any]:
"""Formats autoscaler "available_node_types" field based on the Ray CR's group
specs.
"""
headGroupSpec = ray_cr_spec["headGroupSpec"]
return {
_HEAD_GROUP_NAME: _node_type_from_group_spec(headGroupSpec, is_head=True),
**{
worker_group_spec["groupName"]: _node_type_from_group_spec(
worker_group_spec, is_head=False
)
for worker_group_spec in ray_cr_spec["workerGroupSpecs"]
},
}
def _node_type_from_group_spec(
group_spec: Dict[str, Any], is_head: bool
) -> Dict[str, Any]:
"""Converts CR group spec to autoscaler node type."""
group_name = _HEAD_GROUP_NAME if is_head else group_spec["groupName"]
if is_head:
# The head node type has no workers because the head is not a worker.
min_workers = max_workers = 0
else:
# `minReplicas` and `maxReplicas` are required fields for each workerGroupSpec.
# numOfHosts specifies the number of workers per replica in KubeRay v1.1+.
min_workers = group_spec["minReplicas"] * group_spec.get("numOfHosts", 1)
max_workers = group_spec["maxReplicas"] * group_spec.get("numOfHosts", 1)
resources = _get_ray_resources_from_group_spec(group_spec, is_head)
labels = _get_labels_from_group_spec(group_spec, group_name)
node_type = {
"min_workers": min_workers,
"max_workers": max_workers,
# `node_config` is a legacy field required for compatibility.
# Pod config data is required by the operator but not by the autoscaler.
"node_config": {},
"resources": resources,
"labels": labels,
}
idle_timeout_s = group_spec.get(IDLE_SECONDS_KEY)
if idle_timeout_s is not None:
node_type["idle_timeout_s"] = float(idle_timeout_s)
return node_type
def _get_ray_resources_from_group_spec(
group_spec: Dict[str, Any], is_head: bool
) -> Dict[str, int]:
"""
Infers Ray resources from group `Resources` field, rayStartCommands, or K8s limits.
The resources extracted are used in autoscaling calculations.
"""
# Set resources from top-level group 'Resources' field if it exists.
group_resources = group_spec.get("resources", {})
ray_start_params = group_spec.get("rayStartParams", {})
# In KubeRay, Ray container is always the first application container of a Ray Pod.
k8s_resources = group_spec["template"]["spec"]["containers"][0].get("resources", {})
group_name = _HEAD_GROUP_NAME if is_head else group_spec["groupName"]
num_cpus = _get_num_cpus(
group_resources, ray_start_params, k8s_resources, group_name
)
num_gpus = _get_num_gpus(
group_resources, ray_start_params, k8s_resources, group_name
)
custom_resource_dict = _get_custom_resources(
group_resources, ray_start_params, group_name
)
num_tpus = _get_num_tpus(group_resources, custom_resource_dict, k8s_resources)
memory = _get_memory(group_resources, ray_start_params, k8s_resources)
# It's not allowed to use object store memory as a resource request, so we don't
# add that to the autoscaler's resources annotations.
resources = {}
assert isinstance(num_cpus, int)
resources["CPU"] = num_cpus
if num_gpus is not None:
resources["GPU"] = num_gpus
if num_tpus is not None:
# Add TPU Ray resource if not already added by ray_start_params,
# but specified in k8s_resource_limits.
if "TPU" not in custom_resource_dict:
resources["TPU"] = num_tpus
"""Add TPU head resource, similar to the GCP node_provider.
Sets the Ray resource TPU-{...}-head to ensure the Ray autoscaler
has sufficient resources to make scaling decisions.
TPU worker groups treat each TPU podslice as a replica, with `NumOfHosts`
specifying the number of workers per slice. Each replica of a TPU worker
group has one TPU head.
For example, a v4-16 worker group with 2 replicas should have the following
resource labels on worker 0 of each replica:
worker 0: resources = {"TPU": 4, "TPU-v4-16-head": 1}
"""
if (
"nodeSelector" in group_spec["template"]["spec"]
and GKE_TPU_TOPOLOGY_LABEL in group_spec["template"]["spec"]["nodeSelector"]
and GKE_TPU_ACCELERATOR_LABEL
in group_spec["template"]["spec"]["nodeSelector"]
):
topology = group_spec["template"]["spec"]["nodeSelector"][
GKE_TPU_TOPOLOGY_LABEL
]
accelerator = group_spec["template"]["spec"]["nodeSelector"][
GKE_TPU_ACCELERATOR_LABEL
]
accelerator_type = utils.tpu_node_selectors_to_type(topology, accelerator)
if accelerator_type:
resources[f"TPU-{accelerator_type}-head"] = 1
else:
logger.error(
f"Pods using TPUs require both `{GKE_TPU_TOPOLOGY_LABEL}` and `{GKE_TPU_ACCELERATOR_LABEL}` node selectors. "
"See https://docs.ray.io/en/latest/cluster/kubernetes/user-guides/tpu.html#configuring-ray-pods-for-tpu-usage "
"and https://cloud.google.com/kubernetes-engine/docs/how-to/tpus."
)
if memory is not None:
resources["memory"] = memory
resources.update(custom_resource_dict)
return resources
def _get_labels_from_group_spec(
group_spec: Dict[str, Any], group_name: str = ""
) -> Dict[str, str]:
"""
Parses Ray node labels for the autoscaling config based on the following
priority:
1. Top-level `labels` field in the group spec.
2. `labels` field in `rayStartParams`.
Args:
group_spec: The group specification dictionary.
group_name: The name of the group (used in warning messages).
Returns:
A dictionary of labels for the node type.
"""
labels_dict = {}
ray_start_params = group_spec.get("rayStartParams", {})
labels_str = ray_start_params.get("labels")
# Use a unique log_once key per group to ensure each group's warning is shown.
log_once_key = (
f"raystartparams_labels_warning_{group_name}"
if group_name
else "raystartparams_labels_warning"
)
if labels_str and log_once(log_once_key):
if group_name:
logger.warning(
f"Ignoring labels: {labels_str} set in rayStartParams for group "
f"'{group_name}'. Group labels are supported in the top-level "
"Labels field starting in KubeRay v1.5"
)
else:
logger.warning(
f"Ignoring labels: {labels_str} set in rayStartParams. "
"Group labels are supported in the top-level Labels field "
"starting in KubeRay v1.5"
)
# Check for top-level structured Labels field.
if "labels" in group_spec and isinstance(group_spec.get("labels"), dict):
labels_dict = group_spec.get("labels")
# Validate node labels follow expected Kubernetes label syntax.
validate_node_label_syntax(labels_dict)
return labels_dict
def _get_num_cpus(
group_resources: Dict[str, str],
ray_start_params: Dict[str, str],
k8s_resources: Dict[str, Dict[str, str]],
group_name: str,
) -> int:
"""Get CPU annotation from `resources` field, ray_start_params or k8s_resources,
with priority for `resources` field.
"""
if "CPU" in group_resources:
if "num-cpus" in ray_start_params:
logger.warning(
f"'CPU' specified in both the top-level 'resources' field and in 'rayStartParams'. "
f"Using the value from 'resources': {group_resources['CPU']}."
)
return _round_up_k8s_quantity(group_resources["CPU"])
if "num-cpus" in ray_start_params:
return int(ray_start_params["num-cpus"])
elif "cpu" in k8s_resources.get("limits", {}):
cpu_quantity: str = k8s_resources["limits"]["cpu"]
return _round_up_k8s_quantity(cpu_quantity)
elif "cpu" in k8s_resources.get("requests", {}):
cpu_quantity: str = k8s_resources["requests"]["cpu"]
return _round_up_k8s_quantity(cpu_quantity)
else:
# Getting the number of CPUs is important, so raise an error if we can't do it.
raise ValueError(
f"Autoscaler failed to detect `CPU` resources for group {group_name}."
"\nSet the `--num-cpus` rayStartParam and/or "
"the CPU resource limit for the Ray container."
)
def _get_memory(
group_resources: Dict[str, str],
ray_start_params: Dict[str, str],
k8s_resources: Dict[str, Dict[str, str]],
) -> Optional[int]:
"""Get memory resource annotation from `resources` field, ray_start_params or k8s_resources,
with priority for `resources` field.
"""
if "memory" in group_resources:
if "memory" in ray_start_params:
logger.warning(
f"'memory' specified in both the top-level 'resources' field and in 'rayStartParams'. "
f"Using the value from 'resources': {group_resources['memory']}."
)
return _round_up_k8s_quantity(group_resources["memory"])
if "memory" in ray_start_params:
return int(ray_start_params["memory"])
elif "memory" in k8s_resources.get("limits", {}):
memory_quantity: str = k8s_resources["limits"]["memory"]
return _round_up_k8s_quantity(memory_quantity)
elif "memory" in k8s_resources.get("requests", {}):
memory_quantity: str = k8s_resources["requests"]["memory"]
return _round_up_k8s_quantity(memory_quantity)
return None
def _get_num_gpus(
group_resources: Dict[str, str],
ray_start_params: Dict[str, str],
k8s_resources: Dict[str, Dict[str, str]],
group_name: str,
) -> Optional[int]:
"""Get GPU resource annotation from `resources` field, ray_start_params or k8s_resources,
with priority for `resources` field.
"""
if "GPU" in group_resources:
if "num-gpus" in ray_start_params:
logger.warning(
f"'GPU' specified in both the top-level 'resources' field and in 'rayStartParams'. "
f"Using the value from 'resources': {group_resources['GPU']}."
)
return _round_up_k8s_quantity(group_resources["GPU"])
elif "num-gpus" in ray_start_params:
return int(ray_start_params["num-gpus"])
else:
for key, resource_quantity in chain(
k8s_resources.get("limits", {}).items(),
k8s_resources.get("requests", {}).items(),
):
# e.g. nvidia.com/gpu
if key.endswith("gpu"):
# Typically, this is a string representing an interger, e.g. "1".
# Convert to int, making no assumptions on the resource_quantity,
# besides that it's valid as a K8s resource quantity.
num_gpus = _round_up_k8s_quantity(resource_quantity)
if num_gpus > 0:
# Only one GPU type supported for now, break out on first
# "/gpu" match.
return num_gpus
return None
def _get_num_tpus(
group_resources: Dict[str, str],
custom_resource_dict: Dict[str, int],
k8s_resources: Dict[str, Dict[str, str]],
) -> Optional[int]:
"""Get TPU custom resource annotation from `resources` field, custom_resource_dict in ray_start_params,
or k8s_resources, with priority for `resources` field.
"""
if "TPU" in group_resources:
return _round_up_k8s_quantity(group_resources["TPU"])
elif "TPU" in custom_resource_dict:
return custom_resource_dict["TPU"]
else:
for typ in ["limits", "requests"]:
tpu_resource_quantity = k8s_resources.get(typ, {}).get("google.com/tpu")
if tpu_resource_quantity is not None:
# Typically, this is a string representing an integer, e.g. "1".
# Convert to int, making no assumptions on the tpu_resource_quantity,
# besides that it's valid as a K8s resource quantity.
num_tpus = _round_up_k8s_quantity(tpu_resource_quantity)
if num_tpus > 0:
return num_tpus
return None
def _round_up_k8s_quantity(quantity: str) -> int:
"""Rounds a Kubernetes resource quantity up to the nearest integer.
Args:
quantity: Resource quantity as a string in the canonical K8s form.
Returns:
The quantity, rounded up, as an integer.
"""
resource_decimal: decimal.Decimal = utils.parse_quantity(quantity)
rounded = resource_decimal.to_integral_value(rounding=decimal.ROUND_UP)
return int(rounded)
def _get_custom_resources(
group_resources: Dict[str, str], ray_start_params: Dict[str, Any], group_name: str
) -> Dict[str, int]:
"""Format custom resources based on the group `resources` field or `resources` Ray start param.
Currently, the value of the rayStartParam `resources` field must
be formatted as follows:
'"{\"Custom1\": 1, \"Custom2\": 5}"'.
This method first converts the input to a correctly formatted
json string and then loads that json string to a dict.
"""
# If the top-level `resources` field is defined, use it as the exclusive source.
if group_resources:
if "resources" in ray_start_params:
logger.warning(
f"custom resources specified in both the top-level 'resources' field and in 'rayStartParams'. "
f"Using the values from 'resources': {group_resources}."
)
standard_keys = {"CPU", "GPU", "TPU", "memory"}
try:
custom_resources = {
k: _round_up_k8s_quantity(v)
for k, v in group_resources.items()
if k not in standard_keys
}
except Exception as e:
logger.error(
f"Error reading `resource` for group {group_name}."
" For the correct format, refer to example configuration at "
"https://github.com/ray-project/ray/blob/master/python/"
"ray/autoscaler/kuberay/ray-cluster.complete.yaml."
)
raise e
return custom_resources
# Otherwise, check rayStartParams.
if "resources" not in ray_start_params:
return {}
resources_string = ray_start_params["resources"]
try:
# Drop the extra pair of quotes and remove the backslash escapes.
# resources_json should be a json string.
resources_json = resources_string[1:-1].replace("\\", "")
# Load a dict from the json string.
resources = json.loads(resources_json)
assert isinstance(resources, dict)
for key, value in resources.items():
assert isinstance(key, str)
assert isinstance(value, int)
except Exception as e:
logger.error(
f"Error reading `resource` rayStartParam for group {group_name}."
" For the correct format, refer to example configuration at "
"https://github.com/ray-project/ray/blob/master/python/"
"ray/autoscaler/kuberay/ray-cluster.complete.yaml."
)
raise e
return resources
@@ -0,0 +1,558 @@
import datetime
import json
import logging
import os
from abc import ABC, abstractmethod
from collections import defaultdict
from typing import Any, Dict, List, Optional, Tuple, Union
import requests
from ray._common.network_utils import build_address
from ray.autoscaler._private.constants import WORKER_LIVENESS_CHECK_KEY
from ray.autoscaler._private.util import NodeID, NodeIP, NodeKind, NodeStatus, NodeType
from ray.autoscaler.batching_node_provider import (
BatchingNodeProvider,
NodeData,
ScaleRequest,
)
from ray.autoscaler.tags import (
NODE_KIND_HEAD,
NODE_KIND_WORKER,
STATUS_UP_TO_DATE,
STATUS_UPDATE_FAILED,
TAG_RAY_USER_NODE_TYPE,
)
# Key for KubeRay label that identifies a Ray pod as head or worker.
KUBERAY_LABEL_KEY_KIND = "ray.io/node-type"
# Key for KubeRay label that identifies the worker group (autoscaler node type) of a
# Ray pod.
KUBERAY_LABEL_KEY_TYPE = "ray.io/group"
# These should be synced with:
# https://github.com/ray-project/kuberay/blob/f2d94ffe213dd8f69481b09c474047cb899fa73b/ray-operator/apis/ray/v1/raycluster_types.go#L165-L171 # noqa
# Kind label value indicating the pod is the head.
KUBERAY_KIND_HEAD = "head"
# Kind label value indicating the pod is the worker.
KUBERAY_KIND_WORKER = "worker"
# KubeRay CRD version
KUBERAY_CRD_VER = os.getenv("KUBERAY_CRD_VER", "v1alpha1")
KUBERAY_REQUEST_TIMEOUT_S = int(os.getenv("KUBERAY_REQUEST_TIMEOUT_S", 60))
RAY_HEAD_POD_NAME = os.getenv("RAY_HEAD_POD_NAME")
# https://kubernetes.io/docs/tasks/run-application/access-api-from-pod
# While running in a Pod, your container can create an HTTPS URL for the
# Kubernetes API server by fetching the KUBERNETES_SERVICE_HOST and
# KUBERNETES_SERVICE_PORT_HTTPS environment variables.
KUBERNETES_SERVICE_HOST = os.getenv(
"KUBERNETES_SERVICE_HOST", "https://kubernetes.default"
)
KUBERNETES_SERVICE_PORT = os.getenv("KUBERNETES_SERVICE_PORT_HTTPS", "443")
KUBERNETES_HOST = build_address(KUBERNETES_SERVICE_HOST, KUBERNETES_SERVICE_PORT)
# Key for GKE label that identifies which multi-host replica a pod belongs to
REPLICA_INDEX_KEY = "replicaIndex"
TOKEN_REFRESH_PERIOD = datetime.timedelta(minutes=1)
# Design:
# Each modification the autoscaler wants to make is posted to the API server goal state
# (e.g. if the autoscaler wants to scale up, it increases the number of
# replicas of the worker group it wants to scale, if it wants to scale down
# it decreases the number of replicas and adds the exact pods that should be
# terminated to the scaleStrategy).
# KubeRayNodeProvider inherits from BatchingNodeProvider.
# Thus, the autoscaler's create and terminate requests are batched into a single
# Scale Request object which is submitted at the end of autoscaler update.
# KubeRay node provider converts the ScaleRequest into a RayCluster CR patch
# and applies the patch in the submit_scale_request method.
# To reduce potential for race conditions, KubeRayNodeProvider
# aborts the autoscaler update if the operator has not yet processed workersToDelete -
# see KubeRayNodeProvider.safe_to_scale().
# Once it is confirmed that workersToDelete have been cleaned up, KubeRayNodeProvider
# clears the workersToDelete list.
# Note: Log handlers set up in autoscaling monitor entrypoint.
logger = logging.getLogger(__name__)
def node_data_from_pod(pod: Dict[str, Any]) -> NodeData:
"""Converts a Ray pod extracted from K8s into Ray NodeData.
NodeData is processed by BatchingNodeProvider.
"""
kind, type = kind_and_type(pod)
status = status_tag(pod)
ip = pod_ip(pod)
replica_index = _replica_index_label(pod)
return NodeData(
kind=kind, type=type, replica_index=replica_index, status=status, ip=ip
)
def kind_and_type(pod: Dict[str, Any]) -> Tuple[NodeKind, NodeType]:
"""Determine Ray node kind (head or workers) and node type (worker group name)
from a Ray pod's labels.
"""
labels = pod["metadata"]["labels"]
kind = (
NODE_KIND_HEAD
if labels[KUBERAY_LABEL_KEY_KIND] == KUBERAY_KIND_HEAD
else NODE_KIND_WORKER
)
type = labels[KUBERAY_LABEL_KEY_TYPE]
return kind, type
def _replica_index_label(pod: Dict[str, Any]) -> Optional[str]:
"""Returns the replicaIndex label for a Pod in a multi-host TPU worker group.
The replicaIndex label is set by the GKE TPU Ray webhook and is of
the form {$WORKER_GROUP_NAME-$REPLICA_INDEX} where $REPLICA_INDEX
is an integer from 0 to Replicas-1.
"""
labels = pod["metadata"]["labels"]
return labels.get(REPLICA_INDEX_KEY, None)
def pod_ip(pod: Dict[str, Any]) -> NodeIP:
return pod["status"].get("podIP", "IP not yet assigned")
def status_tag(pod: Dict[str, Any]) -> NodeStatus:
"""Convert pod state to Ray autoscaler node status.
See the doc string of the class
batching_node_provider.NodeData for the semantics of node status.
"""
if (
"containerStatuses" not in pod["status"]
or not pod["status"]["containerStatuses"]
):
return "pending"
state = pod["status"]["containerStatuses"][0]["state"]
if "pending" in state:
return "pending"
if "running" in state:
return STATUS_UP_TO_DATE
if "waiting" in state:
return "waiting"
if "terminated" in state:
return STATUS_UPDATE_FAILED
raise ValueError("Unexpected container state.")
def worker_delete_patch(group_index: str, workers_to_delete: List[NodeID]):
path = f"/spec/workerGroupSpecs/{group_index}/scaleStrategy"
value = {"workersToDelete": workers_to_delete}
return replace_patch(path, value)
def worker_replica_patch(group_index: str, target_replicas: int):
path = f"/spec/workerGroupSpecs/{group_index}/replicas"
value = target_replicas
return replace_patch(path, value)
def replace_patch(path: str, value: Any) -> Dict[str, Any]:
return {"op": "replace", "path": path, "value": value}
def load_k8s_secrets() -> Tuple[Dict[str, str], str]:
"""
Loads secrets needed to access K8s resources.
Returns:
headers: Headers with K8s access token
verify: Path to certificate
"""
with open("/var/run/secrets/kubernetes.io/serviceaccount/token") as secret:
token = secret.read()
headers = {
"Authorization": "Bearer " + token,
}
verify = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
return headers, verify
def url_from_resource(
namespace: str,
path: str,
kuberay_crd_version: str = KUBERAY_CRD_VER,
kubernetes_host: str = KUBERNETES_HOST,
) -> str:
"""Convert resource path to REST URL for Kubernetes API server.
Args:
namespace: The K8s namespace of the resource
path: The part of the resource path that starts with the resource type.
Supported resource types are "pods" and "rayclusters".
kuberay_crd_version: The API version of the KubeRay CRD.
Looks like "v1alpha1", "v1".
kubernetes_host: The host of the Kubernetes API server.
Uses $KUBERNETES_SERVICE_HOST and
$KUBERNETES_SERVICE_PORT to construct the kubernetes_host if not provided.
When set by Kubernetes,
$KUBERNETES_SERVICE_HOST could be an IP address. That's why the https
scheme is added here.
Defaults to "https://kubernetes.default:443".
Returns:
The REST URL for the resource.
"""
if kubernetes_host.startswith("http://"):
raise ValueError("Kubernetes host must be accessed over HTTPS.")
if not kubernetes_host.startswith("https://"):
kubernetes_host = "https://" + kubernetes_host
if path.startswith("pods"):
api_group = "/api/v1"
elif path.startswith("rayclusters"):
api_group = "/apis/ray.io/" + kuberay_crd_version
else:
raise NotImplementedError("Tried to access unknown entity at {}".format(path))
return kubernetes_host + api_group + "/namespaces/" + namespace + "/" + path
def _worker_group_index(raycluster: Dict[str, Any], group_name: str) -> int:
"""Extract worker group index from RayCluster."""
group_names = [
spec["groupName"] for spec in raycluster["spec"].get("workerGroupSpecs", [])
]
return group_names.index(group_name)
def _worker_group_max_replicas(
raycluster: Dict[str, Any], group_index: int
) -> Optional[int]:
"""Extract the maxReplicas of a worker group.
If maxReplicas is unset, return None, to be interpreted as "no constraint".
At time of writing, it should be impossible for maxReplicas to be unset, but it's
better to handle this anyway.
"""
return raycluster["spec"]["workerGroupSpecs"][group_index].get("maxReplicas")
def _worker_group_replicas(raycluster: Dict[str, Any], group_index: int):
# 1 is the default replicas value used by the KubeRay operator
return raycluster["spec"]["workerGroupSpecs"][group_index].get("replicas", 1)
def _worker_group_num_of_hosts(raycluster: Dict[str, Any], group_index: int):
# 1 is the default numOfHosts value used by the KubeRay operator
return raycluster["spec"]["workerGroupSpecs"][group_index].get("numOfHosts", 1)
class IKubernetesHttpApiClient(ABC):
"""
An interface for a Kubernetes HTTP API client.
This interface could be used to mock the Kubernetes API client in tests.
"""
@abstractmethod
def get(self, path: str) -> Dict[str, Any]:
"""Wrapper for REST GET of resource with proper headers."""
pass
@abstractmethod
def patch(
self,
path: str,
payload: Union[List[Dict[str, Any]], Dict[str, Any]],
content_type: str = "application/json-patch+json",
) -> Dict[str, Any]:
"""Wrapper for REST PATCH of resource with proper headers."""
pass
class KubernetesHttpApiClient(IKubernetesHttpApiClient):
def __init__(self, namespace: str, kuberay_crd_version: str = KUBERAY_CRD_VER):
self._kuberay_crd_version = kuberay_crd_version
self._namespace = namespace
self._token_expires_at = datetime.datetime.now() + TOKEN_REFRESH_PERIOD
self._headers, self._verify = None, None
def _get_refreshed_headers_and_verify(self):
if (datetime.datetime.now() >= self._token_expires_at) or (
self._headers is None or self._verify is None
):
logger.info("Refreshing K8s API client token and certs.")
self._headers, self._verify = load_k8s_secrets()
self._token_expires_at = datetime.datetime.now() + TOKEN_REFRESH_PERIOD
return self._headers, self._verify
else:
return self._headers, self._verify
def get(self, path: str) -> Dict[str, Any]:
"""Wrapper for REST GET of resource with proper headers.
Args:
path: The part of the resource path that starts with the resource type.
Returns:
The JSON response of the GET request.
Raises:
HTTPError: If the GET request fails.
"""
url = url_from_resource(
namespace=self._namespace,
path=path,
kuberay_crd_version=self._kuberay_crd_version,
)
headers, verify = self._get_refreshed_headers_and_verify()
result = requests.get(
url,
headers=headers,
timeout=KUBERAY_REQUEST_TIMEOUT_S,
verify=verify,
)
if not result.status_code == 200:
result.raise_for_status()
return result.json()
def patch(
self,
path: str,
payload: Union[List[Dict[str, Any]], Dict[str, Any]],
content_type: str = "application/json-patch+json",
) -> Dict[str, Any]:
"""Wrapper for REST PATCH of resource with proper headers
Args:
path: The part of the resource path that starts with the resource type.
payload: The patch payload, either a JSON Patch list or a
strategic-merge patch object.
content_type: The content type of the merge strategy.
Returns:
The JSON response of the PATCH request.
Raises:
HTTPError: If the PATCH request fails.
"""
url = url_from_resource(
namespace=self._namespace,
path=path,
kuberay_crd_version=self._kuberay_crd_version,
)
headers, verify = self._get_refreshed_headers_and_verify()
result = requests.patch(
url,
json.dumps(payload),
headers={**headers, "Content-type": content_type},
timeout=KUBERAY_REQUEST_TIMEOUT_S,
verify=verify,
)
if not result.status_code == 200:
result.raise_for_status()
return result.json()
class KubeRayNodeProvider(BatchingNodeProvider): # type: ignore
def __init__(
self,
provider_config: Dict[str, Any],
cluster_name: str,
):
logger.info("Creating KubeRayNodeProvider.")
self.namespace = provider_config["namespace"]
self.cluster_name = cluster_name
self.k8s_api_client = KubernetesHttpApiClient(self.namespace)
assert (
provider_config.get(WORKER_LIVENESS_CHECK_KEY, True) is False
), f"To use KubeRayNodeProvider, must set `{WORKER_LIVENESS_CHECK_KEY}:False`."
BatchingNodeProvider.__init__(self, provider_config, cluster_name)
def get_node_data(self) -> Dict[NodeID, NodeData]:
"""Queries K8s for pods in the RayCluster. Converts that pod data into a
map of pod name to Ray NodeData, as required by BatchingNodeProvider.
"""
# Store the raycluster CR
self._raycluster = self._get(f"rayclusters/{self.cluster_name}")
# Get the pods resource version.
# Specifying a resource version in list requests is important for scalability:
# https://kubernetes.io/docs/reference/using-api/api-concepts/#semantics-for-get-and-list
resource_version = self._get_pods_resource_version()
if resource_version:
logger.info(
f"Listing pods for RayCluster {self.cluster_name}"
f" in namespace {self.namespace}"
f" at pods resource version >= {resource_version}."
)
# Filter pods by cluster_name.
label_selector = requests.utils.quote(f"ray.io/cluster={self.cluster_name}")
resource_path = f"pods?labelSelector={label_selector}"
if resource_version:
resource_path += (
f"&resourceVersion={resource_version}"
+ "&resourceVersionMatch=NotOlderThan"
)
pod_list = self._get(resource_path)
fetched_resource_version = pod_list["metadata"]["resourceVersion"]
logger.info(
f"Fetched pod data at resource version" f" {fetched_resource_version}."
)
# Extract node data from the pod list.
node_data_dict = {}
for pod in pod_list["items"]:
# Kubernetes sets metadata.deletionTimestamp immediately after admitting a
# request to delete an object. Full removal of the object may take some time
# after the deletion timestamp is set. See link for details:
# https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-deletion
if "deletionTimestamp" in pod["metadata"]:
# Ignore pods marked for termination.
continue
pod_name = pod["metadata"]["name"]
node_data_dict[pod_name] = node_data_from_pod(pod)
return node_data_dict
def submit_scale_request(self, scale_request: ScaleRequest):
"""Converts the scale request generated by BatchingNodeProvider into
a patch that modifies the RayCluster CR's replicas and/or workersToDelete
fields. Then submits the patch to the K8s API server.
"""
# Transform the scale request into a patch payload.
patch_payload = self._scale_request_to_patch_payload(
scale_request, self._raycluster
)
# Submit the patch to K8s.
logger.info(
"Autoscaler is submitting the following patch to RayCluster "
f"{self.cluster_name} in namespace {self.namespace}."
)
logger.info(patch_payload)
self._submit_raycluster_patch(patch_payload)
def safe_to_scale(self) -> bool:
"""Returns False iff non_terminated_nodes contains any pods in the RayCluster's
workersToDelete lists.
Explanation:
If there are any workersToDelete which are non-terminated,
we should wait for the operator to do its job and delete those
pods. Therefore, we back off the autoscaler update.
If, on the other hand, all of the workersToDelete have already been cleaned up,
then we patch away the workersToDelete lists and return True.
In the future, we may consider having the operator clean up workersToDelete
on it own:
https://github.com/ray-project/kuberay/issues/733
Note (Dmitri):
It is stylistically bad that this function has a side effect.
"""
# Get the list of nodes.
node_set = set(self.node_data_dict.keys())
worker_groups = self._raycluster["spec"].get("workerGroupSpecs", [])
# Accumulates the indices of worker groups with non-empty workersToDelete
non_empty_worker_group_indices = []
for group_index, worker_group in enumerate(worker_groups):
workersToDelete = worker_group.get("scaleStrategy", {}).get(
"workersToDelete", []
)
if workersToDelete:
non_empty_worker_group_indices.append(group_index)
for worker in workersToDelete:
if worker in node_set:
# The operator hasn't removed this worker yet. Abort
# the autoscaler update.
logger.warning(f"Waiting for operator to remove worker {worker}.")
return False
# All required workersToDelete have been removed.
# Clean up the workersToDelete field.
patch_payload = []
for group_index in non_empty_worker_group_indices:
patch = worker_delete_patch(group_index, workers_to_delete=[])
patch_payload.append(patch)
if patch_payload:
logger.info("Cleaning up workers to delete.")
logger.info(f"Submitting patch {patch_payload}.")
self._submit_raycluster_patch(patch_payload)
# It's safe to proceed with the autoscaler update.
return True
def _get_pods_resource_version(self) -> str:
"""
Extract a recent pods resource version by reading the head pod's
metadata.resourceVersion of the response.
"""
if not RAY_HEAD_POD_NAME:
return None
pod_resp = self._get(f"pods/{RAY_HEAD_POD_NAME}")
return pod_resp["metadata"]["resourceVersion"]
def _scale_request_to_patch_payload(
self, scale_request: ScaleRequest, raycluster: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Converts autoscaler scale request into a RayCluster CR patch payload."""
patch_payload = []
# Collect patches for replica counts.
for node_type, target_replicas in scale_request.desired_num_workers.items():
group_index = _worker_group_index(raycluster, node_type)
group_max_replicas = _worker_group_max_replicas(raycluster, group_index)
# Cap the replica count to maxReplicas.
if group_max_replicas is not None and group_max_replicas < target_replicas:
logger.warning(
"Autoscaler attempted to create "
+ "more than maxReplicas pods of type {}.".format(node_type)
)
target_replicas = group_max_replicas
# Check if we need to change the target count.
if target_replicas == _worker_group_replicas(raycluster, group_index):
# No patch required.
continue
# Need to patch replica count. Format the patch and add it to the payload.
patch = worker_replica_patch(group_index, target_replicas)
patch_payload.append(patch)
# Maps node_type to nodes to delete for that group.
deletion_groups = defaultdict(list)
for worker in scale_request.workers_to_delete:
node_type = self.node_tags(worker)[TAG_RAY_USER_NODE_TYPE]
deletion_groups[node_type].append(worker)
for node_type, workers_to_delete in deletion_groups.items():
group_index = _worker_group_index(raycluster, node_type)
patch = worker_delete_patch(group_index, workers_to_delete)
patch_payload.append(patch)
return patch_payload
def _submit_raycluster_patch(self, patch_payload: List[Dict[str, Any]]):
"""Submits a patch to modify a RayCluster CR."""
path = "rayclusters/{}".format(self.cluster_name)
self._patch(path, patch_payload)
def _get(self, path: str) -> Dict[str, Any]:
"""Wrapper for REST GET of resource with proper headers."""
return self.k8s_api_client.get(path)
def _patch(self, path: str, payload: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Wrapper for REST PATCH of resource with proper headers."""
return self.k8s_api_client.patch(path, payload)
@@ -0,0 +1,143 @@
import logging
import os
import subprocess
import time
import ray
from ray._common.network_utils import build_address
from ray._common.ray_constants import (
LOGGING_ROTATE_BACKUP_COUNT,
LOGGING_ROTATE_BYTES,
)
from ray._common.utils import env_integer, try_to_create_directory
from ray._private import ray_constants
from ray._private.ray_logging import setup_component_logger
from ray._private.services import get_node_ip_address
from ray._private.utils import get_all_node_info_until_retrieved
from ray._raylet import GcsClient
from ray.autoscaler._private.kuberay.autoscaling_config import AutoscalingConfigProducer
from ray.autoscaler._private.monitor import Monitor
from ray.autoscaler.v2.instance_manager.config import KubeRayConfigReader
from ray.autoscaler.v2.utils import is_autoscaler_v2
from ray.core.generated.gcs_service_pb2 import GetAllNodeInfoRequest
logger = logging.getLogger(__name__)
BACKOFF_S = 5
def _get_log_dir(gcs_client: GcsClient) -> str:
head_node_selector = GetAllNodeInfoRequest.NodeSelector()
head_node_selector.is_head_node = True
# We need to wait until head node's raylet is registered in GCS.
node_infos = get_all_node_info_until_retrieved(
gcs_client,
node_selectors=[head_node_selector],
)
node_info = next(iter(node_infos))
temp_dir = getattr(node_info, "temp_dir", None)
if temp_dir is None:
raise Exception(
"Node temp_dir was not found in NodeInfo. did the head node's raylet start successfully?"
)
return os.path.join(temp_dir, ray._private.ray_constants.SESSION_LATEST, "logs")
def run_kuberay_autoscaler(cluster_name: str, cluster_namespace: str):
"""Wait until the Ray head container is ready. Then start the autoscaler."""
head_ip = get_node_ip_address()
ray_address = build_address(head_ip, 6379)
while True:
try:
# Autoscaler Ray version might not exactly match GCS version, so skip the
# version check when checking GCS status.
subprocess.check_call(
[
"ray",
"health-check",
"--address",
ray_address,
"--skip-version-check",
]
)
logger.info("The Ray head is ready. Starting the autoscaler.")
break
except subprocess.CalledProcessError:
logger.warning(
f"The Ray head is not ready. Will check again in {BACKOFF_S} seconds."
)
time.sleep(BACKOFF_S)
gcs_client = GcsClient(ray_address)
log_dir = _get_log_dir(gcs_client)
# The Ray head container sets up the log directory. Thus, we set up logging
# only after the Ray head is ready.
_setup_logging(log_dir)
# autoscaling_config_producer reads the RayCluster CR from K8s and uses the CR
# to output an autoscaling config.
autoscaling_config_producer = AutoscalingConfigProducer(
cluster_name, cluster_namespace
)
if is_autoscaler_v2(fetch_from_server=True, gcs_client=gcs_client):
from ray.autoscaler.v2.monitor import AutoscalerMonitor as MonitorV2
MonitorV2(
address=gcs_client.address,
config_reader=KubeRayConfigReader(autoscaling_config_producer),
log_dir=log_dir,
monitor_ip=head_ip,
).run()
else:
Monitor(
address=gcs_client.address,
# The `autoscaling_config` arg can be a dict or a `Callable: () -> dict`.
# In this case, it's a callable.
autoscaling_config=autoscaling_config_producer,
monitor_ip=head_ip,
# Let the autoscaler process exit after it hits 5 exceptions.
# (See ray.autoscaler._private.constants.AUTOSCALER_MAX_NUM_FAILURES.)
# Kubernetes will then restart the autoscaler container.
retry_on_failure=False,
).run()
def _setup_logging(log_dir: str) -> None:
"""Log to autoscaler log file
(typically, /tmp/ray/session_latest/logs/monitor.*)
Also log to pod stdout (logs viewable with `kubectl logs <head-pod> -c autoscaler`).
Args:
log_dir: The path to the log directory.
"""
# The director should already exist, but try (safely) to create it just in case.
try_to_create_directory(log_dir)
# Write logs at info level to monitor.log.
max_bytes = env_integer("RAY_ROTATION_MAX_BYTES", LOGGING_ROTATE_BYTES)
backup_count = env_integer("RAY_ROTATION_BACKUP_COUNT", LOGGING_ROTATE_BACKUP_COUNT)
setup_component_logger(
logging_level=ray_constants.LOGGER_LEVEL,
logging_format=ray_constants.LOGGER_FORMAT,
log_dir=log_dir,
filename=ray_constants.MONITOR_LOG_FILE_NAME, # monitor.log
max_bytes=max_bytes,
backup_count=backup_count,
)
# For the autoscaler, the root logger _also_ needs to write to stderr, not just
# ray_constants.MONITOR_LOG_FILE_NAME.
level = logging.getLevelName(ray_constants.LOGGER_LEVEL.upper())
stderr_handler = logging._StderrHandler()
stderr_handler.setFormatter(logging.Formatter(ray_constants.LOGGER_FORMAT))
stderr_handler.setLevel(level)
logging.root.setLevel(level)
logging.root.addHandler(stderr_handler)
# The stdout handler was set up in the Ray CLI entry point.
# See ray.scripts.scripts::cli().
@@ -0,0 +1,112 @@
# Source:
# https://github.com/kubernetes-client/python/blob/master/kubernetes/utils/quantity.py
from decimal import Decimal, InvalidOperation
from typing import Optional, Union
from ray._private.accelerators.tpu import (
get_num_chips_from_topology,
get_tpu_cores_per_chip,
)
# Mapping used to get generation for TPU-{accelerator}-head resource
# https://cloud.google.com/kubernetes-engine/docs/how-to/tpus#run
gke_tpu_accelerator_to_generation = {
"tpu-v4-podslice": "v4",
"tpu-v5-lite-device": "v5litepod",
"tpu-v5-lite-podslice": "v5litepod",
"tpu-v5p-slice": "v5p",
"tpu-v6e-slice": "v6e",
"tpu7x": "v7x",
}
def parse_quantity(quantity: Union[str, int, float, Decimal]) -> Decimal:
"""Parse kubernetes canonical form quantity like 200Mi to a decimal number.
Supported SI suffixes:
base1024: Ki | Mi | Gi | Ti | Pi | Ei
base1000: n | u | m | "" | k | M | G | T | P | E
See
https://github.com/kubernetes/apimachinery/blob/master/pkg/api/resource/quantity.go
Args:
quantity: kubernetes canonical form quantity.
Returns:
The parsed quantity as a decimal number.
Raises:
ValueError: On invalid or unknown input
"""
if isinstance(quantity, (int, float, Decimal)):
return Decimal(quantity)
exponents = {
"n": -3,
"u": -2,
"m": -1,
"K": 1,
"k": 1,
"M": 2,
"G": 3,
"T": 4,
"P": 5,
"E": 6,
}
quantity = str(quantity)
number = quantity
suffix = None
if len(quantity) >= 2 and quantity[-1] == "i":
if quantity[-2] in exponents:
number = quantity[:-2]
suffix = quantity[-2:]
elif len(quantity) >= 1 and quantity[-1] in exponents:
number = quantity[:-1]
suffix = quantity[-1:]
try:
number = Decimal(number)
except InvalidOperation:
raise ValueError("Invalid number format: {}".format(number))
if suffix is None:
return number
if suffix.endswith("i"):
base = 1024
elif len(suffix) == 1:
base = 1000
else:
raise ValueError("{} has unknown suffix".format(quantity))
# handle SI inconsistency
if suffix == "ki":
raise ValueError("{} has unknown suffix".format(quantity))
if suffix[0] not in exponents:
raise ValueError("{} has unknown suffix".format(quantity))
exponent = Decimal(exponents[suffix[0]])
return number * (base**exponent)
def tpu_node_selectors_to_type(topology: str, accelerator: str) -> Optional[str]:
"""Convert Kubernetes gke-tpu nodeSelectors to TPU accelerator_type
for a kuberay TPU worker group.
Args:
topology: value of the cloud.google.com/gke-tpu-topology Kubernetes
nodeSelector, describes the physical topology of the TPU podslice.
accelerator: value of the cloud.google.com/gke-tpu-accelerator nodeSelector,
the name of the TPU accelerator, e.g. tpu-v4-podslice
Returns:
A string, accelerator_type, e.g. "v4-8".
"""
if topology and accelerator:
generation = gke_tpu_accelerator_to_generation.get(accelerator)
if not generation:
return None
num_chips = get_num_chips_from_topology(topology)
num_cores = num_chips * get_tpu_cores_per_chip(generation)
return f"{generation}-{num_cores}"
return None
@@ -0,0 +1,37 @@
import logging
from ray._private.ray_constants import DEBUG_AUTOSCALING_STATUS_LEGACY
from ray.experimental.internal_kv import _internal_kv_initialized, _internal_kv_put
"""This file provides legacy support for the old info string in order to
ensure the dashboard's `api/cluster_status` does not break backwards
compatibilty.
"""
logger = logging.getLogger(__name__)
def legacy_log_info_string(autoscaler, nodes):
tmp = "Cluster status: "
tmp += info_string(autoscaler, nodes)
tmp += "\n"
tmp += autoscaler.load_metrics.info_string()
tmp += "\n"
tmp += autoscaler.resource_demand_scheduler.debug_string(
nodes,
autoscaler.pending_launches.breakdown(),
autoscaler.load_metrics.get_resource_utilization(),
)
if _internal_kv_initialized():
_internal_kv_put(DEBUG_AUTOSCALING_STATUS_LEGACY, tmp, overwrite=True)
logger.debug(tmp)
def info_string(autoscaler, nodes):
suffix = ""
if autoscaler.updaters:
suffix += " ({} updating)".format(len(autoscaler.updaters))
if autoscaler.num_failed_updates:
suffix += " ({} failed to update)".format(len(autoscaler.num_failed_updates))
return "{} nodes{}".format(len(nodes), suffix)
@@ -0,0 +1,383 @@
import logging
import time
from collections import Counter
from functools import reduce
from typing import Any, Callable, Dict, List, Optional
from ray._private.gcs_utils import PlacementGroupTableData
from ray.autoscaler._private.constants import (
AUTOSCALER_MAX_RESOURCE_DEMAND_VECTOR_SIZE,
AUTOSCALER_REPORT_PER_NODE_STATUS,
)
from ray.autoscaler._private.util import (
DictCount,
LoadMetricsSummary,
NodeIP,
ResourceDict,
)
from ray.core.generated.common_pb2 import PlacementStrategy
logger = logging.getLogger(__name__)
def add_resources(dict1: Dict[str, float], dict2: Dict[str, float]) -> Dict[str, float]:
"""Add the values in two dictionaries.
Args:
dict1: The first dictionary.
dict2: The second dictionary.
Returns:
A new dictionary (inputs remain unmodified).
"""
new_dict = dict1.copy()
for k, v in dict2.items():
new_dict[k] = v + new_dict.get(k, 0)
return new_dict
def freq_of_dicts(
dicts: List[Dict],
serializer: Optional[Callable[[Dict], Any]] = None,
deserializer: Callable[[Any], Any] = dict,
) -> DictCount:
"""Count a list of dictionaries (or unhashable types).
This is somewhat annoying because mutable data structures aren't hashable,
and set/dict keys must be hashable.
Args:
dicts: A list of dictionaries to be counted.
serializer: A custom serialization function. The output type
must be hashable. The default serializer converts a dictionary into
a frozenset of KV pairs.
deserializer: A custom deserialization function. See the
serializer for information about the intermediate type. For
dictionaries the output type is the same as the input type.
Returns:
A list of tuples. Each entry in the list is a tuple containing a unique
entry from `dicts` and its corresponding frequency count.
"""
if serializer is None:
serializer = lambda d: frozenset(d.items()) # noqa: E731
freqs = Counter(serializer(d) for d in dicts)
as_list = []
for as_set, count in freqs.items():
as_list.append((deserializer(as_set), count))
return as_list
class LoadMetrics:
"""Container for cluster load metrics.
Metrics here are updated from raylet heartbeats. The autoscaler
queries these metrics to determine when to scale up, and which nodes
can be removed.
"""
def __init__(self):
self.last_heartbeat_time_by_ip = {}
self.static_resources_by_ip = {}
self.dynamic_resources_by_ip = {}
self.node_id_by_ip = {}
self.waiting_bundles = []
self.infeasible_bundles = []
self.pending_placement_groups = []
self.resource_requests = []
self.ray_nodes_last_used_time_by_ip = {}
def __bool__(self):
"""A load metrics instance is Falsey iff the autoscaler process
has not received a resource message from the GCS.
"""
return bool(self.node_id_by_ip)
def update(
self,
ip: str,
node_id: bytes,
static_resources: Dict[str, Dict],
dynamic_resources: Dict[str, Dict],
node_idle_duration_s: float,
waiting_bundles: List[Dict[str, float]] = None,
infeasible_bundles: List[Dict[str, float]] = None,
pending_placement_groups: List[PlacementGroupTableData] = None,
):
self.static_resources_by_ip[ip] = static_resources
self.node_id_by_ip[ip] = node_id
if not waiting_bundles:
waiting_bundles = []
if not infeasible_bundles:
infeasible_bundles = []
if not pending_placement_groups:
pending_placement_groups = []
# We are not guaranteed to have a corresponding dynamic resource
# for every static resource because dynamic resources are based on
# the available resources in the heartbeat, which does not exist
# if it is zero. Thus, we have to update dynamic resources here.
dynamic_resources_update = dynamic_resources.copy()
for resource_name, capacity in self.static_resources_by_ip[ip].items():
if resource_name not in dynamic_resources_update:
dynamic_resources_update[resource_name] = 0.0
self.dynamic_resources_by_ip[ip] = dynamic_resources_update
now = time.time()
self.ray_nodes_last_used_time_by_ip[ip] = now - node_idle_duration_s
self.last_heartbeat_time_by_ip[ip] = now
self.waiting_bundles = waiting_bundles
self.infeasible_bundles = infeasible_bundles
self.pending_placement_groups = pending_placement_groups
def mark_active(self, ip):
assert ip is not None, "IP should be known at this time"
logger.debug("Node {} is newly setup, treating as active".format(ip))
self.last_heartbeat_time_by_ip[ip] = time.time()
def prune_active_ips(self, active_ips: List[str]):
"""The Raylet ips stored by LoadMetrics are obtained by polling
the GCS in Monitor.update_load_metrics().
On the other hand, the autoscaler gets a list of node ips from
its NodeProvider.
This method removes from LoadMetrics the ips unknown to the autoscaler.
Args:
active_ips: The node ips known to the autoscaler.
"""
active_ips = set(active_ips)
def prune(mapping, should_log):
unwanted_ips = set(mapping) - active_ips
for unwanted_ip in unwanted_ips:
if should_log:
logger.info("LoadMetrics: " f"Removed ip: {unwanted_ip}.")
del mapping[unwanted_ip]
if unwanted_ips and should_log:
logger.info(
"LoadMetrics: "
"Removed {} stale ip mappings: {} not in {}".format(
len(unwanted_ips), unwanted_ips, active_ips
)
)
assert not (unwanted_ips & set(mapping))
prune(self.ray_nodes_last_used_time_by_ip, should_log=True)
prune(self.static_resources_by_ip, should_log=False)
prune(self.node_id_by_ip, should_log=False)
prune(self.dynamic_resources_by_ip, should_log=False)
prune(self.last_heartbeat_time_by_ip, should_log=False)
def get_node_resources(self):
"""Return a list of node resources (static resource sizes).
Returns:
An iterable of node resource dicts.
Example:
>>> from ray.autoscaler._private.load_metrics import LoadMetrics
>>> metrics = LoadMetrics(...) # doctest: +SKIP
>>> metrics.get_node_resources() # doctest: +SKIP
[{"CPU": 1}, {"CPU": 4, "GPU": 8}] # for two different nodes
"""
return self.static_resources_by_ip.values()
def get_static_node_resources_by_ip(self) -> Dict[NodeIP, ResourceDict]:
"""Return a dict of node resources for every node ip.
Returns:
A mapping from node IP to its static resource dict.
Example:
>>> from ray.autoscaler._private.load_metrics import LoadMetrics
>>> metrics = LoadMetrics(...) # doctest: +SKIP
>>> metrics.get_static_node_resources_by_ip() # doctest: +SKIP
{127.0.0.1: {"CPU": 1}, 127.0.0.2: {"CPU": 4, "GPU": 8}}
"""
return self.static_resources_by_ip
def get_resource_utilization(self):
return self.dynamic_resources_by_ip
def _get_resource_usage(self):
resources_used = {}
resources_total = {}
for ip, max_resources in self.static_resources_by_ip.items():
avail_resources = self.dynamic_resources_by_ip[ip]
for resource_id, amount in max_resources.items():
used = amount - avail_resources[resource_id]
if resource_id not in resources_used:
resources_used[resource_id] = 0.0
resources_total[resource_id] = 0.0
resources_used[resource_id] += used
resources_total[resource_id] += amount
used = max(0, used)
return resources_used, resources_total
def get_resource_demand_vector(self, clip=True):
if clip:
# Bound the total number of bundles to
# 2xMAX_RESOURCE_DEMAND_VECTOR_SIZE. This guarantees the resource
# demand scheduler bin packing algorithm takes a reasonable amount
# of time to run.
return (
self.waiting_bundles[:AUTOSCALER_MAX_RESOURCE_DEMAND_VECTOR_SIZE]
+ self.infeasible_bundles[:AUTOSCALER_MAX_RESOURCE_DEMAND_VECTOR_SIZE]
)
else:
return self.waiting_bundles + self.infeasible_bundles
def get_resource_requests(self):
return self.resource_requests
def get_pending_placement_groups(self):
return self.pending_placement_groups
def resources_avail_summary(self) -> str:
"""Return a concise string of cluster size to report to event logs.
For example, "3 CPUs, 4 GPUs".
"""
total_resources = (
reduce(add_resources, self.static_resources_by_ip.values())
if self.static_resources_by_ip
else {}
)
out = "{} CPUs".format(int(total_resources.get("CPU", 0)))
if "GPU" in total_resources:
out += ", {} GPUs".format(int(total_resources["GPU"]))
if "TPU" in total_resources:
out += ", {} TPUs".format(int(total_resources["TPU"]))
return out
def summary(self):
available_resources = (
reduce(add_resources, self.dynamic_resources_by_ip.values())
if self.dynamic_resources_by_ip
else {}
)
total_resources = (
reduce(add_resources, self.static_resources_by_ip.values())
if self.static_resources_by_ip
else {}
)
usage_dict = {}
for key in total_resources:
if key in ["memory", "object_store_memory"]:
total = total_resources[key]
available = available_resources[key]
usage_dict[key] = (total - available, total)
else:
total = total_resources[key]
usage_dict[key] = (total - available_resources[key], total)
summarized_demand_vector = freq_of_dicts(
self.get_resource_demand_vector(clip=False)
)
summarized_resource_requests = freq_of_dicts(self.get_resource_requests())
def placement_group_serializer(pg):
bundles = tuple(
frozenset(bundle.unit_resources.items()) for bundle in pg.bundles
)
return (bundles, pg.strategy)
def placement_group_deserializer(pg_tuple):
# We marshal this as a dictionary so that we can easily json.dumps
# it later.
# TODO (Alex): Would there be a benefit to properly
# marshalling this (into a protobuf)?
bundles = list(map(dict, pg_tuple[0]))
return {
"bundles": freq_of_dicts(bundles),
"strategy": PlacementStrategy.Name(pg_tuple[1]),
}
summarized_placement_groups = freq_of_dicts(
self.get_pending_placement_groups(),
serializer=placement_group_serializer,
deserializer=placement_group_deserializer,
)
nodes_summary = freq_of_dicts(self.static_resources_by_ip.values())
usage_by_node = None
if AUTOSCALER_REPORT_PER_NODE_STATUS:
usage_by_node = {}
for ip, totals in self.static_resources_by_ip.items():
available = self.dynamic_resources_by_ip.get(ip, {})
usage_by_node[ip] = {}
for resource, total in totals.items():
usage_by_node[ip][resource] = (
total - available.get(resource, 0),
total,
)
return LoadMetricsSummary(
usage=usage_dict,
resource_demand=summarized_demand_vector,
pg_demand=summarized_placement_groups,
request_demand=summarized_resource_requests,
node_types=nodes_summary,
usage_by_node=usage_by_node,
)
def set_resource_requests(self, requested_resources):
if requested_resources is not None:
assert isinstance(requested_resources, list), requested_resources
self.resource_requests = [
request for request in requested_resources if len(request) > 0
]
def info_string(self):
return " - " + "\n - ".join(
["{}: {}".format(k, v) for k, v in sorted(self._info().items())]
)
def _info(self):
resources_used, resources_total = self._get_resource_usage()
now = time.time()
idle_times = [now - t for t in self.ray_nodes_last_used_time_by_ip.values()]
heartbeat_times = [now - t for t in self.last_heartbeat_time_by_ip.values()]
most_delayed_heartbeats = sorted(
self.last_heartbeat_time_by_ip.items(), key=lambda pair: pair[1]
)[:5]
most_delayed_heartbeats = {ip: (now - t) for ip, t in most_delayed_heartbeats}
def format_resource(key, value):
if key in ["object_store_memory", "memory"]:
return "{} GiB".format(round(value / (1024 * 1024 * 1024), 2))
else:
return round(value, 2)
return {
"ResourceUsage": ", ".join(
[
"{}/{} {}".format(
format_resource(rid, resources_used[rid]),
format_resource(rid, resources_total[rid]),
rid,
)
for rid in sorted(resources_used)
if not rid.startswith("node:")
]
),
"NodeIdleSeconds": "Min={} Mean={} Max={}".format(
int(min(idle_times)) if idle_times else -1,
int(float(sum(idle_times)) / len(idle_times)) if idle_times else -1,
int(max(idle_times)) if idle_times else -1,
),
"TimeSinceLastHeartbeat": "Min={} Mean={} Max={}".format(
int(min(heartbeat_times)) if heartbeat_times else -1,
int(float(sum(heartbeat_times)) / len(heartbeat_times))
if heartbeat_times
else -1,
int(max(heartbeat_times)) if heartbeat_times else -1,
),
"MostDelayedHeartbeats": most_delayed_heartbeats,
}
+15
View File
@@ -0,0 +1,15 @@
import importlib
def load_function_or_class(path):
"""Load a function or class at runtime given a full path.
Example of the path: mypkg.mysubpkg.myclass
"""
class_data = path.split(".")
if len(class_data) < 2:
raise ValueError("You need to pass a valid path like mymodule.provider_class")
module_path = ".".join(class_data[:-1])
fn_or_class_str = class_data[-1]
module = importlib.import_module(module_path)
return getattr(module, fn_or_class_str)
@@ -0,0 +1,131 @@
import copy
import os
from typing import Any, Dict, Tuple
from ray._common.utils import get_default_ray_temp_dir
from ray.autoscaler._private.cli_logger import cli_logger
unsupported_field_message = "The field {} is not supported for on-premise clusters."
LOCAL_CLUSTER_NODE_TYPE = "local.cluster.node"
def prepare_local(config: Dict[str, Any]) -> Tuple[Dict[str, Any], bool]:
"""
Prepare local cluster config for ingestion by cluster launcher and
autoscaler.
"""
config = copy.deepcopy(config)
for field in "head_node", "worker_nodes", "available_node_types":
if config.get(field):
# If the config already contains the internal node type, it's been prepared via ray up already hence return as-is.
if (
field == "available_node_types"
and LOCAL_CLUSTER_NODE_TYPE in config.get(field, {})
):
return config, False
err_msg = unsupported_field_message.format(field)
cli_logger.abort(err_msg)
# We use a config with a single node type for on-prem clusters.
# Resources internally detected by Ray are not overridden by the autoscaler
# (see NodeProvider.do_update)
config["available_node_types"] = {
LOCAL_CLUSTER_NODE_TYPE: {"node_config": {}, "resources": {}}
}
config["head_node_type"] = LOCAL_CLUSTER_NODE_TYPE
if "coordinator_address" in config["provider"]:
config = prepare_coordinator(config)
else:
config = prepare_manual(config)
return config, True
def prepare_coordinator(config: Dict[str, Any]) -> Dict[str, Any]:
config = copy.deepcopy(config)
# User should explicitly set the max number of workers for the coordinator
# to allocate.
if "max_workers" not in config:
cli_logger.abort(
"The field `max_workers` is required when using an "
"automatically managed on-premise cluster."
)
node_type = config["available_node_types"][LOCAL_CLUSTER_NODE_TYPE]
# The autoscaler no longer uses global `min_workers`.
# Move `min_workers` to the node_type config.
node_type["min_workers"] = config.pop("min_workers", 0)
node_type["max_workers"] = config["max_workers"]
return config
def prepare_manual(config: Dict[str, Any]) -> Dict[str, Any]:
"""Validates and sets defaults for configs of manually managed on-prem
clusters.
- Checks for presence of required `worker_ips` and `head_ips` fields.
- Defaults min and max workers to the number of `worker_ips`.
- Caps min and max workers at the number of `worker_ips`.
- Writes min and max worker info into the single worker node type.
"""
config = copy.deepcopy(config)
if ("worker_ips" not in config["provider"]) or (
"head_ip" not in config["provider"]
):
cli_logger.abort(
"Please supply a `head_ip` and list of `worker_ips`. "
"Alternatively, supply a `coordinator_address`."
)
num_ips = len(config["provider"]["worker_ips"])
node_type = config["available_node_types"][LOCAL_CLUSTER_NODE_TYPE]
# Default to keeping all provided ips in the cluster.
config.setdefault("max_workers", num_ips)
# The autoscaler no longer uses global `min_workers`.
# We will move `min_workers` to the node_type config.
min_workers = config.pop("min_workers", num_ips)
max_workers = config["max_workers"]
if min_workers > num_ips:
cli_logger.warning(
f"The value of `min_workers` supplied ({min_workers}) is greater"
f" than the number of available worker ips ({num_ips})."
f" Setting `min_workers={num_ips}`."
)
node_type["min_workers"] = num_ips
else:
node_type["min_workers"] = min_workers
if max_workers > num_ips:
cli_logger.warning(
f"The value of `max_workers` supplied ({max_workers}) is greater"
f" than the number of available worker ips ({num_ips})."
f" Setting `max_workers={num_ips}`."
)
node_type["max_workers"] = num_ips
config["max_workers"] = num_ips
else:
node_type["max_workers"] = max_workers
if max_workers < num_ips:
cli_logger.warning(
f"The value of `max_workers` supplied ({max_workers}) is less"
f" than the number of available worker ips ({num_ips})."
f" At most {max_workers} Ray worker nodes will connect to the cluster."
)
return config
def get_lock_path(cluster_name: str) -> str:
return os.path.join(
get_default_ray_temp_dir(), "cluster-{}.lock".format(cluster_name)
)
def get_state_path(cluster_name: str) -> str:
return os.path.join(
get_default_ray_temp_dir(), "cluster-{}.state".format(cluster_name)
)
def bootstrap_local(config: Dict[str, Any]) -> Dict[str, Any]:
return config
@@ -0,0 +1,110 @@
import json
import logging
from http.client import RemoteDisconnected
from ray.autoscaler.node_provider import NodeProvider
from ray.autoscaler.tags import TAG_RAY_CLUSTER_NAME
logger = logging.getLogger(__name__)
class CoordinatorSenderNodeProvider(NodeProvider):
"""NodeProvider for automatically managed private/local clusters.
The cluster management is handled by a remote coordinating server.
The server listens on <coordinator_address>, therefore, the address
should be provided in the provider section in the cluster config.
The server receieves HTTP requests from this class and uses
LocalNodeProvider to get their responses.
"""
def __init__(self, provider_config, cluster_name):
NodeProvider.__init__(self, provider_config, cluster_name)
self.coordinator_address = provider_config["coordinator_address"]
def _get_http_response(self, request):
headers = {
"Content-Type": "application/json",
}
request_message = json.dumps(request).encode()
http_coordinator_address = "http://" + self.coordinator_address
try:
import requests # `requests` is not part of stdlib.
from requests.exceptions import ConnectionError
r = requests.get(
http_coordinator_address,
data=request_message,
headers=headers,
timeout=None,
)
except (RemoteDisconnected, ConnectionError):
logger.exception(
"Could not connect to: "
+ http_coordinator_address
+ ". Did you run python coordinator_server.py"
+ " --ips <list_of_node_ips> --host <HOST> --port <PORT>?"
)
raise
except ImportError:
logger.exception(
"Not all Ray Autoscaler dependencies were found. "
"In Ray 1.4+, the Ray CLI, autoscaler, and dashboard will "
'only be usable via `pip install "ray[default]"`. Please '
"update your install command."
)
raise
response = r.json()
return response
def non_terminated_nodes(self, tag_filters):
# Only get the non terminated nodes associated with this cluster name.
tag_filters[TAG_RAY_CLUSTER_NAME] = self.cluster_name
request = {"type": "non_terminated_nodes", "args": (tag_filters,)}
return self._get_http_response(request)
def is_running(self, node_id):
request = {"type": "is_running", "args": (node_id,)}
return self._get_http_response(request)
def is_terminated(self, node_id):
request = {"type": "is_terminated", "args": (node_id,)}
return self._get_http_response(request)
def node_tags(self, node_id):
request = {"type": "node_tags", "args": (node_id,)}
return self._get_http_response(request)
def external_ip(self, node_id):
request = {"type": "external_ip", "args": (node_id,)}
response = self._get_http_response(request)
return response
def internal_ip(self, node_id):
request = {"type": "internal_ip", "args": (node_id,)}
response = self._get_http_response(request)
return response
def create_node(self, node_config, tags, count):
# Tag the newly created node with this cluster name. Helps to get
# the right nodes when calling non_terminated_nodes.
tags[TAG_RAY_CLUSTER_NAME] = self.cluster_name
request = {
"type": "create_node",
"args": (node_config, tags, count),
}
self._get_http_response(request)
def set_node_tags(self, node_id, tags):
request = {"type": "set_node_tags", "args": (node_id, tags)}
self._get_http_response(request)
def terminate_node(self, node_id):
request = {"type": "terminate_node", "args": (node_id,)}
self._get_http_response(request)
def terminate_nodes(self, node_ids):
request = {"type": "terminate_nodes", "args": (node_ids,)}
self._get_http_response(request)
@@ -0,0 +1,329 @@
import json
import logging
import os
import socket
from threading import RLock
from filelock import FileLock
from ray.autoscaler._private.local.config import (
LOCAL_CLUSTER_NODE_TYPE,
bootstrap_local,
get_lock_path,
get_state_path,
)
from ray.autoscaler.node_provider import NodeProvider
from ray.autoscaler.tags import (
NODE_KIND_HEAD,
NODE_KIND_WORKER,
STATUS_UP_TO_DATE,
TAG_RAY_NODE_KIND,
TAG_RAY_NODE_NAME,
TAG_RAY_NODE_STATUS,
TAG_RAY_USER_NODE_TYPE,
)
logger = logging.getLogger(__name__)
filelock_logger = logging.getLogger("filelock")
filelock_logger.setLevel(logging.WARNING)
class ClusterState:
def __init__(self, lock_path, save_path, provider_config):
self.lock = RLock()
os.makedirs(os.path.dirname(lock_path), exist_ok=True)
self.file_lock = FileLock(lock_path)
self.save_path = save_path
with self.lock:
with self.file_lock:
if os.path.exists(self.save_path):
workers = json.loads(open(self.save_path).read())
head_config = workers.get(provider_config["head_ip"])
if (
not head_config
or head_config.get("tags", {}).get(TAG_RAY_NODE_KIND)
!= NODE_KIND_HEAD
):
workers = {}
logger.info("Head IP changed - recreating cluster.")
else:
workers = {}
logger.info(
"ClusterState: Loaded cluster state: {}".format(list(workers))
)
for worker_ip in provider_config["worker_ips"]:
if worker_ip not in workers:
workers[worker_ip] = {
"tags": {TAG_RAY_NODE_KIND: NODE_KIND_WORKER},
"state": "terminated",
}
else:
assert (
workers[worker_ip]["tags"][TAG_RAY_NODE_KIND]
== NODE_KIND_WORKER
)
if provider_config["head_ip"] not in workers:
workers[provider_config["head_ip"]] = {
"tags": {TAG_RAY_NODE_KIND: NODE_KIND_HEAD},
"state": "terminated",
}
else:
assert (
workers[provider_config["head_ip"]]["tags"][TAG_RAY_NODE_KIND]
== NODE_KIND_HEAD
)
# Relevant when a user reduces the number of workers
# without changing the headnode.
list_of_node_ips = list(provider_config["worker_ips"])
list_of_node_ips.append(provider_config["head_ip"])
for worker_ip in list(workers):
if worker_ip not in list_of_node_ips:
del workers[worker_ip]
# Set external head ip, if provided by user.
# Necessary if calling `ray up` from outside the network.
# Refer to LocalNodeProvider.external_ip function.
external_head_ip = provider_config.get("external_head_ip")
if external_head_ip:
head = workers[provider_config["head_ip"]]
head["external_ip"] = external_head_ip
assert len(workers) == len(provider_config["worker_ips"]) + 1
with open(self.save_path, "w") as f:
logger.debug(
"ClusterState: Writing cluster state: {}".format(workers)
)
f.write(json.dumps(workers))
def get(self):
with self.lock:
with self.file_lock:
workers = json.loads(open(self.save_path).read())
return workers
def put(self, worker_id, info):
assert "tags" in info
assert "state" in info
with self.lock:
with self.file_lock:
workers = self.get()
workers[worker_id] = info
with open(self.save_path, "w") as f:
logger.info(
"ClusterState: "
"Writing cluster state: {}".format(list(workers))
)
f.write(json.dumps(workers))
class OnPremCoordinatorState(ClusterState):
"""Generates & updates the state file of CoordinatorSenderNodeProvider.
Unlike ClusterState, which generates a cluster specific file with
predefined head and worker ips, OnPremCoordinatorState overwrites
ClusterState's __init__ function to generate and manage a unified
file of the status of all the nodes for multiple clusters.
"""
def __init__(self, lock_path, save_path, list_of_node_ips):
self.lock = RLock()
self.file_lock = FileLock(lock_path)
self.save_path = save_path
with self.lock:
with self.file_lock:
if os.path.exists(self.save_path):
nodes = json.loads(open(self.save_path).read())
else:
nodes = {}
logger.info(
"OnPremCoordinatorState: "
"Loaded on prem coordinator state: {}".format(nodes)
)
# Filter removed node ips.
for node_ip in list(nodes):
if node_ip not in list_of_node_ips:
del nodes[node_ip]
for node_ip in list_of_node_ips:
if node_ip not in nodes:
nodes[node_ip] = {
"tags": {},
"state": "terminated",
}
assert len(nodes) == len(list_of_node_ips)
with open(self.save_path, "w") as f:
logger.info(
"OnPremCoordinatorState: "
"Writing on prem coordinator state: {}".format(nodes)
)
f.write(json.dumps(nodes))
class LocalNodeProvider(NodeProvider):
"""NodeProvider for private/local clusters.
`node_id` is overloaded to also be `node_ip` in this class.
When `cluster_name` is provided, it manages a single cluster in a cluster
specific state file. But when `cluster_name` is None, it manages multiple
clusters in a unified state file that requires each node to be tagged with
TAG_RAY_CLUSTER_NAME in create and non_terminated_nodes function calls to
associate each node with the right cluster.
The current use case of managing multiple clusters is by
OnPremCoordinatorServer which receives node provider HTTP requests
from CoordinatorSenderNodeProvider and uses LocalNodeProvider to get
the responses.
"""
def __init__(self, provider_config, cluster_name):
NodeProvider.__init__(self, provider_config, cluster_name)
if cluster_name:
lock_path = get_lock_path(cluster_name)
state_path = get_state_path(cluster_name)
self.state = ClusterState(
lock_path,
state_path,
provider_config,
)
self.use_coordinator = False
else:
# LocalNodeProvider with a coordinator server.
self.state = OnPremCoordinatorState(
"/tmp/coordinator.lock",
"/tmp/coordinator.state",
provider_config["list_of_node_ips"],
)
self.use_coordinator = True
def non_terminated_nodes(self, tag_filters):
workers = self.state.get()
matching_ips = []
for worker_ip, info in workers.items():
if info["state"] == "terminated":
continue
ok = True
for k, v in tag_filters.items():
if info["tags"].get(k) != v:
ok = False
break
if ok:
matching_ips.append(worker_ip)
return matching_ips
def nodes_for_teardown(self, tag_filters):
"""Return all known node ids matching tag_filters regardless of state.
The local state file on the machine invoking ``ray down`` may show
workers as terminated because only the head node's autoscaler updates
them to running. During teardown we still need to reach these nodes
to stop their Docker containers.
Nodes that have already been fully torn down (i.e. terminate_node was
called, setting teardown_complete) are excluded so they are not
targeted again on subsequent teardown passes.
"""
workers = self.state.get()
return [
worker_ip
for worker_ip, info in workers.items()
if all(info["tags"].get(k) == v for k, v in tag_filters.items())
and not info.get("teardown_complete", False)
]
def is_running(self, node_id):
return self.state.get()[node_id]["state"] == "running"
def is_terminated(self, node_id):
return not self.is_running(node_id)
def node_tags(self, node_id):
return self.state.get()[node_id]["tags"]
def external_ip(self, node_id):
"""Returns an external ip if the user has supplied one.
Otherwise, use the same logic as internal_ip below.
This can be used to call ray up from outside the network, for example
if the Ray cluster exists in an AWS VPC and we're interacting with
the cluster from a laptop (where using an internal_ip will not work).
Useful for debugging the local node provider with cloud VMs."""
node_state = self.state.get()[node_id]
ext_ip = node_state.get("external_ip")
if ext_ip:
return ext_ip
else:
return socket.gethostbyname(node_id)
def internal_ip(self, node_id):
return socket.gethostbyname(node_id)
def set_node_tags(self, node_id, tags):
with self.state.lock:
with self.state.file_lock:
info = self.state.get()[node_id]
info["tags"].update(tags)
self.state.put(node_id, info)
def create_node(self, node_config, tags, count):
"""Creates min(count, currently available) nodes."""
node_type = tags[TAG_RAY_NODE_KIND]
with self.state.lock:
with self.state.file_lock:
workers = self.state.get()
for node_id, info in workers.items():
if info["state"] == "terminated" and (
self.use_coordinator
or info["tags"][TAG_RAY_NODE_KIND] == node_type
):
info["tags"] = tags
info["state"] = "running"
info.pop("teardown_complete", None)
self.state.put(node_id, info)
count = count - 1
if count == 0:
return
def terminate_node(self, node_id):
workers = self.state.get()
info = workers[node_id]
info["state"] = "terminated"
info["teardown_complete"] = True
self.state.put(node_id, info)
@staticmethod
def bootstrap_config(cluster_config):
return bootstrap_local(cluster_config)
def record_local_head_state_if_needed(local_provider: LocalNodeProvider) -> None:
"""This function is called on the Ray head from StandardAutoscaler.reset
to record the head node's own existence in the cluster state file.
This is necessary because `provider.create_node` in
`commands.get_or_create_head_node` records the head state on the
cluster-launching machine but not on the head.
"""
head_ip = local_provider.provider_config["head_ip"]
cluster_name = local_provider.cluster_name
# If the head node is not marked as created in the cluster state file,
if head_ip not in local_provider.non_terminated_nodes({}):
# These tags are based on the ones in commands.get_or_create_head_node;
# keep in sync.
head_tags = {
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
TAG_RAY_USER_NODE_TYPE: LOCAL_CLUSTER_NODE_TYPE,
TAG_RAY_NODE_NAME: "ray-{}-head".format(cluster_name),
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
}
# Mark the head node as created in the cluster state file.
local_provider.create_node(node_config={}, tags=head_tags, count=1)
assert head_ip in local_provider.non_terminated_nodes({})
@@ -0,0 +1,33 @@
import datetime
import logging
from ray.autoscaler._private.cli_logger import cli_logger
logger = logging.getLogger(__name__)
class LogTimer:
def __init__(self, message, show_status=False):
self._message = message
self._show_status = show_status
def __enter__(self):
self._start_time = datetime.datetime.utcnow()
def __exit__(self, *error_vals):
if cli_logger.log_style != "record":
return
td = datetime.datetime.utcnow() - self._start_time
status = ""
if self._show_status:
status = "failed" if any(error_vals) else "succeeded"
cli_logger.print(
" ".join(
[
self._message,
status,
"[LogTimer={:.0f}ms]".format(td.total_seconds() * 1000),
]
)
)
+743
View File
@@ -0,0 +1,743 @@
"""Autoscaler monitoring loop daemon."""
import argparse
import json
import logging
import os
import signal
import sys
import time
import traceback
from collections import Counter
from dataclasses import asdict
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
import ray
import ray._private.ray_constants as ray_constants
from ray._common.network_utils import (
build_address,
get_localhost_ip,
is_localhost,
parse_address,
)
from ray._common.ray_constants import (
LOGGING_ROTATE_BACKUP_COUNT,
LOGGING_ROTATE_BYTES,
)
from ray._private import logging_utils
from ray._private.event.event_logger import get_event_logger
from ray._private.ray_logging import setup_component_logger
from ray._raylet import GcsClient
from ray.autoscaler._private.autoscaler import StandardAutoscaler
from ray.autoscaler._private.commands import teardown_cluster
from ray.autoscaler._private.constants import (
AUTOSCALER_MAX_RESOURCE_DEMAND_VECTOR_SIZE,
AUTOSCALER_METRIC_PORT,
AUTOSCALER_UPDATE_INTERVAL_S,
DISABLE_LAUNCH_CONFIG_CHECK_KEY,
)
from ray.autoscaler._private.event_summarizer import EventSummarizer
from ray.autoscaler._private.load_metrics import LoadMetrics
from ray.autoscaler._private.prom_metrics import AutoscalerPrometheusMetrics
from ray.autoscaler._private.util import format_readonly_node_type
from ray.autoscaler.v2.sdk import get_cluster_resource_state
from ray.core.generated import gcs_pb2
from ray.core.generated.event_pb2 import Event as RayEvent
from ray.experimental.internal_kv import (
_initialize_internal_kv,
_internal_kv_del,
_internal_kv_get,
_internal_kv_initialized,
_internal_kv_put,
)
try:
import prometheus_client
except ImportError:
prometheus_client = None
logger = logging.getLogger(__name__)
def parse_resource_demands(
resource_load_by_shape: "gcs_pb2.ResourceLoad",
) -> Tuple[List[Dict], List[Dict]]:
"""Handle the message.resource_load_by_shape protobuf for the demand
based autoscaling. Catch and log all exceptions so this doesn't
interfere with the utilization based autoscaler until we're confident
this is stable. Worker queue backlogs are added to the appropriate
resource demand vector.
Args:
resource_load_by_shape: The resource demands in protobuf form or None.
Returns:
Tuple of (Waiting bundles both ready and feasible, and Infeasible bundles).
"""
waiting_bundles, infeasible_bundles = [], []
try:
for resource_demand_pb in list(resource_load_by_shape.resource_demands):
request_shape = dict(resource_demand_pb.shape)
for _ in range(resource_demand_pb.num_ready_requests_queued):
waiting_bundles.append(request_shape)
for _ in range(resource_demand_pb.num_infeasible_requests_queued):
infeasible_bundles.append(request_shape)
# Infeasible and ready states for tasks are (logically)
# mutually exclusive.
if resource_demand_pb.num_infeasible_requests_queued > 0:
backlog_queue = infeasible_bundles
else:
backlog_queue = waiting_bundles
for _ in range(resource_demand_pb.backlog_size):
backlog_queue.append(request_shape)
if (
len(waiting_bundles + infeasible_bundles)
> AUTOSCALER_MAX_RESOURCE_DEMAND_VECTOR_SIZE
):
break
except Exception:
logger.exception("Failed to parse resource demands.")
return waiting_bundles, infeasible_bundles
# Readonly provider config (e.g., for laptop mode, manually setup clusters).
BASE_READONLY_CONFIG = {
"cluster_name": "default",
"max_workers": 0,
"upscaling_speed": 1.0,
"docker": {},
"idle_timeout_minutes": 0,
"provider": {
"type": "readonly",
"use_node_id_as_ip": True, # For emulated multi-node on laptop.
DISABLE_LAUNCH_CONFIG_CHECK_KEY: True, # No launch check.
},
"auth": {},
"available_node_types": {
"ray.head.default": {"resources": {}, "node_config": {}, "max_workers": 0}
},
"head_node_type": "ray.head.default",
"file_mounts": {},
"cluster_synced_files": [],
"file_mounts_sync_continuously": False,
"rsync_exclude": [],
"rsync_filter": [],
"initialization_commands": [],
"setup_commands": [],
"head_setup_commands": [],
"worker_setup_commands": [],
"head_start_ray_commands": [],
"worker_start_ray_commands": [],
}
class Monitor:
"""Autoscaling monitor.
This process periodically collects stats from the GCS and triggers
autoscaler updates.
"""
def __init__(
self,
address: str,
autoscaling_config: Union[str, Callable[[], Dict[str, Any]]],
log_dir: str = None,
prefix_cluster_info: bool = False,
monitor_ip: Optional[str] = None,
retry_on_failure: bool = True,
):
self.gcs_address = address
worker = ray._private.worker.global_worker
# TODO: eventually plumb ClusterID through to here
self.gcs_client = GcsClient(address=self.gcs_address)
_initialize_internal_kv(self.gcs_client)
if monitor_ip:
monitor_addr = build_address(monitor_ip, AUTOSCALER_METRIC_PORT)
self.gcs_client.internal_kv_put(
b"AutoscalerMetricsAddress", monitor_addr.encode(), True, None
)
self._session_name = self.get_session_name(self.gcs_client)
logger.info(f"session_name: {self._session_name}")
worker.mode = 0
head_node_ip = parse_address(self.gcs_address)[0]
self.load_metrics = LoadMetrics()
self.last_avail_resources = None
self.event_summarizer = EventSummarizer()
self.prefix_cluster_info = prefix_cluster_info
self.retry_on_failure = retry_on_failure
self.autoscaling_config = autoscaling_config
self.autoscaler = None
# If set, we are in a manually created cluster (non-autoscaling) and
# simply mirroring what the GCS tells us the cluster node types are.
self.readonly_config = None
if log_dir:
try:
self.event_logger = get_event_logger(
RayEvent.SourceType.AUTOSCALER, log_dir
)
except Exception:
self.event_logger = None
else:
self.event_logger = None
self.prom_metrics = AutoscalerPrometheusMetrics(session_name=self._session_name)
if monitor_ip and prometheus_client:
# If monitor_ip wasn't passed in, then don't attempt to start the
# metric server to keep behavior identical to before metrics were
# introduced
try:
logger.info(
"Starting autoscaler metrics server on port {}".format(
AUTOSCALER_METRIC_PORT
)
)
kwargs = (
{"addr": get_localhost_ip()} if is_localhost(head_node_ip) else {}
)
prometheus_client.start_http_server(
port=AUTOSCALER_METRIC_PORT,
registry=self.prom_metrics.registry,
**kwargs,
)
# Reset some gauges, since we don't know which labels have
# leaked if the autoscaler was restarted.
self.prom_metrics.pending_nodes.clear()
self.prom_metrics.active_nodes.clear()
except Exception:
logger.exception(
"An exception occurred while starting the metrics server."
)
elif not prometheus_client:
logger.warning(
"`prometheus_client` not found, so metrics will not be exported."
)
logger.info("Monitor: Started")
def _initialize_autoscaler(self):
if self.autoscaling_config:
autoscaling_config = self.autoscaling_config
else:
# This config mirrors the current setup of the manually created
# cluster. Each node gets its own unique node type.
self.readonly_config = BASE_READONLY_CONFIG
# Note that the "available_node_types" of the config can change.
def get_latest_readonly_config():
return self.readonly_config
autoscaling_config = get_latest_readonly_config
self.autoscaler = StandardAutoscaler(
autoscaling_config,
self.load_metrics,
self.gcs_client,
self._session_name,
prefix_cluster_info=self.prefix_cluster_info,
event_summarizer=self.event_summarizer,
prom_metrics=self.prom_metrics,
)
def update_load_metrics(self):
"""Fetches resource usage data from GCS and updates load metrics."""
# TODO(jinbum-kim): Still needed since some fields aren't in cluster_resource_state.
# Remove after v1 autoscaler fully migrates to get_cluster_resource_state().
# ref: https://github.com/ray-project/ray/pull/57130
response = self.gcs_client.get_all_resource_usage(timeout=60)
resources_batch_data = response.resource_usage_data
log_resource_batch_data_if_desired(resources_batch_data)
# This is a workaround to get correct idle_duration_ms
# from "get_cluster_resource_state"
# ref: https://github.com/ray-project/ray/pull/48519#issuecomment-2481659346
cluster_resource_state = get_cluster_resource_state(self.gcs_client)
ray_node_states = cluster_resource_state.node_states
ray_nodes_idle_duration_ms_by_id = {
node.node_id: node.idle_duration_ms for node in ray_node_states
}
# Tell the readonly node provider what nodes to report.
if self.readonly_config:
new_nodes = []
for msg in list(cluster_resource_state.node_states):
node_id = msg.node_id.hex()
new_nodes.append((node_id, msg.node_ip_address))
self.autoscaler.provider._set_nodes(new_nodes)
waiting_bundles, infeasible_bundles = parse_resource_demands(
resources_batch_data.resource_load_by_shape
)
pending_placement_groups = list(
resources_batch_data.placement_group_load.placement_group_data
)
mirror_node_types = {}
for resource_message in cluster_resource_state.node_states:
node_id = resource_message.node_id
# Generate node type config based on GCS reported node list.
if self.readonly_config:
# Keep prefix in sync with ReadonlyNodeProvider.
node_type = format_readonly_node_type(node_id.hex())
resources = {}
for k, v in resource_message.total_resources.items():
resources[k] = v
mirror_node_types[node_type] = {
"resources": resources,
"node_config": {},
"max_workers": 1,
}
total_resources = dict(resource_message.total_resources)
available_resources = dict(resource_message.available_resources)
use_node_id_as_ip = self.autoscaler is not None and self.autoscaler.config[
"provider"
].get("use_node_id_as_ip", False)
# "use_node_id_as_ip" is a hack meant to address situations in
# which there's more than one Ray node residing at a given ip.
# TODO (Dmitri): Stop using ips as node identifiers.
# https://github.com/ray-project/ray/issues/19086
if use_node_id_as_ip:
peloton_id = total_resources.get("NODE_ID_AS_RESOURCE")
# Legacy support https://github.com/ray-project/ray/pull/17312
if peloton_id is not None:
ip = str(int(peloton_id))
else:
ip = node_id.hex()
else:
ip = resource_message.node_ip_address
idle_duration_s = 0.0
if node_id in ray_nodes_idle_duration_ms_by_id:
idle_duration_s = ray_nodes_idle_duration_ms_by_id[node_id] / 1000
else:
logger.warning(
f"node_id {node_id} not found in ray_nodes_idle_duration_ms_by_id"
)
self.load_metrics.update(
ip,
node_id,
total_resources,
available_resources,
idle_duration_s,
waiting_bundles,
infeasible_bundles,
pending_placement_groups,
)
if self.readonly_config:
self.readonly_config["available_node_types"].update(mirror_node_types)
def get_session_name(self, gcs_client: GcsClient) -> Optional[str]:
"""Obtain the session name from the GCS.
If the GCS doesn't respond, session name is considered None.
In this case, the metrics reported from the monitor won't have
the correct session name.
"""
if not _internal_kv_initialized():
return None
session_name = gcs_client.internal_kv_get(
b"session_name",
ray_constants.KV_NAMESPACE_SESSION,
timeout=10,
)
if session_name:
session_name = session_name.decode()
return session_name
def update_resource_requests(self):
"""Fetches resource requests from the internal KV and updates load."""
if not _internal_kv_initialized():
return
data = _internal_kv_get(
ray._private.ray_constants.AUTOSCALER_RESOURCE_REQUEST_CHANNEL
)
if data:
try:
resource_request = json.loads(data)
self.load_metrics.set_resource_requests(resource_request)
except Exception:
logger.exception("Error parsing resource requests")
def _run(self):
"""Run the monitor loop."""
while True:
try:
gcs_request_start_time = time.time()
self.update_load_metrics()
gcs_request_time = time.time() - gcs_request_start_time
self.update_resource_requests()
self.update_event_summary()
load_metrics_summary = self.load_metrics.summary()
status = {
"gcs_request_time": gcs_request_time,
"time": time.time(),
"monitor_pid": os.getpid(),
}
if self.autoscaler and not self.load_metrics:
# load_metrics is Falsey iff we haven't collected any
# resource messages from the GCS, which can happen at startup if
# the GCS hasn't yet received data from the Raylets.
# In this case, do not do an autoscaler update.
# Wait to get load metrics.
logger.info(
"Autoscaler has not yet received load metrics. Waiting."
)
elif self.autoscaler:
# Process autoscaling actions
update_start_time = time.time()
self.autoscaler.update()
status["autoscaler_update_time"] = time.time() - update_start_time
autoscaler_summary = self.autoscaler.summary()
try:
self.emit_metrics(
load_metrics_summary,
autoscaler_summary,
self.autoscaler.all_node_types,
)
except Exception:
logger.exception("Error emitting metrics")
if autoscaler_summary:
status["autoscaler_report"] = asdict(autoscaler_summary)
status[
"non_terminated_nodes_time"
] = (
self.autoscaler.non_terminated_nodes.non_terminated_nodes_time # noqa: E501
)
for msg in self.event_summarizer.summary():
# Need to prefix each line of the message for the lines to
# get pushed to the driver logs.
for line in msg.split("\n"):
logger.info(
"{}{}".format(
ray_constants.LOG_PREFIX_EVENT_SUMMARY, line
)
)
if self.event_logger:
self.event_logger.info(line)
self.event_summarizer.clear()
status["load_metrics_report"] = asdict(load_metrics_summary)
as_json = json.dumps(status)
if _internal_kv_initialized():
_internal_kv_put(
ray_constants.DEBUG_AUTOSCALING_STATUS, as_json, overwrite=True
)
except Exception:
# By default, do not exit the monitor on failure.
if self.retry_on_failure:
logger.exception("Monitor: Execution exception. Trying again...")
else:
raise
# Wait for a autoscaler update interval before processing the next
# round of messages.
time.sleep(AUTOSCALER_UPDATE_INTERVAL_S)
def emit_metrics(self, load_metrics_summary, autoscaler_summary, node_types):
if autoscaler_summary is None:
return None
for resource_name in ["CPU", "GPU", "TPU"]:
_, total = load_metrics_summary.usage.get(resource_name, (0, 0))
pending = autoscaler_summary.pending_resources.get(resource_name, 0)
self.prom_metrics.cluster_resources.labels(
resource=resource_name,
SessionName=self.prom_metrics.session_name,
).set(total)
self.prom_metrics.pending_resources.labels(
resource=resource_name,
SessionName=self.prom_metrics.session_name,
).set(pending)
pending_node_count = Counter()
for _, node_type, _ in autoscaler_summary.pending_nodes:
pending_node_count[node_type] += 1
for node_type, count in autoscaler_summary.pending_launches.items():
pending_node_count[node_type] += count
for node_type in node_types:
count = pending_node_count[node_type]
self.prom_metrics.pending_nodes.labels(
SessionName=self.prom_metrics.session_name,
NodeType=node_type,
).set(count)
for node_type in node_types:
count = autoscaler_summary.active_nodes.get(node_type, 0)
self.prom_metrics.active_nodes.labels(
SessionName=self.prom_metrics.session_name,
NodeType=node_type,
).set(count)
failed_node_counts = Counter()
for _, node_type in autoscaler_summary.failed_nodes:
failed_node_counts[node_type] += 1
# NOTE: This metric isn't reset with monitor resets. This means it will
# only be updated when the autoscaler' node tracker remembers failed
# nodes. If the node type failure is evicted from the autoscaler, the
# metric may not update for a while.
for node_type, count in failed_node_counts.items():
self.prom_metrics.recently_failed_nodes.labels(
SessionName=self.prom_metrics.session_name,
NodeType=node_type,
).set(count)
def update_event_summary(self):
"""Report the current size of the cluster.
To avoid log spam, only cluster size changes (CPU, GPU or TPU count change)
are reported to the event summarizer. The event summarizer will report
only the latest cluster size per batch.
"""
avail_resources = self.load_metrics.resources_avail_summary()
if not self.readonly_config and avail_resources != self.last_avail_resources:
self.event_summarizer.add(
"Resized to {}.", # e.g., Resized to 100 CPUs, 4 GPUs, 4 TPUs.
quantity=avail_resources,
aggregate=lambda old, new: new,
)
self.last_avail_resources = avail_resources
def destroy_autoscaler_workers(self):
"""Cleanup the autoscaler, in case of an exception in the run() method.
We kill the worker nodes, but retain the head node in order to keep
logs around, keeping costs minimal. This monitor process runs on the
head node anyway, so this is more reliable."""
if self.autoscaler is None:
return # Nothing to clean up.
if self.autoscaling_config is None:
# This is a logic error in the program. Can't do anything.
logger.error("Monitor: Cleanup failed due to lack of autoscaler config.")
return
logger.info("Monitor: Exception caught. Taking down workers...")
clean = False
while not clean:
try:
teardown_cluster(
config_file=self.autoscaling_config,
yes=True, # Non-interactive.
workers_only=True, # Retain head node for logs.
override_cluster_name=None,
keep_min_workers=True, # Retain minimal amount of workers.
)
clean = True
logger.info("Monitor: Workers taken down.")
except Exception:
logger.error("Monitor: Cleanup exception. Trying again...")
time.sleep(2)
def _handle_failure(self, error):
if (
self.autoscaler is not None
and os.environ.get("RAY_AUTOSCALER_FATESHARE_WORKERS", "") == "1"
):
self.autoscaler.kill_workers()
# Take down autoscaler workers if necessary.
self.destroy_autoscaler_workers()
# Something went wrong, so push an error to all current and future
# drivers.
message = f"The autoscaler failed with the following error:\n{error}"
if _internal_kv_initialized():
_internal_kv_put(
ray_constants.DEBUG_AUTOSCALING_ERROR, message, overwrite=True
)
from ray._private.utils import publish_error_to_driver
publish_error_to_driver(
ray_constants.MONITOR_DIED_ERROR,
message,
gcs_client=self.gcs_client,
)
def _signal_handler(self, sig, frame):
try:
self._handle_failure(
f"Terminated with signal {sig}\n"
+ "".join(traceback.format_stack(frame))
)
except Exception:
logger.exception("Monitor: Failure in signal handler.")
sys.exit(sig + 128)
def run(self):
# Register signal handlers for autoscaler termination.
# Signals will not be received on windows
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
try:
if _internal_kv_initialized():
# Delete any previous autoscaling errors.
_internal_kv_del(ray_constants.DEBUG_AUTOSCALING_ERROR)
self._initialize_autoscaler()
self._run()
except Exception:
logger.exception("Error in monitor loop")
self._handle_failure(traceback.format_exc())
raise
def log_resource_batch_data_if_desired(
resources_batch_data: gcs_pb2.ResourceUsageBatchData,
) -> None:
if os.getenv("AUTOSCALER_LOG_RESOURCE_BATCH_DATA") == "1":
logger.info("Logging raw resource message pulled from GCS.")
logger.info(resources_batch_data)
logger.info("Done logging raw resource message.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description=("Parse GCS server for the monitor to connect to.")
)
parser.add_argument(
"--gcs-address", required=False, type=str, help="The address (ip:port) of GCS."
)
parser.add_argument(
"--autoscaling-config",
required=False,
type=str,
help="the path to the autoscaling config file",
)
parser.add_argument(
"--logging-level",
required=False,
type=str,
default=ray_constants.LOGGER_LEVEL,
choices=ray_constants.LOGGER_LEVEL_CHOICES,
help=ray_constants.LOGGER_LEVEL_HELP,
)
parser.add_argument(
"--logging-format",
required=False,
type=str,
default=ray_constants.LOGGER_FORMAT,
help=ray_constants.LOGGER_FORMAT_HELP,
)
parser.add_argument(
"--logging-filename",
required=False,
type=str,
default=ray_constants.MONITOR_LOG_FILE_NAME,
help="Specify the name of log file, "
"log to stdout if set empty, default is "
f'"{ray_constants.MONITOR_LOG_FILE_NAME}"',
)
parser.add_argument(
"--logs-dir",
required=True,
type=str,
help="Specify the path of the temporary directory used by Ray processes.",
)
parser.add_argument(
"--logging-rotate-bytes",
required=False,
type=int,
default=LOGGING_ROTATE_BYTES,
help="Specify the max bytes for rotating "
"log file, default is "
f"{LOGGING_ROTATE_BYTES} bytes.",
)
parser.add_argument(
"--logging-rotate-backup-count",
required=False,
type=int,
default=LOGGING_ROTATE_BACKUP_COUNT,
help="Specify the backup count of rotated log file, default is "
f"{LOGGING_ROTATE_BACKUP_COUNT}.",
)
parser.add_argument(
"--monitor-ip",
required=False,
type=str,
default=None,
help="The IP address of the machine hosting the monitor process.",
)
parser.add_argument(
"--stdout-filepath",
required=False,
type=str,
default="",
help="The filepath to dump monitor stdout.",
)
parser.add_argument(
"--stderr-filepath",
required=False,
type=str,
default="",
help="The filepath to dump monitor stderr.",
)
args = parser.parse_args()
# Disable log rotation for windows, because NTFS doesn't allow file deletion when there're multiple owners or borrowers, which happens to be how ray accesses log files.
logging_rotation_bytes = args.logging_rotate_bytes if sys.platform != "win32" else 0
logging_rotation_backup_count = (
args.logging_rotate_backup_count if sys.platform != "win32" else 1
)
setup_component_logger(
logging_level=args.logging_level,
logging_format=args.logging_format,
log_dir=args.logs_dir,
filename=args.logging_filename,
max_bytes=logging_rotation_bytes,
backup_count=logging_rotation_backup_count,
)
# Setup stdout/stderr redirect files if redirection enabled.
logging_utils.redirect_stdout_stderr_if_needed(
args.stdout_filepath,
args.stderr_filepath,
logging_rotation_bytes,
logging_rotation_backup_count,
)
logger.info(f"Starting monitor using ray installation: {ray.__file__}")
logger.info(f"Ray version: {ray.__version__}")
logger.info(f"Ray commit: {ray.__commit__}")
logger.info(f"Monitor started with command: {sys.argv}")
if args.autoscaling_config:
autoscaling_config = os.path.expanduser(args.autoscaling_config)
else:
autoscaling_config = None
bootstrap_address = args.gcs_address
if bootstrap_address is None:
raise ValueError("--gcs-address must be set!")
monitor = Monitor(
bootstrap_address,
autoscaling_config,
log_dir=args.logs_dir,
monitor_ip=args.monitor_ip,
)
monitor.run()
@@ -0,0 +1,221 @@
import copy
import logging
import operator
import threading
import time
import traceback
from typing import Any, Dict, Optional
from ray.autoscaler._private.node_provider_availability_tracker import (
NodeProviderAvailabilityTracker,
)
from ray.autoscaler._private.prom_metrics import AutoscalerPrometheusMetrics
from ray.autoscaler._private.util import hash_launch_conf
from ray.autoscaler.node_launch_exception import NodeLaunchException
from ray.autoscaler.tags import (
NODE_KIND_WORKER,
STATUS_UNINITIALIZED,
TAG_RAY_LAUNCH_CONFIG,
TAG_RAY_NODE_KIND,
TAG_RAY_NODE_NAME,
TAG_RAY_NODE_STATUS,
TAG_RAY_USER_NODE_TYPE,
)
logger = logging.getLogger(__name__)
class BaseNodeLauncher:
"""Launches Ray nodes in the main thread using
`BaseNodeLauncher.launch_node()`.
This is a superclass of NodeLauncher, which launches nodes asynchronously
in the background.
By default, the subclass NodeLauncher is used to launch nodes in subthreads.
That behavior can be flagged off in the provider config by setting
`foreground_node_launch: True`; the autoscaler will then makes blocking calls to
BaseNodeLauncher.launch_node() in the main thread.
"""
def __init__(
self,
provider,
pending,
event_summarizer,
node_provider_availability_tracker: NodeProviderAvailabilityTracker,
session_name: Optional[str] = None,
prom_metrics=None,
node_types=None,
index=None,
*args,
**kwargs,
):
self.pending = pending
self.event_summarizer = event_summarizer
self.node_provider_availability_tracker = node_provider_availability_tracker
self.prom_metrics = prom_metrics or AutoscalerPrometheusMetrics(
session_name=session_name
)
self.provider = provider
self.node_types = node_types
self.index = str(index) if index is not None else ""
def launch_node(
self, config: Dict[str, Any], count: int, node_type: str
) -> Optional[Dict]:
self.log("Got {} nodes to launch.".format(count))
created_nodes = self._launch_node(config, count, node_type)
self.pending.dec(node_type, count)
return created_nodes
def _launch_node(
self, config: Dict[str, Any], count: int, node_type: str
) -> Optional[Dict]:
if self.node_types:
assert node_type, node_type
# The `worker_nodes` field is deprecated in favor of per-node-type
# node_configs. We allow it for backwards-compatibility.
launch_config = copy.deepcopy(config.get("worker_nodes", {}))
if node_type:
launch_config.update(
config["available_node_types"][node_type]["node_config"]
)
resources = copy.deepcopy(
config["available_node_types"][node_type]["resources"]
)
labels = copy.deepcopy(
config["available_node_types"][node_type].get("labels", {})
)
launch_hash = hash_launch_conf(launch_config, config["auth"])
node_config = copy.deepcopy(config.get("worker_nodes", {}))
node_tags = {
TAG_RAY_NODE_NAME: "ray-{}-worker".format(config["cluster_name"]),
TAG_RAY_NODE_KIND: NODE_KIND_WORKER,
TAG_RAY_NODE_STATUS: STATUS_UNINITIALIZED,
TAG_RAY_LAUNCH_CONFIG: launch_hash,
}
# A custom node type is specified; set the tag in this case, and also
# merge the configs. We merge the configs instead of overriding, so
# that the bootstrapped per-cloud properties are preserved.
# TODO(ekl) this logic is duplicated in commands.py (keep in sync)
if node_type:
node_tags[TAG_RAY_USER_NODE_TYPE] = node_type
node_config.update(launch_config)
node_launch_start_time = time.time()
error_msg = None
full_exception = None
created_nodes = {}
try:
created_nodes = self.provider.create_node_with_resources_and_labels(
node_config, node_tags, count, resources, labels
)
except NodeLaunchException as node_launch_exception:
self.node_provider_availability_tracker.update_node_availability(
node_type, int(node_launch_start_time), node_launch_exception
)
if node_launch_exception.src_exc_info is not None:
full_exception = "\n".join(
traceback.format_exception(*node_launch_exception.src_exc_info)
)
error_msg = (
f"Failed to launch {{}} node(s) of type {node_type}. "
f"({node_launch_exception.category}): "
f"{node_launch_exception.description}"
)
except Exception:
error_msg = f"Failed to launch {{}} node(s) of type {node_type}."
full_exception = traceback.format_exc()
else:
# Record some metrics/observability information when a node is launched.
launch_time = time.time() - node_launch_start_time
for _ in range(count):
# Note: when launching multiple nodes we observe the time it
# took all nodes to launch for each node. For example, if 4
# nodes were created in 25 seconds, we would observe the 25
# second create time 4 times.
self.prom_metrics.worker_create_node_time.observe(launch_time)
self.prom_metrics.started_nodes.inc(count)
self.node_provider_availability_tracker.update_node_availability(
node_type=node_type,
timestamp=int(node_launch_start_time),
node_launch_exception=None,
)
if error_msg is not None:
self.event_summarizer.add(
error_msg,
quantity=count,
aggregate=operator.add,
)
self.log(error_msg)
self.prom_metrics.node_launch_exceptions.inc()
self.prom_metrics.failed_create_nodes.inc(count)
else:
self.log("Launching {} nodes, type {}.".format(count, node_type))
self.event_summarizer.add(
"Adding {} node(s) of type " + str(node_type) + ".",
quantity=count,
aggregate=operator.add,
)
if full_exception is not None:
self.log(full_exception)
return created_nodes
def log(self, statement):
# launcher_class is "BaseNodeLauncher", or "NodeLauncher" if called
# from that subclass.
launcher_class: str = type(self).__name__
prefix = "{}{}:".format(launcher_class, self.index)
logger.info(prefix + " {}".format(statement))
class NodeLauncher(BaseNodeLauncher, threading.Thread):
"""Launches nodes asynchronously in the background."""
def __init__(
self,
provider,
queue,
pending,
event_summarizer,
node_provider_availability_tracker,
session_name: Optional[str] = None,
prom_metrics=None,
node_types=None,
index=None,
*thread_args,
**thread_kwargs,
):
self.queue = queue
BaseNodeLauncher.__init__(
self,
provider=provider,
pending=pending,
event_summarizer=event_summarizer,
session_name=session_name,
node_provider_availability_tracker=node_provider_availability_tracker,
prom_metrics=prom_metrics,
node_types=node_types,
index=index,
)
threading.Thread.__init__(self, *thread_args, **thread_kwargs)
def run(self):
"""Collects launch data from queue populated by StandardAutoscaler.
Launches nodes in a background thread.
Overrides threading.Thread.run().
NodeLauncher.start() executes this loop in a background thread.
"""
while True:
config, count, node_type = self.queue.get()
# launch_node is implemented in BaseNodeLauncher
self.launch_node(config, count, node_type)
@@ -0,0 +1,165 @@
import threading
import time
from dataclasses import dataclass
from typing import Callable, Dict, Optional, Tuple
from ray.autoscaler._private.constants import (
AUTOSCALER_NODE_AVAILABILITY_MAX_STALENESS_S,
)
from ray.autoscaler.node_launch_exception import NodeLaunchException
@dataclass
class UnavailableNodeInformation:
category: str
description: str
@dataclass
class NodeAvailabilityRecord:
node_type: str
is_available: bool
last_checked_timestamp: float
unavailable_node_information: Optional[UnavailableNodeInformation]
@dataclass
class NodeAvailabilitySummary:
node_availabilities: Dict[
str, NodeAvailabilityRecord
] # Mapping from node type to node availability record.
@classmethod
def from_fields(cls, **fields) -> Optional["NodeAvailabilitySummary"]:
"""Implement marshalling from nested fields. pydantic isn't a core dependency
so we're implementing this by hand instead."""
parsed = {}
node_availabilites_dict = fields.get("node_availabilities", {})
for node_type, node_availability_record_dict in node_availabilites_dict.items():
unavailable_information_dict = node_availability_record_dict.pop(
"unavailable_node_information", None
)
unavaiable_information = None
if unavailable_information_dict is not None:
unavaiable_information = UnavailableNodeInformation(
**unavailable_information_dict
)
parsed[node_type] = NodeAvailabilityRecord(
unavailable_node_information=unavaiable_information,
**node_availability_record_dict,
)
return NodeAvailabilitySummary(node_availabilities=parsed)
def __eq__(self, other: "NodeAvailabilitySummary"):
return self.node_availabilities == other.node_availabilities
def __bool__(self) -> bool:
return bool(self.node_availabilities)
class NodeProviderAvailabilityTracker:
"""A thread safe, TTL cache of node provider availability. We don't use
cachetools.TTLCache because it always sets the expiration time relative to
insertion time, but in our case, we want entries to expire relative to when
the node creation was attempted (and entries aren't necessarily added in
order). We want the entries to expire because the information grows stale
over time.
"""
def __init__(
self,
timer: Callable[[], float] = time.time,
ttl: float = AUTOSCALER_NODE_AVAILABILITY_MAX_STALENESS_S,
):
"""A cache that tracks the availability of nodes and throw away
entries which have grown too stale.
Args:
timer: A function that returns the current time in seconds.
ttl: The ttl from the insertion timestamp of an entry.
"""
self.timer = timer
self.ttl = ttl
# Mapping from node type to (eviction_time, record)
self.store: Dict[str, Tuple[float, NodeAvailabilityRecord]] = {}
# A global lock to simplify thread safety handling.
self.lock = threading.RLock()
def _update_node_availability_requires_lock(
self,
node_type: str,
timestamp: int,
node_launch_exception: Optional[NodeLaunchException],
) -> None:
if node_launch_exception is None:
record = NodeAvailabilityRecord(
node_type=node_type,
is_available=True,
last_checked_timestamp=timestamp,
unavailable_node_information=None,
)
else:
info = UnavailableNodeInformation(
category=node_launch_exception.category,
description=node_launch_exception.description,
)
record = NodeAvailabilityRecord(
node_type=node_type,
is_available=False,
last_checked_timestamp=timestamp,
unavailable_node_information=info,
)
expiration_time = timestamp + self.ttl
# TODO (Alex): In theory it would be nice to make this dictionary
# ordered by expiration time, unfortunately that's a bit difficult
# since `update_node_availability` can be called with out of order
# timestamps.
self.store[node_type] = (expiration_time, record)
self._remove_old_entries()
def update_node_availability(
self,
node_type: str,
timestamp: int,
node_launch_exception: Optional[NodeLaunchException],
) -> None:
"""
Update the availability and details of a single ndoe type.
Args:
node_type: The node type.
timestamp: The timestamp that this information is accurate as of.
node_launch_exception: Details about why the node launch failed. If
empty, the node type will be considered available."""
with self.lock:
self._update_node_availability_requires_lock(
node_type, timestamp, node_launch_exception
)
def summary(self) -> NodeAvailabilitySummary:
"""
Returns a summary of node availabilities and their staleness.
Returns
A summary of node availabilities and their staleness.
"""
with self.lock:
self._remove_old_entries()
return NodeAvailabilitySummary(
{node_type: record for node_type, (_, record) in self.store.items()}
)
def _remove_old_entries(self):
"""Remove any expired entries from the cache."""
cur_time = self.timer()
with self.lock:
for key, (expiration_time, _) in list(self.store.items()):
if expiration_time < cur_time:
del self.store[key]
@@ -0,0 +1,77 @@
from typing import List, Set, Tuple
from ray.autoscaler._private import constants
class NodeTracker:
"""Map nodes to their corresponding logs.
We need to be a little careful here. At an given point in time, node_id <->
ip can be interchangeably used, but the node_id -> ip relation is not
bijective _across time_ since IP addresses can be reused. Therefore, we
should treat node_id as the only unique identifier.
"""
def __init__(self):
# Mapping from node_id -> (ip, node type, stdout_path, process runner)
self.node_mapping = {}
# A quick, inefficient FIFO cache implementation.
self.lru_order = []
def _add_node_mapping(self, node_id: str, value: str):
if node_id in self.node_mapping:
return
assert len(self.lru_order) == len(self.node_mapping)
if len(self.lru_order) >= constants.AUTOSCALER_MAX_NODES_TRACKED:
# The LRU eviction case
node_id = self.lru_order.pop(0)
del self.node_mapping[node_id]
self.node_mapping[node_id] = value
self.lru_order.append(node_id)
def track(self, node_id: str, ip: str, node_type: str):
"""
Begin to track a new node.
Args:
node_id: The node id.
ip: The node ip address.
node_type: The node type.
"""
if node_id not in self.node_mapping:
self._add_node_mapping(node_id, (ip, node_type))
def untrack(self, node_id: str):
"""Gracefully stop tracking a node. If a node is intentionally removed from
the cluster, we should stop tracking it so we don't mistakenly mark it
as failed.
Args:
node_id: The node id which failed.
"""
if node_id in self.node_mapping:
self.lru_order.remove(node_id)
del self.node_mapping[node_id]
def get_all_failed_node_info(
self, non_failed_ids: Set[str]
) -> List[Tuple[str, str]]:
"""Get the information about all failed nodes. A failed node is any node which
we began to track that is not pending or alive (i.e. not failed).
Args:
non_failed_ids: Nodes are failed unless they are in this set.
Returns:
List[Tuple[str, str]]: A list of tuples. Each tuple is the ip
address and type of a failed node.
"""
failed_nodes = self.node_mapping.keys() - non_failed_ids
failed_info = []
# Returning the list in order is important for display purposes.
for node_id in filter(lambda node_id: node_id in failed_nodes, self.lru_order):
failed_info.append(self.node_mapping[node_id])
return failed_info
@@ -0,0 +1,292 @@
from typing import Optional
class NullMetric:
"""Mock metric class to be used in case of prometheus_client import error."""
def set(self, *args, **kwargs):
pass
def observe(self, *args, **kwargs):
pass
def inc(self, *args, **kwargs):
pass
def labels(self, *args, **kwargs):
return self
def clear(self):
pass
try:
from prometheus_client import CollectorRegistry, Counter, Gauge, Histogram
# The metrics in this class should be kept in sync with
# python/ray/tests/test_metrics_agent.py
class AutoscalerPrometheusMetrics:
def __init__(
self, session_name: str = None, registry: Optional[CollectorRegistry] = None
):
self.registry: CollectorRegistry = registry or CollectorRegistry(
auto_describe=True
)
self._session_name = session_name
# Buckets: 5 seconds, 10 seconds, 20 seconds, 30 seconds,
# 45 seconds, 1 minute, 1.5 minutes, 2 minutes,
# 3 minutes, 4 minutes, 5 minutes, 6 minutes,
# 8 minutes, 10 minutes, 12 minutes, 15 minutes
# 20 minutes, 25 minutes, 30 minutes
# used for both worker launch time and worker update time
histogram_buckets = [
5,
10,
20,
30,
45,
60,
90,
120,
180,
240,
300,
360,
480,
600,
720,
900,
1200,
1500,
1800,
]
# Buckets: .01 seconds to 1000 seconds.
# Used for autoscaler update time.
update_time_buckets = [0.01, 0.1, 1, 10, 100, 1000]
self.worker_create_node_time: Histogram = Histogram(
"worker_create_node_time_seconds",
"Worker launch time. This is the time it takes for a call to "
"a node provider's create_node method to return. Note that "
"when nodes are launched in batches, the launch time for that "
"batch will be observed once for *each* node in that batch. "
"For example, if 8 nodes are launched in 3 minutes, a launch "
"time of 3 minutes will be observed 8 times.",
labelnames=("SessionName",),
unit="seconds",
namespace="autoscaler",
registry=self.registry,
buckets=histogram_buckets,
).labels(SessionName=session_name)
self.worker_update_time: Histogram = Histogram(
"worker_update_time_seconds",
"Worker update time. This is the time between when an updater "
"thread begins executing and when it exits successfully. This "
"metric only observes times for successful updates.",
labelnames=("SessionName",),
unit="seconds",
namespace="autoscaler",
registry=self.registry,
buckets=histogram_buckets,
).labels(SessionName=session_name)
self.update_time: Histogram = Histogram(
"update_time",
"Autoscaler update time. This is the time for an autoscaler "
"update iteration to complete.",
labelnames=("SessionName",),
unit="seconds",
namespace="autoscaler",
registry=self.registry,
buckets=update_time_buckets,
).labels(SessionName=session_name)
self.pending_nodes: Gauge = Gauge(
"pending_nodes",
"Number of nodes pending to be started.",
labelnames=(
"NodeType",
"SessionName",
),
unit="nodes",
namespace="autoscaler",
registry=self.registry,
)
self.active_nodes: Gauge = Gauge(
"active_nodes",
"Number of nodes in the cluster.",
labelnames=(
"NodeType",
"SessionName",
),
unit="nodes",
namespace="autoscaler",
registry=self.registry,
)
self.recently_failed_nodes = Gauge(
"recently_failed_nodes",
"The number of recently failed nodes. This count could reset "
"at undefined times.",
labelnames=(
"NodeType",
"SessionName",
),
unit="nodes",
namespace="autoscaler",
registry=self.registry,
)
self.started_nodes: Counter = Counter(
"started_nodes",
"Number of nodes started.",
labelnames=("SessionName",),
unit="nodes",
namespace="autoscaler",
registry=self.registry,
).labels(SessionName=session_name)
self.stopped_nodes: Counter = Counter(
"stopped_nodes",
"Number of nodes stopped.",
labelnames=("SessionName",),
unit="nodes",
namespace="autoscaler",
registry=self.registry,
).labels(SessionName=session_name)
self.updating_nodes: Gauge = Gauge(
"updating_nodes",
"Number of nodes in the process of updating.",
labelnames=("SessionName",),
unit="nodes",
namespace="autoscaler",
registry=self.registry,
).labels(SessionName=session_name)
self.recovering_nodes: Gauge = Gauge(
"recovering_nodes",
"Number of nodes in the process of recovering.",
labelnames=("SessionName",),
unit="nodes",
namespace="autoscaler",
registry=self.registry,
).labels(SessionName=session_name)
self.running_workers: Gauge = Gauge(
"running_workers",
"Number of worker nodes running.",
labelnames=("SessionName",),
unit="nodes",
namespace="autoscaler",
registry=self.registry,
).labels(SessionName=session_name)
self.failed_create_nodes: Counter = Counter(
"failed_create_nodes",
"Number of nodes that failed to be created due to an "
"exception in the node provider's create_node method.",
labelnames=("SessionName",),
unit="nodes",
namespace="autoscaler",
registry=self.registry,
).labels(SessionName=session_name)
self.failed_updates: Counter = Counter(
"failed_updates",
"Number of failed worker node updates.",
labelnames=("SessionName",),
unit="updates",
namespace="autoscaler",
registry=self.registry,
).labels(SessionName=session_name)
self.successful_updates: Counter = Counter(
"successful_updates",
"Number of succesfful worker node updates.",
labelnames=("SessionName",),
unit="updates",
namespace="autoscaler",
registry=self.registry,
).labels(SessionName=session_name)
self.failed_recoveries: Counter = Counter(
"failed_recoveries",
"Number of failed node recoveries.",
labelnames=("SessionName",),
unit="recoveries",
namespace="autoscaler",
registry=self.registry,
).labels(SessionName=session_name)
self.successful_recoveries: Counter = Counter(
"successful_recoveries",
"Number of successful node recoveries.",
labelnames=("SessionName",),
unit="recoveries",
namespace="autoscaler",
registry=self.registry,
).labels(SessionName=session_name)
self.update_loop_exceptions: Counter = Counter(
"update_loop_exceptions",
"Number of exceptions raised in the update loop of the autoscaler.",
labelnames=("SessionName",),
unit="exceptions",
namespace="autoscaler",
registry=self.registry,
).labels(SessionName=session_name)
self.node_launch_exceptions: Counter = Counter(
"node_launch_exceptions",
"Number of exceptions raised while launching nodes.",
labelnames=("SessionName",),
unit="exceptions",
namespace="autoscaler",
registry=self.registry,
).labels(SessionName=session_name)
self.reset_exceptions: Counter = Counter(
"reset_exceptions",
"Number of exceptions raised while resetting the autoscaler.",
labelnames=("SessionName",),
unit="exceptions",
namespace="autoscaler",
registry=self.registry,
).labels(SessionName=session_name)
self.config_validation_exceptions: Counter = Counter(
"config_validation_exceptions",
"Number of exceptions raised while validating the config "
"during a reset.",
labelnames=("SessionName",),
unit="exceptions",
namespace="autoscaler",
registry=self.registry,
).labels(SessionName=session_name)
self.drain_node_exceptions: Counter = Counter(
"drain_node_exceptions",
"Number of exceptions raised when making a DrainNode rpc"
"prior to node termination.",
labelnames=("SessionName",),
unit="exceptions",
namespace="autoscaler",
registry=self.registry,
).labels(SessionName=session_name)
# This represents the autoscaler's view of essentially
# `ray.cluster_resources()`, it may be slightly different from the
# core metric from an eventual consistency perspective.
self.cluster_resources: Gauge = Gauge(
"cluster_resources",
"Total logical resources in the cluster.",
labelnames=("resource", "SessionName"),
unit="resources",
namespace="autoscaler",
registry=self.registry,
)
# This represents the pending launches + nodes being set up for the
# autoscaler.
self.pending_resources: Gauge = Gauge(
"pending_resources",
"Pending logical resources in the cluster.",
labelnames=("resource", "SessionName"),
unit="resources",
namespace="autoscaler",
registry=self.registry,
)
@property
def session_name(self):
return self._session_name
except ImportError:
class AutoscalerPrometheusMetrics(object):
def __init__(self, session_name: str = None):
pass
def __getattr__(self, attr):
return NullMetric()
+299
View File
@@ -0,0 +1,299 @@
import copy
import json
import logging
import os
from typing import Any, Dict
import yaml
from ray.autoscaler._private.loader import load_function_or_class
logger = logging.getLogger(__name__)
# For caching provider instantiations across API calls of one python session
_provider_instances = {}
# Minimal config for compatibility with legacy-style external configs.
MINIMAL_EXTERNAL_CONFIG = {
"available_node_types": {
"ray.head.default": {},
"ray.worker.default": {},
},
"head_node_type": "ray.head.default",
"head_node": {},
"worker_nodes": {},
}
def _import_aws(provider_config):
try:
# boto3 and botocore are imported in multiple places in the codebase,
# so we just import them here to ensure that they are installed.
import boto3 # noqa: F401
except ImportError as e:
raise ImportError(
"The Ray AWS VM launcher requires the AWS SDK for Python (Boto3) "
"to be installed. You can install it with `pip install boto3`."
) from e
from ray.autoscaler._private.aws.node_provider import AWSNodeProvider
return AWSNodeProvider
def _import_gcp(provider_config):
try:
import googleapiclient # noqa: F401
except ImportError as e:
raise ImportError(
"The Ray GCP VM launcher requires the Google API Client to be installed. "
"You can install it with `pip install google-api-python-client`."
) from e
from ray.autoscaler._private.gcp.node_provider import GCPNodeProvider
return GCPNodeProvider
def _import_azure(provider_config):
from ray.autoscaler._private._azure.node_provider import AzureNodeProvider
return AzureNodeProvider
def _import_vsphere(provider_config):
from ray.autoscaler._private.vsphere.node_provider import VsphereWcpNodeProvider
return VsphereWcpNodeProvider
def _import_local(provider_config):
if "coordinator_address" in provider_config:
from ray.autoscaler._private.local.coordinator_node_provider import (
CoordinatorSenderNodeProvider,
)
return CoordinatorSenderNodeProvider
else:
from ray.autoscaler._private.local.node_provider import LocalNodeProvider
return LocalNodeProvider
def _import_readonly(provider_config):
from ray.autoscaler._private.readonly.node_provider import ReadOnlyNodeProvider
return ReadOnlyNodeProvider
def _import_fake_multinode(provider_config):
from ray.autoscaler._private.fake_multi_node.node_provider import (
FakeMultiNodeProvider,
)
return FakeMultiNodeProvider
def _import_fake_multinode_docker(provider_config):
from ray.autoscaler._private.fake_multi_node.node_provider import (
FakeMultiNodeDockerProvider,
)
return FakeMultiNodeDockerProvider
def _import_kuberay(provider_config):
from ray.autoscaler._private.kuberay.node_provider import KubeRayNodeProvider
return KubeRayNodeProvider
def _import_aliyun(provider_config):
from ray.autoscaler._private.aliyun.node_provider import AliyunNodeProvider
return AliyunNodeProvider
def _import_spark(provider_config):
from ray.autoscaler._private.spark.node_provider import SparkNodeProvider
return SparkNodeProvider
def _load_fake_multinode_defaults_config():
import ray.autoscaler._private.fake_multi_node as ray_fake_multinode
return os.path.join(os.path.dirname(ray_fake_multinode.__file__), "example.yaml")
def _load_read_only_defaults_config():
import ray.autoscaler._private.readonly as ray_readonly
return os.path.join(os.path.dirname(ray_readonly.__file__), "defaults.yaml")
def _load_fake_multinode_docker_defaults_config():
import ray.autoscaler._private.fake_multi_node as ray_fake_multinode
return os.path.join(
os.path.dirname(ray_fake_multinode.__file__), "example_docker.yaml"
)
def _load_local_defaults_config():
import ray.autoscaler.local as ray_local
return os.path.join(os.path.dirname(ray_local.__file__), "defaults.yaml")
def _load_aws_defaults_config():
import ray.autoscaler.aws as ray_aws
return os.path.join(os.path.dirname(ray_aws.__file__), "defaults.yaml")
def _load_vsphere_defaults_config():
import ray.autoscaler.vsphere as ray_vsphere
return os.path.join(os.path.dirname(ray_vsphere.__file__), "defaults.yaml")
def _load_gcp_defaults_config():
import ray.autoscaler.gcp as ray_gcp
return os.path.join(os.path.dirname(ray_gcp.__file__), "defaults.yaml")
def _load_azure_defaults_config():
import ray.autoscaler.azure as ray_azure
return os.path.join(os.path.dirname(ray_azure.__file__), "defaults.yaml")
def _load_aliyun_defaults_config():
import ray.autoscaler.aliyun as ray_aliyun
return os.path.join(os.path.dirname(ray_aliyun.__file__), "defaults.yaml")
def _import_external(provider_config):
provider_cls = load_function_or_class(path=provider_config["module"])
return provider_cls
_NODE_PROVIDERS = {
"local": _import_local,
"fake_multinode": _import_fake_multinode,
"fake_multinode_docker": _import_fake_multinode_docker,
"readonly": _import_readonly,
"aws": _import_aws,
"gcp": _import_gcp,
"vsphere": _import_vsphere,
"azure": _import_azure,
"kuberay": _import_kuberay,
"aliyun": _import_aliyun,
"external": _import_external, # Import an external module
"spark": _import_spark,
}
_PROVIDER_PRETTY_NAMES = {
"readonly": "Readonly (Manual Cluster Setup)",
"fake_multinode": "Fake Multinode",
"fake_multinode_docker": "Fake Multinode Docker",
"local": "Local",
"aws": "AWS",
"gcp": "GCP",
"azure": "Azure",
"kuberay": "KubeRay",
"aliyun": "Aliyun",
"external": "External",
"vsphere": "vSphere",
"spark": "Spark",
}
_DEFAULT_CONFIGS = {
"fake_multinode": _load_fake_multinode_defaults_config,
"fake_multinode_docker": _load_fake_multinode_docker_defaults_config,
"local": _load_local_defaults_config,
"aws": _load_aws_defaults_config,
"gcp": _load_gcp_defaults_config,
"azure": _load_azure_defaults_config,
"aliyun": _load_aliyun_defaults_config,
"vsphere": _load_vsphere_defaults_config,
"readonly": _load_read_only_defaults_config,
}
def _get_node_provider_cls(provider_config: Dict[str, Any]):
"""Get the node provider class for a given provider config.
Note that this may be used by private node providers that proxy methods to
built-in node providers, so we should maintain backwards compatibility.
Args:
provider_config: provider section of the autoscaler config.
Returns:
NodeProvider class
"""
importer = _NODE_PROVIDERS.get(provider_config["type"])
if importer is None:
raise NotImplementedError(
"Unsupported node provider: {}".format(provider_config["type"])
)
return importer(provider_config)
def _get_node_provider(
provider_config: Dict[str, Any], cluster_name: str, use_cache: bool = True
) -> Any:
"""Get the instantiated node provider for a given provider config.
Note that this may be used by private node providers that proxy methods to
built-in node providers, so we should maintain backwards compatibility.
Args:
provider_config: provider section of the autoscaler config.
cluster_name: cluster name from the autoscaler config.
use_cache: whether or not to use a cached definition if available. If
False, the returned object will also not be stored in the cache.
Returns:
NodeProvider
"""
provider_key = (json.dumps(provider_config, sort_keys=True), cluster_name)
if use_cache and provider_key in _provider_instances:
return _provider_instances[provider_key]
provider_cls = _get_node_provider_cls(provider_config)
new_provider = provider_cls(provider_config, cluster_name)
if use_cache:
_provider_instances[provider_key] = new_provider
return new_provider
def _clear_provider_cache():
global _provider_instances
_provider_instances = {}
def _get_default_config(provider_config):
"""Retrieve a node provider.
This is an INTERNAL API. It is not allowed to call this from any Ray
package outside the autoscaler.
"""
if provider_config["type"] == "external":
return copy.deepcopy(MINIMAL_EXTERNAL_CONFIG)
load_config = _DEFAULT_CONFIGS.get(provider_config["type"])
if load_config is None:
raise NotImplementedError(
"Unsupported node provider: {}".format(provider_config["type"])
)
path_to_default = load_config()
with open(path_to_default) as f:
defaults = yaml.safe_load(f)
return defaults
@@ -0,0 +1,31 @@
cluster_name: default
max_workers: 0
provider:
type: readonly
# This must be true since the nodes share the same ip!
use_node_id_as_ip: True
disable_node_updaters: True
disable_launch_config_check: True
available_node_types:
ray.head.default:
resources: {}
node_config: {}
max_workers: 0
head_node_type: ray.head.default
upscaling_speed: 1.0
#
# !!! Configurations below are not supported in fake cluster mode !!!
#
auth: {}
docker: {}
initialization_commands: []
setup_commands: []
head_setup_commands: []
worker_setup_commands: []
head_start_ray_commands: []
worker_start_ray_commands: []
file_mounts: {}
cluster_synced_files: []
file_mounts_sync_continuously: false
rsync_exclude: []
rsync_filter: []
@@ -0,0 +1,80 @@
from typing import List, Tuple
from ray.autoscaler._private.util import format_readonly_node_type
from ray.autoscaler.node_provider import NodeProvider
from ray.autoscaler.tags import (
NODE_KIND_HEAD,
STATUS_UP_TO_DATE,
TAG_RAY_NODE_KIND,
TAG_RAY_NODE_NAME,
TAG_RAY_NODE_STATUS,
TAG_RAY_USER_NODE_TYPE,
)
class ReadOnlyNodeProvider(NodeProvider):
"""A node provider that merely reports the current cluster state.
This is used for laptop mode / manual cluster setup modes, in order to
provide status reporting in the same way for users."""
def __init__(self, provider_config, cluster_name):
NodeProvider.__init__(self, provider_config, cluster_name)
self.nodes = {}
def is_readonly(self):
return True
def _set_nodes(self, nodes: List[Tuple[str, str]]):
"""Update the set of nodes in the cluster.
Args:
nodes: List of (node_id, node_manager_address) tuples.
"""
new_nodes = {}
for node_id, node_manager_address in nodes:
# We make up a fake node type for each node (since each node
# could have its own unique configuration).
new_nodes[node_id] = {
# Keep prefix in sync with node config gen in monitor.py
"node_type": format_readonly_node_type(node_id),
"ip": node_manager_address,
}
self.nodes = new_nodes
def non_terminated_nodes(self, tag_filters):
return list(self.nodes.keys())
def is_running(self, node_id):
return node_id in self.nodes
def is_terminated(self, node_id):
return node_id not in self.nodes
def node_tags(self, node_id):
tags = {
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
TAG_RAY_USER_NODE_TYPE: self.nodes[node_id]["node_type"],
TAG_RAY_NODE_NAME: node_id,
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
}
return tags
def external_ip(self, node_id):
return node_id
def internal_ip(self, node_id):
return node_id
def set_node_tags(self, node_id, tags):
raise AssertionError("Readonly node provider cannot be updated")
def create_node(self, node_config, tags, count):
raise AssertionError("Readonly node provider cannot be updated")
def terminate_node(self, node_id):
raise AssertionError("Readonly node provider cannot be updated")
@staticmethod
def bootstrap_config(cluster_config):
return cluster_config
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,250 @@
import json
import logging
import sys
from threading import RLock
from typing import Any, Dict, Optional
import requests
from ray._common.network_utils import build_address
from ray.autoscaler.node_launch_exception import NodeLaunchException
from ray.autoscaler.node_provider import NodeProvider
from ray.autoscaler.tags import (
NODE_KIND_HEAD,
NODE_KIND_WORKER,
STATUS_SETTING_UP,
STATUS_UP_TO_DATE,
TAG_RAY_NODE_KIND,
TAG_RAY_NODE_NAME,
TAG_RAY_NODE_STATUS,
TAG_RAY_USER_NODE_TYPE,
)
logger = logging.getLogger(__name__)
HEAD_NODE_ID = 0
HEAD_NODE_TYPE = "ray.head.default"
class SparkNodeProvider(NodeProvider):
"""A node provider that implements provider for nodes of Ray on spark."""
def __init__(self, provider_config, cluster_name):
NodeProvider.__init__(self, provider_config, cluster_name)
self.lock = RLock()
self._nodes = {
str(HEAD_NODE_ID): {
"tags": {
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
TAG_RAY_USER_NODE_TYPE: HEAD_NODE_TYPE,
TAG_RAY_NODE_NAME: HEAD_NODE_ID,
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
}
},
}
self._next_node_id = 0
self.ray_head_ip = self.provider_config["ray_head_ip"]
# The port of spark job server. We send http request to spark job server
# to launch spark jobs, ray worker nodes are launched by spark task in
# spark jobs.
spark_job_server_port = self.provider_config["spark_job_server_port"]
self.spark_job_server_url = (
f"http://{build_address(self.ray_head_ip, spark_job_server_port)}"
)
self.ray_head_port = self.provider_config["ray_head_port"]
# The unique id for the Ray on spark cluster.
self.cluster_id = self.provider_config["cluster_unique_id"]
def get_next_node_id(self):
with self.lock:
self._next_node_id += 1
return self._next_node_id
def non_terminated_nodes(self, tag_filters):
with self.lock:
nodes = []
died_nodes = []
for node_id in self._nodes:
if node_id == str(HEAD_NODE_ID):
status = "running"
else:
status = self._query_node_status(node_id)
if status == "running":
if (
self._nodes[node_id]["tags"][TAG_RAY_NODE_STATUS]
== STATUS_SETTING_UP
):
self._nodes[node_id]["tags"][
TAG_RAY_NODE_STATUS
] = STATUS_UP_TO_DATE
logger.info(
f"Spark node provider node {node_id} starts running."
)
if status == "terminated":
died_nodes.append(node_id)
else:
tags = self.node_tags(node_id)
ok = True
for k, v in tag_filters.items():
if tags.get(k) != v:
ok = False
if ok:
nodes.append(node_id)
for died_node_id in died_nodes:
self._nodes.pop(died_node_id)
return nodes
def _query_node_status(self, node_id):
spark_job_group_id = self._gen_spark_job_group_id(node_id)
response = requests.post(
url=self.spark_job_server_url + "/query_task_status",
json={"spark_job_group_id": spark_job_group_id},
)
response.raise_for_status()
decoded_resp = response.content.decode("utf-8")
json_res = json.loads(decoded_resp)
return json_res["status"]
def is_running(self, node_id):
with self.lock:
return (
node_id in self._nodes
and self._nodes[node_id]["tags"][TAG_RAY_NODE_STATUS]
== STATUS_UP_TO_DATE
)
def is_terminated(self, node_id):
with self.lock:
return node_id not in self._nodes
def node_tags(self, node_id):
with self.lock:
return self._nodes[node_id]["tags"]
def _get_ip(self, node_id: str) -> Optional[str]:
return node_id
def external_ip(self, node_id):
return self._get_ip(node_id)
def internal_ip(self, node_id):
return self._get_ip(node_id)
def set_node_tags(self, node_id, tags):
assert node_id in self._nodes
self._nodes[node_id]["tags"].update(tags)
def create_node(
self, node_config: Dict[str, Any], tags: Dict[str, str], count: int
) -> Optional[Dict[str, Any]]:
raise AssertionError("This method should not be called.")
def _gen_spark_job_group_id(self, node_id):
return (
f"ray-cluster-{self.ray_head_port}-{self.cluster_id}"
f"-worker-node-{node_id}"
)
def create_node_with_resources_and_labels(
self, node_config, tags, count, resources, labels
):
for _ in range(count):
self._create_node_with_resources_and_labels(
node_config, tags, resources, labels
)
def _create_node_with_resources_and_labels(
self, node_config, tags, resources, labels
):
from ray.util.spark.cluster_init import _append_resources_config
with self.lock:
resources = resources.copy()
node_type = tags[TAG_RAY_USER_NODE_TYPE]
# NOTE:
# "NODE_ID_AS_RESOURCE" value must be an integer,
# but `node_id` used by autoscaler must be a string.
node_id = str(self.get_next_node_id())
resources["NODE_ID_AS_RESOURCE"] = int(node_id)
conf = self.provider_config.copy()
num_cpus_per_node = resources.pop("CPU")
num_gpus_per_node = resources.pop("GPU")
heap_memory_per_node = resources.pop("memory")
object_store_memory_per_node = resources.pop("object_store_memory")
conf["worker_node_options"] = _append_resources_config(
conf["worker_node_options"], resources
)
response = requests.post(
url=self.spark_job_server_url + "/create_node",
json={
"spark_job_group_id": self._gen_spark_job_group_id(node_id),
"spark_job_group_desc": (
"This job group is for spark job which runs the Ray "
f"cluster worker node {node_id} connecting to ray "
f"head node {build_address(self.ray_head_ip, self.ray_head_port)}"
),
"using_stage_scheduling": conf["using_stage_scheduling"],
"ray_head_ip": self.ray_head_ip,
"ray_head_port": self.ray_head_port,
"ray_temp_dir": conf["ray_temp_dir"],
"num_cpus_per_node": num_cpus_per_node,
"num_gpus_per_node": num_gpus_per_node,
"heap_memory_per_node": heap_memory_per_node,
"object_store_memory_per_node": object_store_memory_per_node,
"worker_node_options": conf["worker_node_options"],
"collect_log_to_path": conf["collect_log_to_path"],
"node_id": resources["NODE_ID_AS_RESOURCE"],
},
)
try:
# Spark job server is locally launched, if spark job server request
# failed, it is unlikely network error but probably unrecoverable
# error, so we make it fast-fail.
response.raise_for_status()
except Exception:
raise NodeLaunchException(
"Node creation failure",
f"Starting ray worker node {node_id} failed",
sys.exc_info(),
)
self._nodes[node_id] = {
"tags": {
TAG_RAY_NODE_KIND: NODE_KIND_WORKER,
TAG_RAY_USER_NODE_TYPE: node_type,
TAG_RAY_NODE_NAME: node_id,
TAG_RAY_NODE_STATUS: STATUS_SETTING_UP,
},
}
logger.info(f"Spark node provider creates node {node_id}.")
def terminate_node(self, node_id):
if node_id in self._nodes:
response = requests.post(
url=self.spark_job_server_url + "/terminate_node",
json={"spark_job_group_id": self._gen_spark_job_group_id(node_id)},
)
response.raise_for_status()
with self.lock:
if node_id in self._nodes:
self._nodes.pop(node_id)
logger.info(f"Spark node provider terminates node {node_id}")
@staticmethod
def bootstrap_config(cluster_config):
return cluster_config
@@ -0,0 +1,244 @@
import json
import logging
import os
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from pyspark.util import inheritable_thread_target
from ray.util.spark.cluster_init import _start_ray_worker_nodes
class SparkJobServerRequestHandler(BaseHTTPRequestHandler):
def setup(self) -> None:
super().setup()
self._handler_lock = threading.RLock()
self._created_node_id_set = set()
self._logger = logging.getLogger(__name__)
if "RAY_ON_SPARK_JOB_SERVER_VERBOSE" in os.environ:
self._logger.setLevel(logging.DEBUG)
else:
self._logger.setLevel(logging.WARN)
def _set_headers(self):
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
def handle_POST(self, path, data):
path_parts = Path(path).parts[1:]
spark_job_group_id = data["spark_job_group_id"]
if path_parts[0] == "create_node":
assert len(path_parts) == 1, f"Illegal request path: {path}"
spark_job_group_desc = data["spark_job_group_desc"]
using_stage_scheduling = data["using_stage_scheduling"]
ray_head_ip = data["ray_head_ip"]
ray_head_port = data["ray_head_port"]
ray_temp_dir = data["ray_temp_dir"]
num_cpus_per_node = data["num_cpus_per_node"]
num_gpus_per_node = data["num_gpus_per_node"]
heap_memory_per_node = data["heap_memory_per_node"]
object_store_memory_per_node = data["object_store_memory_per_node"]
worker_node_options = data["worker_node_options"]
collect_log_to_path = data["collect_log_to_path"]
node_id = data["node_id"]
self._created_node_id_set.add(node_id)
def start_ray_worker_thread_fn():
try:
err_msg = _start_ray_worker_nodes(
spark_job_server=self.server,
spark_job_group_id=spark_job_group_id,
spark_job_group_desc=spark_job_group_desc,
num_worker_nodes=1,
using_stage_scheduling=using_stage_scheduling,
ray_head_ip=ray_head_ip,
ray_head_port=ray_head_port,
ray_temp_dir=ray_temp_dir,
num_cpus_per_node=num_cpus_per_node,
num_gpus_per_node=num_gpus_per_node,
heap_memory_per_node=heap_memory_per_node,
object_store_memory_per_node=object_store_memory_per_node,
worker_node_options=worker_node_options,
collect_log_to_path=collect_log_to_path,
node_id=node_id,
)
if err_msg:
self._logger.warning(
f"Spark job {spark_job_group_id} hosting Ray worker node "
f"launching failed, error:\n{err_msg}"
)
except Exception:
if spark_job_group_id in self.server.task_status_dict:
self.server.task_status_dict.pop(spark_job_group_id)
msg = (
f"Spark job {spark_job_group_id} hosting Ray worker node exit."
)
if self._logger.level > logging.DEBUG:
self._logger.warning(
f"{msg} To see details, you can set "
"'RAY_ON_SPARK_JOB_SERVER_VERBOSE' environmental variable "
"to '1' before calling 'ray.util.spark.setup_ray_cluster'."
)
else:
# This branch is only for debugging Ray-on-Spark purpose.
# User can configure 'RAY_ON_SPARK_JOB_SERVER_VERBOSE'
# environment variable to make the spark job server logging
# showing full exception stack here.
self._logger.debug(msg, exc_info=True)
threading.Thread(
target=inheritable_thread_target(start_ray_worker_thread_fn),
args=(),
daemon=True,
).start()
self.server.task_status_dict[spark_job_group_id] = "pending"
return {}
elif path_parts[0] == "check_node_id_availability":
node_id = data["node_id"]
with self._handler_lock:
if node_id in self._created_node_id_set:
# If the node with the node id has been created,
# it shouldn't be created twice so fail fast here.
# The case happens when a Ray node is down unexpected
# caused by spark worker node down and spark tries to
# reschedule the spark task, so it triggers node
# creation with duplicated node id.
return {"available": False}
else:
self._created_node_id_set.add(node_id)
return {"available": True}
elif path_parts[0] == "terminate_node":
assert len(path_parts) == 1, f"Illegal request path: {path}"
self.server.spark.sparkContext.cancelJobGroup(spark_job_group_id)
if spark_job_group_id in self.server.task_status_dict:
self.server.task_status_dict.pop(spark_job_group_id)
return {}
elif path_parts[0] == "notify_task_launched":
if spark_job_group_id in self.server.task_status_dict:
# Note that if `spark_job_group_id` not in task_status_dict,
# the task has been terminated
self.server.task_status_dict[spark_job_group_id] = "running"
self._logger.info(f"Spark task in {spark_job_group_id} has started.")
return {}
elif path_parts[0] == "query_task_status":
if spark_job_group_id in self.server.task_status_dict:
return {"status": self.server.task_status_dict[spark_job_group_id]}
else:
return {"status": "terminated"}
elif path_parts[0] == "query_last_worker_err":
return {"last_worker_err": self.server.last_worker_error}
else:
raise ValueError(f"Illegal request path: {path}")
def do_POST(self):
"""Reads post request body"""
self._set_headers()
content_len = int(self.headers["content-length"])
content_type = self.headers["content-type"]
assert content_type == "application/json"
path = self.path
post_body = self.rfile.read(content_len).decode("utf-8")
post_body_json = json.loads(post_body)
with self._handler_lock:
response_body_json = self.handle_POST(path, post_body_json)
response_body = json.dumps(response_body_json)
self.wfile.write(response_body.encode("utf-8"))
def log_request(self, code="-", size="-"):
# Make logs less verbose.
pass
class SparkJobServer(ThreadingHTTPServer):
"""
High level design:
1. In Ray on spark autoscaling mode, How to start and terminate Ray worker node ?
It uses spark job to launch Ray worker node,
and each spark job contains only one spark task, the corresponding spark task
creates Ray worker node as subprocess.
When autoscaler request terminating specific Ray worker node, it cancels
corresponding spark job to trigger Ray worker node termination.
Because we can only cancel spark job not spark task when we need to scale
down a Ray worker node. So we have to have one spark job for each Ray worker node.
2. How to create / cancel spark job from spark node provider?
Spark node provider runs in autoscaler process that is different process
than the one that executes "setup_ray_cluster" API. User calls "setup_ray_cluster"
API in spark application driver node, and the semantic is "setup_ray_cluster"
requests spark resources from this spark application.
Internally, "setup_ray_cluster" should use "spark session" instance to request
spark application resources. But spark node provider runs in another python
process, in order to share spark session to the separate NodeProvider process,
it sets up a spark job server that runs inside spark application driver process
(the process that calls "setup_ray_cluster" API), and in NodeProvider process,
it sends RPC request to the spark job server for creating spark jobs in the
spark application.
Note that we cannot create another spark session in NodeProvider process,
because if doing so, it means we create another spark application, and then
it causes NodeProvider requests resources belonging to the new spark application,
but we need to ensure all requested spark resources belong to
the original spark application that calls "setup_ray_cluster" API.
Note:
The server must inherit ThreadingHTTPServer because request handler uses
the active spark session in current process to create spark jobs, so all request
handler must be running in current process.
"""
def __init__(self, server_address, spark, ray_node_custom_env):
super().__init__(server_address, SparkJobServerRequestHandler)
self.spark = spark
# For ray on spark autoscaling mode,
# for each ray worker node, we create an individual spark job
# to launch it, the corresponding spark job has only one
# spark task that starts ray worker node, and the spark job
# is assigned with a unique spark job group ID that is used
# to cancel this spark job (i.e., kill corresponding ray worker node).
# Each spark task has status of pending, running, or terminated.
# the task_status_dict key is spark job group id,
# and value is the corresponding spark task status.
# each spark task holds a ray worker node.
self.task_status_dict = {}
self.last_worker_error = None
self.ray_node_custom_env = ray_node_custom_env
def shutdown(self) -> None:
super().shutdown()
for spark_job_group_id in list(self.task_status_dict.keys()):
self.spark.sparkContext.cancelJobGroup(spark_job_group_id)
# Sleep 1 second to wait for all spark job cancellation
# The spark job cancellation will do things asyncly in a background thread,
# On Databricks platform, when detaching a notebook, it triggers SIGTERM
# and then sigterm handler triggers Ray cluster shutdown, without sleep,
# after the SIGTERM handler execution the process is killed and then
# these cancelling spark job background threads are killed.
time.sleep(1)
def _start_spark_job_server(host, port, spark, ray_node_custom_env):
server = SparkJobServer((host, port), spark, ray_node_custom_env)
def run_server():
server.serve_forever()
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
return server
@@ -0,0 +1,446 @@
import os
import re
import subprocess
import sys
import tempfile
import time
from typing import IO, Any, List, Optional
from ray.autoscaler._private.cli_logger import cf, cli_logger
CONN_REFUSED_PATIENCE = 30 # how long to wait for sshd to run
_redirect_output = False # Whether to log command output to a temporary file
_allow_interactive = True # whether to pass on stdin to running commands.
def is_output_redirected():
return _redirect_output
def set_output_redirected(val: bool):
"""Choose between logging to a temporary file and to `sys.stdout`.
The default is to log to a file.
Args:
val: If true, subprocess output will be redirected to
a temporary file.
"""
global _redirect_output
_redirect_output = val
def does_allow_interactive():
return _allow_interactive
def set_allow_interactive(val: bool):
"""Choose whether to pass on stdin to running commands.
The default is to pipe stdin and close it immediately.
Args:
val: If true, stdin will be passed to commands.
"""
global _allow_interactive
_allow_interactive = val
class ProcessRunnerError(Exception):
def __init__(self, msg, msg_type, code=None, command=None, special_case=None):
super(ProcessRunnerError, self).__init__(
"{} (discovered={}): type={}, code={}, command={}".format(
msg, special_case, msg_type, code, command
)
)
self.msg_type = msg_type
self.code = code
self.command = command
self.special_case = special_case
_ssh_output_regexes = {
"known_host_update": re.compile(
r"\s*Warning: Permanently added '.+' \(.+\) " r"to the list of known hosts.\s*"
),
"connection_closed": re.compile(r"\s*Shared connection to .+ closed.\s*"),
"timeout": re.compile(
r"\s*ssh: connect to host .+ port .+: " r"Operation timed out\s*"
),
"conn_refused": re.compile(
r"\s*ssh: connect to host .+ port .+: Connection refused\s*"
)
# todo: check for other connection failures for better error messages?
}
def _read_subprocess_stream(
f: IO[str], output_file: Optional[IO[str]], is_stdout: bool = False
) -> Optional[str]:
"""Read and process a subprocess output stream.
The goal is to find error messages and respond to them in a clever way.
Currently just used for SSH messages (CONN_REFUSED, TIMEOUT, etc.), so
the user does not get confused by these.
Ran in a thread each for both `stdout` and `stderr` to
allow for cross-platform asynchronous IO.
Note: `select`-based IO is another option, but Windows has
no support for `select`ing pipes, and Linux support varies somewhat.
Spefically, Older *nix systems might also have quirks in how they
handle `select` on pipes.
Args:
f: File object for the stream.
output_file: File object to which filtered output is written.
is_stdout:
When `is_stdout` is `False`, the stream is assumed to
be `stderr`. Different error message detectors are used,
and the output is displayed to the user unless it matches
a special case (e.g. SSH timeout), in which case this is
left up to the caller.
Returns:
The detected special case name (e.g. "ssh_timeout") or None.
"""
detected_special_case = None
while True:
# ! Readline here is crucial.
# ! Normal `read()` will block until EOF instead of until
# something is available.
line = f.readline()
if line is None or line == "":
# EOF
break
if line[-1] == "\n":
line = line[:-1]
if not is_stdout:
if _ssh_output_regexes["connection_closed"].fullmatch(line) is not None:
# Do not log "connection closed" messages which SSH
# puts in stderr for no reason.
#
# They are never errors since the connection will
# close no matter whether the command succeeds or not.
continue
if _ssh_output_regexes["timeout"].fullmatch(line) is not None:
# Timeout is not really an error but rather a special
# condition. It should be handled by the caller, since
# network conditions/nodes in the early stages of boot
# are expected to sometimes cause connection timeouts.
if detected_special_case is not None:
raise ValueError(
"Bug: ssh_timeout conflicts with another "
"special codition: " + detected_special_case
)
detected_special_case = "ssh_timeout"
continue
if _ssh_output_regexes["conn_refused"].fullmatch(line) is not None:
# Connection refused is not really an error but
# rather a special condition. It should be handled by
# the caller, since network conditions/nodes in the
# early stages of boot are expected to sometimes cause
# CONN_REFUSED.
if detected_special_case is not None:
raise ValueError(
"Bug: ssh_conn_refused conflicts with another "
"special codition: " + detected_special_case
)
detected_special_case = "ssh_conn_refused"
continue
if _ssh_output_regexes["known_host_update"].fullmatch(line) is not None:
# Since we ignore SSH host control anyway
# (-o UserKnownHostsFile=/dev/null),
# we should silence the host control warnings.
continue
cli_logger.error(line)
if output_file is not None and output_file != subprocess.DEVNULL:
output_file.write(line + "\n")
return detected_special_case
def _run_and_process_output(
cmd: List[str],
stdout_file: Optional[IO[str]],
process_runner: Any = subprocess,
stderr_file: Optional[IO[str]] = None,
use_login_shells: bool = False,
):
"""Run a command and process its output for special cases.
Calls a standard 'check_call' if process_runner is not subprocess.
Specifically, run all command output through regex to detect
error conditions and filter out non-error messages that went to stderr
anyway (SSH writes ALL of its "system" messages to stderr even if they
are not actually errors).
Args:
cmd: Command to run.
stdout_file: File to redirect stdout to.
process_runner: Used for command execution. Assumed to have
'check_call' and 'check_output' inplemented.
stderr_file: File to redirect stderr to.
use_login_shells: Whether to disable special output processing because
an interactive login shell is in use.
Returns:
The return code of the executed process.
Implementation notes:
1. `use_login_shells` disables special processing
If we run interactive apps, output processing will likely get
overwhelmed with the interactive output elements.
Thus, we disable output processing for login shells. This makes
the logging experience considerably worse, but it only degrades
to old-style logging.
For example, `pip install` outputs HUNDREDS of progress-bar lines
when downloading a package, and we have to
read + regex + write all of them.
After all, even just printing output to console can often slow
down a fast-printing app, and we do more than just print, and
all that from Python, which is much slower than C regarding
stream processing.
2. `stdin=PIPE` for subprocesses
Do not inherit stdin as it messes with bash signals
(ctrl-C for SIGINT) and these commands aren't supposed to
take input anyway.
3. `ThreadPoolExecutor` without the `Pool`
We use `ThreadPoolExecutor` to create futures from threads.
Threads are never reused.
This approach allows us to have no custom synchronization by
off-loading the return value and exception passing to the
standard library (`ThreadPoolExecutor` internals).
This instance will be `shutdown()` ASAP so it's fine to
create one in such a weird place.
The code is thus 100% thread-safe as long as the stream readers
are read-only except for return values and possible exceptions.
"""
stdin_overwrite = subprocess.PIPE
# This already should be validated in a higher place of the stack.
assert not (
does_allow_interactive() and is_output_redirected()
), "Cannot redirect output while in interactive mode."
if process_runner != subprocess or (
does_allow_interactive() and not is_output_redirected()
):
stdin_overwrite = None
# See implementation note #1
if use_login_shells or process_runner != subprocess:
return process_runner.check_call(
cmd,
# See implementation note #2
stdin=stdin_overwrite,
stdout=stdout_file,
stderr=stderr_file,
)
with subprocess.Popen(
cmd,
# See implementation note #2
stdin=stdin_overwrite,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1, # line buffering
universal_newlines=True, # text mode outputs
) as p:
from concurrent.futures import ThreadPoolExecutor
# Closing stdin might be necessary to signal EOF to some
# apps (they might get stuck waiting for input forever otherwise).
p.stdin.close()
# See implementation note #3
with ThreadPoolExecutor(max_workers=2) as executor:
stdout_future = executor.submit(
_read_subprocess_stream, p.stdout, stdout_file, is_stdout=True
)
stderr_future = executor.submit(
_read_subprocess_stream, p.stderr, stderr_file, is_stdout=False
)
# Wait for completion.
executor.shutdown()
# Update `p.returncode`
p.poll()
detected_special_case = stdout_future.result()
if stderr_future.result() is not None:
if detected_special_case is not None:
# This might some day need to be changed.
# We should probably make sure the two special cases
# are compatible then and that we can handle both by
# e.g. reporting both to the caller.
raise ValueError(
"Bug: found a special case in both stdout and "
"stderr. This is not valid behavior at the time "
"of writing this code."
)
detected_special_case = stderr_future.result()
if p.returncode > 0:
# Process failed, but not due to a signal, since signals
# set the exit code to a negative value.
raise ProcessRunnerError(
"Command failed",
"ssh_command_failed",
code=p.returncode,
command=cmd,
special_case=detected_special_case,
)
elif p.returncode < 0:
# Process failed due to a signal, since signals
# set the exit code to a negative value.
raise ProcessRunnerError(
"Command failed",
"ssh_command_failed",
code=p.returncode,
command=cmd,
special_case="died_to_signal",
)
return p.returncode
def run_cmd_redirected(
cmd: List[str],
process_runner: Any = subprocess,
silent: bool = False,
use_login_shells: bool = False,
):
"""Run a command and optionally redirect output to a file.
Args:
cmd: Command to run.
process_runner: Process runner used for executing commands.
silent: If true, the command output will be silenced completely
(redirected to /dev/null), unless verbose logging
is enabled. Use this for running utility commands like
rsync.
use_login_shells: Whether to disable special output processing because
an interactive login shell is in use.
Returns:
The return code of the executed process.
"""
if silent and cli_logger.verbosity < 1:
return _run_and_process_output(
cmd,
process_runner=process_runner,
stdout_file=process_runner.DEVNULL,
stderr_file=process_runner.DEVNULL,
use_login_shells=use_login_shells,
)
if not is_output_redirected():
return _run_and_process_output(
cmd,
process_runner=process_runner,
stdout_file=sys.stdout,
stderr_file=sys.stderr,
use_login_shells=use_login_shells,
)
else:
tmpfile_path = os.path.join(
tempfile.gettempdir(), "ray-up-{}-{}.txt".format(cmd[0], time.time())
)
with open(
tmpfile_path,
mode="w",
# line buffering
buffering=1,
) as tmp:
cli_logger.verbose("Command stdout is redirected to {}", cf.bold(tmp.name))
return _run_and_process_output(
cmd,
process_runner=process_runner,
stdout_file=tmp,
stderr_file=tmp,
use_login_shells=use_login_shells,
)
def handle_ssh_fails(
e: ProcessRunnerError,
first_conn_refused_time: Optional[float],
retry_interval: float,
) -> Optional[float]:
"""Handle SSH system failures coming from a subprocess.
Args:
e: The `ProcessRunnerException` to handle.
first_conn_refused_time:
The time (as reported by this function) or None,
indicating the last time a CONN_REFUSED error was caught.
After exceeding a patience value, the program will be aborted
since SSH will likely never recover.
retry_interval: The interval after which the command will be retried,
used here just to inform the user.
Returns:
The updated `first_conn_refused_time`, or None if not applicable.
"""
if e.msg_type != "ssh_command_failed":
return
if e.special_case == "ssh_conn_refused":
if (
first_conn_refused_time is not None
and time.time() - first_conn_refused_time > CONN_REFUSED_PATIENCE
):
cli_logger.error(
"SSH connection was being refused "
"for {} seconds. Head node assumed "
"unreachable.",
cf.bold(str(CONN_REFUSED_PATIENCE)),
)
cli_logger.abort(
"Check the node's firewall settings "
"and the cloud network configuration."
)
cli_logger.warning("SSH connection was refused.")
cli_logger.warning(
"This might mean that the SSH daemon is "
"still setting up, or that "
"the host is inaccessible (e.g. due to "
"a firewall)."
)
return time.time()
if e.special_case in ["ssh_timeout", "ssh_conn_refused"]:
cli_logger.print(
"SSH still not available, retrying in {} seconds.",
cf.bold(str(retry_interval)),
)
else:
raise e
return first_conn_refused_time
+576
View File
@@ -0,0 +1,576 @@
import logging
import os
import subprocess
import time
import traceback
from threading import Thread
from typing import Any, Dict, List, Optional
import click
from ray._common.usage import usage_constants, usage_lib
from ray.autoscaler._private import subprocess_output_util as cmd_output_util
from ray.autoscaler._private.cli_logger import cf, cli_logger
from ray.autoscaler._private.command_runner import (
AUTOSCALER_NODE_START_WAIT_S,
ProcessRunnerError,
)
from ray.autoscaler._private.constants import (
LABELS_ENVIRONMENT_VARIABLE,
RESOURCES_ENVIRONMENT_VARIABLE,
)
from ray.autoscaler._private.event_system import CreateClusterEvent, global_event_system
from ray.autoscaler._private.log_timer import LogTimer
from ray.autoscaler.tags import (
STATUS_SETTING_UP,
STATUS_SYNCING_FILES,
STATUS_UP_TO_DATE,
STATUS_UPDATE_FAILED,
STATUS_WAITING_FOR_SSH,
TAG_RAY_FILE_MOUNTS_CONTENTS,
TAG_RAY_NODE_STATUS,
TAG_RAY_RUNTIME_CONFIG,
)
logger = logging.getLogger(__name__)
NUM_SETUP_STEPS = 7
READY_CHECK_INTERVAL = 5
class NodeUpdater:
"""A process for syncing files and running init commands on a node.
Arguments:
node_id: the Node ID
provider_config: Provider section of autoscaler yaml
provider: NodeProvider Class
auth_config: Auth section of autoscaler yaml
cluster_name: the name of the cluster.
file_mounts: Map of remote to local paths
initialization_commands: Commands run before container launch
setup_commands: Commands run before ray starts
ray_start_commands: Commands to start ray
runtime_hash: Used to check for config changes
file_mounts_contents_hash: Used to check for changes to file mounts
is_head_node: Whether to use head start/setup commands
node_resources: Optional resources string that the raylet should
advertise on startup.
node_labels: Optional labels for the node.
cluster_synced_files: Files synced from the head to worker nodes after
the head node finishes its setup.
rsync_options: Extra options related to the rsync command.
process_runner: the module to use to run the commands
in the CommandRunner. E.g., subprocess.
use_internal_ip: Wwhether the node_id belongs to an internal ip
or external ip.
docker_config: Docker section of autoscaler yaml
restart_only: Whether to skip setup commands & just restart ray
for_recovery: True if updater is for a recovering node. Only used for
metric tracking.
"""
def __init__(
self,
node_id: str,
provider_config: dict,
provider: Any,
auth_config: dict,
cluster_name: str,
file_mounts: dict,
initialization_commands: list,
setup_commands: list,
ray_start_commands: list,
runtime_hash: str,
file_mounts_contents_hash: str,
is_head_node: bool,
node_resources: Optional[str] = None,
node_labels: Optional[Dict[str, str]] = None,
cluster_synced_files: Optional[List[str]] = None,
rsync_options: Optional[Dict[str, Any]] = None,
process_runner: Any = subprocess,
use_internal_ip: bool = False,
docker_config: Optional[Dict[str, Any]] = None,
restart_only: bool = False,
for_recovery: bool = False,
):
self.log_prefix = "NodeUpdater: {}: ".format(node_id)
# Three cases:
# 1) use_internal_ip arg is True -> use_internal_ip is True
# 2) worker node -> use value of provider_config["use_internal_ips"]
# 3) head node -> use value of provider_config["use_internal_ips"] unless
# overridden by provider_config["use_external_head_ip"]
use_internal_ip = use_internal_ip or (
provider_config.get("use_internal_ips", False)
and not (
is_head_node and provider_config.get("use_external_head_ip", False)
)
)
self.cmd_runner = provider.get_command_runner(
self.log_prefix,
node_id,
auth_config,
cluster_name,
process_runner,
use_internal_ip,
docker_config,
)
self.daemon = True
self.node_id = node_id
self.provider_type = provider_config.get("type")
self.provider = provider
# Some node providers don't specify empty structures as
# defaults. Better to be defensive.
file_mounts = file_mounts or {}
self.file_mounts = {
remote: os.path.expanduser(local) for remote, local in file_mounts.items()
}
self.initialization_commands = initialization_commands
self.setup_commands = setup_commands
self.ray_start_commands = ray_start_commands
self.node_resources = node_resources
self.node_labels = node_labels
self.runtime_hash = runtime_hash
self.file_mounts_contents_hash = file_mounts_contents_hash
# TODO (Alex): This makes the assumption that $HOME on the head and
# worker nodes is the same. Also note that `cluster_synced_files` is
# set on the head -> worker updaters only (so `expanduser` is only run
# on the head node).
cluster_synced_files = cluster_synced_files or []
self.cluster_synced_files = [
os.path.expanduser(path) for path in cluster_synced_files
]
self.rsync_options = rsync_options or {}
self.auth_config = auth_config
self.is_head_node = is_head_node
self.docker_config = docker_config
self.restart_only = restart_only
self.update_time = None
self.for_recovery = for_recovery
def run(self):
update_start_time = time.time()
if (
cmd_output_util.does_allow_interactive()
and cmd_output_util.is_output_redirected()
):
# this is most probably a bug since the user has no control
# over these settings
msg = (
"Output was redirected for an interactive command. "
"Either do not pass `--redirect-command-output` "
"or also pass in `--use-normal-shells`."
)
cli_logger.abort(msg)
try:
with LogTimer(
self.log_prefix + "Applied config {}".format(self.runtime_hash)
):
self.do_update()
except Exception as e:
self.provider.set_node_tags(
self.node_id, {TAG_RAY_NODE_STATUS: STATUS_UPDATE_FAILED}
)
cli_logger.error("New status: {}", cf.bold(STATUS_UPDATE_FAILED))
cli_logger.error("!!!")
if hasattr(e, "cmd"):
stderr_output = getattr(e, "stderr", "No stderr available")
cli_logger.error(
"Setup command `{}` failed with exit code {}. stderr: {}",
cf.bold(e.cmd),
e.returncode,
stderr_output,
)
else:
cli_logger.verbose_error("Exception details: {}", str(vars(e)))
full_traceback = traceback.format_exc()
cli_logger.error("Full traceback: {}", full_traceback)
# todo: handle this better somehow?
cli_logger.error("Error message: {}", str(e))
cli_logger.error("!!!")
cli_logger.newline()
if isinstance(e, click.ClickException):
# todo: why do we ignore this here
return
raise
tags_to_set = {
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
TAG_RAY_RUNTIME_CONFIG: self.runtime_hash,
}
if self.file_mounts_contents_hash is not None:
tags_to_set[TAG_RAY_FILE_MOUNTS_CONTENTS] = self.file_mounts_contents_hash
self.provider.set_node_tags(self.node_id, tags_to_set)
cli_logger.labeled_value("New status", STATUS_UP_TO_DATE)
self.update_time = time.time() - update_start_time
self.exitcode = 0
def sync_file_mounts(self, sync_cmd, step_numbers=(0, 2)):
# step_numbers is (# of previous steps, total steps)
previous_steps, total_steps = step_numbers
nolog_paths = []
if cli_logger.verbosity == 0:
nolog_paths = ["~/ray_bootstrap_key.pem", "~/ray_bootstrap_config.yaml"]
def do_sync(remote_path, local_path, allow_non_existing_paths=False):
if allow_non_existing_paths and not os.path.exists(local_path):
cli_logger.print("sync: {} does not exist. Skipping.", local_path)
# Ignore missing source files. In the future we should support
# the --delete-missing-args command to delete files that have
# been removed
return
assert os.path.exists(local_path), local_path
if os.path.isdir(local_path):
if not local_path.endswith("/"):
local_path += "/"
if not remote_path.endswith("/"):
remote_path += "/"
with LogTimer(
self.log_prefix + "Synced {} to {}".format(local_path, remote_path)
):
is_docker = (
self.docker_config and self.docker_config["container_name"] != ""
)
if not is_docker:
# The DockerCommandRunner handles this internally.
self.cmd_runner.run(
"mkdir -p {}".format(os.path.dirname(remote_path)),
run_env="host",
)
sync_cmd(local_path, remote_path, docker_mount_if_possible=True)
if remote_path not in nolog_paths:
# todo: timed here?
cli_logger.print(
"{} from {}", cf.bold(remote_path), cf.bold(local_path)
)
# Rsync file mounts
with cli_logger.group(
"Processing file mounts", _numbered=("[]", previous_steps + 1, total_steps)
):
for remote_path, local_path in self.file_mounts.items():
do_sync(remote_path, local_path)
previous_steps += 1
if self.cluster_synced_files:
with cli_logger.group(
"Processing worker file mounts",
_numbered=("[]", previous_steps + 1, total_steps),
):
cli_logger.print("synced files: {}", str(self.cluster_synced_files))
for path in self.cluster_synced_files:
do_sync(path, path, allow_non_existing_paths=True)
previous_steps += 1
else:
cli_logger.print(
"No worker file mounts to sync",
_numbered=("[]", previous_steps + 1, total_steps),
)
def wait_ready(self, deadline):
with cli_logger.group(
"Waiting for SSH to become available", _numbered=("[]", 1, NUM_SETUP_STEPS)
):
with LogTimer(self.log_prefix + "Got remote shell"):
cli_logger.print("Running `{}` as a test.", cf.bold("uptime"))
first_conn_refused_time = None
while True:
if time.time() > deadline:
raise Exception("wait_ready timeout exceeded.")
if self.provider.is_terminated(self.node_id):
raise Exception(
"wait_ready aborting because node "
"detected as terminated."
)
try:
# Run outside of the container
self.cmd_runner.run("uptime", timeout=10, run_env="host")
cli_logger.success("Success.")
return True
except ProcessRunnerError as e:
first_conn_refused_time = cmd_output_util.handle_ssh_fails(
e,
first_conn_refused_time,
retry_interval=READY_CHECK_INTERVAL,
)
time.sleep(READY_CHECK_INTERVAL)
except Exception as e:
# TODO(maximsmol): we should not be ignoring
# exceptions if they get filtered properly
# (new style log + non-interactive shells)
#
# however threading this configuration state
# is a pain and I'm leaving it for later
retry_str = "(" + str(e) + ")"
if hasattr(e, "cmd"):
if isinstance(e.cmd, str):
cmd_ = e.cmd
elif isinstance(e.cmd, list):
cmd_ = " ".join(e.cmd)
else:
logger.debug(
f"e.cmd type ({type(e.cmd)}) not list or str."
)
cmd_ = str(e.cmd)
retry_str = "(Exit Status {}): {}".format(
e.returncode, cmd_
)
cli_logger.print(
"SSH still not available {}, retrying in {} seconds.",
cf.dimmed(retry_str),
cf.bold(str(READY_CHECK_INTERVAL)),
)
time.sleep(READY_CHECK_INTERVAL)
def do_update(self):
self.provider.set_node_tags(
self.node_id, {TAG_RAY_NODE_STATUS: STATUS_WAITING_FOR_SSH}
)
cli_logger.labeled_value("New status", STATUS_WAITING_FOR_SSH)
deadline = time.time() + AUTOSCALER_NODE_START_WAIT_S
self.wait_ready(deadline)
global_event_system.execute_callback(CreateClusterEvent.ssh_control_acquired)
node_tags = self.provider.node_tags(self.node_id)
logger.debug("Node tags: {}".format(str(node_tags)))
if self.provider_type == "aws" and self.provider.provider_config:
from ray.autoscaler._private.aws.cloudwatch.cloudwatch_helper import (
CloudwatchHelper,
)
CloudwatchHelper(
self.provider.provider_config, self.node_id, self.provider.cluster_name
).update_from_config(self.is_head_node)
if node_tags.get(TAG_RAY_RUNTIME_CONFIG) == self.runtime_hash:
# When resuming from a stopped instance the runtime_hash may be the
# same, but the container will not be started.
init_required = self.cmd_runner.run_init(
as_head=self.is_head_node,
file_mounts=self.file_mounts,
sync_run_yet=False,
)
if init_required:
node_tags[TAG_RAY_RUNTIME_CONFIG] += "-invalidate"
# This ensures that `setup_commands` are not removed
self.restart_only = False
if self.restart_only:
self.setup_commands = []
# runtime_hash will only change whenever the user restarts
# or updates their cluster with `get_or_create_head_node`
if node_tags.get(TAG_RAY_RUNTIME_CONFIG) == self.runtime_hash and (
not self.file_mounts_contents_hash
or node_tags.get(TAG_RAY_FILE_MOUNTS_CONTENTS)
== self.file_mounts_contents_hash
):
# todo: we lie in the confirmation message since
# full setup might be cancelled here
cli_logger.print(
"Configuration already up to date, "
"skipping file mounts, initalization and setup commands.",
_numbered=("[]", "2-6", NUM_SETUP_STEPS),
)
else:
cli_logger.print(
"Updating cluster configuration.", _tags=dict(hash=self.runtime_hash)
)
self.provider.set_node_tags(
self.node_id, {TAG_RAY_NODE_STATUS: STATUS_SYNCING_FILES}
)
cli_logger.labeled_value("New status", STATUS_SYNCING_FILES)
self.sync_file_mounts(self.rsync_up, step_numbers=(1, NUM_SETUP_STEPS))
# Only run setup commands if runtime_hash has changed because
# we don't want to run setup_commands every time the head node
# file_mounts folders have changed.
if node_tags.get(TAG_RAY_RUNTIME_CONFIG) != self.runtime_hash:
# Run init commands
self.provider.set_node_tags(
self.node_id, {TAG_RAY_NODE_STATUS: STATUS_SETTING_UP}
)
cli_logger.labeled_value("New status", STATUS_SETTING_UP)
if self.initialization_commands:
with cli_logger.group(
"Running initialization commands",
_numbered=("[]", 4, NUM_SETUP_STEPS),
):
global_event_system.execute_callback(
CreateClusterEvent.run_initialization_cmd
)
with LogTimer(
self.log_prefix + "Initialization commands",
show_status=True,
):
for cmd in self.initialization_commands:
global_event_system.execute_callback(
CreateClusterEvent.run_initialization_cmd,
{"command": cmd},
)
try:
# Overriding the existing SSHOptions class
# with a new SSHOptions class that uses
# this ssh_private_key as its only __init__
# argument.
# Run outside docker.
self.cmd_runner.run(
cmd,
ssh_options_override_ssh_key=self.auth_config.get( # noqa: E501
"ssh_private_key"
),
run_env="host",
)
except ProcessRunnerError as e:
if e.msg_type == "ssh_command_failed":
cli_logger.error("Failed.")
cli_logger.error("See above for stderr.")
raise click.ClickException(
"Initialization command failed."
) from None
else:
cli_logger.print(
"No initialization commands to run.",
_numbered=("[]", 4, NUM_SETUP_STEPS),
)
with cli_logger.group(
"Initializing command runner",
# todo: fix command numbering
_numbered=("[]", 5, NUM_SETUP_STEPS),
):
self.cmd_runner.run_init(
as_head=self.is_head_node,
file_mounts=self.file_mounts,
sync_run_yet=True,
)
if self.setup_commands:
with cli_logger.group(
"Running setup commands",
# todo: fix command numbering
_numbered=("[]", 6, NUM_SETUP_STEPS),
):
global_event_system.execute_callback(
CreateClusterEvent.run_setup_cmd
)
with LogTimer(
self.log_prefix + "Setup commands", show_status=True
):
total = len(self.setup_commands)
for i, cmd in enumerate(self.setup_commands):
global_event_system.execute_callback(
CreateClusterEvent.run_setup_cmd, {"command": cmd}
)
if cli_logger.verbosity == 0 and len(cmd) > 30:
cmd_to_print = cf.bold(cmd[:30]) + "..."
else:
cmd_to_print = cf.bold(cmd)
cli_logger.print(
"{}", cmd_to_print, _numbered=("()", i, total)
)
try:
# Runs in the container if docker is in use
self.cmd_runner.run(cmd, run_env="auto")
except ProcessRunnerError as e:
if e.msg_type == "ssh_command_failed":
cli_logger.error("Failed.")
cli_logger.error("See above for stderr.")
raise click.ClickException("Setup command failed.")
else:
cli_logger.print(
"No setup commands to run.",
_numbered=("[]", 6, NUM_SETUP_STEPS),
)
with cli_logger.group(
"Starting the Ray runtime", _numbered=("[]", 7, NUM_SETUP_STEPS)
):
global_event_system.execute_callback(CreateClusterEvent.start_ray_runtime)
with LogTimer(self.log_prefix + "Ray start commands", show_status=True):
for cmd in self.ray_start_commands:
env_vars = {}
if self.is_head_node:
if usage_lib.usage_stats_enabled():
env_vars[usage_constants.USAGE_STATS_ENABLED_ENV_VAR] = 1
else:
# Disable usage stats collection in the cluster.
env_vars[usage_constants.USAGE_STATS_ENABLED_ENV_VAR] = 0
# Add a resource override env variable if needed.
# Local NodeProvider doesn't need resource and label override.
if self.provider_type != "local":
if self.node_resources:
env_vars[
RESOURCES_ENVIRONMENT_VARIABLE
] = self.node_resources
if self.node_labels:
env_vars[LABELS_ENVIRONMENT_VARIABLE] = self.node_labels
try:
old_redirected = cmd_output_util.is_output_redirected()
cmd_output_util.set_output_redirected(False)
# Runs in the container if docker is in use
self.cmd_runner.run(
cmd, environment_variables=env_vars, run_env="auto"
)
cmd_output_util.set_output_redirected(old_redirected)
except ProcessRunnerError as e:
if e.msg_type == "ssh_command_failed":
cli_logger.error("Failed.")
cli_logger.error("See above for stderr.")
raise click.ClickException("Start command failed.")
global_event_system.execute_callback(
CreateClusterEvent.start_ray_runtime_completed
)
def rsync_up(self, source, target, docker_mount_if_possible=False):
options = {}
options["docker_mount_if_possible"] = docker_mount_if_possible
options["rsync_exclude"] = self.rsync_options.get("rsync_exclude")
options["rsync_filter"] = self.rsync_options.get("rsync_filter")
self.cmd_runner.run_rsync_up(source, target, options=options)
cli_logger.verbose(
"`rsync`ed {} (local) to {} (remote)", cf.bold(source), cf.bold(target)
)
def rsync_down(self, source, target, docker_mount_if_possible=False):
options = {}
options["docker_mount_if_possible"] = docker_mount_if_possible
options["rsync_exclude"] = self.rsync_options.get("rsync_exclude")
options["rsync_filter"] = self.rsync_options.get("rsync_filter")
self.cmd_runner.run_rsync_down(source, target, options=options)
cli_logger.verbose(
"`rsync`ed {} (remote) to {} (local)", cf.bold(source), cf.bold(target)
)
class NodeUpdaterThread(NodeUpdater, Thread):
def __init__(self, *args, **kwargs):
Thread.__init__(self)
NodeUpdater.__init__(self, *args, **kwargs)
self.exitcode = -1
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
# Ray on vSphere Architecture Guide
To support ray on vSphere, the implementation has been added into [python/ray/autoscaler/_private/vsphere](../vsphere) directory. The following sections will explain the vSphere terminologies used in the code and also explain the whole code flow.
# vSphere Terminologies
## [OVF file](https://techdocs.broadcom.com/us/en/vmware-cis/vsphere/vsphere/8-0/vsphere-virtual-machine-administration-guide-8-0.html)
OVF format is a packaging and distribution format for virtual machines. It is a standard which can be used to describe the VM metadata. We use the OVF files to create the virtual machines which will act as Ray head and worker node.
## VI Admin
The term VI stands for [Virtual Infrastructure](https://techdocs.broadcom.com/us/en/vmware-cis/vsphere/vsphere/8-0/vsphere-virtual-machine-administration-guide-8-0/introduction-to-vmware-vsphere-virtual-machinesvsphere-vm-admin/virtual-machines-and-the-virtual-infrastructurevsphere-vm-admin.html).
A VI Admin is used to describe a persona that manages the lifecycle of VMware infrastructure. VI Admins engage in a range of activities. A subset of them are listed below:
1. Provisioning [ESXi](https://www.vmware.com/in/products/esxi-and-esx.html) (Hypervisor developed by VMware) hosts.
2. Provisioning a vSphere infrastructure.
3. Managing lifecycle of VMs.
4. Provisioning [vSAN](https://docs.vmware.com/en/VMware-vSAN/index.html) storage.
# Code Flow
## Node Creation on `ray up`
The following sections explain the code flow in a sequential manner. The execution is triggered from the moment user executed `ray up` command
### Inject private Key ([config.py](./config.py))
During running `ray up`, the private key is injected into `config["auth"]["ssh_private_key"]`. The bootstrap machine (where the `ray up` command is executed) and the head node subsequently use this key to SSH onto the ray worker nodes.
### Update vSphere Configs ([config.py](./config.py))
Used to make sure that the user has created the YAML file with valid configs.
### Create Nodes ([node_provider.py](./cluster_operator_client.py))
#### Call `create_node`
Starts the creation of nodes with `create_node` function, which internally calls `_create_node`.
## Autoscaling
### Get and create nodes ([node_provider.py](./cluster_operator_client.py))
The autoscaler can find the currently running nodes with `non_terminated_nodes` function and can request for new nodes by calling `create_node` function.
### Fetch node IPs ([node_provider.py](./cluster_operator_client.py))
The autoscaler can use `external_ip` or `internal_ip` function to fetch a node's IP.
## Cluster tear down ([node_provider.py](./cluster_operator_client.py))
`terminate_nodes` function gets called on ray down command's execution. It deletes all the nodes.
View File
@@ -0,0 +1,685 @@
import base64
import ipaddress
import logging
import os
import random
import string
from enum import Enum
from threading import RLock
from typing import Any, Dict, Optional, Tuple
from kubernetes import client, config
from ray.autoscaler.tags import (
NODE_KIND_HEAD,
NODE_KIND_WORKER,
STATUS_SETTING_UP,
STATUS_UNINITIALIZED,
STATUS_UP_TO_DATE,
TAG_RAY_CLUSTER_NAME,
TAG_RAY_NODE_KIND,
TAG_RAY_NODE_NAME,
TAG_RAY_NODE_STATUS,
TAG_RAY_USER_NODE_TYPE,
)
# Design:
# Each modification the autoscaler wants to make is posted to the API server's desired
# state (e.g. if the autoscaler wants to scale up, it adds VM name to the desired
# worker list it wants to scale, if it wants to scale down it removes the name from
# the list).
# VMRay CRD
VMRAY_CRD_VER = os.getenv("VMRAY_CRD_VER", "v1alpha1")
VMRAY_GROUP = "vmray.broadcom.com"
VMRAYCLUSTER_PLURAL = "vmrayclusters"
# VirtualMachineService CRD
VMSERVICE_CRD_VER = os.getenv("VMSERVICE_CRD_VER", "v1alpha2")
VMSERVICE_GROUP = "vmoperator.vmware.com"
VMSERVICE_PLURAL = "virtualmachineservices"
SERVICE_ACCOUNT_TOKEN = os.getenv("SVC_ACCOUNT_TOKEN", None)
logger = logging.getLogger(__name__)
cur_path = os.path.dirname(__file__)
class VMNodeStatus(Enum):
INITIALIZED = "initialized"
RUNNING = "running"
FAIL = "failure"
class KubernetesHttpApiClient(object):
def __init__(self, ca_cert: str, api_server: str):
token = SERVICE_ACCOUNT_TOKEN
# If SERVICE_ACCOUNT_TOKEN not present, use local
# ~/.kube/config file. Active context will be used.
# This is useful when Ray CLI are used and local autoscaler needs
# communicate with the k8s API server
# If the token is present then use that for communication.
if not token:
self.client = client.ApiClient(config.load_kube_config())
else:
configuration = client.Configuration()
configuration.api_key["authorization"] = token
configuration.api_key_prefix["authorization"] = "Bearer"
configuration.host = f"https://{api_server}"
if ca_cert:
configuration.ssl_ca_cert = ca_cert
else:
configuration.verify_ssl = False
self.client = client.ApiClient(configuration)
# Use customObjectsApi to access custom resources
self.custom_object_api = client.CustomObjectsApi(self.client)
class ClusterOperatorClient(KubernetesHttpApiClient):
def __init__(
self,
cluster_name: str,
provider_config: Dict[str, Any],
cluster_config: Dict[str, Any],
):
self.cluster_name = cluster_name
self.vmraycluster_nounce = None
self.max_worker_nodes = None
self.vsphere_config = provider_config["vsphere_config"]
self.namespace = self.vsphere_config["namespace"]
self.k8s_api_client = KubernetesHttpApiClient(
self.vsphere_config.get("ca_cert"),
self.vsphere_config.get("api_server"),
)
self.lock = RLock()
if cluster_config:
self.max_worker_nodes = cluster_config["max_workers"]
self.head_setup_commands = cluster_config["head_setup_commands"]
self.available_node_types = cluster_config["available_node_types"]
self.head_node_type = cluster_config["head_node_type"]
# docker configurations.
self.provider_auth = cluster_config["auth"]
self.docker = cluster_config["docker"]
# create docker login info secret, if it exists.
docker_auth_secret_name = self._create_docker_auth_secrets()
if docker_auth_secret_name:
self.docker_config = {
"auth_secret_name": docker_auth_secret_name,
}
else:
self.docker_config = None
else:
self._set_max_worker_nodes()
self._create_tls_secrets()
def _create_docker_auth_secrets(self):
docker_auth_secret_name = self.cluster_name + "-docker-auth"
docker_auth = self.vsphere_config.get("docker_auth", {})
username = docker_auth.get("username", None)
password = docker_auth.get("password", None)
kp = {}
if username and password:
kp["username"] = username
kp["password"] = password
registry = docker_auth.get("registry", None)
if registry:
kp["registry"] = registry
self._create_secret(self.namespace, docker_auth_secret_name, kp)
return docker_auth_secret_name
return None
def _create_tls_secrets(self):
# If token is passed that means its instance of autoscaler
# running inside the head node, so validate if tls server cert
# and key are available then create a secret with their
# value.
tls_enabled = os.environ.get("RAY_USE_TLS", None) == "1"
if not SERVICE_ACCOUNT_TOKEN or not tls_enabled:
return
tls_cert = None
tls_key = None
cert_path = os.environ.get("RAY_TLS_SERVER_CERT", None)
key_path = os.environ.get("RAY_TLS_SERVER_KEY", None)
if cert_path:
with open(cert_path) as f:
tls_cert = f.read()
if key_path:
with open(key_path) as f:
tls_key = f.read()
if tls_cert and tls_key:
kp = {"tls.crt": tls_cert, "tls.key": tls_key}
self._create_secret(self.namespace, self.cluster_name + "-tls", kp)
def list_vms(self, tag_filters: Dict[str, str]) -> Tuple[list, dict]:
"""Queries K8s for VMs in the RayCluster and filter them as per
tags provided in the tag_filters.
"""
logger.info(f"Getting nodes using tags \n{tag_filters}")
tag_cache = {}
filters = tag_filters.copy()
# Use Ray cluster name to get resources
if TAG_RAY_CLUSTER_NAME not in tag_filters:
filters[TAG_RAY_CLUSTER_NAME] = self.cluster_name
nodes = []
vmray_cluster_response = self._get_cluster_response()
if not vmray_cluster_response:
return nodes, tag_cache
vmray_cluster_status = vmray_cluster_response.get("status", {})
if not vmray_cluster_status:
return nodes, tag_cache
vmray_cluster_spec = vmray_cluster_response.get("spec", {})
# Check for a head node
if NODE_KIND_HEAD in tag_filters.values() or not tag_filters:
head_node_status = vmray_cluster_status.get("head_node_status", {})
# head node found
if head_node_status:
node_id = self._get_head_name()
nodes.append(node_id)
# Setting head node status
status = head_node_status.get("vm_status", None)
head_nt = vmray_cluster_spec["head_node"]["node_type"]
tag_cache[node_id] = self._set_tags(
node_id, NODE_KIND_HEAD, head_nt, status, filters
)
# Check current worker nodes
if NODE_KIND_WORKER in tag_filters.values() or not tag_filters:
current_workers = vmray_cluster_status.get("current_workers", {})
desired_workers = vmray_cluster_spec.get("autoscaler_desired_workers", {})
# worker nodes found
for worker in current_workers.keys():
nodes.append(worker)
# setting worker node status
status = current_workers[worker].get("vm_status", None)
node_type = desired_workers.get(worker, "")
tag_cache[worker] = self._set_tags(
worker, NODE_KIND_WORKER, node_type, status, filters
)
# List VMs from the desired workers' list
for worker in desired_workers.keys():
if worker in current_workers.keys():
continue
nodes.append(worker)
node_type = desired_workers.get(worker, "")
tag_cache[worker] = self._set_tags(
worker, NODE_KIND_WORKER, node_type, STATUS_SETTING_UP, filters
)
logger.info(
f"Non terminated nodes {nodes}, Tags for these are: {tag_cache}"
)
return nodes, tag_cache
def is_vm_power_on(self, node_id: str) -> bool:
"""Check current vm list. If its state is Running then return
true else false."""
node = self._get_node(node_id)
if node:
return node.get("vm_status", None) == VMNodeStatus.RUNNING.value
logger.info(f"VM {node_id} not found")
return False
def is_vm_creating(self, node_id: str) -> bool:
"""Check current vm list. If its state is INITIALIZED then return
true else false."""
node = self._get_node(node_id)
if node:
return node.get("vm_status", None) == VMNodeStatus.INITIALIZED.value
logger.info(f"VM {node_id} is not yet initialized")
return False
def set_node_tags(self, tags: Dict[str, str]) -> None:
"""
Not required
"""
pass
def get_vm_external_ip(self, node_id: str) -> Optional[str]:
"""Check current worker list and get the external ip."""
node = {}
# For a Ray head node, return external IP of the VMService
# Ray head node is not accessible directly.
if node_id == self._get_head_name():
ingress = self._get_vm_service_ingress()
for item in ingress:
if "ip" in item.keys():
node = item
break
else:
worker_node = self._get_node(node_id)
if (
worker_node
and worker_node.get("vm_status", None) == VMNodeStatus.RUNNING.value
):
node = worker_node
ip = node.get("ip", None)
# Validate returned IP
if ip and _is_ipv4(ip):
return ip
logger.info(
f"External IPv4 address: {ip} of VM: {node_id}"
f"is either invalid or not available"
)
return None
def delete_node(self, node_id: str) -> None:
"""Remove name of the vm from the desired worker list and patch
the VmRayCluster CR"""
with self.lock:
vmray_cluster_response = self._get_cluster_response()
vmray_cluster_spec = vmray_cluster_response.get("spec", {})
# Get desired workers
desired_workers = vmray_cluster_spec.get("autoscaler_desired_workers", {})
logger.info(f"Current desired workers: {desired_workers}")
# remove the node from the desired workers list
if node_id in desired_workers:
# By default it follow patch application of `merge-patch+json`
# so we need to remove the node ids by making them null.
# refs:
# 1. https://kubernetes.io/docs/tasks/manage-kubernetes-objects/
# update-api-object-kubectl-patch/#use-a-json-merge-patch-to-update-a-deployment
# 2. https://github.com/kubernetes-client/python/blob/master/kubernetes/
# client/api/custom_objects_api.py#L3106
payload = {"spec": {"autoscaler_desired_workers": {node_id: None}}}
logger.info(f"Deleting VM {node_id} | payload: {payload}")
self.k8s_api_client.custom_object_api.patch_namespaced_custom_object(
VMRAY_GROUP,
VMRAY_CRD_VER,
self.namespace,
VMRAYCLUSTER_PLURAL,
self.cluster_name,
payload,
async_req=False,
)
elif node_id == self._get_head_name():
# Handle case to delete a head node
# Delete VMRayCluster which will delete head node
# as well as associated secrets and other resources.
self.k8s_api_client.custom_object_api.delete_namespaced_custom_object(
VMRAY_GROUP,
VMRAY_CRD_VER,
self.namespace,
VMRAYCLUSTER_PLURAL,
self.cluster_name,
)
def create_nodes(
self,
tags: Dict[str, str],
to_be_launched_node_count: int,
node_config: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Ask cluster operator to create worker VMs"""
logger.info(
f"Creating {to_be_launched_node_count} nodes with tags: {tags}"
f"and with config: {node_config}"
)
created_nodes_dict = {}
with self.lock:
if to_be_launched_node_count > 0:
new_desired_workers = {}
new_vm_names = {}
for _ in range(to_be_launched_node_count):
name = self._create_node_name(tags[TAG_RAY_NODE_NAME])
new_vm_names[name] = tags[TAG_RAY_USER_NODE_TYPE]
# Create a head node
# Autoscaler sends a tag
# head_node_tags[TAG_RAY_NODE_NAME] = "ray-{}-head".format(
# config["cluster_name"])
if "head" in tags[TAG_RAY_NODE_NAME]:
# head node will be created as a part of VMRayCluster CR
self._create_ssh_secret()
self._create_vmraycluster()
else:
# Once VMRayCluster CR is created, update it to create worker
# nodes.
vmray_cluster_response = self._get_cluster_response()
vmray_cluster_spec = vmray_cluster_response.get("spec", {})
# get desired workers
desired_workers = vmray_cluster_spec.get(
"autoscaler_desired_workers", {}
)
# If workers are present in both the list then it shows stable
# state for the cluster.
# Append new VM names with existing one
if desired_workers:
new_desired_workers.update(desired_workers)
new_desired_workers.update(new_vm_names)
logger.info(f"New desired state will be {new_desired_workers}")
if len(new_desired_workers) > self.max_worker_nodes:
logger.warning(
"Autoscaler attempted to create more than max_workers VMs."
)
return created_nodes_dict
payload = {
"spec": {"autoscaler_desired_workers": new_desired_workers}
}
custom_api = self.k8s_api_client.custom_object_api
custom_api.patch_namespaced_custom_object(
VMRAY_GROUP,
VMRAY_CRD_VER,
self.namespace,
VMRAYCLUSTER_PLURAL,
self.cluster_name,
payload,
async_req=False,
)
for vm in new_vm_names:
created_nodes_dict[vm] = vm
return created_nodes_dict
def _get_cluster_response(self):
response = {}
try:
response = (
self.k8s_api_client.custom_object_api.get_namespaced_custom_object(
VMRAY_GROUP,
VMRAY_CRD_VER,
self.namespace,
VMRAYCLUSTER_PLURAL,
self.cluster_name,
)
)
return response
except client.exceptions.ApiException as e:
# If HTTP 404 received means the cluster is not yet created.
logger.warning(
f"Exception while getting {self.cluster_name}. Exception: {str(e)}"
)
if e.status == 404:
logger.warning(f"{self.cluster_name} not available. Creating new one.")
return response
def _get_node(self, node_id: str) -> Any:
vmray_cluster_response = self._get_cluster_response()
vmray_cluster_status = vmray_cluster_response.get("status", {})
if not vmray_cluster_status:
return {}
head_node_status = vmray_cluster_status.get("head_node_status", {})
current_workers = vmray_cluster_status.get("current_workers", {})
# head node is found
if head_node_status and node_id == self._get_head_name():
return head_node_status
# worker nodes found
for worker in current_workers.keys():
if worker == node_id:
return current_workers.get(worker)
# If worker not found in the current worker then it might be getting created
# and not yet ready. So check if it is in the desired workers list.
vmray_cluster_spec = vmray_cluster_response.get("spec", {})
desired_workers = vmray_cluster_spec.get("autoscaler_desired_workers", {})
for worker in desired_workers.keys():
if worker == node_id:
# set vm_status as VM in the desired workers' list will not
# have vm_status field.
node = {"vm_status": VMNodeStatus.INITIALIZED.value}
return node
logger.info(f"VM {node_id} not found")
return {}
def safe_to_scale(self):
"""
It is safe to scale as long as total number of workers(desired + current)
do not exceeds cluster level max_workers.
This function should handle cases:
1. If there are workers in the desired_workers list but not in the
current_workers list that means few workers are not yet up and running.
2. If there are workers in the current_workers list but not in a
desired_workers list indicates workers are not yet deleted completely
and we should wait.
3. If workers are present in both the list shows stable state for the cluster.
"""
vmray_cluster_response = self._get_cluster_response()
vmray_cluster_status = vmray_cluster_response.get("status", {})
if not vmray_cluster_status:
return False
current_workers = vmray_cluster_status.get("current_workers", {})
vmray_cluster_spec = vmray_cluster_response.get("spec", {})
desired_workers = vmray_cluster_spec.get("autoscaler_desired_workers", {})
logger.info(
f"Checking is it safe to scale:\n"
f"Current workers: {current_workers.keys()} \n"
f"Autoscaler desired workers: {desired_workers}"
)
# Do not scale until reaches desired state
if len(desired_workers) != len(current_workers):
return False
# Wait until all nodes are in a Running state
for worker in current_workers.values():
if worker.get("vm_status", None) != VMNodeStatus.RUNNING.value:
return False
return True
def _create_node_name(self, node_name_tag):
"""Create name for a Ray node"""
# The nodes are named as follows:
# <cluster-name>-h-<random alphanumeric string> for the head node
# <cluster-name>-w-<<random alphanumeric string>> for the worker nodes
random_str = "".join(
random.choice(string.ascii_lowercase + string.digits) for _ in range(8)
)
if "head" in node_name_tag:
self.vmraycluster_nounce = random_str
return f"{self.cluster_name}-h-" + self.vmraycluster_nounce
return f"{self.cluster_name}-w-" + random_str
def _get_head_name(self):
if not self.vmraycluster_nounce:
vmray_cluster_response = self._get_cluster_response()
self.vmraycluster_nounce = vmray_cluster_response["metadata"]["labels"][
"vmray.kubernetes.io/head-nounce"
]
return f"{self.cluster_name}-h-" + self.vmraycluster_nounce
def _create_vmraycluster(self):
# Define vmraycluster config structure.
ray_cluster_config = {}
ray_cluster_config["apiVersion"] = VMRAY_GROUP + "/" + VMRAY_CRD_VER
ray_cluster_config["kind"] = "VMRayCluster"
ray_cluster_config["metadata"] = {}
ray_cluster_config["spec"] = {}
# Start reading values from local bootstrap config.
ray_cluster_config["metadata"]["name"] = self.cluster_name
ray_cluster_config["metadata"]["labels"] = {
"vmray.kubernetes.io/head-nounce": self.vmraycluster_nounce,
"vmray.io/created-by": "ray-cli",
}
ray_cluster_config["metadata"]["namespace"] = self.namespace
ray_cluster_config["spec"]["api_server"] = {}
ray_cluster_config["spec"]["api_server"]["location"] = self.vsphere_config.get(
"api_server"
)
ray_cluster_config["spec"]["ray_docker_image"] = self.docker["image"]
# Set head node specific config.
ray_cluster_config["spec"]["head_node"] = {}
ray_cluster_config["spec"]["head_node"][
"head_setup_commands"
] = self.head_setup_commands
ray_cluster_config["spec"]["head_node"][
"port"
] = 6379 # using default GCS port for now.
ray_cluster_config["spec"]["head_node"]["node_type"] = self.head_node_type
# Set common node config & available node types.
ray_cluster_config["spec"]["common_node_config"] = {}
ray_cluster_config["spec"]["common_node_config"][
"vm_image"
] = self.vsphere_config.get("vm_image")
ray_cluster_config["spec"]["common_node_config"][
"storage_class"
] = self.vsphere_config.get("storage_class")
ray_cluster_config["spec"]["common_node_config"][
"vm_password_salt_hash"
] = self.vsphere_config.get("vm_password_salt_hash", "")
ray_cluster_config["spec"]["common_node_config"][
"max_workers"
] = self.max_worker_nodes
available_node_types = {}
for node_type, node_config in self.available_node_types.items():
available_node_types[node_type] = {}
available_node_types[node_type]["vm_class"] = node_config[
"node_config"
].get("vm_class", None)
available_node_types[node_type]["resources"] = node_config.get(
"resources", {}
)
available_node_types[node_type]["min_workers"] = node_config.get(
"min_workers", 0
)
available_node_types[node_type]["max_workers"] = node_config.get(
"max_workers", 2
)
ray_cluster_config["spec"]["common_node_config"][
"available_node_types"
] = available_node_types
ray_cluster_config["spec"]["common_node_config"][
"vm_user"
] = self.provider_auth["ssh_user"]
if self.docker_config is not None:
ray_cluster_config["spec"]["docker_config"] = self.docker_config
logger.info(f"Creating VmRayCluster \n{ray_cluster_config}")
self.k8s_api_client.custom_object_api.create_namespaced_custom_object(
VMRAY_GROUP,
VMRAY_CRD_VER,
self.namespace,
VMRAYCLUSTER_PLURAL,
ray_cluster_config,
)
def _set_tags(self, node_id, node_kind, node_user_type, node_status, tags):
new_tags = tags.copy()
if node_status == VMNodeStatus.RUNNING.value:
new_tags[TAG_RAY_NODE_STATUS] = STATUS_UP_TO_DATE
elif node_status == VMNodeStatus.INITIALIZED.value:
new_tags[TAG_RAY_NODE_STATUS] = STATUS_SETTING_UP
else:
new_tags[TAG_RAY_NODE_STATUS] = STATUS_UNINITIALIZED
new_tags[TAG_RAY_NODE_NAME] = node_id
new_tags[TAG_RAY_NODE_KIND] = node_kind
new_tags[TAG_RAY_USER_NODE_TYPE] = node_user_type
return new_tags
def _set_max_worker_nodes(self):
vmray_cluster_response = self._get_cluster_response()
if not self.max_worker_nodes:
vmray_cluster_spec = vmray_cluster_response.get("spec", {})
common_node_config = vmray_cluster_spec.get("common_node_config", {})
# If max_workers is not provided then default to 2
# ref: https://docs.ray.io/en/latest/cluster/vms/references/
# ray-cluster-configuration.html#max-workers
self.max_worker_nodes = common_node_config.get("max_workers", 2)
logger.info(f"Max worker is set to {self.max_worker_nodes}")
def _get_vm_service_ingress(self):
response = self._get_vm_service()
status = response.get("status", {})
if not status:
return []
ingress = status["loadBalancer"].get("ingress", [])
logger.info(f"VM service ingress is {ingress}")
return ingress
def _get_vm_service(self):
try:
return self.k8s_api_client.custom_object_api.get_namespaced_custom_object(
VMSERVICE_GROUP,
VMSERVICE_CRD_VER,
self.namespace,
VMSERVICE_PLURAL,
self._get_head_name(),
)
except client.exceptions.ApiException as e:
logger.warning(
f"Exception while getting vm service in namespace {self.namespace}."
f"Exception: {str(e)}"
)
return {}
def _create_secret(self, namespace, name, kp):
data = {}
for k, v in kp.items():
# Base64 encode the SSH key
val = v
if type(v) is str:
val = v.encode("utf-8")
data[k] = base64.b64encode(val).decode("utf-8")
# Create metadata
metadata = client.V1ObjectMeta(name=name)
# Secret object
secret = client.V1Secret(
api_version="v1", kind="Secret", metadata=metadata, data=data, type="Opaque"
)
instance = client.CoreV1Api(self.k8s_api_client.client)
# Create the secret in the specified namespace.
try:
instance.create_namespaced_secret(namespace=namespace, body=secret)
logger.info(f"Secret {name} created in namespace {namespace}")
except client.exceptions.ApiException as e:
print("Failure while creating Secret [`%s`] : %s\n" % (name, e))
def _create_ssh_secret(self):
"""Create a K8s SSH secret using a local private SSH key
specified in the config."""
# Define secret name
secret_name = f"{self.cluster_name}-ssh-key"
pvt_key = self.provider_auth.get("ssh_private_key")
private_key_path = os.path.expanduser(pvt_key)
# Read the SSH private key
with open(private_key_path, "rb") as ssh_key:
ssh_key_data = ssh_key.read()
kp = {"ssh-pvt-key": ssh_key_data}
self._create_secret(self.namespace, secret_name, kp)
def _is_ipv4(ip):
try:
ipaddress.IPv4Address(ip)
return True
except ipaddress.AddressValueError:
return False
@@ -0,0 +1,148 @@
import copy
import logging
import os
from cryptography.hazmat.primitives import serialization as crypto_serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from ray.autoscaler._private.constants import DISABLE_NODE_UPDATERS_KEY
from ray.autoscaler._private.event_system import CreateClusterEvent, global_event_system
from ray.autoscaler._private.util import check_legacy_fields
PRIVATE_KEY_NAME = "ray-bootstrap-key.pem"
PUBLIC_KEY_NAME = "ray_bootstrap_public_key.key"
PRIVATE_KEY_PATH = os.path.expanduser(f"~/{PRIVATE_KEY_NAME}")
PUBLIC_KEY_PATH = os.path.expanduser(f"~/{PUBLIC_KEY_NAME}")
logger = logging.getLogger(__name__)
def bootstrap_vsphere(config):
# create a copy of the input config to modify
config = copy.deepcopy(config)
# Log warnings if user included deprecated `head_node` or `worker_nodes`
# fields. Raise error if no `available_node_types`
check_legacy_fields(config)
# Configure SSH access, using an existing key pair if possible.
config = configure_key_pair(config)
# Configure docker run command to be executed on head and wroker nodes
config = configure_run_options(config)
global_event_system.execute_callback(
CreateClusterEvent.ssh_keypair_downloaded,
{"ssh_key_path": config["auth"]["ssh_private_key"]},
)
logger.info(f"{config}")
return config
def configure_key_pair(config):
logger.info("Configuring keys for Ray Cluster Launcher to ssh into the head node.")
if not os.path.exists(PRIVATE_KEY_PATH):
logger.warning(
"Private key file at path {} was not found".format(PRIVATE_KEY_PATH)
)
_create_ssh_keys()
logger.info(
f"New SSH key pair {PRIVATE_KEY_PATH} and {PUBLIC_KEY_PATH} created."
)
# updater.py file uses the following config to ssh onto the head node
# Also, copies the file onto the head node
config["auth"]["ssh_private_key"] = PRIVATE_KEY_PATH
# The path where the public key should be copied onto the remote host
public_key_remote_path = f"~/{PUBLIC_KEY_NAME}"
# Copy the public key to the remote host
config["file_mounts"][public_key_remote_path] = PUBLIC_KEY_PATH
return config
def configure_run_options(config):
ssh_user = config["auth"]["ssh_user"]
# By default enable TLS for Head-Worker grpc communication
tls_enable = (
1 if config["provider"]["vsphere_config"].get("tls_enable", True) else 0
)
# Configure common run options
if "run_options" not in config["docker"]:
config["docker"]["run_options"] = []
config["docker"]["run_options"].append(f"--env RAY_USE_TLS={tls_enable}")
# Configure head_run_options
if "head_run_options" not in config["docker"]:
config["docker"]["head_run_options"] = []
config["docker"]["head_run_options"].append(
f"--env-file /home/{ssh_user}/svc-account-token.env"
)
# Configure worker_run_options
if "worker_run_options" not in config["docker"]:
config["docker"]["worker_run_options"] = []
if tls_enable == 1:
# Generate TLS cert and key for head and worker nodes.
# This needs to be done before ray start command
config["head_start_ray_commands"].insert(0, "sh /home/ray/gencert.sh")
config["worker_start_ray_commands"].insert(0, "sh /home/ray/gencert.sh")
config["docker"]["run_options"].append(
f"-v /home/{ssh_user}/ca.crt:/home/ray/ca.crt"
)
config["docker"]["run_options"].append(
f"-v /home/{ssh_user}/ca.key:/home/ray/ca.key"
)
config["docker"]["run_options"].append(
f"-v /home/{ssh_user}/gencert.sh:/home/ray/gencert.sh"
)
config["docker"]["run_options"].append("--env RAY_TLS_CA_CERT=/home/ray/ca.crt")
config["docker"]["run_options"].append(
"--env RAY_TLS_SERVER_KEY=/home/ray/tls.key"
)
config["docker"]["run_options"].append(
"--env RAY_TLS_SERVER_CERT=/home/ray/tls.crt"
)
return config
def disable_node_updater(config):
logger.info(
"Disabling NodeUpdater threads as Cluster Operator is "
+ "responsible for Ray setup on nodes."
)
config["provider"][DISABLE_NODE_UPDATERS_KEY] = True
return config
def _create_ssh_keys():
"""Create SSH keys as specified"""
# Create a private key
private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
# Encode it in PEM format
unencrypted_private_key = private_key.private_bytes(
encoding=crypto_serialization.Encoding.PEM,
format=crypto_serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=crypto_serialization.NoEncryption(),
)
# Create a public key
public_key = private_key.public_key().public_bytes(
encoding=crypto_serialization.Encoding.PEM,
format=crypto_serialization.PublicFormat.SubjectPublicKeyInfo,
)
# Write keys
with open(PRIVATE_KEY_PATH, "wb") as pvt_key:
# manage access mode for the pvt key
os.chmod(PRIVATE_KEY_PATH, 0o600)
pvt_key.write(unencrypted_private_key)
with open(PUBLIC_KEY_PATH, "wb") as pub_key:
# manage access mode for the pvt key
pub_key.write(public_key)
+125
View File
@@ -0,0 +1,125 @@
import logging
import threading
from typing import Any, Dict
from ray.autoscaler._private.vsphere.cluster_operator_client import (
ClusterOperatorClient,
)
from ray.autoscaler._private.vsphere.config import bootstrap_vsphere
from ray.autoscaler.node_provider import NodeProvider
from ray.autoscaler.tags import (
STATUS_SETTING_UP,
TAG_RAY_CLUSTER_NAME,
TAG_RAY_NODE_NAME,
TAG_RAY_NODE_STATUS,
)
logger = logging.getLogger(__name__)
class VsphereWcpNodeProvider(NodeProvider):
max_terminate_nodes = 1000
cluster_config = None
def __init__(self, provider_config, cluster_name):
NodeProvider.__init__(self, provider_config, cluster_name)
self.tag_cache = {}
self.tag_cache_lock = threading.Lock()
self.client = ClusterOperatorClient(
cluster_name, provider_config, VsphereWcpNodeProvider.cluster_config
)
@staticmethod
def bootstrap_config(cluster_config):
config = bootstrap_vsphere(cluster_config)
VsphereWcpNodeProvider.cluster_config = config
return config
def non_terminated_nodes(self, tag_filters):
nodes, tag_cache = self.client.list_vms(tag_filters)
with self.tag_cache_lock:
for node_id in nodes:
for k, v in tag_cache[node_id].items():
if node_id in self.tag_cache.keys():
self.tag_cache[node_id][k] = v
else:
self.tag_cache[node_id] = {}
self.tag_cache[node_id][k] = v
logger.info(f"Non terminated nodes' tags are {self.tag_cache}")
return nodes
def is_running(self, node_id):
return self.client.is_vm_power_on(node_id)
def is_terminated(self, node_id):
if self.client.is_vm_power_on(node_id):
return False
else:
# If the node is not powered on but has the creating tag, then it could
# be under reconfiguration, such as plugging the GPU. In this case we
# should consider the node is not terminated, it will be turned on later
return not self.client.is_vm_creating(node_id)
def node_tags(self, node_id):
with self.tag_cache_lock:
return self.tag_cache[node_id]
def external_ip(self, node_id):
return self.client.get_vm_external_ip(node_id)
def internal_ip(self, node_id):
# Currently vSphere VMs do not show an internal IP. So we just return the
# external IP
return self.client.get_vm_external_ip(node_id)
def set_node_tags(self, node_id, tags):
# This method gets called from the Ray and it passes
# node_id. It updates old tags (if present) with new values.
with self.tag_cache_lock:
for k, v in tags.items():
# update tags for node_id
self.tag_cache[node_id][k] = v
logger.info(f"Updated tags for {node_id} to: {self.tag_cache[node_id]}")
def create_node(self, node_config, tags, count) -> Dict[str, Any]:
"""Creates instances.
Returns dict mapping instance id to VM object for the created
instances.
"""
to_be_launched_node_count = count
created_nodes_dict = {}
if to_be_launched_node_count > 0:
created_nodes_dict = self.client.create_nodes(
tags, to_be_launched_node_count, node_config
)
# make sure to mark newly created nodes as ready
# so autoscaler shouldn't provision new ones
with self.tag_cache_lock:
for node_id in created_nodes_dict.keys():
self.tag_cache[node_id] = tags.copy()
self.tag_cache[node_id][TAG_RAY_NODE_STATUS] = STATUS_SETTING_UP
self.tag_cache[node_id][TAG_RAY_NODE_NAME] = node_id
self.tag_cache[node_id][TAG_RAY_CLUSTER_NAME] = self.cluster_name
logger.info(
f"Node {node_id} created with tags: {self.tag_cache[node_id]}"
)
return created_nodes_dict
def terminate_node(self, node_id):
if not node_id or self.client.is_vm_creating(node_id):
return
# Delete node iff it is either in a running or a failure state
self.client.delete_node(node_id)
with self.tag_cache_lock:
if node_id in self.tag_cache:
self.tag_cache.pop(node_id)
def terminate_nodes(self, node_ids):
if not node_ids:
return
for node_id in node_ids:
self.terminate_node(node_id)
def safe_to_scale(self) -> bool:
return self.client.safe_to_scale()