Fallstudie

Benutzerdefinierte horizontale Skalierung für WebSocket in AWS

Bild des ITGix-Teams
ITGix-Team
Passionierte DevOps- und Cloud-Ingenieure
02.04.2024
Lesezeit: 11 Minuten.
Zuletzt aktualisiert: 14.01.2025

Inhaltsübersicht

Als engagiertes DevOps-Beratungsunternehmen sind wir stets bestrebt, unseren geschätzten Kunden optimale Lösungen zu liefern. Heute freuen wir uns, einen bemerkenswerten Anwendungsfall vorzustellen, der unsere Zusammenarbeit mit einem Kunden bei der Bereitstellung, Optimierung und Skalierung von WebSocket-fähigen Servern demonstriert.

Das WebSocket-Protokoll ist ein zustandsbehaftetes Vollduplex-Protokoll, das für die Kommunikation zwischen Client und Server verwendet wird. Die Verbindung zwischen dem Client und dem Server wird so lange aufrechterhalten, bis sie entweder vom Server oder vom Client beendet wird. Sobald die Verbindung entweder durch den Client oder den Server beendet wird, wird sie auf beiden Seiten beendet. Der Server sendet daraufhin den Handshake und baut eine neue Verbindung auf, die solange aufrecht erhalten wird, bis entweder der Server oder der Browser die Verbindung beendet.

ProtokollHTTPWebSocket
TypAnfrage/AntwortVoll-Duplex
VerwendungZustandslosZustandsabhängig
OverheadHöherUnter
KopfzeileSchwer (für Anfragen) (für Antworten)Licht (klein)
EchtzeitNicht geeignet für EchtzeitGeeignet für Echtzeitanwendungen
AnwendungsfälleWeb-Browsing, REST, APIs, einseitige DatenübertragungEchtzeit-Anwendungen, Online-Spiele, Chat-Anwendungen

Aufgrund der Funktionsweise des WebSocket-Protokolls werden viele Verbindungen zu den Servern hergestellt, und alle Server können eine bestimmte Anzahl von Verbindungen verarbeiten, bevor sie ihre Grenze erreichen. In unserem Anwendungsfall, als wir diese Anzahl von Verbindungen erreichten, hatten die Server weder eine hohe CPU-Auslastung noch eine hohe Speicherauslastung, so dass wir nach anderen Möglichkeiten suchen mussten, die Server zu skalieren. 

Wir beschlossen, dass die beste Lösung darin bestehen würde, die Server auf der Grundlage der durchschnittlichen Anzahl der Verbindungen, die sie haben, zu skalieren. Nachdem wir uns für eine Skalierungsmethode entschieden hatten, mussten wir mit der Implementierung und dem Testen der Skalierungsmethode beginnen. 

Für die Umsetzung mussten wir folgende Schritte unternehmen:

  1. Eine benutzerdefinierte Metrik, die an CloudWatch gesendet wird und die durchschnittliche Anzahl der Verbindungen der Server in der Autoscaling-Gruppe angibt.
  2. Überprüfen Sie die Server-, ALB- und Zielgruppenkonfiguration auf Anpassungen.
  3. Lasttests zur Ermittlung der Anzahl der Verbindungen, die jeder Server verarbeiten kann. 
  4. Erstellen der benutzerdefinierten Autoscaling-Richtlinie mit CloudWarch-Alarmen.
  5. Lasttests zur Bestätigung, dass die benutzerdefinierte Autoskalierungsrichtlinie korrekt skaliert wird 

Der CloudWatch-Agent von AWS verfügt über eine benutzerdefinierte Metrik, die die Anzahl der Verbindungen verfolgt. Der Name der Metrik lautet tcp_established. In der CloudWacth-Konfigurationsdatei (amazon-cloudwatch-agent.json) können Sie das folgende Beispiel hinzufügen:

