🧪 第15章 部署物件侦测应用
📄 一句话概括

tag 只是再挂一个名字,不复制;push 按层传,已有的层跳过。 部署不是覆盖,是新增一个修订版再把流量切过去——所以回滚不用重新构建。 验收六条,四条是错误路径,而且每一条都必须是 JSON。

📝 本章和前面几章的关系

这一篇对应教材 15.8 到 15.9:将 Docker 映像档加入 GCR、确认上传情况、部署至 Cloud Run。 上一篇把镜像建好了,这一篇把它弄到公网上,并且真的验一遍。第 3 篇到这儿收尾。

一、步骤 4:打个标签,推上去

docker tag detect-api:v1 gcr.io/my-project/detect-api:v1
docker push gcr.io/my-project/detect-api:v1
gcloud container images list-tags gcr.io/my-project/detect-api

教材 15.8。tag 是「再起个名字」,push 是「传上去」。 docker tag 不改文件,只给同一个镜像再挂一个名字。所以它几乎不花时间,也不是复制。 为什么必须换个名字才能推: 镜像仓库的地址就是名字的一部分。gcr.io/my-project/detect-api:v1 这个名字本身指明了往哪推—— registry 主机、项目、仓库、标签,四段。

docker push 是分层传的:本地已有、仓库也有的层会跳过。第一次推慢,改一行代码再推就快。 这也是为什么 Dockerfile 要分层写——上一篇那个顺序,在这儿兑现第二次。 教材 15.8.1 的「确认上传情况」就是第三条命令:看 tag 和 digest 在不在。 digest 那一串 sha256 是镜像的内容指纹,同一个 digest 到哪都是同一份东西。

二、步骤 5:部署与它的五个参数

gcloud run deploy detect-api \
  --image gcr.io/my-project/detect-api:v1 \
  --platform managed \
  --region asia-east1 \
  --allow-unauthenticated \
  --memory 1Gi --cpu 1 --timeout 60
参数 为什么
--platform managed 托管版,不用自己管机器
--region 放哪个机房,跟着项目走
--allow-unauthenticated 允许匿名访问,公开 API 才加
--memory 1Gi 模型占内存,给小了会被 OOM 杀掉
--timeout 60 单请求最长 60 秒,超了直接断

--allow-unauthenticated 这条要想清楚。不加的话,所有请求都要带 Google 的身份令牌。 做内部服务时这是对的;做公开 API 时加了它,等于谁都能调、谁都能烧你的额度。 --memory 给小了会被 OOM 杀掉,而且报错很不直观——容器直接退出,日志里能看到 Killed, 但不会说「内存不够」。 这两个参数加起来,就是「部署」这件事真正的决策点:给谁用、给多少资源。 技术参数里最难的从来不是怎么填,是填多少。

三、部署之后你得到了什么

东西 是什么
一条 URL 形如 https://detect-api-xxxx.run.app
一个修订版 每次部署生成一个,带时间戳
流量指向 默认 100% 给最新那个修订版

修订版这个概念值得记:部署不是「覆盖」,是「新增一个修订版,然后把流量切过去」。 所以回滚就是把流量切回上一个修订版,不用重新构建、不用重新推镜像。 顺着想一步:既然流量可以切,就能灰度——把 10% 的流量给新版本,看着没问题再加到 100%。 这是 Cloud Run 这类平台给的东西,自己搭服务器得另外做。

四、部署完,六条验收

验证项 怎么打 期望
健康检查 GET /api/health 200 且含 status
正常一张图 POST /api/detect 200 且 count 大于等于 1
缺文件 空请求 400 且是 JSON
类型不对 传 .txt 415 且是 JSON
太大 传超限文件 413 且是 JSON
不存在的路径 GET /api/nope 404 且是 JSON

六项里四项是错误路径。 这不是苛刻,是因为正常路径你本地已经测过一百遍了,部署之后变的往往是错误路径。 尤其 413 和 404:本地跑着 dev server 时行为和线上不一样——线上前面通常还有一层负载均衡, 它自己也会先拦掉一些请求,拦完返回的东西未必是你的 JSON。

五、拿本地服务把这六条真走一遍

验证项 状态码 响应体
健康检查 200 {"status":"ok"}
正常一张图 200 {"count":1,...}
缺文件 400 {"error":"no file"}
类型不对 415 {"error":"unsupported"}
太大 413 {"error":"payload too large"}
不存在的路径 404 {"error":"not found"}

六项全对:200、200、400、415、413、404,而且每一项都是 JSON。 最后一项用的是 /api/nope 而不是 /nope。 差别很实在:错误处理器按路径前缀分流,/nope 走的是页面那条路, 返回 HTML——要验 JSON,请求就得落在 /api 下面。 第五项那个 413 是 @app.errorhandler 补出来的——第 14 章那个洞,在这儿成了验收清单上的一项。

