亚马逊AWS官方博客

生成你在游戏中的形象——面向游戏运营活动的 AI 生图解决方案

随着 Stable Diffusion 等 AI 生图方案逐步普及,越来越多的场景被开发和落地。其中面向游戏 C 端玩家的 AI 生图营销活动场景正在被逐步验证:在某个游戏社区中,玩家一键从手机上传一张照片,AI 会将自动识别该照片中的元素并替换成游戏中相应的角色或物品,替换后的照片可以进一步被玩家传播,进而扩大活动影响力。这样的活动已经被应用于游戏新版本发布、展会等场景,能有效提升玩家与游戏之间的粘性,让玩家成为游戏的推广大使,为游戏拉新。

本文将根据企业级客户实际案例经验,探讨如何高效地办好这么一场游戏 AI 生图活动,并提出我们的解决方案,我们总结了以下要点:

  • 包含了游戏风格与素材的 AI 模型,并调试出相应的推理算法。
  • 有效触达游戏玩家的客户端,可以是游戏客户端本身,也可以是社交媒体,比如常见的游戏玩家聚集地,海外有 Discord,国内有 Fanbook、微信等。本方案选用 Discord 作为我们演示的客户端。
  • 一套能快速伸缩承载高并发出图请求的后端架构:本方案选用 EKS,搭载了 EFS、Bottlerocket、GPU 时间分片虚拟化和 Karpenter 等组件作为演示的后端架构。

一、方案架构

方案上大致可以划分两大部份:

客户端和请求接入层:

首先我们选择 Discord 作为我们的客户端。Discord 是一款集语音、视频以及文字聊天于一体的服务软件。最早服务于电子游戏社区,但现在也用于 AI、Web 3.0、艺术、音乐等领域。用户可以在 Discord 上创建和加入各种类型的服务器,与其他用户进行实时的聊天和交流。Discord 服务器是 Discord 的核心功能,每个服务器里还能有自己的频道,用户可以在频道内与其他用户进行实时的文字、语音和视频聊天。在我们的场景里面,游戏玩家正是通过这些频道发起生图请求,并通过 Discord 服务器传递到接入层中。

接入层实现了一个 Discord bot,该 bot 包含两部分功能:1,更新 command,command 是用于引导游戏玩家便捷输入照片和提示词,并获得相应的输出照片。2,对来自游戏玩家的请求做筛选过滤和初步处理,在 3 秒钟之内(Discord 协议规定)做出初步响应,同时把符合条件需要生图的请求转发给 SQS。

接入层的 Discord bot 的实现参考了 Amazon Blog:An elastic deployment of Stable Diffusion with Discord on AWS,采用的是 API Gateway + Lambda 的 Serverless 架构,该架构提供了事件驱动型计算服务,使用者无需预置服务器便可快速构建自动扩展的程序。Discord bot 是 SQS 消息队列的生产端,通过 SQS 来实现与后端 AI 推理层的解耦。

后端 AI 推理集群:

SQS 的消费端是基于 EKS 构建起来后端 AI 推理集群。

首先,是一个 controller 模块,它会从 SQS 消费来自游戏玩家的消息以及相应的消息回调接口,之后按频道把消息分发到不同的 Stable Diffusion(SD)生图服务中,等待生图完成后,会再把生成的图片等结果发送到指定的消息回调接口,至此,一条游戏玩家生图请求就算最终完成了。

其次,是 sd-svc 服务,每一个服务托管了一种我们预设好的 Stable Diffusion 模型和算法组合,在本文后续部份,我们将会用 AUTOMATIC1111/stable-diffusion-webui 来托管我们的模型和算法。AUTOMATIC1111/stable-diffusion-webui 是一款当前流行的基于 Stable Diffusion 打造的工具应用,通过它可以方便进行文生图和图生图,并集成各种社区的插件,比如 LoRA 和 ControlNet 等。它自带 Web 界面,也同时支持 Web API 访问,本文中将使用 Web API 来访问。

在模型选择上,我们将选择一个已经事先训练好的包含魔兽世界游戏素材的模型 rpg_V4.safetensors,以及一个 ControlNet Canny 模型 control_v11p_sd15_canny 做为演示。在实际项目中,您还可以根据实际的游戏,利用该游戏素材,训练出具备该游戏特色的模型(关于 Stable Diffusion 模型训练,不是本文重点,如果感兴趣,可以参考如 Hugging Face 的训练一个 diffusion 模型文档)。