{
        "agent": {
                "metrics_collection_interval": 60
        },
        "metrics": {
                "namespace": "TPC_Connections",
                "append_dimensions": {
                        "AutoScalingGroupName": "${aws:AutoScalingGroupName}",
                        "InstanceId": "${aws:InstanceId}"
                },
                "aggregation_dimensions": [
                        [
                                "AutoScalingGroupName"
                        ]
                ],
                        "netstat": {
                                "measurement": [
                                        {
                                                "name": "tcp_established",
                                                "rename": "TCPconnections",
                                                "unit": "Count"
                                        }
                                ],
                                "metrics_collection_interval": 60
                }
        }
}

Danach starten Sie den CloudWatch-Agenten neu. Wenn die Metrik korrekt gesendet wird, sollten Sie ein ähnliches Diagramm sehen:

Starten Sie den CloudWatch-Agenten neu.

Nachdem die benutzerdefinierte Metrik fertig ist, müssen wir die Konfigurationen unserer Server, des Application Load Balancer und der Zielgruppe überprüfen. 

  1. Beginnen wir mit den Serverkonfigurationen: 

Erhöhen Sie die Anzahl der offenen Dateien im System. Linux hat eine Standardbegrenzung für die maximale Anzahl der offenen Dateien: 

ulimit -n

1024

Sie können die maximale Anzahl der geöffneten Dateien ändern, indem Sie die Datei limits.conf in /etc/security/limits.d bearbeiten. Am Ende der Datei können Sie die neuen Grenzen hinzufügen: 

* soft nofile 1048576

* hard nofile 1048576

Starten Sie danach Ihre Sitzung neu, und Sie werden die neue Grenze sehen:

ulimit -n

1048576

Die nächste Serverkonfiguration, die Sie anpassen sollten, ist die Anzahl der Ports. Erstellen Sie in /etc/sysctl.d/ eine neue Datei namens net.ipv4.ip_local_port_range.conf und fügen Sie Folgendes hinzu: 

net.ipv4.ip_local_port_range = 10000 65535

Wir verwenden 10000 als untere Grenze, um zu vermeiden, dass bereits genutzte Ports einbezogen werden. 

Und als letztes aktivieren Sie Keep Alive auf dem Webserver. In unserem Fall verwenden wir Nginx und fügten Folgendes zur Nginx-Konfiguration hinzu: 

keepalive_timeout 65;
  1. ALB-Konfiguration: 

Das Einzige, was hier angepasst werden muss, ist das Idle-Session-Timeout. Denken Sie daran, das Keep-Alive des Nginx etwas höher zu setzen als das Idle-Timeout, um sicherzustellen, dass der Nginx die Verbindungen nicht vor dem ALB schließt. 

Während unserer Lasttests konnten wir keinen signifikanten Unterschied im Verhalten der Anwendung feststellen, wenn die Idle-Sitzung länger als 60 Sekunden dauerte. Daher haben wir die Standardkonfiguration beibehalten und lediglich die Keep-Alive-Einstellung von Nginx angepasst.

  1. Zielgruppen-Konfiguration

Zunächst müssen wir die Methode wählen, die für den Lastausgleich der Verbindungen verwendet werden soll. Um sicherzustellen, dass der Server eine ähnliche Anzahl von Verbindungen erhält, haben wir uns für die Round-Robin-Methode zum Lastausgleich entschieden. 

Danach müssen wir einen Gesundheitscheck erstellen, der die Anzahl der Verbindungen auf jedem Server verfolgt und den Server als ungesund kennzeichnet, wenn er die maximale Anzahl an Verbindungen erreicht, deren Schwellenwert später während der Tests bestimmt wird.  

Die letzte Anpassung der Zielgruppe bestand darin, Sticky Sessions zu aktivieren. Sticky Sessions sind perfekt für das WebSocket-Protokoll, da sie sicherstellen, dass jede Anfrage des Benutzers an dasselbe Ziel gesendet wird. Allerdings müssen Sie bedenken, dass Sie für Sticky Sessions auch Cookies verwenden müssen, in unserem Fall haben wir uns entschieden, vom Load Balancer generierte Cookies anstelle von Anwendungscookies zu verwenden. 

Nachdem die oben genannten Anpassungen vorgenommen worden waren, begannen wir mit der ersten Serie von Belastungstests.

