部署 AWS Lambda 以禁用无响应站点

负载均衡器弹性的构建块

本指南解释了如何在多站点部署中解决两个站点之间的脑裂场景。如果一个站点发生故障,它还会禁用复制,以便另一个站点可以继续为请求提供服务。

此部署旨在与多站点部署概念指南中描述的设置一起使用。将此部署与多站点部署构建块指南中概述的其他构建块一起使用。

我们提供这些蓝图是为了展示一个功能完整的最小示例,该示例具有适用于常规安装的良好基线性能。您仍然需要使其适应您的环境和您组织的标准及安全最佳实践。

架构

在多站点部署中站点之间的网络通信发生故障时,两个站点之间不再可能继续复制数据。Infinispan 配置了 FAIL 故障策略,该策略确保一致性优先于可用性。因此,所有用户请求都将收到错误消息,直到故障得到解决,可以通过恢复网络连接或禁用跨站点复制来解决。

在这种情况下,通常使用仲裁来确定哪些站点标记为在线或离线。但是,由于多站点部署仅包含两个站点,因此这是不可能的。相反,我们利用“隔离”来确保当一个站点无法连接到另一个站点时,负载均衡器配置中仅保留一个站点,因此只有该站点能够为后续用户请求提供服务。

除了负载均衡器配置之外,隔离过程还禁用两个 Infinispan 集群之间的复制,以允许从负载均衡器配置中保留的站点为用户请求提供服务。因此,一旦禁用复制,站点将失去同步。

为了从不同步状态中恢复,需要手动重新同步,如同步站点中所述。这就是为什么通过隔离移除的站点在网络通信故障解决后不会自动重新添加的原因。只有在两个站点已使用概述的步骤使站点上线同步后,才应重新添加移除的站点。

在本指南中,我们描述了如何使用 Prometheus 警报和 AWS Lambda 函数的组合来实现隔离。当 Infinispan 服务器指标检测到脑裂时,会触发 Prometheus 警报,这会导致 Prometheus AlertManager 调用基于 AWS Lambda 的 Webhook。触发的 Lambda 函数检查当前的 Global Accelerator 配置,并移除报告为离线的站点。

在真正的脑裂场景中,两个站点都仍在运行但网络通信中断,两个站点都可能同时触发 Webhook。我们通过确保在给定时间只能执行单个 Lambda 实例来防止这种情况。AWS Lambda 中的逻辑确保负载均衡器配置中始终保留一个站点条目。

先决条件

  • 基于 ROSA HCP 的多站点 Keycloak 部署

  • 已安装 AWS CLI

  • AWS Global Accelerator 负载均衡器

  • 已安装 jq 工具