在面向 C 端游戏玩家的场景,如何在满足高并发的服务的同时,也兼顾成本效益,这是绕不开的话题,为此我们做了以下优化:

  • bottlerocket-images-cache + 高性能 EBS:实现快速集群扩容。一般的扩容过程是:请求新增 Node → 启动并初始化 Node → 启动 Pod → 拉取 Container 镜像 → 启动进程初始化 → 开始提供服务,由于 Stable Diffusion 用到的 pytorch 框架以及相应依赖的工具包还有模型十分巨大,一个包含这些完整工具链 + 模型的镜像往往会达到 10G 以上,再加上 Stable Diffusion webUI 本身在第一次启动还有初始化过程,会导致集群扩容过程缓慢,一次扩容往往会达到 10 分钟以上。这样的扩容速度在面向 C 端场景里面,会显得比较滞后。我们的做法是:提前预设好优化过的容器镜像,并通过 bottlerocket-image-cache 把镜像制作成 snapshot,做为 volumn 被 Node 在启动时挂载,同时适当提升该 volumn 的 IO 吞吐,从而节省了大量的启动和初始化时间。在我们的实验环境里,总共 13GB(运行环境 78G + 2G 的 Checkpoint 模型文件 + 1.3G 的 ControlNet 模型文件)的镜像在优化之后,搭配 IO 吞吐为 500MB 的 GP3 EBS,从请求 Node 到可以开始提供服务(已加载 ckpt 模型)一共花了 1:40 分钟。
  • EFS:实现模型文件一份存储和被动态加载。所有 pod 都可以通过挂载同一个 EFS 文件系统,实现动态加载模型的效果,运维人员只需要维护一份模型文件即可。同时 EFS 优秀的 IO 吞吐(Gbit/s 级别)也保障模型加载的速度。
  • GPU 分片:提升 GPU 使用率,降低成本。如果您选用的 GPU 卡性能很强劲,而且 AI 推理的任务比较简单,无法占满该 GPU 卡,那么可以考虑让多个推理任务同时复用一张 GPU 卡来提升 GPU 卡的使用率。利用 NVIDIA/k8s-device-plugin 的时间分片能力可以很方便的管理 GPU 的算力。在我们的实验环境中,我们把一张型号为 A10g 的显卡切分为 3 个分片,每个分片各跑一个 pod,在推理任务接连不断满负荷的情况下,GPU 的利用率可以提升 9%(相同工作量的任务,总完成时间减少 11.9%)。
  • Karpenter + Spot:更高的集群利用率加上更好的成本方案,进一步优化成本。

二、生图活动-端到端体验

打开 Discord 客户端,切换到已经绑定了 Discord bot 的频道中,在输入框输入“/”,会出/img2img 的提示词,按照指引选择一张照片,prompt 是可选的,能在前面 image 的基础上做最终效果调整,不加 prompt 举例:

加了 prompt “dog, Akita type” 举例,把一只猫微调为一只狗,增加可玩性,您也可以加入更多调节的选项给玩家提升高级玩家体验。

三、前置条件

本博客假设您已熟悉 Terraform、Docker、Discord、Amazon EC2、Amazon Elastic Block Store(Amazon EBS)、Amazon Virtual Private Cloud(Amazon VPC)、AWS Identity and Access Management(IAM)、Amazon API Gateway、AWS Lambda、Amazon SQS,Amazon Elastic Container Registry(Amazon ECR)、Elastic Kubernetes Service(EKS)、AWS Elastic File System(Amazon EFS)、Helm。

为了走通以下实验部份,您需要:

  • 一个调试部署环境,该环境需要能运行 terraform、aws cli、docke,kubectl、eksctl、jq 命令,同时需要访问互联网。建议您选用基于 X86 架构的 AWS Cloud9 作为调试部署环境。
  • 一个 AWS IAM 用户或者角色,具备开通部署 AWS service 的权限。
  • 一个 discord 应用,您可以参考 Discord Document 来创建一个 Discord 应用,并加入您用来测试的 channel。创建完成后,您需要记录下:
    • Discord Bot token (Bot → Reset Token)
    • Discord Application ID(General Information → APPLICATION ID)
    • Discord Public Key(General Information → PUBLIC KEY)
  • EC2 的 Quota Limit:本次实验至少会用到 2xlarge * 1 和 m5.large * 2,如您不确定是否 limit 足够,建议按照该指引检查下。

四、方案走读

1、下载实验代码:

# Download lab repository
git clone https://github.com/aws-samples/gai-game-activity.git

2、部署客户端和请求接入层

为了便于部署和实践,我们使用 terraform 进行部署,在开始部署前,你需要按需配置以下参数:

cd gai-game-activity/terraform/discord-bot
cat <<EOF >config.tfvars
account_id = "your-account-id"                  # 替换成自己的 account id
project_id = "discord-sd-gai"
unique_id = "prod"
discord_application_id = Discord-Application-ID # 替换成自己的 discord app id
discord_public_key = "Discord-Public-Key"       # 替换成自己的 discord public key
discord_bot_secret = "Discord-Bot-token"        # 替换成自己的 discord bot token
region = "ap-northeast-1"                       # 选择部署在东京 region,可替换成自己的 region
EOF

部署客户端和请求接入层

terraform init
terraform apply -var-file=config.tfvars  # 确认无误后输入“yes”

部署的 service 会包括 1 个 API Gateway、2 个 Lambda function 和 1 个 SQS,等待 1 分钟左右时间,部署完成后会输出:

AWSContainerSQSQueueExecutionPolicy = "arn:aws:iam::733851053666:policy/AWSContainerSQSQueueExecutionPolicy-discord-sd-gai"
discord_interactions_endpoint_url = "https://nnihs2zr4j.execute-api.ap-northeast-1.amazonaws.com"
project_id = "discord-sd-gai-prod"
sqs_queue_url = "https://sqs.ap-northeast-1.amazonaws.com/733851053666/discord-sd-gai-prod.fifo"

sqs_queue_url 是创建的 SQS 的访问入口,AWSContainerSQSQueueExecutionPolicy 包含了能访问该 SQS 的最小权限,后面需将赋予 controller-sd 使之具备访问 SQS 的最小权限。

discord_interactions_endpoint_url 需要部署到 discord 后端,打开 discord application 设置页面,找到相应的 app 页,在如下位置填入该 url:

保存设置,至此,打开 Discord 客户,进入相应的 Discord server,就能看到我们的上面部署的机器人在线了,类似如下:

3、部署后端 AI 推理集群

后端 AI 推理集群是基于 EKS 搭建的,我们已经写好 terraform 格式的模版配置,并暴露出部份变量,比如集群名称、EKS 集群版本(与 K8S 版本相同)、区域等信息,您可以根据自己的实际情况修改:

# file://gai-game-activity/terraform/eks-sd-cluster/main.tf中的locals代码块可自行修改
locals {
    name = "eks-game-gai"
    cluster_name = local.name
    cluster_version = 1.25
    region = "ap-northeast-1"
    partition    = data.aws_partition.current.partition

    vpc_cidr = "10.0.0.0/16"
    azs      = slice(data.aws_availability_zones.available.names, 0, 3)

    tags = {
        Blueprint  = local.name
        GithubRepo = "github.com/aws-ia/terraform-aws-eks-blueprints"
    }
}

之后,运行以下命令部署 EKS 集群:

cd gai-game-activity/terraform/eks-sd-cluster/
terraform init
terraform plan
terraform apply

EKS 集群初始化部署大约需要等待 20 分钟,创建完成后会得到如下输出:

configure_kubectl = "aws eks --region ap-northeast-1 update-kubeconfig --name eks-game-gai"
efs_id = "fs-02218742f82e0c7a6"
eks_api_server_url = "https://A7A44AD054DB10F5264EB18F7F848B35.gr7.ap-northeast-1.eks.amazonaws.com"
public_subnet_id_list = [
  "subnet-07f4c37a5d42e7908",
  "subnet-0cea1bc3681c6f914",
  "subnet-0efcc5abb8923969e",
]
vpc_id = "vpc-0f413f22ddcdd46e4"

在 terminal 中执行上面输出的 configure_kubectl 命令,将会在 terminal 本地创建 kube 配置,并设置当前默认的 kube context,之后便可以执行 kubectl 来操控 EKS 集群:

aws eks --region ap-northeast-1 update-kubeconfig --name eks-game-gai

4、EFS 设置和模型下载初始化

编辑 efs-pv-pvc.yaml,修改成上一小节中 terrafrom 创建好的 EFS ID。

storageClassName: efs-sc
  csi:
    driver: efs.csi.aws.com
    # eks-tester
    volumeHandle: fs-02218742f82e0c7a6 # line 17
    volumeAttributes:
      encryptInTransit: "true"

之后部署 EFS 的 pv 和 pvc:

kubectl apply -f efs-sc.yaml
kubectl apply -f efs-pv-pvc.yaml
kubectl get pvc
NAME                       STATUS   VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   AGE
stable-diffusion-datadir   Bound    efs-pv   200Gi      RWX            efs-sc         6m41s

上传模型文件到 EFS 文件系统,通过直接运行下面的 yaml 部署模型到 EFS 文件系统中(你也可以开一台 EC2 挂载该 EFS,再通过 EC2 把模型文件上传 EFS 文件系统)。

kubectl apply -f efs-load-model.yaml
kubectl get pod 
sleep 20
#check the log dynamic
kubectl logs -f efs-load-model

#if you face any error, you can login the pod to check
kubectl exec -it efs-load-model sh

模型链接:

待模型初始化完成(约 5 分钟),可以直接删除该 pod。

kubectl delete -f efs-load-model.yaml

5、准备 controller-sd 镜像

controller-sd 做承载了以下工作:负责从 SQS 读取和解析用户请求,打包推理参数并向 sd-svc 发起推理请求,待 sd-svc 完成推理后解析得到生成的图片,再发送到 Discord Server 的回调接口,最终会展示到 Discord 客户端上。controller-sd 发送到 sd-svc 的核心推理参数如下:

payload = {
    "init_images": [encoded_image],
    "resize_mode": 0,
    "denoising_strength": 0.75,
    "prompt": "(world of warcraft style:1.2),8k,intricate,detailed,master piece",
    "negative_prompt": "nsfw",
    "sampler_name": "Euler a",
    "batch_size": 1,
    "steps": 20,
    "cfg_scale": 7,
    "width": 512,   
    "height": 512,
    "restore_faces": False,
    "script_name": "",
    "send_images": True,
    "alwayson_scripts": {
        "controlnet": {
            "args": [
                {
                    "image": encoded_image,
                    "module": "canny",
                    "model": "control_v11p_sd15_canny [d14c016b]",
                    "processor_res": 512,
                    "threshold_a": 100,
                    "threshold_b": 200,
                    "control_mode": "ControlNet is more important"
                }
            ]
        }
    }
}