Wir haben unsere ersten Lasttests verwendet, um zu ermitteln, wie viele Verbindungen ein Server bewältigen kann. Wir verwendeten die verteilten Lasttests von AWS oder die Lasttests. Die Entwickler der Anwendung haben die Tests geschrieben. Wir haben die folgende Methode für die Tests verwendet: 

  • Starten Sie den Test
  • Überwachen Sie die Anzahl der Verbindungen auf dem Server mit sudo netstat -apn | wc -l 
  • Gleichzeitig überprüften wir auch die Anwendung, um sicherzustellen, dass sie korrekt funktioniert.

Im ersten Test, den wir durchgeführt haben, haben wir in der Zustandsprüfung ein Limit von 18.000 Verbindungen gesetzt, um das Verhalten des Servers zu überwachen, wenn er von der Zielgruppe als ungesund eingestuft wird. Dabei haben wir festgestellt, dass die ALB weiterhin Datenverkehr an die ungesunden Instanzen sendet, was ein Standardverhalten der ALB ist. 

Als wir das oben beschriebene Verhalten bemerkten, skalierten wir auf zwei Instanzen, um die Zustandsprüfung zu testen und sicherzustellen, dass kein Datenverkehr an diese Instanz gesendet wird, sobald sie als ungesund eingestuft wird. 

Als wir bestätigten, dass kein Datenverkehr an eine als ungesund eingestufte Instanz gesendet wurde, begannen wir, den Schwellenwert für die Zustandsprüfung zu erhöhen. Zunächst auf 20.000 Verbindungen, und wir überwachten die Anwendung weiter, die einwandfrei funktionierte. Nach einigen weiteren schrittweisen Erhöhungen konnten wir den Grenzwert für die Anzahl der Verbindungen auf 28.000 pro Instanz festlegen. 

Anhand der Ergebnisse unserer Lasttests und der benutzerdefinierten Metrik TCPconnections , die wir oben eingerichtet haben, können wir nun die benutzerdefinierten CloudWatch-Alarme und Richtlinien für die automatische Skalierung erstellen, die wir für die Skalierung nach außen und innen verwenden werden. 

Zunächst müssen Sie die CloudWatch-Alarme erstellen, da diese für die Autoscaling-Richtlinien benötigt werden. Die CloudWatch-Alarme werden aufgerufen: 

  • High-tcp-Verbindungen
  • Low-tcp-Verbindungen 

Die Alarme werden in den beiden Richtlinien verwendet, die wir als nächstes erstellen werden. Wir haben die beiden Richtlinien benannt: 

  • low_tcp_connections_policy
  • high_tcp_connections_policy 
  1. Low_tcp_connections_policy:

Die Richtlinie für niedrige TCP-Verbindungen wird für die Skalierung verwendet, und wir verwenden eine einfache Skalierung. Die Richtlinie entfernt eine Instanz, wenn die durchschnittliche Anzahl der TCP-Verbindungen aller Instanzen in der ASG in drei aufeinanderfolgenden Überprüfungszeiträumen von 300 Sekunden oder 5 Minuten gleich oder niedriger als 9500 ist. Wenn also 15 Minuten lang die Anzahl der Verbindungen unter 9500 liegt, wird eine Instanz entfernt. Wir haben uns für einen längeren Zeitraum entschieden, weil der Datenverkehr uneinheitlich ist und wir Spitzen beobachtet haben, die ein Hinein- und ein paar Minuten später ein Herausskalieren verursachten. Dieses ständige Hinein- und Herausskalieren war für uns ein Problem, weil die verbleibenden Instanzen die TCP-Verbindungen erhielten, die von den entfernten Instanzen abgezogen wurden, wodurch sie ihr Limit an maximalen Verbindungen erreichten, was zu Latenz in der Anwendung führte.

  1. High_tcp_connections_policy:

Die Richtlinie für hohe TCP-Verbindungen wird für die Skalierung verwendet. Hier haben wir uns für eine stufenweise Skalierung anstelle einer einfachen Skalierung entschieden, damit wir mit den Verkehrsspitzen Schritt halten können. Bei der stufenweisen Skalierung ist zu beachten, dass eine untere und obere Grenze verwendet wird. Wenn der Schwellenwert des Alarms überschritten wird, prüft der Alarm die untere und obere Grenze der Stufenskalierung, um zu wissen, welche Stufe ausgeführt werden sollte. → https://docs.aws.amazon.com/autoscaling/ec2/userguide/as-scaling-simple-step.html#as-scaling-steps 

