部署 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 Secret

    命令
    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. 公开一个函数 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. 允许公开调用函数 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. 上传所需的 Environment 变量

      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 Operator 部署高可用性 Infinispan 中定义的 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 的规模,以便下一步不会导致操作员重新创建部署
    2 缩减 Gossip Router 部署的规模。将 ${NAMESPACE} 替换为包含 Infinispan 服务器的命名空间
  2. 通过检查 Openshift 控制台中的 观察警报 菜单,验证 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 以包含两个端点。有关详细信息,请参阅 使站点在线 指南。