其中 prompt 预设了出图的效果,再实际运行中,需要根据所使用的模型做相应的调整。下面开始打包镜像:

cd gai-game-activity/controller-image/
cat build_push.sh  # 查看和修改相应的配置,其中下面几个变量要跟上面 EKS 保持一致

检查 build_push.sh 里的变量,其中下面几个值需要关注下:

AWS_REGION='ap-northeast-1'   # 跟 EKS 集群 region 保持一致
cluster_name='eks-game-gai'   # 跟 EKS 集群名字保持一致

确认无误后执行脚本开始打包 image 并 push 到 ECR:

bash build_push.sh

脚本会输出一个 image_url,作为 controller 的镜像:

Outputs:
controller_sd_image_url = your-account-id.dkr.ecr.ap-northeast-1.amazonaws.com/controller-sd:0.1

创建一个拥有 SQS 读写权限的 IAM Role for Service Account(IRSA)给 controller deployment 使用,您需要在前面的第 2 小节部分执行 Terraform 输出中找到 AWSContainerSQSQueueExecutionPolicy 的名字,并进行授权。

# 给 deployment 加 iam role 的权限
AWSContainerSQSQueueExecutionPolicy="<Input the policy arn>" # SQS 访问权限,包含在前面部署客户端和请求接入层的输出里
AWS_REGION='ap-northeast-1'
cluster_name='eks-game-gai'
sa_name='sqsexec-sa'
eksctl create iamserviceaccount --cluster=${cluster_name} --region=${AWS_REGION} --name=${sa_name} --attach-policy-arn=${AWSContainerSQSQueueExecutionPolicy} --approve 
#output sample:
#[ℹ]  created serviceaccount "default/sqsexec-sa"

6、部署 controller-sd 服务

controller-sd 服务会以多线程和异步方式连接 SQS,消费来自游戏玩家的消息以及相应的消息回调接口,之后按频道把消息分发到不同的 Stable Diffusion(SD)生图服务中先部署,为了降低 NAT Gateway 和 EC2 成本,我们创建了一个使用 Spot 的 CPU 机型配置 Karpenter provisioner,controller-sd 使用该 provisioner 进行部署。

cd gai-game-activity/terraform/eks-sd-cluster
kubectl apply -f karpenter-provisioner-cpu.yaml

修改 controller-sd 的配置:

cd gai-game-activity/controller-image/kubernetes

编辑 controller-sd.yaml,其中 containers 下的 image 以及环境变量 env 下的 SQSQUEUEURL 需要手动设置,分别是前面步骤输出的 controller_sd_image_url和 sqs_queue_url 变量:

spec:
      serviceAccountName: sqsexec-sa
      nodeSelector:
        "eks/node-type": "cpu"
        "karpenter.sh/provisioner-name": "public-karpenter"
      tolerations:
      - key: sd/service
        operator: Exists
        effect: NoSchedule
      terminationGracePeriodSeconds: 0
      containers:
        - name: controller-sd
          image: controller_sd_image_url变量值 #line 27
          resources:
            requests:
              cpu: 0.1
          env:
          - name: SQSQUEUEURL 
            value: "sqs_queue_url变量的值" #line 34
          - name: REGION
            value: "ap-northeast-1"  #line 36
          - name: ENDPOINT
            value: "http://stable-diffusion"

编辑完成后,部署 controller-sd:

kubectl apply -f controller-sd.yaml

这时应该已经有新的 node 被创建,这是由前面 karpenter-provisioner-cpu.yaml 中 provisioner 创建的,查看 Karpenter 日志,会找到跟以下类似的输出,这是 karpenter 在 launch 并 register ec2:

# kubectl logs -f -n karpenter -l app.kubernetes.io/name=karpenter -c controller
...
2023-08-22T14:14:30.862Z INFO controller.machine.lifecycle launched machine {"commit": "34d50bf-dirty", "machine": "public-karpenter-488nt", "provisioner": "public-karpenter", "provider-id": "aws:///ap-northeast-1d/i-04f4240e4f2f11128", "instance-type": "c6i.large", "zone": "ap-northeast-1d", "capacity-type": "spot", "allocatable": {"cpu":"1930m","ephemeral-storage":"17Gi","memory":"3114Mi","pods":"29"}}
2023-08-22T14:14:52.765Z DEBUG controller.machine.lifecycle registered machine {"commit": "34d50bf-dirty", "machine": "public-karpenter-488nt", "provisioner": "public-karpenter", "provider-id": "aws:///ap-northeast-1d/i-04f4240e4f2f11128", "node": "ip-10-0-39-126.ap-northeast-1.compute.internal"}