这六条可以写成一个脚本,每次部署跑一遍。 第 11 章那些测试是在代码层验,这六条是在部署之后验,两者不重复。 少了这一步的后果很具体:部署完了,页面能打开,你以为成了,结果第一个用户传了张 3MB 的图, 拿到一坨 HTML,来问你「接口是不是坏了」。

六、三层防护,强弱不一样

层 在哪里 怎么坏
代码 第 1~11 章 测试能挡住
环境 第 15 章 镜像能挡住
网络与权限 第 15 章 只能靠部署后验

代码层有测试,环境层有镜像,而「线上到底通不通」只能真的打一次。 这也解释了为什么「本地能跑」这句话在工程上几乎不成立—— 它只覆盖了第一层。第 15 章从 Docker 讲到 Cloud Run,讲的就是把第二层和第三层补上。

七、第 3 篇到此结束

篇 做了什么 章
第 0 篇 环境、第一行代码 0
第 1 篇 Flask 的地基:路由、模板、数据库、验证 1~3
第 2 篇 一个完整的物件侦测网站加测试 4~11
第 3 篇 把它做成 API,打包,部署 12~15
第 4 篇 开发机器学习 API 16~17

回头看这 15 章,最值钱的不是「会写 Flask」,是那条链路:从「一个请求进来」到「一个响应出去」, 中间每一步都能说清在干什么。 下一篇开始第 4 篇,换方向:不再「用」别人训练好的模型, 而是自己做一个能对外提供服务的机器学习 API。

八、动手 Demo:步骤 4~5:推上去、部署、然后逐条验

这个 Demo 前三节是那几条命令(推不上去,本沙箱既没有 Docker 服务也没有 GCP 账号), 第四节是把六条验收在本地真打一遍。 第四节的六行是这一篇唯一能真验的东西,每一行都是真请求、真状态码。

gcloud run deploy detect-api \
  --image gcr.io/my-project/detect-api:v1 \
  --platform managed \
  --region asia-east1 \
  --allow-unauthenticated \
  --memory 1Gi --cpu 1 --timeout 60

实跑输出:

--------------------------------------------------------------------
步骤 4~5:推上去、部署、然后逐条验
--------------------------------------------------------------------

  ① 步骤 4:推到 GCR
  --------------------------------------------------------------------

  [平台原样 · 本沙箱不执行]
  | docker tag detect-api:v1 gcr.io/my-project/detect-api:v1
  | docker push gcr.io/my-project/detect-api:v1
  | gcloud container images list-tags gcr.io/my-project/detect-api
  教材 15.8。tag 是「再起个名字」,push 是「传上去」。

  ! docker tag 不改文件,只是给同一个镜像再挂一个名字。所以它几乎不花时间,也不是复制。
  ! push 是分层传的:本地已有、仓库也有的层会跳过。
  所以第一次推慢,改一行代码再推就快——这也是为什么 Dockerfile 要分层写。

  ② 步骤 5:部署
  --------------------------------------------------------------------

  [平台原样 · 本沙箱不执行]
  | gcloud run deploy detect-api \
  |   --image gcr.io/my-project/detect-api:v1 \
  |   --platform managed \
  |   --region asia-east1 \
  |   --allow-unauthenticated \
  |   --memory 1Gi --cpu 1 --timeout 60
  教材 15.9。参数不少,但每一个都能说清为什么。


| 参数 | 为什么 |
|---|---|
| `--platform managed` | 托管版,不用自己管机器 |
| `--region` | 放哪个机房,跟着项目走 |
| `--allow-unauthenticated` | 允许匿名访问,**公开 API 才加** |
| `--memory 1Gi` | 模型占内存,给小了会被 OOM 杀掉 |
| `--timeout 60` | 单请求最长 60 秒,超了直接断 |

  ! --allow-unauthenticated 这一条要想清楚。不加的话,所有请求都要带 Google 的身份令牌。
  做内部服务时这是对的;做公开 API 时加了它,等于谁都能调、谁都能烧你的额度。
  ! --memory 给小了会被 OOM 杀掉,而且报错信息很不直观——容器直接退出,日志里能看到 Killed,
  但不会说「内存不够」。
  ! 这两个参数加起来,就是「部署」这件事真正的决策点:给谁用、给多少资源。

  ③ 部署完,怎么验证
  --------------------------------------------------------------------