Das bedeutet auch, dass die untere Grenze des ersten Schritts vom Schwellenwert des Alarms ausgeht, in unserem Fall ist der Schwellenwert 20 000, so dass die Schritte wie folgt aussehen:

  • Fügen Sie zwei Instanzen hinzu, wenn die durchschnittliche Anzahl der Verbindungen zwischen 20 000 und 25 000 liegt
  • Fügen Sie zwei Instanzen hinzu, wenn die durchschnittliche Anzahl der Verbindungen zwischen 25 000 und 30 000 liegt
  • Fügen Sie drei Instanzen hinzu, wenn die durchschnittlichen Verbindungen zwischen 30 000 und unendlich liegen 

Wir hatten noch nicht den Fall, dass die zweite Stufe zum Einsatz kam, aber bei größeren Verkehrsspitzen ist eine Skalierung auf allen Stufen möglich. Ein Hinweis: Die dritte Stufe wurde nur hinzugefügt, um sicherzustellen, dass wir im Falle einer Überlastung der Instanzen schnell genug skalieren können. 

  1. Terraform Code für die Alarme und Richtlinien: 
  1. Terraform Code für die Alarme:
locals {

  create_high_tcp_connections = var.cloudwatch_ec2_checks_enabled || var.create_high_tcp_connections

  create_low_tcp_connections  = var.cloudwatch_ec2_checks_enabled || var.create_low_tcp_connections

}

#######################################################

#################High TCP connections##################

#######################################################

resource "aws_cloudwatch_metric_alarm" "high_tcp_connections" {

  count               = local.create_high_tcp_connections ? 1 : 0

  alarm_name          = "${var.env}-${var.application_name}-high-tcp-connections"

  comparison_operator = "GreaterThanOrEqualToThreshold"

  evaluation_periods  = var.high_tcp_evaluation_periods //"5"

  datapoints_to_alarm = var.high_tcp_datapoint_to_alarm //"5"

  metric_name         = "TCPconnections"

  namespace           = "ASG_Memory"

  period              = var.high_tcp_period //"60"

  statistic           = "Average"

  threshold           = var.high_tcp_threshold //"60"

  dimensions = {

    AutoScalingGroupName = aws_autoscaling_group.application_asg.0.name

  }

  alarm_description  = "Trigger an alert when AutoScaling Group instances ${aws_autoscaling_group.application_asg.0.name} have reached high TCP connections"

  alarm_actions      = [var.internal_sns_topic, aws_autoscaling_policy.high_tcp_connections_policy[count.index].arn]

  ok_actions         = [var.internal_sns_topic]

  treat_missing_data = "breaching"

  depends_on = [

    aws_autoscaling_policy.high_tcp_connections_policy,

  ]

}

#######################################################

#################Low TCP connections###################

#######################################################

resource "aws_cloudwatch_metric_alarm" "low_tcp_connections" {

  count               = local.create_low_tcp_connections ? 1 : 0

  alarm_name          = "${var.env}-${var.application_name}-low-tcp-connections"

  comparison_operator = "LessThanOrEqualToThreshold"

  evaluation_periods  = var.low_tcp_evaluation_periods //"5"

  datapoints_to_alarm = var.low_tcp_datapoint_to_alarm //"5"

  metric_name         = "TCPconnections"

  namespace           = "ASG_Memory"

  period              = var.low_tcp_period //"60"

  statistic           = "Average"

  threshold           = var.low_tcp_threshold //"60"

  dimensions = {

    AutoScalingGroupName = aws_autoscaling_group.application_asg.0.name

  }

  alarm_description  = "Trigger an alert when AutoScaling Group instances ${aws_autoscaling_group.application_asg.0.name} have reached low TCP connections"

  alarm_actions      = [var.internal_sns_topic, aws_autoscaling_policy.low_tcp_connections_policy[count.index].arn]

  ok_actions         = [var.internal_sns_topic]

  treat_missing_data = "breaching"

  depends_on = [

    aws_autoscaling_policy.low_tcp_connections_policy,

  ]

}
  1. Terraform-Code für automatische Skalierungsrichtlinien: 