# kubectl get po -l app=controller-sd
NAME READY STATUS RESTARTS AGE
controller-sd-f78475459-4dmqr 1/1 Running 1 (33s ago) 108s
controller-sd-f78475459-dtrr8 1/1 Running 1 (26s ago) 69s

# 查看是否有新 node 被启动
# kubectl get node 

7、准备 Stable Diffusion 的 sd-svc 镜像

cd gai-game-activity/sd-image
export version=0.1
bash build_push.sh $version

以上命令会得到以下类似输出,该输出需要先保存起来:

sd_svc_image_url = 733851053666.dkr.ecr.ap-northeast-1.amazonaws.com/sd-game-activity:0.1-init

在 sd-svc 镜像中,我们做了 2 点优化:

  • 初始化完成的镜像。镜像的核心是 AUTOMATIC1111/stable-diffusion-webui项目,以及 ControlNet extension 等,都打包到镜像中。在用 docker build 完成后,又通过 docker commit 把一个已经初始化的 container 运行时再次生成镜像,为的就是后续用该镜像启动可以节省初始化时间。详见 build_push.sh 文件。
  • 推理加速。该镜像已安装了若干加速工具,比如 deepspeed accelerate xformers 等,这对后续 stable diffusion 的推理也起到了加速作用,在我们的测试场景中,这几个工具的组合可以起到 10% 到 28% 的加速作用,具体效果受推理算法和模型影响。详见 Dockerfile.inference.k8s 文件。

8、制作用于加速启动的 Bottlerocket snapshot

通过 bottlerocket-images-cache 来创建用于加速启动的 snapshot,加速原理如下图所示。该过程中会临时创建一台带 GPU 的 EC2,拉取镜像到该 EC2,然后再制作 snapshot,如下图到第 1、2 步:

cd bottlerocket-images-cache
bash build.sh ${sd_svc_image_url} your-subnet-id # 要选择有 g5 机器的子网

这里 your-subnet-id 需要替换成实际的子网 ID(有可能会遇到 g5 机器不足问题,可以通过 CloudFormation 查看),该子网是指定了 bottlerocket-images-cache 临时创建的 EC2 所在的子网,这里需要填在同 region(本实验选择 ap-northeast-1)找任何一个能主动访问互联网(通过互联网拉取镜像,访问 SSM)的子网 ID。以上命令执行完成后会得到输出如下:

All done! Created snapshot in ap-northeast-1: snap-015f7f04b51dece9d

这里 snap-015f7f04b51dece9d 是快照的 ID,需要记下来。

9、通过 Helm chart 安装 nvidia-k8s-plugin 插件

NVIDIA k8s Plugin 允许通过其配置文件中的一组扩展选项进行 GPU 的超额分配(基于 CUDA Time-Slicing 原理)。它以 Daemonset 的形式提供,自动在集群的每个节点上公开 GPU 的数量调整,跟踪 GPU 的健康状态,和在 Kubernetes 集群中运行支持 GPU 的容器。

安装步骤如下,具体配置详情可查看 nvdp.yaml:

cd gai-game-activity/terraform/eks-sd-cluster/nvidia-k8s-plugin
helm repo add nvdp https://nvidia.github.io/k8s-device-plugin
helm repo update
helm upgrade -i nvdp nvdp/nvidia-device-plugin \
  --namespace nvidia-device-plugin  -f ./nvdp.yaml \
  --create-namespace
  
#check the plugin status
kubectl get all -n nvidia-device-plugin

10、部署 Stable Difussion 的 sd-svc 服务

编辑 terraform/eks-sd-cluster/karpenter-provisioner-gpu.yaml,找到以下位置,填入小节 8 中的快照 ID:

    - deviceName: /dev/xvdb
      ebs:
        volumeSize: 100Gi
        volumeType: gp3
        snapshotID: snap-015f7f04b51dece9d #line 52
        throughput: 500

更新 karpenter provisioner:

kubectl apply -f karpenter-provisioner-gpu.yaml 

编辑 sd-image/kubernetes/deployment-sd.yaml 文件,找到以下位置,在 image 的值填入上面 sd_svc_image_url:

containers:
      - name: stable-diffusion-webui
        image: 733851053666.dkr.ecr.ap-northeast-1.amazonaws.com/sd-game-activity:0.1-init #line 28 
        resources:
          limits:
            nvidia.com/gpu: 1

部署 sd-svc 服务:

cd sd-image/kubernetes
kubectl  apply -f deployment-sd.yaml
kubectl logs -f -n karpenter -l app.kubernetes.io/name=karpenter -c controller

可以看到类似以下的日志:

2023-08-23T11:54:32.040Z        INFO    controller.machine.lifecycle    launched machine        {"commit": "34d50bf-dirty", "machine": "gpu-bottlerocket-ntxpm", "provisioner": "gpu-bottlerocket", "provider-id": "aws:///ap-northeast-1c/i-09dc63636b79ba95b", "instance-type": "g5.2xlarge", "zone": "ap-northeast-1c", "capacity-type": "spot", "allocatable": {"cpu":"7910m","ephemeral-storage":"89Gi","memory":"29317Mi","nvidia.com/gpu":"1","pods":"58"}}
2023-08-23T11:54:47.459Z        DEBUG   controller.machine.lifecycle    registered machine      {"commit": "34d50bf-dirty", "machine": "gpu-bottlerocket-ntxpm", "provisioner": "gpu-bottlerocket", "provider-id": "aws:///ap-northeast-1c/i-09dc63636b79ba95b", "node": "ip-10-0-29-158.ap-northeast-1.compute.internal"}

通过 Pod 可以看到 container 的镜像已经在本地 node 上了:

# kubectl describe po stable-diffusion-xxx-xxx
...
  Normal   Pulled            19s (x4 over 118s)  kubelet            Container image "733851053666.dkr.ecr.ap-northeast-1.amazonaws.com/sd-game-activity:0.1-init" already present on machine

试试把 stable-diffusion 的 replicas 设为 2:

$ kubectl scale deployment stable-diffusion --replicas  2
$ kubectl get po -o wide
NAME                                READY   STATUS    RESTARTS   AGE     IP            NODE                                             NOMINATED NODE   READINESS GATES
stable-diffusion-7f4b7b5589-h6clb   1/1     Running   0          7m47s   10.0.27.45    ip-10-0-29-158.ap-northeast-1.compute.internal   <none>           <none>
stable-diffusion-7f4b7b5589-ptd2k   1/1     Running   0          4m11s   10.0.19.216   ip-10-0-29-158.ap-northeast-1.compute.internal   <none>           <none>

可以看到有 2 个 pod 同时跑在一个 node 上,这是因为 deployment-sd.yaml 里面,每个 pod 设置了:

      resources:
          limits:
            nvidia.com/gpu: 1

而在 nvidia-k8s-plugin/nvdp.yaml 里面,每个 GPU 卡被设置了可分成 2 个 replicas:

      sharing:
        timeSlicing:
          renameByDefault: false
          resources:
          - name: nvidia.com/gpu
            replicas: 2

再试试把 stable-diffusion 的 replicas 设为 3:

$ kubectl scale deployment stable-diffusion --replicas  3
$ kubectl get po -o wide
stable-diffusion-7f4b7b5589-gnltj   1/1     Running   0               40m    10.0.22.70    ip-10-0-22-52.ap-northeast-1.compute.internal    <none>           <none>
stable-diffusion-7f4b7b5589-h6clb   1/1     Running   0               45m    10.0.27.45    ip-10-0-29-158.ap-northeast-1.compute.internal   <none>           <none>
stable-diffusion-7f4b7b5589-ptd2k   1/1     Running   0               42m    10.0.19.216   ip-10-0-29-158.ap-northeast-1.compute.internal   <none>           <none>

新增第 3 个 pod 之后,由于已经找不到空闲的 gpu replica,会触发 Karpenter 扩容一个 node,并把该 pod 指定到该 node 上。

到这里就完成最后的部署了,查看下部署结果:

$ kubectl get pod,svc,deployment
NAME                                    READY   STATUS    RESTARTS   AGE
pod/controller-sd-5c6c9bfb6-jnwrl       1/1     Running   0          62m
pod/controller-sd-5c6c9bfb6-snmz7       1/1     Running   0          63m
pod/stable-diffusion-7f4b7b5589-9shmw   1/1     Running   0          58m

NAME                       TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
service/kubernetes         ClusterIP   172.20.0.1      <none>        443/TCP        80m
service/stable-diffusion   NodePort    172.20.209.98   <none>        80:32182/TCP   70m

NAME                               READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/controller-sd      2/2     2            2           82m
deployment.apps/stable-diffusion   1/1     1            1           70m

11、GPU 水平扩容方案

GPU 自动缩放通过 NVIDIA 的数据中心 GPU管理器(DCGM)将 GPU 利用率作为 Prometheus 自定义指标公开,并根据该指标执行水平自动缩放。为了方便验证功能,本次使用自建的 Prometheus stack 去保存指标,生产环境推荐采用 Amazon Managed Prometheus(AMP)保存 Metrics,并通过 Grafana 进行可视化。

  • 通过数据中心 GPU 管理器导出器(DCGM Exporter)收集 GPU 指标,Prometheus Adapter 通过连接 Prometheus Server 检查配置项中是否有匹配的自定义指标 metrics,并注册到 metrics.k8s.io API 中,以供 HPA 等使用
  • 根据 Prometheus 自定义指标扩展 Pod,实现水平 Pod 自动缩放(HPA),使用集群 Karpenter 来实现 GPU 集群的自动缩放。

DCGM Exporter 安装

Nvidia 的 DCGM Expoerter 通过如“nodeSelector: eks/gpu-type: nvidia”标签,找到跑 GPU 的机点,并在该节点运行 DaemonSet 进行采集 GPU 相关指标,并通过 9400 端口和 path /metrics 进行暴露。

cd observability-gpu/
kubectl apply -f dcgm-exporter.yaml
# 检查 dcgm 运行情况
sleep 3
kubectl get pod | grep -i dcgm

