import argparse import subprocess import sys import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed import ray import random import ray.util # The goal of the this script is to simulate cross AZ transient network failures periodically on a Ray job. # We do this by modifying the iptables to drop all inbound and outbound traffic for a given duration # except for intra-node and SSH traffic. After the duration, the iptables rules are restored. # The failure script is run in a background thread while the main command is run in the foreground. # NOTE: The script itself does not spin up a Ray cluster, it operates on the assumption that an existing # Ray cluster is running and we are able to SSH into the nodes (like on Anyscale). PARALLEL = 500 # concurrent SSH sessions SSH_USER = "ubuntu" # Anyscale default AFFECT_WORKER_RATIO = 0.50 # failure affects 50% of worker nodes EXTRA_SSH = [ "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10", ] def iptables_cmd(self_ip: str, seconds: int) -> str: return f"""\ nohup setsid bash -lc ' sudo iptables -w -A INPUT -p tcp --dport 22 -j ACCEPT sudo iptables -w -A OUTPUT -p tcp --sport 22 -j ACCEPT sudo iptables -w -A INPUT -s 127.0.0.0/8 -d 127.0.0.0/8 -j ACCEPT sudo iptables -w -A OUTPUT -s 127.0.0.0/8 -d 127.0.0.0/8 -j ACCEPT sudo iptables -w -A INPUT -s {self_ip} -d {self_ip} -j ACCEPT sudo iptables -w -A OUTPUT -s {self_ip} -d {self_ip} -j ACCEPT sudo iptables -w -A INPUT -j DROP sudo iptables -w -A OUTPUT -j DROP sleep {seconds} sudo iptables -w -D OUTPUT -j DROP sudo iptables -w -D INPUT -j DROP sudo iptables -w -D OUTPUT -s {self_ip} -d {self_ip} -j ACCEPT sudo iptables -w -D INPUT -s {self_ip} -d {self_ip} -j ACCEPT sudo iptables -w -D OUTPUT -s 127.0.0.0/8 -d 127.0.0.0/8 -j ACCEPT sudo iptables -w -D INPUT -s 127.0.0.0/8 -d 127.0.0.0/8 -j ACCEPT sudo iptables -w -D OUTPUT -p tcp --sport 22 -j ACCEPT sudo iptables -w -D INPUT -p tcp --dport 22 -j ACCEPT ' &>/dev/null & """ def ssh_run(ip: str, cmd: str) -> tuple[bool, str]: """Run SSH command on remote host.""" target = f"{SSH_USER}@{ip}" res = subprocess.run( ["ssh", *EXTRA_SSH, target, cmd], capture_output=True, text=True ) ok = res.returncode == 0 msg = res.stdout.strip() if ok else (res.stderr.strip() or res.stdout.strip()) return ok, msg def simulate_cross_az_network_failure(seconds: int): if not ray.is_initialized(): ray.init(address="auto") nodes = ray.nodes() all_ips = [n["NodeManagerAddress"] for n in nodes if n.get("Alive", False)] # Always inject failures on the head node head_ip = next( ( n["NodeManagerAddress"] for n in nodes if n.get("NodeManagerAddress") == ray.util.get_node_ip_address() ), None, ) print(f"Discovered {len(all_ips)} alive nodes") print(f"Head node: {head_ip}") worker_ips = [ip for ip in all_ips if ip != head_ip] print(f"Eligible worker nodes: {len(worker_ips)}") if not worker_ips: print("ERROR: No worker nodes found") return k = max(1, int(len(worker_ips) * AFFECT_WORKER_RATIO)) affected = random.sample(worker_ips, k) # NOTE: When running this script on Anyscale with longer failure durations the blacked out head node could # cause your workspace to lag and die. To avoid this, comment out the below line. affected.append(head_ip) print( f"Affecting {len(affected)} nodes (~{AFFECT_WORKER_RATIO*100:.0f}% of workers + head node):" ) print(", ".join(affected[:10]) + (" ..." if len(affected) > 10 else "")) cmds = {ip: iptables_cmd(ip, seconds) for ip in affected} print(f"\nTriggering {seconds}s of transient network failure...") successes, failures = [], {} with ThreadPoolExecutor(max_workers=PARALLEL) as ex: futs = {ex.submit(ssh_run, ip, cmds[ip]): ip for ip in affected} for fut in as_completed(futs): ip = futs[fut] try: ok, msg = fut.result() if ok: successes.append(ip) else: failures[ip] = msg except Exception as e: failures[ip] = str(e) print("\n=== Summary ===") print(f"Succeeded: {len(successes)} nodes") print(f"Failed : {len(failures)} nodes") if failures: for ip, msg in list(failures.items()): print(f" {ip}: {msg}") def network_failure_loop(interval, network_failure_duration): """ Run the network failure loop in a background thread at regular intervals. Args: interval: Interval in seconds between network failure events network_failure_duration: Duration in seconds of each network failure """ print( f"[NETWORK FAILURE {time.strftime('%H:%M:%S')}] Starting network failure thread with interval: {interval} seconds" ) while True: # Sleep for the interval duration time.sleep(interval) # Simulate a network failure print( f"[NETWORK FAILURE {time.strftime('%H:%M:%S')}] Triggering network failure simulation..." ) try: simulate_cross_az_network_failure(network_failure_duration) except Exception as e: print( f"[NETWORK FAILURE {time.strftime('%H:%M:%S')}] ERROR: Network failure simulation failed: {e}" ) def parse_args(): parser = argparse.ArgumentParser( description="Run benchmark with network failure injection at regular intervals", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Run map_benchmark with network failures injected every 300 seconds, each lasting 5 seconds python simulate_cross_az_network_failure.py --network-failure-interval 300 --network-failure-duration 5 --command python map_benchmark.py --api map_batches --sf 1000 """, ) parser.add_argument( "--network-failure-interval", type=int, required=True, help="Interval in seconds between network failure events", ) parser.add_argument( "--network-failure-duration", type=int, required=True, help="Duration in seconds of each network failure", ) parser.add_argument( "--command", nargs=argparse.REMAINDER, required=True, help="The main command to run (e.g., 'python map_benchmark.py --api map_batches ...')", ) return parser.parse_args() def main(): args = parse_args() # Validate command (argparse catches missing --command, but not empty --command) if not args.command: print("ERROR: --command requires at least one argument") print( "Usage: python simulate_cross_az_network_failure.py --network-failure-interval --network-failure-duration --command " ) sys.exit(1) print("=" * 80) print("Running with Network Failure Injection") print("=" * 80) print(f"Network failure interval: {args.network_failure_interval} seconds") print(f"Network failure duration: {args.network_failure_duration} seconds") print(f"Command: {' '.join(args.command)}") print("=" * 80) print() # Start network failure thread as daemon - it will die with the process network_failure_thread = threading.Thread( target=network_failure_loop, args=(args.network_failure_interval, args.network_failure_duration), daemon=True, ) network_failure_thread.start() try: # Run the main command in the foreground print( f"[MAIN {time.strftime('%H:%M:%S')}] Starting command: {' '.join(args.command)}" ) main_result = subprocess.run(args.command) print( f"\n[MAIN {time.strftime('%H:%M:%S')}] Command completed with exit code: {main_result.returncode}" ) exit_code = main_result.returncode except KeyboardInterrupt: print("\n[MAIN] Interrupted by user") exit_code = 130 except Exception as e: print(f"[MAIN] ERROR: {e}") exit_code = 1 print("\n" + "=" * 80) print(f"Execution completed with exit code: {exit_code}") print("=" * 80) sys.exit(exit_code) if __name__ == "__main__": main()