照着做,就能跑通模块 A

这份手册假设你没碰过 AWS。每一步都告诉你:去哪个网址、点哪个按钮、填什么内容、看到什么画面算成功。
全程只用浏览器,不用装任何软件。跟着 12 个步骤走完,你会得到一个能接收数据、存进两个数据库、按 id 求和返回的完整系统。

只用 EC2(规则要求)不用 Lambda不用 API Gateway Amazon Linux约 2 小时走完

开始之前

你要准备什么

项目说明
一个 AWS 账号能登录控制台就行。比赛时主办方会给账号
一台能上网的电脑装了 Chrome / Edge 浏览器
约 2 小时其中 Aurora 创建要等 6-10 分钟,EC2 启动要等 2-3 分钟
少量费用按宁夏区域算,6 小时比赛大约 ¥4-5。用免费套餐则更低

最重要的一件事:确认区域

区域选错 = 白干。AWS 的资源是按区域隔离的,你在"宁夏"建的东西,切到"北京"就看不见了。

每次打开控制台第一件事:看右上角,确认显示 中国(宁夏)。比赛时则确认 美国东部(弗吉尼亚北部)us-east-1。 不对就点它切换。
控制台主页
控制台主页 · 右上角红框处就是区域,必须确认

三条红线(违反直接取消成绩)

1. 只能用 EC2 跑程序 —— 不许用 Lambda、EKS、ECS、Fargate。
2. 不许用 API Gateway —— API 必须自己写在 EC2 上。
3. 操作系统用 Amazon Linux 2
第 3 条有个现实问题:Amazon Linux 2 已于 2025 年 6 月停止支持,现在控制台快速启动里默认只剩 Amazon Linux 2023。 本手册用 AL2023,因为两者命令几乎一样(yum 在 AL2023 上会自动转向 dnf),所有脚本两边都能跑。 比赛环境如果提供 AL2,直接照抄即可。

先搞懂 6 个名词

不用背,扫一眼就行。后面遇到哪个忘了,回来看。

1. EC2 —— 一台云服务器

就是一台租来的电脑,装了 Linux 系统。你的程序(API、网站)都跑在上面。比赛规定只能用这个。

2. DynamoDB —— 一个"表格型"数据库

AWS 自己的数据库,不需要装、不需要管服务器。就像一个 Excel 表:一列是 id(编号),一列是 value(值)。题目第一组数据存这里。

3. Aurora —— 一个 MySQL 数据库

就是常见的 MySQL,AWS 托管版。题目第二组数据存这里。需要建库、建表。

4. S3 —— 一个网盘

存文件用的。题目里用它放 server.zip(网站程序包)。自动发布就是盯着这里有没有新文件。

5. IAM 角色 —— 给服务器的"通行证"

EC2 默认什么都访问不了。给它挂一个"角色",就像给它发一张通行证,它才有权限去读写 DynamoDB、S3。这一步新手最容易忘,忘了 API 就一直报错没权限。

6. 安全组 —— 防火墙

决定哪些端口能被访问。我们要开 80(网页)、22(远程登录)、443(加密网页)。不开 80,外面就访问不了你的 API。

整个系统的工作过程(先有个印象):
外部请求 → EC2 的 80 端口 → Nginx 判断路径 →
  • 路径是 /post_data1 → 交给 API → 存进 DynamoDB
  • 路径是 /post_data2 → 交给 API → 存进 Aurora
  • 路径是 /get_value?id=01 → 交给 API → 两边各查一次,加起来返回
  • 其他路径 → 交给 Web 应用(8082 端口)

步骤 1 · 建 DynamoDB 表

为什么要做:题目要求第一组数据({"id":"01","value":"123"})存进 DynamoDB。得先有个表才能存。

操作步骤

打开网址 → https://cn-northwest-1.console.amazonaws.cn/dynamodbv2/home#create-table
(比赛时把 cn-northwest-1 换成 us-east-1,下面所有网址同理)
  1. 进去后点右上角橙色按钮 创建表
  2. 按下面这张表填:
字段填什么说明
表名称cr-data1名字随便起,后面代码里要对应上
分区键id就是"编号"这列的名字
分区键类型字符串(默认就是)不用改
表设置按需(默认)按实际用量计费,最便宜
填好的建表表单
填好的样子:表名 cr-data1,分区键 id,类型"字符串"
  1. 拉到最下面,点橙色按钮 创建表
✅ 成功的标志:页面跳回表列表,顶部出现绿色横幅 "cr-data1 表已成功创建",列表里能看到 cr-data1,状态是"活动"。
DynamoDB 创建成功
成功的样子:绿色横幅 + 表状态"活动"
如果报错 "表已存在":说明你(或别人)已经建过同名表了。换个名字,比如 cr-data1-b, 但要记得后面步骤 8 的代码里 DDB_TABLE 也要跟着改。

步骤 2 · 建 S3 存储桶

为什么要做:题目第二阶段要做"自动发布"——比赛方会把新的 server.zip 传到一个桶里,你的服务器要能自动发现并更新。这个桶就是那个"中转站"。

操作步骤

打开网址 → https://cn-northwest-1.console.amazonaws.cn/s3/bucket/create
字段填什么说明
存储桶名称cr-autodeploy-你的账号ID
例如 cr-autodeploy-578617970405
全球唯一!全世界 AWS 用户的桶名不能重复,所以一定要加上你的账号 ID 或一串随机数字
区域保持默认(中国宁夏)别改
阻止公有访问保持默认(全部勾选)安全选项,不用动
其他全部保持默认版本控制、加密、标签都不用开

拉到最下面点 创建存储桶