查看指标是否有被正常采集:

NAME=$(kubectl get pods -l "app.kubernetes.io/name=dcgm-exporter" \
                         -o "jsonpath={ .items[0].metadata.name}")
kubectl port-forward $NAME 8081:9400 &
curl -sL http://127.0.0.1:8081/metrics
#output sample
#DCGM_FI_DEV_ROW_REMAP_FAILURE{gpu="0",UUID="GPU-00998aa2-3cfe-3f0c-d2f9-7ee88e66d699",device="nvidia0",modelName="NVIDIA A10G",Hostname="dcgm-exporter-zc5rb",DCGM_FI_DRIVER_VERSION="515.86.01",container="stable-diffusion-webui",namespace="default",pod="stable-diffusion-f88bc8df6-25ddn"} 0

Prometheus stack 本地自建安装

kube-prometheus-stack.values 增加了 gpu-metrics job(line 3249)去采集 GPU 节点的指标信息。

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update # 一定要更新 repo, 要不然会不能发现 GPU 相关指标
# Get the value value
helm install prometheus-community/kube-prometheus-stack \
--create-namespace --namespace prometheus \
--generate-name \
--values kube-prometheus-stack.values

可以通过以下命令进行端口映射,在本地访问 Grafana,默认密码为(ID : admin Password: prom-operator),您可以加载 12239 模版对 GPU 进行 Dashboard 可视化。

kubectl -n prometheus port-forward $(kubectl -n prometheus get pod -l app.kubernetes.io/name=grafana -o jsonpath='{.items[0].metadata.name}') 8080:3000  

Prometheus Adapter 安装

Prometheus Adapter 是一个 Kubernetes API Extension Server。因此,当调用度量 API 时,将返回相应的度量标准,可以替换度量服务器。

prometheus_service=$(kubectl get svc -nprometheus -lapp=kube-prometheus-stack-prometheus -ojsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}')

helm upgrade \
--install prometheus-adapter prometheus-community/prometheus-adapter \
--set rbac.create=true,prometheus.url=http://${prometheus_service}.prometheus.svc.cluster.local,prometheus.port=9090

等待约 2 分钟后,可以查看 custom.metrics.k8s.io API 所记录的 GPU 指标。

kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1 | jq -r . | grep DCGM_FI_DEV_MEM_COPY_UTIL
      "name": "namespaces/DCGM_FI_DEV_MEM_COPY_UTIL",
      "name": "jobs.batch/DCGM_FI_DEV_MEM_COPY_UTIL",
      "name": "pods/DCGM_FI_DEV_MEM_COPY_UTIL",

部署 HPA

查看 hpa.yaml 文件中对 pod 自动扩缩容的设置,并部署 HPA。

kubectl apply -f hpa.yaml

进行 SD 压测,托管方案已经可以工作:

jasonxie:~/environment/observability (main) $ kubectl get hpa
NAME                   REFERENCE                     TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
stable-diffusion-hpa   Deployment/stable-diffusion   100/90    1         2         1          29s
jasonxie:~/environment/observability (main) $ kubectl get hpa
NAME                   REFERENCE                     TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
stable-diffusion-hpa   Deployment/stable-diffusion   100/90    1         2         2          31s
jasonxie:~/environment/observability (main) $ kubectl get pod 
NAME                                READY   STATUS    RESTARTS       AGE
stable-diffusion-664fd6f984-8vmc4   1/1     Running   0              24s
stable-diffusion-664fd6f984-b5tgh   1/1     Running   0              18m

五、性能测试分析总结

1、扩容速度的讨论

$ kubectl scale deployment stable-diffusion --replicas 0
$ kubectl get no --show-labels | grep -i gpu
# 确保输出为空白,所有带 GPU 的 Node 均已 terminated,如果输出不为空白,则等待 10 秒后继续执行上面命令,直到输出为空白继续执行下面命令
$ date +%s && kubectl scale deployment stable-diffusion --replicas 1                                                                   
1692876244
deployment.apps/stable-diffusion scaled
$ date +%s && kubectl logs -l app.kubernetes.io/instance=stable-diffusion | tail -n1
1692876261
No resources found in default namespace.
# 输出为空,或者是 No resources found in default namespace.说明新的 pod 还未创建成功,继续重复执行该命令
$ date +%s && kubectl logs -l app.kubernetes.io/instance=stable-diffusion | tail -n1
1692876343
Model loaded in 20.0s ...
# 直到输出最后一行出现 Model loaded 的字样出现,这说明 pod 从 container 中的进程已经完成初始化,包括加载了 checkpoint 模型。如果输出不是这样,请间隔 1~2 秒继续执行

那么启动时间为:1692876343 – 1692876244 =99 秒,这是从 0 开始做冷扩容的时间,该时间主要由几部分时间构成:Node 启动时间 + Pod 启动时间 + Container 初始化时间(未包括 ControlNet 模型的加载时间),在这之后,container 就完成了热身,可以立马投入到推理中。