resource "aws_autoscaling_policy" "high_tcp_connections_policy" {

  count                     = var.high_tcp_policy_enabled ? 1 : 0

  name                      = "high_tcp_connections_policy"

  policy_type               = "StepScaling"

  adjustment_type           = "ChangeInCapacity"

  estimated_instance_warmup = 150 // default 300

  autoscaling_group_name    = aws_autoscaling_group.application_asg[count.index].name

  step_adjustment {

    scaling_adjustment          = var.scaling_adjustment_1

    metric_interval_lower_bound = var.metric_interval_lower_1

    metric_interval_upper_bound = var.metric_interval_upper_1

  }

  step_adjustment {

    scaling_adjustment          = var.scaling_adjustment_2

    metric_interval_lower_bound = var.metric_interval_lower_2

    metric_interval_upper_bound = var.metric_interval_upper_2

  }

  step_adjustment {

    scaling_adjustment          = var.scaling_adjustment_3

    metric_interval_lower_bound = var.metric_interval_lower_3

    metric_interval_upper_bound = var.metric_interval_upper_3

  }

}

resource "aws_autoscaling_policy" "low_tcp_connections_policy" {

  count                  = var.low_tcp_policy_enabled ? 1 : 0

  name                   = "low_tcp_connections_policy"

  policy_type            = "SimpleScaling"

  adjustment_type        = "ChangeInCapacity"

  scaling_adjustment     = var.low_tcp_scaling_adjustment

  autoscaling_group_name = aws_autoscaling_group.application_asg[count.index].name

}

Nachdem die benutzerdefinierten Skalierungsrichtlinien erstellt wurden, müssen wir erneut Lasttests durchführen, um zu bestätigen, dass die Richtlinien korrekt skaliert werden. 

Der Standardalgorithmus von ALBs ist der Round-Robin-Algorithmus, den wir verwendet haben. Wir müssen also auch testen, ob die Verbindungen gleichmäßig verteilt sind. Zu diesem Zweck haben wir einen weiteren Lasttest durchgeführt und das folgende Skript verwendet, um die Anzahl der Verbindungen auf jedem Server in der Autoskalierungsgruppe zu überwachen: 

import boto3

import paramiko

def get_autoscaling_group_instances_private_ips(autoscaling_group_name, region='us-east-1'):

    # Create an EC2 client

    ec2_client = boto3.client('ec2', region_name=region)

    # Create an Auto Scaling client

    autoscaling_client = boto3.client('autoscaling', region_name=region)

    # Get the Auto Scaling Group details

    response = autoscaling_client.describe_auto_scaling_groups(AutoScalingGroupNames=[autoscaling_group_name])

    if 'AutoScalingGroups' not in response or not response['AutoScalingGroups']:

        print(f"Auto Scaling Group '{autoscaling_group_name}' not found.")

        return []

    # Extract instance IDs from the Auto Scaling Group

    instance_ids = [instance['InstanceId'] for instance in response['AutoScalingGroups'][0]['Instances']]

    # Get the private IP addresses of instances

    response = ec2_client.describe_instances(InstanceIds=instance_ids)

    private_ips = []

    for reservation in response['Reservations']:

        for instance in reservation['Instances']:

            private_ips.append(instance['PrivateIpAddress'])

    return private_ips

def ssh_and_run_command(instance_ip, username, key_path, command):

    # Connect to the instance using paramiko

    client = paramiko.SSHClient()

    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())

    # Load the private key file

    private_key = paramiko.RSAKey(filename=key_path)

    try:

        client.connect(instance_ip, username=username, pkey=private_key)

        # Run the command

        stdin, stdout, stderr = client.exec_command(command)

        print(f"Output from {instance_ip}:")

        print(stdout.read().decode('utf-8'))

    except Exception as e:

        print(f"Error connecting to {instance_ip}: {e}")

    finally:

        # Close the SSH connection

        client.close()

# Example usage