✅ 成功的标志:顶部绿色横幅 "已成功创建存储桶 cr-autodeploy-xxx"
S3 创建成功
成功的样子:绿色横幅 + 桶详情页面
最容易踩的坑:如果提示"存储桶名称已被使用",说明名字撞车了。在后面多加点数字,比如 cr-autodeploy-578617970405-a7
把最终用的桶名记下来,步骤 10 和步骤 11 都要用。

步骤 3 · 建 IAM 角色(通行证)

为什么要做:EC2 服务器默认什么 AWS 服务都访问不了。没有这张"通行证", 你的 API 写代码"存进 DynamoDB"时会直接报权限错误。这是新手最常卡住的地方。

操作步骤

打开网址 → https://console.amazonaws.cn/iamv2/home#/roles/create

第 1 步:选可信实体

  • AWS 服务(默认就是这个)
  • "服务或使用案例"那里选 EC2
  • 下一步
IAM 第1步
第 1 步:AWS 服务 → EC2 → 下一步
如果那个下拉框你点不开(偶尔会卡):改用 自定义信任策略 那一项, 在文本框里粘贴下面这段,效果完全一样:
{
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Principal": { "Service": "ec2.amazonaws.com" },
        "Action": "sts:AssumeRole"
    }]
}
信任策略 JSON
备用方案:自定义信任策略里贴上面那段 JSON

第 2 步:勾选权限(5 个)

在搜索框里一个一个搜,搜到就勾选上。每勾一个,右上角计数会 +1。

搜索关键词勾选这个作用
DynamoDBAmazonDynamoDBFullAccess读写 DynamoDB 表
RDSAmazonRDSFullAccess连 Aurora 数据库
S3AmazonS3FullAccess从桶里下载 server.zip
SSMManagedAmazonSSMManagedInstanceCore让你能用浏览器直接连服务器(步骤 7 要用)
CloudWatchAgentServerCloudWatchAgentServerPolicy上报监控指标
✅ 检查点:标题应显示 "权限策略 (5/606)" —— 5 是你勾的数量,606 是总数。不是 5 说明勾漏了。
已选5个策略
勾完 5 个的样子,右上角计数 (5/606)

下一步

第 3 步:起名字

字段填什么
角色名称CR-EC2-Role
描述随便填或不填
第3步命名
第 3 步:填角色名称

拉到最下面点 创建角色

✅ 成功的标志:绿色横幅 "已创建角色 CR-EC2-Role"
角色创建成功
成功:绿色横幅 + 角色出现在列表里

步骤 4 · 建 Aurora 数据库

为什么要做:题目第二组数据({"id":"01","value":"456"})要存进 Aurora(MySQL)。而且最后 /get_value 要从这里读数据出来做加法。
⏰ 先做这步!Aurora 创建要 6-10 分钟,是全程最慢的一步。建上之后不用盯着,去做步骤 5、6,回来就好了。

操作步骤

打开网址 → https://cn-northwest-1.console.amazonaws.cn/rds/home#launch-dbinstance:
点橙色按钮 创建数据库
字段填什么 / 选什么说明
数据库创建方法标准创建能自己控制所有选项
引擎类型Aurora (MySQL Compatible)别选 PostgreSQL!
模板开发/测试生产模板会贵很多
数据库集群标识符cr-aurora数据库集群的名字
主用户名admin默认就是
凭证管理自行管理这样能自己设密码(选 Secrets Manager 会多花钱)
主密码 / 确认密码123456789QWERTY8 位以上,字母+数字,记下来
实例类可突增类 → db.t3.small最便宜的档,别选"内存优化类"
多可用区部署不创建 Aurora 副本比赛不需要,省一半钱
公开访问更安全,且 EC2 在同一 VPC 内能连上

拉到最下面点 创建数据库

✅ 成功的标志:跳到数据库列表,看到 cr-aurora,状态先是"正在创建", 6-10 分钟后变成"可用(Available)"。

创建完成后:记下两个重要信息

  1. 在数据库列表点 cr-aurora 进去
  2. 在"连接和安全"标签页找到 终端节点(Endpoint),长得像:
    cr-aurora.cluster-xxxxxx.cn-northwest-1.rds.amazonaws.com.cn
  3. 把这个地址复制下来,步骤 8 要填进配置文件
重要:Aurora 建好时里面没有 crdb 这个库。步骤 7 连上服务器后,需要执行一条命令建库(步骤 8 里有)。

步骤 5 · 启动 EC2 服务器

为什么要做:这是真正跑程序的地方。API、Nginx、网站全在这一台机器上。开机时我们会塞一段"自动装机脚本",它启动后会自动把环境全部装好。

操作步骤

打开网址 → https://cn-northwest-1.console.amazonaws.cn/ec2/home#Instances:
点右上角橙色按钮 启动实例
区块填什么 / 选什么说明
名称和标签名称填 cr-app-server方便认
应用程序和操作系统映像Amazon Linux 2023(默认那个)不要选 Ubuntu/Windows
实例类型t3.micro免费套餐可用,够用
密钥对(登录)创建新密钥对,名字填 cr-key 系统会要求必须有一个。但我们后面用 Session Manager 登录,不需要下载这个文件
网络设置 · 防火墙选"创建安全组",勾选
✅ 允许 SSH 流量
✅ 允许来自互联网的 HTTP 流量
✅ 允许来自互联网的 HTTPS 流量
HTTP(80) 必须勾,否则 API 访问不了
配置存储默认 8 GiB gp3免费额度内,不用改

关键一步:填"高级详细信息"里的用户数据

  1. 展开最下面的 高级详细信息(点标题左边的箭头)
  2. 往下找到 用户数据 – 可选 这个文本框
  3. 打开本手册的 代码附录,复制完整的 user-data 脚本
  4. 粘贴进去
