Einführung
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.
Was ist das WebSocket-Protokoll?
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.
Vergleich zwischen HTTP und WebSocket:
| Protokoll | HTTP | WebSocket |
| Typ | Anfrage/Antwort | Voll-Duplex |
| Verwendung | Zustandslos | Zustandsabhängig |
| Overhead | Höher | Unter |
| Kopfzeile | Schwer (für Anfragen) (für Antworten) | Licht (klein) |
| Echtzeit | Nicht geeignet für Echtzeit | Geeignet für Echtzeitanwendungen |
| Anwendungsfälle | Web-Browsing, REST, APIs, einseitige Datenübertragung | Echtzeit-Anwendungen, Online-Spiele, Chat-Anwendungen |
Benutzerdefinierte horizontale Skalierung für WebSocket:
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:
- Eine benutzerdefinierte Metrik, die an CloudWatch gesendet wird und die durchschnittliche Anzahl der Verbindungen der Server in der Autoscaling-Gruppe angibt.
- Überprüfen Sie die Server-, ALB- und Zielgruppenkonfiguration auf Anpassungen.
- Lasttests zur Ermittlung der Anzahl der Verbindungen, die jeder Server verarbeiten kann.
- Erstellen der benutzerdefinierten Autoscaling-Richtlinie mit CloudWarch-Alarmen.
- Lasttests zur Bestätigung, dass die benutzerdefinierte Autoskalierungsrichtlinie korrekt skaliert wird
Benutzerdefinierte Metrik zur Vergrößerung/Verkleinerung:
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:

Überprüfen Sie die Server-, ALB- und Zielgruppenkonfiguration:
Nachdem die benutzerdefinierte Metrik fertig ist, müssen wir die Konfigurationen unserer Server, des Application Load Balancer und der Zielgruppe überprüfen.
- 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;
- 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.
- 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.
Lasttests zur Ermittlung der Anzahl der Verbindungen, die ein Server verarbeiten kann:
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.
Erstellen Sie eine benutzerdefinierte Autoscaling-Richtlinie und CloudWatch-Alarme:
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
- 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.
- 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.
- Terraform Code für die Alarme und Richtlinien:
- 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,
]
}
- 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
}
Lasttests, um zu bestätigen, dass die Autoscaling-Richtlinien korrekt funktionieren:
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.
Probleme, auf die wir gestoßen sind
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.
Schlussfolgerung
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.