| 验证项 | 怎么打 | 期望 |
|---|---|---|
| 健康检查 | GET /api/health | 200 且含 status |
| 正常一张图 | POST /api/detect | 200 且 count ≥ 1 |
| 缺文件 | POST /api/detect 空请求 | 400 且是 JSON |
| 类型不对 | 传 .txt | 415 且是 JSON |
| 太大 | 传超限文件 | 413 且是 JSON |
| 不存在的路径 | GET /nope | 404 且是 JSON |

  ! 六项里四项是错误路径。
  这不是苛刻,是因为正常路径你本地已经测过一百遍了,
  部署之后变的往往是错误路径——尤其是 413 和 404 这两项,本地跑着 dev server 时行为和线上不一样。

  ④ 拿本地服务把这些项真走一遍
  --------------------------------------------------------------------
  健康检查 -> 200  JSON  {"status":"ok"}
  正常一张图 -> 200  JSON  {"count":1,"tags":[{"label":"spot"}]}
  缺文件 -> 400  JSON  {"error":"no file"}
  类型不对 -> 415  JSON  {"detail":"txt","error":"unsupported"}
  太大 -> 413  JSON  {"error":"payload too large"}
  不存在的路径 -> 404  JSON  {"error":"not found"}
  ! 六项全对:200、200、400、415、413、404,而且每一项都是 JSON。
  ! 最后一项用的是 /api/nope 而不是 /nope。
  差别很实在:错误处理器按路径前缀分流,/nope 走的是页面那条路,返回 HTML——要验 JSON,
  请求就得落在 /api 下面。
  ! 第五项那个 413 是 @app.errorhandler 补出来的——第 14 章那个洞,在这儿成了验收清单上的一项。
  ! 这六条可以写成一个脚本,每次部署跑一遍。
  第 11 章那些测试是在代码层验,这六条是在「部署之后」验。
  ! 少了这一步的后果很具体:部署完了,页面能打开,你以为成了,结果第一个用户传了张 3MB 的图,
  拿到一坨 HTML,来问你「接口是不是坏了」。

  ⑤ 这一章之后
  --------------------------------------------------------------------

| 层 | 在哪里 | 怎么坏 |
|---|---|---|
| 代码 | 第 1~11 章 | 测试能挡住 |
| 环境 | 第 15 章 | 镜像能挡住 |
| 网络与权限 | 第 15 章 | 只能靠部署后验 |

  ! 三层的防护强度是不一样的。代码层有测试,环境层有镜像,而「线上到底通不通」只能真的打一次。
  ! 这也解释了为什么「本地能跑」这句话在工程上几乎不成立——它只覆盖了第一层。

  ⑥ 全书到这里
  --------------------------------------------------------------------

| 篇 | 做了什么 | 章 |
|---|---|---|
| 第 0 篇 | 环境、第一行代码 | 0 |
| 第 1 篇 | Flask 的地基:路由、模板、数据库、验证 | 1~3 |
| 第 2 篇 | 一个完整的物件侦测网站 + 测试 | 4~11 |
| 第 3 篇 | 把它做成 API,打包,部署 | 12~15 |
| 第 4 篇 | 开发机器学习 API | 16~17 |

  ! 回头看这 15 章,最值钱的不是「会写 Flask」,是那条链路:从「一个请求进来」到「一个响应出去」,
  中间每一步都能说清在干什么。
  ! 下一篇开始第 4 篇,换方向:不再「用」模型,而是「做」一个能对外提供服务的机器学习 API。

第一节 docker tag 不是复制,第三节 docker push 也不是全量重传。 第四节六项里有四项是错误路径,这是刻意排的。 第六节那张五行的表是全书索引,后面还剩第 4 篇。

30 秒实验

  1. 把这六条验收写成一个脚本,跑一次打印六个状态码。
  2. 给 /api/detect 打一个 10MB 的请求,看 413 的响应体里说没说上限是多少。
  3. 想一想:为什么回滚不需要重新构建镜像?

常见误区

  1. 用 latest 部署:出问题不知道回哪个版本。
  2. --allow-unauthenticated 随手加:内部服务变成公开接口。
  3. 部署完只看首页能不能打开:错误路径全返回 HTML 也没发现。
  4. --memory 给太小:被 OOM 杀掉,日志里只有 Killed。
  5. 回滚时重新 build 一遍:没必要,切修订版流量就行。
自测三问(点开对答案) 1. docker tag 会复制镜像吗? —— 不会,只是给同一堆层再挂一个名字。 2. 为什么第二次 push 快? —— 按层传,仓库里已经有的层直接跳过。 3. 部署完最少验哪几项? —— 六项:健康检查、正常一张图、400、415、413、404,而且每一项都该是 JSON。

第 4 篇从这里开始:不再调别人训练好的模型,而是自己做一个能对外服务的机器学习 API。

相关笔记