这段脚本干了什么(了解即可):
  • 安装 Python、Nginx、解压工具
  • 安装 Flask、Gunicorn、boto3(连 AWS 用)、pymysql(连 MySQL 用)
  • 把 API 代码写到 /opt/crapp/api.py
  • 把 API 注册成系统服务,开机自启、崩溃自动重启
  • 配置 Nginx:把 80 端口的请求按路径分给 API(5000)或网站(8082)
  • 设置每分钟检查一次 S3 桶的自动发布脚本
你什么都不用改,整段复制粘贴即可。

拉到最下面,右侧点 启动实例

✅ 成功的标志:页面显示"启动成功",点实例 ID 进去,等 1-2 分钟, 实例状态变成"正在运行",状态检查变成"2/2 项检查已通过"。
EC2 正在运行
成功:实例状态"正在运行",能看到公网 IP 和 DNS
记下这两个:
  • 公有 IPv4 地址:如 52.82.29.246 —— 步骤 8 测试要用
  • 公有 IPv4 DNS:如 ec2-52-82-29-246.cn-northwest-1.compute.amazonaws.com.cn —— 步骤 11 提交要用

步骤 6 · 给 EC2 挂上 IAM 角色

别跳过这步!而且不能跳着做。步骤 3 建的"角色"和 EC2 实际挂的"实例配置文件"是两个不同的东西。 很多人卡在这:角色在 IAM 控制台里能看到,EC2 下拉里死活没有——就是因为缺了中间这个壳。

6.1 先建实例配置文件(壳)

为什么多一步:EC2 不能直接接收一个 IAM 角色,必须先给角色"穿一件外套",叫实例配置文件 (Instance Profile)。这个壳和角色一一对应,是 EC2 实际认的那个东西。
  1. 打开 https://console.amazonaws.cn/iam/home#/roles/details/CR-EC2-Role
  2. 在角色详情页找到 实例配置文件 这一节(在"权限"和"信任关系"附近)
  3. 如果显示"无关联的实例配置文件",点右边的 创建
  4. 起名:CR-EC2-Profile
  5. 提交。系统会把这个壳和 CR-EC2-Role 绑定
如果角色页找不到"实例配置文件"那一节:说明控制台是旧版渲染,换这条路:
① 左侧 访问管理 → 角色 → 点 CR-EC2-Role 进去
② 顶部有 角色 ARN 那段,挨着找"实例配置文件" 区域
✅ 成功的标志:角色页的"实例配置文件"区域出现 CR-EC2-Profile,且状态显示"正在使用"或"已关联"。

6.2 把壳挂到 EC2 上

  1. 回到 EC2 实例列表,勾选 cr-app-server 前面的方框
  2. 点顶部的 操作 按钮
  3. 鼠标移到 安全
  4. 修改 IAM 角色
  5. 在下拉框里选 CR-EC2-Profile不是 CR-EC2-Role!下拉里出现的是壳的名字)
  6. 更新 IAM 角色
✅ 成功的标志:点实例进去,在"详细信息"标签页里能看到 IAM 角色: CR-EC2-Profile(显示的是壳名,点击会跳到角色页)。
小知识:这个操作叫"热挂载",不用重启服务器,几秒钟就生效。 AWS 这样设计是为了让"角色"和"用这个角色的实例"在概念上分离——一个角色可以被多个实例配置文件用。

步骤 7 · 用浏览器连上服务器

为什么要做:要去服务器上执行几条命令(建数据库、改配置、测试)。
好消息:不用装 PuTTY、不用下载密钥文件、不用配 SSH。因为步骤 3 勾了 SSM 权限, AWS 提供了一个直接在网页里开终端的功能,叫 Session Manager。

操作步骤

  1. EC2 实例列表,勾选 cr-app-server
  2. 点顶部 连接 按钮
  3. Session Manager 标签页
  4. 连接
✅ 成功的标志:浏览器新开一个黑底窗口,里面显示命令行提示符,类似:
sh-5.2$
现在你输入的每条命令,都直接跑在云服务器上。
如果"连接"按钮是灰的,或者提示超时:
① 确认步骤 6 的 IAM 角色挂上了(最常见原因)
② 确认实例状态是"正在运行"且状态检查"2/2 通过"
③ 等 2 分钟再试 —— SSM 代理刚开机时还没注册上来

进去后第一件事:切到 root 用户

Session Manager 默认登录的是普通用户,装软件要管理员权限。执行:

sudo su -
cd ~

提示符变成 [root@ip-xxx ~]# 就对了。

步骤 8 · 部署并测试 API

为什么要做:这是核心验收环节。题目三个接口能不能通,全在这里验证。

8.1 先确认自动装机跑完了

在 Session Manager 终端里执行:

tail -20 /var/log/user-data.log
看到 ==== done ... ==== 就说明装完了。如果没看到,等 2 分钟再看(yum 安装需要时间)。

8.2 放行 Aurora 3306 + 建 crdb 数据库

如果 API 日志里出现 Can't connect to MySQL serverAccess denied,八成是这两个问题。

8.2.1 放行 3306 端口

EC2 访问 Aurora 的 3306 端口,必须要在 Aurora 所属的安全组里加一条入站规则:

打开网址 → https://cn-northwest-1.console.amazonaws.cn/ec2/home?region=cn-northwest-1#SecurityGroups:
找到 Aurora 用的安全组(通常是 default) → 编辑入站规则 → 添加规则
字段填什么
类型自定义 TCP
端口范围3306
自定义 → 选 EC2 实例用的安全组(如 launch-wizard-2 / sg-0267ac394adca56a7
✅ 成功:保存规则后,在 EC2 里执行 timeout 5 bash -c 'cat </dev/null >/dev/tcp/你的Aurora端点/3306' 不再超时。
default 安全组已放行 3306
default 安全组入站规则:原规则(所有流量,来自自身)+ 新增 3306 来自 launch-wizard-2

8.2.2 建库建表

你的Aurora端点 换成步骤 4 记下的那个地址:

mysql -h 你的Aurora端点 -u admin -p'123456789QWERTY' \
  -e "CREATE DATABASE IF NOT EXISTS crdb CHARACTER SET utf8mb4; USE crdb; CREATE TABLE IF NOT EXISTS data2 (id VARCHAR(64) PRIMARY KEY, value VARCHAR(255) NOT NULL) ENGINE=InnoDB; SHOW DATABASES;"
✅ 成功:输出的数据库列表里能看到 crdb,表列表里能看到 data2
如果提示 mysql: command not found先装客户端:
dnf install -y mariadb105
如果提示 Access denied说明密码不对。去 RDS 控制台 → 集群 → 修改 → 重新设置主密码,并勾选立即应用

8.3 写入数据库配置

把下面整段复制进去(替换掉其中的 Aurora 端点):

cat > /etc/crapp.env <<EOF
AWS_REGION=cn-northwest-1
DDB_TABLE=cr-data1
DB_HOST=你的Aurora端点
DB_USER=admin
DB_PASS=123456789QWERTY
DB_NAME=crdb
EOF
chmod 600 /etc/crapp.env
systemctl restart cr-api
sleep 3
systemctl status cr-api --no-pager
✅ 成功:状态显示绿色的 active (running)。如果是红色的 failed,看下面排查。

8.4 测试三个接口(关键!)

依次执行这三条 curl 命令:

# ① 存第一组数据到 DynamoDB
curl -s -X POST http://localhost/post_data1 \
  -H "Content-Type: application/json" \
  -d '{"id":"01","value":"123"}'
# 期望输出: {"message":"ok"}

# ② 存第二组数据到 Aurora
curl -s -X POST http://localhost/post_data2 \
  -H "Content-Type: application/json" \
  -d '{"id":"01","value":"456"}'
# 期望输出: {"message":"ok"}

# ③ 查询求和 —— 这是评分点!
curl -s "http://localhost/get_value?id=01"
# 期望输出: {"message":"579"}
🎉 看到 {"message":"579"} 就成功了!123 + 456 = 579,说明两个库都通了,求和也对。
浏览器访问 /get_value?id=01 返回 579
浏览器访问 http://52.82.29.246/get_value?id=01 成功返回 {"message":"579"}

8.5 从外网再测一遍

在你自己电脑的浏览器里打开(换成你的公网 IP):

http://52.82.29.246/get_value?id=01
✅ 浏览器里显示 {"message":"579"} 就大功告成。
外网打不开?九成是安全组没开 80 端口。去 EC2 → 安全组 → 找到实例用的那个(launch-wizard-2)→ 入站规则 → 确认有 HTTP/80 且来源是 0.0.0.0/0。

步骤 9 · 部署 Web 应用

为什么要做:题目第二阶段要求把主办方给的网站程序(server.zip)跑起来,并且能通过 http://你的域名 直接访问。
关于 server.zip:这是主办方提供的网站程序包,比赛当天才给真实下载地址。 网上流传的示例地址(temp-418028000011.s3...)现在已经 404 失效不要照抄地址。重点是搞懂它长什么样、怎么跑起来。

server.zip 长什么样

文件作用
run.sh启动脚本。必须有可执行权限,否则网站起不来
index.html网站页面(实际包里可能还有别的文件)

它运行时会读同目录 config.json 里的 server_port(题目指定 8082)。 这个 config.json 由我们的自动发布脚本自动生成,不用你操心。

9.1 自己做一份测试包(强烈建议先练一遍)

拿不到主办方的包,就自己做一份,把整条链路先跑通:

mkdir -p /tmp/mk/index
cat > /tmp/mk/run.sh <<'EOF'
#!/bin/bash
cd "$(dirname "$0")"
PORT=$(python3 -c "import json;print(json.load(open('config.json'))['server_port'])" 2>/dev/null || echo 8082)
echo "[run.sh] starting web app on port $PORT"
exec python3 -m http.server "$PORT" --bind 0.0.0.0
EOF

cat > /tmp/mk/index/index.html <<'EOF'
<h1>Cloud Raiser Web App (v1)</h1>
<p>这个页面由 S3 自动发布部署到 EC2</p>
EOF

chmod +x /tmp/mk/run.sh
cd /tmp/mk && zip -r /tmp/server.zip run.sh index
ls -la /tmp/server.zip

9.2 上传到你自己的 S3 桶

aws s3 cp /tmp/server.zip s3://你的桶名/server.zip --region cn-northwest-1

# 确认上传成功(返回的一串字符就是 ETag,也就是文件指纹)
aws s3api head-object --bucket 你的桶名 --key server.zip \
  --region cn-northwest-1 --query ETag --output text

9.3 什么都不用做,等它自动部署

最多等 1 分钟,自动发布脚本会发现新文件并启动网站。然后验证:

# 看自动发布日志
tail -5 /var/log/autodeploy.log
# 期望看到两行:
#   [autodeploy] new server.zip detected (xxxx)
#   [autodeploy] web app launched

# 确认 8082 端口有人在听
ss -lntp | grep 8082

# 访问网站
curl -s http://localhost/ | head -5
✅ 成功:能看到网站 HTML,浏览器打开 http://你的IP/ 能看到页面。
Web 应用通过 80 端口访问成功
浏览器访问 http://52.82.29.246/ —— 网站已由自动发布部署上线
如果返回 502 Bad Gateway:说明 Nginx 通了,但它后面的网站没起来。九成是这个坑:
run.sh 没有执行权限。unzip 解压时不会还原 zip 里存的执行权限位, 解压出来的 run.sh-rw-r--r--,直接跑就报 Permission denied
chmod +x /opt/webapp/app/run.sh
# 重新触发一次部署
rm -f /opt/webapp/.last_etag && /opt/crapp/autodeploy.sh
本手册的自动发布脚本已内置这行 chmod;如果你自己写脚本,千万别漏。

(备用)手动部署方式

如果主办方给了可直接下载的网址,也可以手动跑一遍(换成真实地址):

mkdir -p /opt/webapp/app
cd /opt/webapp
curl -o server.zip 主办方给的地址
unzip -o server.zip -d /opt/webapp/app
chmod +x /opt/webapp/app/run.sh          # ← 别漏这行

# 建端口配置文件(题目指定 8082)
cat > /opt/webapp/app/config.json <<EOF
{"server_port": 8082}
EOF

# 启动网站
cd /opt/webapp/app && nohup ./run.sh > /var/log/webapp.log 2>&1 &
sleep 3
curl -s http://localhost:8082 | head -20
✅ 成功:能看到网站返回的 HTML 内容。

通过 80 端口访问

步骤 5 的 Nginx 已经配好了:访问 /(根路径)会自动转给 8082 的网站。 所以你直接访问:

http://52.82.29.246/
✅ 能看到网站页面就成功。同一个域名,/ 是网站,/get_value 是 API,互不干扰。
原理一句话:Nginx 像个前台,看你访问的路径决定把你领去哪个房间: /post_data1/get_value 领去 API(5000 端口),其他一律领去网站(8082 端口)。

步骤 10 · 配置自动发布

这是全题最大的陷阱,想清楚:
正常人第一反应是"S3 有新文件 → 触发 Lambda → 通知 EC2 更新"。 但规则第 1 条就禁了 Lambda!
所以必须换个办法:让 EC2 自己每分钟去 S3 看一眼,发现文件变了就自动更新。
怎么判断文件变了?每个 S3 文件都有个 ETag(相当于文件指纹,内容一变它就变)。 脚本记下上次的 ETag,每分钟比对一次,不一样就说明有新版本。

验证自动发布是否在工作

# 确认桶名已写入配置
cat /etc/crapp.d/bucket
# 应该输出你的桶名: cr-autodeploy-xxxx

# 确认定时任务已注册
cat /etc/cron.d/cr-autodeploy
# 应该输出: * * * * * root /opt/crapp/autodeploy.sh ...

# 手动跑一次看效果
/opt/crapp/autodeploy.sh
echo "退出码: $?"

# 看日志
tail -20 /var/log/autodeploy.log
✅ 成功:日志里出现 [autodeploy] new server.zip detectedweb app launched

模拟比赛验收

比赛结束时,主办方会往你的桶里传一个新的 server.zip。你现在就能自己测:

  1. 改一下测试包的版本号,重新打包上传(内容必须真的变了,ETag 才会变)
  2. 什么都不做,等 最多 1 分钟
  3. 看服务器上的日志:tail -f /var/log/autodeploy.log
  4. 访问 http://你的IP/ 确认网站已更新
✅ 实测记录(本手册作者真跑过):
  • v1 包上传 → 约 1 分钟后网站上线,页面显示 autodeploy v1 OK
  • 改版本号重打包 → 新 ETag 2c0b9b4a... 上传
  • 不碰服务器,纯等 80 秒 → 页面自动变成 autodeploy v2 OK
全程零人工干预,这就是评分想要的"自动发布"。
一个容易想错的地方:ETag 只在文件内容变化时才会变。 如果你重新上传了一个一模一样的文件,ETag 不变,脚本就不会重新部署——这是正确行为,不是 bug。 测试时记得真的改点内容(比如版本号)。
比赛当天的保命检查:自动发布脚本必须一直跑着。如果 cron 服务停了就白搭,用这条确认:
systemctl status crond
不是 active (running) 就执行 systemctl enable --now crond

步骤 11 · 提交答案

主办方的 Cloud Raiser 平台有三个输入框,全部要填。填错格式不给分。
输入框填什么例子常见错误
第 1 个
(API 地址)
你的域名,不带路径 http://ec2-52-82-29-246.cn-northwest-1.compute.amazonaws.com.cn ❌ 写成 http://.../post_data1
❌ 结尾多写个 /
第 2 个
(网站地址)
和上面一样 http://ec2-52-82-29-246.cn-northwest-1.compute.amazonaws.com.cn ❌ 写成 http://...:8082(端口不能暴露)
第 3 个
(S3 桶名)
纯桶名,不是 ARN cr-autodeploy-578617970405 ❌ 写成 arn:aws-cn:s3:::cr-autodeploy-xxx

每填一个,点旁边的 Update 按钮提交。

三个框都填完后,主办方的系统会自动:
  • 往你的 API 发数据,检查 /get_value 返回对不对
  • 访问你的网站,检查页面正不正常
  • 比赛结束后往你的桶里传新 server.zip1 分钟后检查网站有没有自动更新
用 IP 还是 DNS?两个都能用,但我建议用 DNS 名(就是 ec2-43-192-... 那个)。 因为 IP 在服务器重启后会变,DNS 名不会。

步骤 12 · 架构优化(加分项)

前面 11 步做完,基础分拿到了。题目第三阶段还要"架构优化与持续改进", 主要看两点:流量涨了扛不扛得住有没有监控

12.1 加负载均衡 + 自动扩容

现在是单台服务器,挂了就全挂。加个 ALB(负载均衡)把流量分给多台,并让服务器数量随流量自动增减。

  1. 建目标组:EC2 → 目标群组 → 创建 → 协议 HTTP:80,健康检查路径填 /healthz
  2. 建启动模板:EC2 → 启动模板 → 创建 → 配置和步骤 5 一样(用户数据还是贴同一段脚本
  3. 建 Auto Scaling 组:EC2 → Auto Scaling 组 → 创建 →
    • 选刚才的启动模板
    • 选 2 个子网
    • 挂到刚才的目标组
    • 所需容量 2、最小 2、最大 6
    • 扩展策略:CPU > 60% 时加一台
  4. 建负载均衡器:EC2 → 负载均衡器 → 创建 ALB → 面向 Internet → 侦听器 80 → 默认转发到目标组
✅ 成功后:访问 ALB 的 DNS 名,效果和直接访问单台一样,但背后有 2 台在扛。
别过度扩容:评分里有一条"不要过度扩展基础架构"。2 台起步、最大 6 台就够了,别上来就开 10 台,会扣分。

12.2 加监控告警

CloudWatch → 告警 → 创建,建议配这三条:

指标条件作用
EC2 CPUUtilization> 70% 持续 5 分钟流量太大要扩容
ALB HTTPCode_Target_5XX_Count> 5 次/分钟程序报错了
ALB UnHealthyHostCount> 0有服务器挂了

12.3 成本参考

资源6 小时费用
EC2 t3.micro × 2≈ ¥0.84
Aurora db.t3.small≈ ¥2.76(最贵一项)
DynamoDB 按需< ¥0.10
S3< ¥0.05
ALB≈ ¥0.96
合计≈ ¥4.7

出问题了怎么办

API 返回 502 Bad Gateway

说明 Nginx 在,但后面的 API 没起来。执行:

systemctl status cr-api --no-pager
journalctl -u cr-api -n 50 --no-pager
tail -30 /var/log/cr-api.err.log

报错 AccessDeniedException / Unable to locate credentials

100% 是步骤 6 没做或没生效。确认:
① EC2 实例详情里"IAM 角色"显示 CR-EC2-Role
② 该角色上挂了 AmazonDynamoDBFullAccessAmazonS3FullAccess

报错 Can't connect to MySQL server(连接超时)

按顺序排查:
cat /etc/crapp.env 看 DB_HOST 是不是 Aurora 端点(不是 IP、不是 localhost)
② Aurora 状态是不是"可用"
Aurora 安全组有没有放行 3306,来源是不是 EC2 的安全组(最常见)
④ EC2 和 Aurora 是不是在同一个 VPC

快速判定第 ③ 条,在 EC2 上执行:

timeout 5 bash -c 'cat </dev/null >/dev/tcp/你的Aurora端点/3306' && echo 通 || echo 不通
新手最容易搞混的一点:EC2 有自己的安全组,Aurora 有另一个安全组。 开 80 端口是在 EC2 的安全组上开;开 3306 是在 Aurora 的安全组上开。 两头都要配,只配一边照样连不上。

报错 Access denied for user 'admin'

用户名对但密码错。去 RDS 控制台 → 集群 database-1修改 → 在"主密码 / 确认主密码"里重设 → 拉到最底 → 修改集群
注意确认页默认选的是"在下一个维护时段应用",要改成"立即应用"才马上生效
改完同步更新服务器配置:

sudo sed -i 's|^DB_PASS=.*|DB_PASS=你的新密码|' /etc/crapp.env
sudo systemctl restart cr-api

报错 Unknown database 'crdb'

库不存在。本手册的 api.py 启动时会自动建库建表; 如果你用的是更早的版本,手工建一次即可:

mysql -h 你的Aurora端点 -u admin -p'你的密码' -e "CREATE DATABASE IF NOT EXISTS crdb CHARACTER SET utf8mb4; USE crdb; CREATE TABLE IF NOT EXISTS data2 (id VARCHAR(64) PRIMARY KEY, value VARCHAR(255) NOT NULL);"

nginx 起不来:"listen" directive is not allowed here

这是 Nginx 配置冲突:我们自己的 conf.d/crapp.conf 已经监听 80, 而系统自带的 nginx.conf 里还有一个默认 server { } 块也监听 80,两个打架。
解决办法是把默认那整个 server 块删掉。注意:只注释 server { 这一行没用, 块里剩下的指令会裸露在外层,照样报错。本手册的 user-data 已用括号配对方式整块删除;手工修的话:

nginx -t                                    # 先看报错在第几行
sudo sed -i '36,53d' /etc/nginx/nginx.conf   # 行号按你的实际报错区间调整
sudo nginx -t                               # 再验证一次
sudo systemctl restart nginx

get_value 返回 0 或者数字不对

说明数据没存进去。先确认两个 post 都返回了 {"message":"ok"},然后直接查库:

# 查 DynamoDB
aws dynamodb scan --table-name cr-data1 --region cn-northwest-1

# 查 Aurora
mysql -h 你的Aurora端点 -u admin -p'123456789QWERTY' crdb \
  -e "SELECT * FROM data2;"

外网访问不了,但服务器上 curl localhost 正常

安全组问题。EC2 → 安全组 → 入站规则,必须有:HTTP / TCP / 80 / 来源 0.0.0.0/0。

Session Manager 连不上

① 步骤 6 的 IAM 角色挂了吗(要有 AmazonSSMManagedInstanceCore)
② 实例开机满 2 分钟了吗(SSM 代理要时间注册)
③ 实例状态检查是不是"2/2 项检查已通过"

网站能开但一直显示旧版

自动发布没生效。检查三件事:

systemctl status crond          # 必须是 active
cat /etc/crapp.d/bucket         # 必须是你的桶名
tail -20 /var/log/autodeploy.log  # 看有没有 detected 字样

代码附录

下面三个文件都可以直接下载。步骤 5 需要 user-data.sh(整段复制粘贴)。

文件用在哪一步下载
user-data.sh步骤 5 — 粘贴到"用户数据"框 ⬇ 下载
api.py已内嵌在 user-data 里,一般不用单独下 ⬇ 下载
cr-stack.templateCloudFormation 一键版(新手不推荐,会漏掉很多理解) ⬇ 下载

user-data.sh 全文(步骤 5 要粘贴的)

注意:下面这段里的 DB_HOST=...DB_PASSDEPLOY_BUCKET 必须换成你自己的真实值(步骤 4 和步骤 2 里有说明)。
#!/bin/bash
# Cloud Raiser - EC2 开机自举(AL2023 健壮版)
# 关键改动:去掉 set -e、所有安装加 || true、直接用 dnf、自动建库建表、正确删除 nginx 默认 server 块
DB_HOST="database-1.cluster-cfud9bwy8iiy.rds.cn-northwest-1.amazonaws.com.cn"
DB_USER="admin"
DB_PASS="123456789QWERTY"
DB_NAME="crdb"
AWS_REGION="cn-northwest-1"
DEPLOY_BUCKET="cr-autodeploy-wsc57980405"
exec > >(tee /var/log/user-data.log) 2>&1
echo "==== start $(date) ===="

# ---------- 1. 装依赖(全程不中断)----------
dnf update -y || true
dnf install -y python3 python3-pip nginx gcc python3-devel || true
dnf install -y cronie || true
dnf install -y mariadb105 || true
pip3 install --quiet --upgrade pip || true
pip3 install --quiet flask gunicorn boto3 pymysql || true
echo "[1] deps done"

# ---------- 2. API 代码 ----------
mkdir -p /opt/crapp /opt/webapp /etc/crapp.d
cat > /opt/crapp/api.py <<'PYEOF'
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Cloud Raiser · 浙江省选拔赛 - 云计算项目 - 架构完善模块 - 阶段1 Restful API
=========================================================================
端点:
    POST /post_data1         把 {id,value} 写入 DynamoDB(表 cr-data1)
    POST /post_data2         把 {id,value} 写入 Aurora MySQL(表 data2)
    GET  /get_value?id=01    从两库按 id 查 value,求和后返回 {"message":"579"}
    GET  /healthz            健康检查

部署方式:Gunicorn + Nginx(端口 80 反向代理到 5000;Web 应用 8082)
"""
import os
import json
import logging
from flask import Flask, request, jsonify
import boto3
import pymysql
from botocore.config import Config

# ---------------- 日志 ----------------
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
    handlers=[
        logging.StreamHandler(),
        logging.FileHandler("/var/log/cr-api.log", encoding="utf-8"),
    ],
)
log = logging.getLogger("cr-api")

app = Flask(__name__)

# ---------------- DynamoDB ----------------
DDB_TABLE = os.environ.get("DDB_TABLE", "cr-data1")
AWS_REGION = os.environ.get("AWS_REGION", "cn-northwest-1")
ddb = boto3.resource(
    "dynamodb",
    region_name=AWS_REGION,
    config=Config(retries={"max_attempts": 3, "mode": "standard"}),
)
table_ddb = ddb.Table(DDB_TABLE)

# ---------------- Aurora MySQL ----------------
DB_HOST = os.environ["DB_HOST"]
DB_USER = os.environ.get("DB_USER", "admin")
DB_PASS = os.environ["DB_PASS"]
DB_NAME = os.environ.get("DB_NAME", "crdb")


def db_conn():
    return pymysql.connect(
        host=DB_HOST,
        user=DB_USER,
        password=DB_PASS,
        database=DB_NAME,
        charset="utf8mb4",
        connect_timeout=5,
        read_timeout=10,
        write_timeout=10,
    )


def _ensure_db_and_table():
    """首次启动时自动创建 database 和 data2 表(建库阶段没建也能自愈)"""
    # 1) 先连 MySQL server(不带 database)建库
    cn = pymysql.connect(
        host=DB_HOST,
        user=DB_USER,
        password=DB_PASS,
        charset="utf8mb4",
        connect_timeout=5,
        read_timeout=10,
        write_timeout=10,
    )
    try:
        with cn.cursor() as c:
            c.execute(f"CREATE DATABASE IF NOT EXISTS {DB_NAME} CHARACTER SET utf8mb4")
        cn.commit()
    finally:
        cn.close()

    # 2) 再连目标库建表
    cn = db_conn()
    try:
        with cn.cursor() as c:
            c.execute(
                """CREATE TABLE IF NOT EXISTS data2 (
                    id   VARCHAR(64)  PRIMARY KEY,
                    value VARCHAR(255) NOT NULL
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"""
            )
        cn.commit()
    finally:
        cn.close()


# 启动时建库建表
try:
    _ensure_db_and_table()
    log.info("data2 table ensured")
except Exception as e:  # pragma: no cover
    log.warning("ensure_table failed: %s", e)


# ---------------- 路由 ----------------
@app.route("/healthz", methods=["GET"])
def healthz():
    return jsonify(ok=True)


@app.route("/post_data1", methods=["POST"])
def post_data1():
    body = request.get_json(force=True, silent=True) or {}
    _id = str(body.get("id", "")).strip()
    val = body.get("value", "")
    if not _id:
        return jsonify(message="id required"), 400
    table_ddb.put_item(Item={"id": _id, "value": str(val)})
    log.info("DDB PUT id=%s val=%s", _id, val)
    return jsonify(message="ok")


@app.route("/post_data2", methods=["POST"])
def post_data2():
    body = request.get_json(force=True, silent=True) or {}
    _id = str(body.get("id", "")).strip()
    val = body.get("value", "")
    if not _id:
        return jsonify(message="id required"), 400
    cn = db_conn()
    try:
        with cn.cursor() as c:
            c.execute(
                """INSERT INTO data2 (id, value) VALUES (%s, %s)
                   ON DUPLICATE KEY UPDATE value=VALUES(value)""",
                (_id, val),
            )
        cn.commit()
    finally:
        cn.close()
    log.info("RDS PUT id=%s val=%s", _id, val)
    return jsonify(message="ok")


@app.route("/get_value", methods=["GET"])
def get_value():
    _id = str(request.args.get("id", "")).strip()
    if not _id:
        return jsonify(message="id required"), 400

    ddb_val = (
        table_ddb.get_item(Key={"id": _id})
        .get("Item", {})
        .get("value", "0")
    )

    rds_val = "0"
    cn = db_conn()
    try:
        with cn.cursor() as c:
            c.execute("SELECT value FROM data2 WHERE id=%s", (_id,))
            row = c.fetchone()
            if row:
                rds_val = row[0]
    finally:
        cn.close()

    try:
        total = int(ddb_val) + int(rds_val)
    except (TypeError, ValueError):
        total = f"{ddb_val}{rds_val}"
    log.info("GET id=%s ddb=%s rds=%s total=%s", _id, ddb_val, rds_val, total)
    return jsonify(message=str(total))


# ---------------- 入口 ----------------
if __name__ == "__main__":
    # 直接 python3 跑也支持;生产由 gunicorn 启动
    app.run(host="127.0.0.1", port=5000)
PYEOF
echo "[2] api.py written"

# ---------- 3. systemd ----------
cat > /etc/crapp.env <<EOF
AWS_REGION=${AWS_REGION}
DDB_TABLE=cr-data1
DB_HOST=${DB_HOST}
DB_USER=${DB_USER}
DB_PASS=${DB_PASS}
DB_NAME=${DB_NAME}
EOF
chmod 600 /etc/crapp.env

cat > /etc/systemd/system/cr-api.service <<'SVCEOF'
[Unit]
Description=CR API (Flask via Gunicorn)
After=network.target
[Service]
WorkingDirectory=/opt/crapp
EnvironmentFile=/etc/crapp.env
ExecStart=/usr/bin/python3 -m gunicorn --workers 2 --threads 4 --bind 127.0.0.1:5000 api:app
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

SVCEOF
echo "[3] systemd unit written"

# ---------- 4. Nginx ----------
echo "${DEPLOY_BUCKET}" > /etc/crapp.d/bucket
mkdir -p /etc/nginx/conf.d
cat > /etc/nginx/conf.d/crapp.conf <<'NGXEOF'
server {
    listen 80 default_server;
    server_name _;
    client_max_body_size 16m;
    location = /post_data1 { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; }
    location = /post_data2 { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; }
    location = /get_value  { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; }
    location = /healthz    { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; }
    location / { proxy_pass http://127.0.0.1:8082; proxy_set_header Host $host; }
}
NGXEOF
if [ -f /etc/nginx/nginx.conf ]; then
  python3 - <<'PYFIX' || true
import re
p='/etc/nginx/nginx.conf'
s=open(p).read()
# 删除默认 server { ... } 块(含嵌套大括号),避免与 conf.d/crapp.conf 冲突
start=-1
for i in range(len(s)):
    if s.startswith('server {', i) and (i==0 or not s[i-1].isalnum()):
        start=i
        break
if start>=0:
    depth=1
    i=start+len('server {')
    while i<len(s) and depth>0:
        if s[i]=='{': depth+=1
        elif s[i]=='}': depth-=1
        i+=1
    s=s[:start]+'    # default server block removed by user-data\n'+s[i:]
open(p,'w').write(s)

PYFIX
fi
echo "[4] nginx config written"

# ---------- 5. 自动发布(cron 轮询 S3 ETag)----------
cat > /opt/crapp/autodeploy.sh <<'WDEOF'
#!/bin/bash
LOCK=/var/lock/cr-autodeploy.lock
WORK=/opt/webapp
BUCKET=$(cat /etc/crapp.d/bucket 2>/dev/null)
[ -z "$BUCKET" ] && exit 0
[ -e "$LOCK" ] && exit 0
trap "rm -f $LOCK" EXIT
touch "$LOCK"
aws s3api get-object --bucket "$BUCKET" --key server.zip /tmp/server.zip.new      --query ETag --output text > /tmp/etag.new 2>/dev/null || exit 0
ETAG=$(cat /tmp/etag.new | tr -d '"')
[ -e "$WORK/.last_etag" ] && [ "$(cat "$WORK/.last_etag")" = "$ETAG" ] && exit 0
echo "[autodeploy] new server.zip detected ($ETAG)"
rm -rf "$WORK/app" && mkdir -p "$WORK/app"
unzip -qo /tmp/server.zip.new -d "$WORK/app" || true
# 关键:unzip 不会还原 zip 里存的执行权限位,必须手动补,否则 run.sh 起不来(502)
chmod +x "$WORK/app/run.sh" 2>/dev/null || true
chmod -R a+rX "$WORK/app" 2>/dev/null || true
echo "$ETAG" > "$WORK/.last_etag"
printf '{"server_port": 8082}\n' > "$WORK/app/config.json"
pkill -f /opt/webapp/app || true
sleep 1
( cd "$WORK/app" && nohup ./run.sh > /var/log/webapp.log 2>&1 & ) || true
echo "[autodeploy] web app launched"

WDEOF
chmod +x /opt/crapp/autodeploy.sh
echo "* * * * * root /opt/crapp/autodeploy.sh >> /var/log/autodeploy.log 2>&1" > /etc/cron.d/cr-autodeploy || true
systemctl enable crond 2>/dev/null || true
systemctl restart crond 2>/dev/null || true
echo "[5] autodeploy installed"

# ---------- 6. 启动服务 ----------
systemctl daemon-reload
systemctl enable cr-api 2>/dev/null || true
systemctl enable nginx 2>/dev/null || true
systemctl restart cr-api 2>/dev/null || true
systemctl restart nginx 2>/dev/null || true
echo "[6] services started"

# ---------- 7. 自检 ----------
sleep 5
echo "--- nginx: $(systemctl is-active nginx) ---"
echo "--- cr-api: $(systemctl is-active cr-api) ---"
echo "--- healthz: $(curl -s -m 5 http://127.0.0.1/healthz) ---"
echo "==== done $(date) ===="