步骤

  1. 启用 Openshift 用户警报路由

    命令
    kubectl apply -f - << EOF
    apiVersion: v1
    kind: ConfigMap
    metadata:
      name: user-workload-monitoring-config
      namespace: openshift-user-workload-monitoring
    data:
      config.yaml: |
        alertmanager:
          enabled: true
          enableAlertmanagerConfig: true
    EOF
    kubectl -n openshift-user-workload-monitoring rollout status --watch statefulset.apps/alertmanager-user-workload
  2. 确定将用于验证 Lambda Webhook 的用户名/密码组合,并创建 AWS Secret 存储密码

    命令
    aws secretsmanager create-secret \
      --name webhook-password \ (1)
      --secret-string changeme \ (2)
      --region eu-west-1 (3)
    1 Secret 的名称
    2 用于身份验证的密码
    3 托管 Secret 的 AWS 区域
  3. 创建用于执行 Lambda 的角色。

    命令
    FUNCTION_NAME= (1)
    ROLE_ARN=$(aws iam create-role \
      --role-name ${FUNCTION_NAME} \
      --assume-role-policy-document \
      '{
        "Version": "2012-10-17",
        "Statement": [
          {
            "Effect": "Allow",
            "Principal": {
              "Service": "lambda.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
          }
        ]
      }' \
      --query 'Role.Arn' \
      --region eu-west-1 \ (2)
      --output text
    )
    1 您选择的与 Lambda 和相关资源关联的名称
    2 托管 Kubernetes 集群的 AWS 区域
  4. 创建并附加 'LambdaSecretManager' 策略,以便 Lambda 可以访问 AWS Secrets

    命令
    POLICY_ARN=$(aws iam create-policy \
      --policy-name LambdaSecretManager \
      --policy-document \
      '{
          "Version": "2012-10-17",
          "Statement": [
              {
                  "Effect": "Allow",
                  "Action": [
                      "secretsmanager:GetSecretValue"
                  ],
                  "Resource": "*"
              }
          ]
      }' \
      --query 'Policy.Arn' \
      --output text
    )
    aws iam attach-role-policy \
      --role-name ${FUNCTION_NAME} \
      --policy-arn ${POLICY_ARN}
  5. 附加 ElasticLoadBalancingReadOnly 策略,以便 Lambda 可以查询已配置的网络负载均衡器

    命令
    aws iam attach-role-policy \
      --role-name ${FUNCTION_NAME} \
      --policy-arn arn:aws:iam::aws:policy/ElasticLoadBalancingReadOnly
  6. 附加 GlobalAcceleratorFullAccess 策略,以便 Lambda 可以更新 Global Accelerator EndpointGroup

    命令
    aws iam attach-role-policy \
      --role-name ${FUNCTION_NAME} \
      --policy-arn arn:aws:iam::aws:policy/GlobalAcceleratorFullAccess
  7. 创建一个包含所需隔离逻辑的 Lambda ZIP 文件

    命令
    LAMBDA_ZIP=/tmp/lambda.zip
    cat << EOF > /tmp/lambda.py
    
    from urllib.error import HTTPError
    
    import boto3
    import jmespath
    import json
    import os
    import urllib3
    
    from base64 import b64decode
    from urllib.parse import unquote
    
    # Prevent unverified HTTPS connection warning
    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
    
    
    class MissingEnvironmentVariable(Exception):
        pass
    
    
    class MissingSiteUrl(Exception):
        pass
    
    
    def env(name):
        if name in os.environ:
            return os.environ[name]
        raise MissingEnvironmentVariable(f"Environment Variable '{name}' must be set")
    
    
    def handle_site_offline(labels):
        a_client = boto3.client('globalaccelerator', region_name='us-west-2')
    
        acceleratorDNS = labels['accelerator']
        accelerator = jmespath.search(f"Accelerators[?(DnsName=='{acceleratorDNS}'|| DualStackDnsName=='{acceleratorDNS}')]", a_client.list_accelerators())
        if not accelerator:
            print(f"Ignoring SiteOffline alert as accelerator with DnsName '{acceleratorDNS}' not found")
            return
    
        accelerator_arn = accelerator[0]['AcceleratorArn']
        listener_arn = a_client.list_listeners(AcceleratorArn=accelerator_arn)['Listeners'][0]['ListenerArn']
    
        endpoint_group = a_client.list_endpoint_groups(ListenerArn=listener_arn)['EndpointGroups'][0]
        endpoints = endpoint_group['EndpointDescriptions']
    
        # Only update accelerator endpoints if two entries exist
        if len(endpoints) > 1:
            # If the reporter endpoint is not healthy then do nothing for now
            # A Lambda will eventually be triggered by the other offline site for this reporter
            reporter = labels['reporter']
            reporter_endpoint = [e for e in endpoints if endpoint_belongs_to_site(e, reporter)][0]
            if reporter_endpoint['HealthState'] == 'UNHEALTHY':
                print(f"Ignoring SiteOffline alert as reporter '{reporter}' endpoint is marked UNHEALTHY")
                return
    
            offline_site = labels['site']
            endpoints = [e for e in endpoints if not endpoint_belongs_to_site(e, offline_site)]
            del reporter_endpoint['HealthState']
            a_client.update_endpoint_group(
                EndpointGroupArn=endpoint_group['EndpointGroupArn'],
                EndpointConfigurations=endpoints
            )
            print(f"Removed site={offline_site} from Accelerator EndpointGroup")
    
            take_infinispan_site_offline(reporter, offline_site)
            print(f"Backup site={offline_site} caches taken offline")
        else:
            print("Ignoring SiteOffline alert only one Endpoint defined in the EndpointGroup")
    
    
    def endpoint_belongs_to_site(endpoint, site):
        lb_arn = endpoint['EndpointId']
        region = lb_arn.split(':')[3]
        client = boto3.client('elbv2', region_name=region)
        tags = client.describe_tags(ResourceArns=[lb_arn])['TagDescriptions'][0]['Tags']
        for tag in tags:
            if tag['Key'] == 'site':
                return tag['Value'] == site
        return false
    
    
    def take_infinispan_site_offline(reporter, offlinesite):
        endpoints = json.loads(INFINISPAN_SITE_ENDPOINTS)
        if reporter not in endpoints:
            raise MissingSiteUrl(f"Missing URL for site '{reporter}' in 'INFINISPAN_SITE_ENDPOINTS' json")
    
        endpoint = endpoints[reporter]
        password = get_secret(INFINISPAN_USER_SECRET)
        url = f"https://{endpoint}/rest/v2/container/x-site/backups/{offlinesite}?action=take-offline"
        http = urllib3.PoolManager(cert_reqs='CERT_NONE')
        headers = urllib3.make_headers(basic_auth=f"{INFINISPAN_USER}:{password}")
        try:
            rsp = http.request("POST", url, headers=headers)
            if rsp.status >= 400:
                raise HTTPError(f"Unexpected response status '%d' when taking site offline", rsp.status)
            rsp.release_conn()
        except HTTPError as e:
            print(f"HTTP error encountered: {e}")
    
    
    def get_secret(secret_name):
        session = boto3.session.Session()
        client = session.client(
            service_name='secretsmanager',
            region_name=SECRETS_REGION
        )
        return client.get_secret_value(SecretId=secret_name)['SecretString']
    
    
    def decode_basic_auth_header(encoded_str):
        split = encoded_str.strip().split(' ')
        if len(split) == 2:
            if split[0].strip().lower() == 'basic':
                try:
                    username, password = b64decode(split[1]).decode().split(':', 1)
                except:
                    raise DecodeError
            else:
                raise DecodeError
        else:
            raise DecodeError
    
        return unquote(username), unquote(password)
    
    
    def handler(event, context):
        print(json.dumps(event))
    
        authorization = event['headers'].get('authorization')
        if authorization is None:
            print("'Authorization' header missing from request")
            return {
                "statusCode": 401
            }
    
        expectedPass = get_secret(WEBHOOK_USER_SECRET)
        username, password = decode_basic_auth_header(authorization)
        if username != WEBHOOK_USER and password != expectedPass:
            print('Invalid username/password combination')
            return {
                "statusCode": 403
            }
    
        body = event.get('body')
        if body is None:
            raise Exception('Empty request body')
    
        body = json.loads(body)
        print(json.dumps(body))
    
        if body['status'] != 'firing':
            print("Ignoring alert as status is not 'firing', status was: '%s'" % body['status'])
            return {
                "statusCode": 204
            }
    
        for alert in body['alerts']:
            labels = alert['labels']
            if labels['alertname'] == 'SiteOffline':
                handle_site_offline(labels)
    
        return {
            "statusCode": 204
        }
    
    
    INFINISPAN_USER = env('INFINISPAN_USER')
    INFINISPAN_USER_SECRET = env('INFINISPAN_USER_SECRET')
    INFINISPAN_SITE_ENDPOINTS = env('INFINISPAN_SITE_ENDPOINTS')
    SECRETS_REGION = env('SECRETS_REGION')
    WEBHOOK_USER = env('WEBHOOK_USER')
    WEBHOOK_USER_SECRET = env('WEBHOOK_USER_SECRET')
    
    EOF
    zip -FS --junk-paths ${LAMBDA_ZIP} /tmp/lambda.py
  8. 创建 Lambda 函数。

    命令
    aws lambda create-function \
      --function-name ${FUNCTION_NAME} \
      --zip-file fileb://${LAMBDA_ZIP} \
      --handler lambda.handler \
      --runtime python3.12 \
      --role ${ROLE_ARN} \
      --region eu-west-1 (1)
    1 托管 Kubernetes 集群的 AWS 区域
  9. 公开 Function URL,以便可以将 Lambda 作为 Webhook 触发

    命令
    aws lambda create-function-url-config \
      --function-name ${FUNCTION_NAME} \
      --auth-type NONE \
      --region eu-west-1 (1)
    1 托管 Kubernetes 集群的 AWS 区域
  10. 允许公开调用 Function URL

    命令
    aws lambda add-permission \
      --action "lambda:InvokeFunctionUrl" \
      --function-name ${FUNCTION_NAME} \
      --principal "*" \
      --statement-id FunctionURLAllowPublicAccess \
      --function-url-auth-type NONE \
      --region eu-west-1 (1)
    1 托管 Kubernetes 集群的 AWS 区域
  11. 配置 Lambda 的环境变量

    1. 在每个 Kubernetes 集群中,检索公开的 Infinispan URL 端点

      kubectl -n ${NAMESPACE} get route infinispan-external -o jsonpath='{.status.ingress[].host}' (1)
      1 ${NAMESPACE} 替换为包含 Infinispan 服务器的命名空间
    2. 上传所需的环境变量

      ACCELERATOR_NAME= (1)
      LAMBDA_REGION= (2)
      CLUSTER_1_NAME= (3)
      CLUSTER_1_ISPN_ENDPOINT= (4)
      CLUSTER_2_NAME= (5)
      CLUSTER_2_ISPN_ENDPOINT= (6)
      INFINISPAN_USER= (7)
      INFINISPAN_USER_SECRET= (8)
      WEBHOOK_USER= (9)
      WEBHOOK_USER_SECRET= (10)
      
      INFINISPAN_SITE_ENDPOINTS=$(echo "{\"${CLUSTER_NAME_1}\":\"${CLUSTER_1_ISPN_ENDPOINT}\",\"${CLUSTER_2_NAME}\":\"${CLUSTER_2_ISPN_ENDPOINT\"}" | jq tostring)
      aws lambda update-function-configuration \
          --function-name ${ACCELERATOR_NAME} \
          --region ${LAMBDA_REGION} \
          --environment "{
            \"Variables\": {
              \"INFINISPAN_USER\" : \"${INFINISPAN_USER}\",
              \"INFINISPAN_USER_SECRET\" : \"${INFINISPAN_USER_SECRET}\",
              \"INFINISPAN_SITE_ENDPOINTS\" : ${INFINISPAN_SITE_ENDPOINTS},
              \"WEBHOOK_USER\" : \"${WEBHOOK_USER}\",
              \"WEBHOOK_USER_SECRET\" : \"${WEBHOOK_USER_SECERT}\",
              \"SECRETS_REGION\" : \"eu-central-1\"
            }
          }"
      1 部署使用的 AWS Global Accelerator 的名称
      2 托管 Kubernetes 集群和 Lambda 函数的 AWS 区域
      3 Infinispan 站点之一的名称,如使用 Infinispan Operator 部署用于 HA 的 Infinispan中所定义
      4 与 CLUSER_1_NAME 站点关联的 Infinispan 端点 URL
      5 第二个 Infinispan 站点的名称
      6 与 CLUSER_2_NAME 站点关联的 Infinispan 端点 URL
      7 具有足够权限在服务器上执行 REST 请求的 Infinispan 用户的用户名
      8 包含与 Infinispan 用户关联的密码的 AWS Secret 的名称
      9 用于验证 Lambda 函数请求的用户名
      10 包含用于验证 Lambda 函数请求的密码的 AWS Secret 的名称
  12. 检索 Lambda 函数 URL

    命令
    aws lambda get-function-url-config \
      --function-name ${FUNCTION_NAME} \
      --query "FunctionUrl" \
      --region eu-west-1 \(1)
      --output text
    1 创建 Lambda 的 AWS 区域
    输出
    https://tjqr2vgc664b6noj6vugprakoq0oausj.lambda-url.eu-west-1.on.aws
  13. 在每个 Kubernetes 集群中,配置 Prometheus 警报路由以在脑裂时触发 Lambda

    命令
    NAMESPACE= # The namespace containing your deployments
    kubectl apply -n ${NAMESPACE} -f - << EOF
    apiVersion: v1
    kind: Secret
    type: kubernetes.io/basic-auth
    metadata:
      name: webhook-credentials
    stringData:
      username: 'keycloak' (1)
      password: 'changme' (2)
    ---
    apiVersion: monitoring.coreos.com/v1beta1
    kind: AlertmanagerConfig
    metadata:
      name: example-routing
    spec:
      route:
        receiver: default
        groupBy:
          - accelerator
        groupInterval: 90s
        groupWait: 60s
        matchers:
          - matchType: =
            name: alertname
            value: SiteOffline
      receivers:
        - name: default
          webhookConfigs:
            - url: 'https://tjqr2vgc664b6noj6vugprakoq0oausj.lambda-url.eu-west-1.on.aws/' (3)
              httpConfig:
                basicAuth:
                  username:
                    key: username
                    name: webhook-credentials
                  password:
                    key: password
                    name: webhook-credentials
                tlsConfig:
                  insecureSkipVerify: true
    ---
    apiVersion: monitoring.coreos.com/v1
    kind: PrometheusRule
    metadata:
      name: xsite-status
    spec:
      groups:
        - name: xsite-status
          rules:
            - alert: SiteOffline
              expr: 'min by (namespace, site) (vendor_jgroups_site_view_status{namespace="default",site="site-b"}) == 0' (4)
              labels:
                severity: critical
                reporter: site-a (5)
                accelerator: a3da6a6cbd4e27b02.awsglobalaccelerator.com (6)
    1 验证 Lambda 请求所需的用户名
    2 验证 Lambda 请求所需的密码
    3 Lambda 函数 URL
    4 命名空间值应为托管 Infinispan CR 的命名空间,站点应为 Infinispan CR 中 spec.service.sites.locations[0].name 定义的远程站点
    5 本地站点的名称,由 Infinispan CR 中的 spec.service.sites.local.name 定义
    6 Global Accelerator 的 DNS