由于每台 EC2 可以同时跑多个 stable-diffusion pod(见前面部署 sd-svc 服务章节),因此接下来在该 EC2 上新增的 Pod 都属于热扩容,不需要新启动 Node,速度会比冷扩容快很多:

$ date +%s && kubectl scale deployment stable-diffusion --replicas 2
1692876831
deployment.apps/stable-diffusion scaled
date +%s && kubectl logs -l app.kubernetes.io/instance=stable-diffusion | tail -n1
1692876842
Model loaded in 12.4s ...

从上面可以看到 stable-diffusion pod 的热扩容时间:1692876842 – 1692876831 = 11 秒

2、nvidia/k8s-device-plugin 带来的执行效率的讨论

测试过程是先为 sd-svc 创建一个 ignress,之后在 cloud9 的 terminal 上跑测试脚本,测试脚本和资源放在目录 gai-game-activity/test_scripts 下面,测试过程是往集群一次发起多个推理请求,然后计算所有请求都完成的总时间。以下是测试方法:

$ kubectl apply -f ../sd-image/kubernetes/ingress-for-testing.yaml 
# 等待 ingress 创建完成,大约 1 分钟
$ kubectl get ingress
NAME               CLASS   HOSTS   ADDRESS                                                                      PORTS   AGE
stable-diffusion   alb     *       k8s-default-stabledi-419cfcfbe7-378193146.ap-northeast-1.elb.amazonaws.com   80      2m

之后跑测试脚本:

$ cd gai-game-activity/test_scripts
$ pip install -r requirements.txt
$ sudo apt install libgl1-mesa-glx
$ ingress="http://$(kubectl get ingress | tail -n1 | awk '{print $4}')"
$ bash testing-qps.sh $ingress 30     # 向 ingress 一次发送 30 个推理请求,得到类似以下输出结果:
# Total cost time: 55, batchCount=30, QPS=.545, avergeTimeTaken=1.83

即 30 个推理请求,总花费时间 55 秒,平均每个请求花费 1.83 秒,QPS 为 0.545,在我们的实测中,每台 g5.2xlarge 的 Node:

timeSlicing.replicas 测试请求数 测试花费时间 时间节省
1 30 59 秒 0
2 30 55 秒 6.80%
3 30 52 秒 11.90%

即在高并发请求场景下,对 GPU 卡做 time slicing 分片虚拟化,是能进一步提升推理效率的。但通过 time slicing 让一张 GPU 同时运行多个 container 的推理任务也会带来副作用:

  • container 会同时共享 GPU 显存,对于推理过程会占用大量显存的任务来说,要谨慎评估,否则会出现推理失败。比如在本实验中,一个 container 在推理过程占用显存小于 8G,那么一个 A10g 的显卡是 24G 显存,24G / 8G = 3,则同时跑 3 个 sd container 是没问题的;
  • 单个推理任务的完成时间可能是变长了的。因为多个任务同时跑本质上争抢了 GPU 卡的时间分片,所以具体到单个任务上,完成推理的时间要变长;
  • 某个推理任务的异常,可能会导致其他推理任务的异常。time slicing 本质是时间分片,不是真正虚拟化,无法做到完全隔离。

六、方案测试清理

清理创建的 EKS IRSA(也可以通过 CloudFormation 删除)

AWS_REGION='ap-northeast-1'
cluster_name='eks-game-gai'
sa_name='sqsexec-sa'
eksctl delete iamserviceaccount --cluster=${cluster_name} --region=${AWS_REGION} --name=${sa_name}

使用 terraform 清理后端推理服务

cd gai-game-activity/terraform/eks-sd-cluster
terraform destroy

使用 terraform 清理前端应用

cd gai-game-activity/terraform/eks-sd-cluster
terraform destroy

清理 Bottlerocket Snapshot

七、结论

在游戏领域,AI 生图营销活动正迅速兴起。本文以实际案例为基础,探讨了面向玩家的 AI 生图活动的工程化解决方案。通过在 Discord 等平台引入 AIGC 生图服务,玩家能够将照片转化为游戏元素,增强了互动与推广效果。独创的优化方案是本方案的亮点,包括预制容器镜像提升扩容速度、GPU 分片优化利用率和通过 EFS 提升模型加载速度,以及 Karpenter 与 Spot 的结合降低成本等。这些创新措施共同提升了活动的性能与可扩展性,为玩家创造了流畅而个性化的体验,同时降低了运营成本。

本篇作者

吴传文

亚马逊云科技解决方案架构师,负责企业级客户的架构设计和咨询,在亚马逊云科技支持多个知名游戏公司在全球的项目发行工作。有着丰富的游戏行业和 WEB3 行业经验,热衷于探索和构建各种有趣的落地应用。

谢志鹏

亚马逊云科技技术客户经理,负责企业级客户的架构设计、成本优化和技术支持等工作,在亚马逊云科技支持多个知名游戏公司在全球的项目发行工作;拥有十年以上 IT 架构、运维经验,在云安全、应用微服务化架构等方面拥有丰富的实战经验。