autoscaling_group_name = 'AUTOSCALING GROUP'

region = 'REGION'

username = 'USERNAME'  

key_path = 'KEY' 

command_to_run = 'sudo netstat -apn | wc -l'

instance_private_ips = get_autoscaling_group_instances_private_ips(autoscaling_group_name, region)

for private_ip in instance_private_ips:

    ssh_and_run_command(private_ip, username, key_path, command_to_run)

Nachdem das Skript erfolgreich abgeschlossen wurde, sollten Sie folgendes sehen:

Output from IP Address:

27908

Output from IP Address:

27784

Output from IP Address:

25366

Wenn ein Fehler auftritt, erscheint die Meldung "Fehler beim Verbinden mit der IP-Adresse".

Dies war der letzte Test, den wir vor der Bereitstellung der Lösung durchgeführt haben. Ein anderer Ansatz, der getestet werden kann, ist die Umstellung des Algorithmus von Round-Robin auf die am wenigsten ausstehenden Anfragen. Dies kann bei Skalierungsereignissen, bei denen viele neue Server zur Autoskalierungsgruppe hinzugefügt werden, nützlich sein. 

Nachdem wir begonnen hatten, die Lösung in der Produktion einzusetzen, stellten wir fest, dass die Autoscaling-Richtlinie nicht schnell genug skalieren kann, wenn wir nur eine oder zwei Instanzen in der Autoscaling-Gruppe haben und der Datenverkehr zunimmt. 

  • In unserem Fall ist der Datenverkehr vorhersehbar, wir wissen, wann es zu Spitzen kommt und wann die Anwendung nicht mehr stark genutzt wird. Wenn der Datenverkehr jedoch nicht leicht vorhersehbar ist, kann dies schwieriger zu lösen sein. Das Lambda wurde später auf geplante Aktionen umgestellt, die direkt von der Autoscaling-Gruppe ausgeführt werden.
  • Der zweite Schritt war das Hinzufügen von Warm-Pool-Instanzen zur Autoscaling-Gruppe. Der Warm-Pool hält eine bestimmte Anzahl von Instanzen angehalten, wenn ein Skalierungsereignis ausgelöst wird, startet die Autoscaling-Gruppe nur die Instanzen, was die Skalierung beschleunigt. 

Wir müssen auch die Skalierungsschwellenwerte anpassen, wenn der Datenverkehr zunimmt oder Spitzen auftreten.

TCP-Verbindungen werden nicht geschlossen, was dazu führen kann, dass viele Instanzen weiterlaufen, obwohl man sie nicht braucht. 

Zusammenfassend lässt sich sagen, dass die benutzerdefinierte Lösung für den Zweck, für den sie entwickelt wurde, und für den Anwendungsfall, in dem der Datenverkehr gut genug bekannt ist und Verkehrsspitzen leicht vorhergesagt werden können, perfekt funktioniert. In Fällen, in denen der Datenverkehr nicht vorhersehbar ist, sind möglicherweise weitere Anpassungen an den Autoskalierungsrichtlinien, Schwellenwerten, Warm-Pools usw. erforderlich. 

Eine Antwort hinterlassen

Weitere Fallstudien

Über den Kunden - Führend in der Entscheidungsautomatisierung für Finanzdienstleistungen Actico automatisiert und verbessert die Automatisierung komplexer Unternehmensentscheidungen - mit bahnbrechenden Technologien und spezifischer Software...
Lesen
Einführung in den EKS-Automodus in einer modernen Infrastruktur In dieser Fallstudie wird untersucht, wie unser Team das neueste Kubernetes-Angebot von AWS - den EKS-Automodus - in die bestehende Infrastruktur eines Kunden eingeführt hat. Diese innovative Funktion hat...
Lesen

Blog-Beiträge

Kontakt aufnehmen
ITGix bietet Ihnen fachkundige Beratung und maßgeschneiderte DevOps-Services, um Ihr Unternehmenswachstum zu beschleunigen.
Newsletter für
Technik-Experten
Schließen Sie sich 12.000+ Geschäftsführern, Designer und Entwickler, die Blogs, e-Books und Fallstudien Fallstudien über neue Technologie erhalten.