验证

要测试 Prometheus 警报是否按预期触发 Webhook,请执行以下步骤来模拟脑裂

  1. 在每个集群中执行以下操作

    命令
    kubectl -n openshift-operators scale --replicas=0 deployment/infinispan-operator-controller-manager (1)
    kubectl -n openshift-operators rollout status -w deployment/infinispan-operator-controller-manager
    kubectl -n ${NAMESPACE} scale --replicas=0 deployment/infinispan-router (2)
    kubectl -n ${NAMESPACE} rollout status -w deployment/infinispan-router
    1 缩减 Infinispan Operator,以便下一步不会导致 Operator 重新创建部署
    2 缩减 Gossip Router 部署。将 ${NAMESPACE} 替换为包含 Infinispan 服务器的命名空间
  2. 通过检查 Openshift 控制台中的 ObserveAlerting 菜单,验证是否在集群上触发了 SiteOffline 事件

  3. 检查 AWS 控制台中的 Global Accelerator EndpointGroup,应该只有一个端点存在

  4. 放大 Infinispan Operator 和 Gossip Router 以重新建立站点之间的连接

    命令
    kubectl -n openshift-operators scale --replicas=1 deployment/infinispan-operator-controller-manager
    kubectl -n openshift-operators rollout status -w deployment/infinispan-operator-controller-manager
    kubectl -n ${NAMESPACE} scale --replicas=1 deployment/infinispan-router (1)
    kubectl -n ${NAMESPACE} rollout status -w deployment/infinispan-router
    1 ${NAMESPACE} 替换为包含 Infinispan 服务器的命名空间
  5. 检查每个站点中的 vendor_jgroups_site_view_status 指标。值为 1 表示该站点可访问。

  6. 更新 Accelerator EndpointGroup 以包含两个端点。有关详细信息,请参阅使站点上线指南。

在此页上