零到全栈 · 课程

模块 7.1:先上线——最朴素的部署方案

第一次把全栈应用 zero-to-tech 发布到互联网。先用一个最朴素的部署方案,包含配置、环境、服务器操作等。


明确目标,清点行囊

清点行囊,现在 zero-to-tech 项目由三样东西组成:

  • 静态前端,基于 React 和 Next.js 开发,可以构建为静态前端代码
  • FastAPI 后端,基于 Python 和 FastAPI 开发,通过 Uvicorn 提供服务
  • SQLite,对应硬盘上的一个 .db 文件

然而,距离上一次把 zero-to-tech 项目发布到服务器上,已经过去整整十几节课了。现在通过浏览器访问服务器的 IP 地址,还可以看到它,但那还仅仅是一个静态前端网站。

目前我们已经实现了后端 API、建了数据库、做了会话,只不过全是在本地开发、本地运行的。

在开发后端功能时,前端也有一些调整,比如说我们给它加了 fetch,让它可以请求后端 API,还添加了历史记录列表、会话这些 feature。

所以如果要做全栈部署,我们前后端都要做,只不过前端只需要推送代码、重新构建。而后端要从零开始部署。

这就是这一节的起点。


如果要让一个本地可运行的项目发布成为一个能够为用户提供价值的产品,仅仅发布到服务器上还并不够,我们还需要关注它后续的迭代、维护,以及运营、安全和用户体验。

我们接下来会分开聊一聊这些怎么做。在模块 7,我们会用 6 节课来展开:

  • 7.1(本节):用最朴素的办法把整套跑通、上线;
  • 7.2:换成更易于维护的部署方式,并通过反向代理实现前后端同源;
  • 7.3:域名与 HTTPS;
  • 7.4:通过日志与统计数据观察用户行为;
  • 7.5:把存储换成 PostgreSQL;
  • 7.6:调优、安全、一键发布。

如何部署

“部署"说到底是一句话:把在我们自己电脑上能跑的东西,在另一台机器上(尤其是服务器)跑起来。

我们这个项目,虽然说是有前端、后端和数据库三部分,但是 SQLite 数据库基本上就是后端的一部分。因为它的安装、创建、使用都是基于 Python 的能力。所以我们可以理解为我们要部署的就是前端和后端。

朴素的部署方案

服务器部署一般也会叫“线上部署”,或者“上线”,这些基本都是同样的意思。

我们的项目在本地运行的时候,是前端通过 npm run dev 监听 3000 端口,后端通过 fastapi dev 监听 8000 端口(背后是 Uvicorn)。我们自己可以通过浏览器访问 3000 端口获取页面,接着页面中的 js 脚本再通过 fetch 访问 8000 端口的后端程序,实现交互。

既然服务器也只是一台电脑,我们可以用一种朴素的方式来思考部署方案:

前端监听服务器的一个端口,后端监听服务器的另一个端口,用户通过浏览器获取前端页面,页面中的 js 脚本再通过 fetch 访问后端程序。

只不过前端不需要监听 3000 端口了,我们前面讲前端的时候一直是用 Nginx 监听 80 端口并转发前端资源的(反而在本地监听 3000 端口只是为了方便开发的“权宜之计”罢了)。所以在这个朴素的部署方案中,前端仍然通过 Nginx 来代理,通过 80 端口给到用户。

而后端则是与本地运行一样的模式,通过 Uvicorn 的能力,在 8000 端口提供服务。

按照这种“朴素的部署方案”,那许多事情对于我们来说,就不是一个全新的知识领域,有不少我们已经知道的东西。但是也存在一些我们要了解的新知识点。

我们已经知道的

1. 前端部署流程

关于前端,我们已经知道该如何部署——先把代码推到服务器,然后构建为静态前端代码,交给 Nginx 就好了。我们在模块 3、4 的时候对此已经轻车熟路,等下我们基本可以按照曾经讲过的前端部署方案来部署。

只不过有一处需要注意——我们在 5.5 那一节的时候,给前端加了一个配置文件 .env.local,而这个配置文件并没有被 git 所追踪,所以等下我们要处理它(先记着账)。

2. Python 环境

我们知道电脑上要有 Python 环境,这是后端运行的基础。在服务器上安装 Python 并不复杂,Ubuntu 自带了 Python,如果我们想要其他版本也可以很轻松地安装。我们已经知道如何安装 Python。

3. .venv 和依赖

我们知道要在 Python 的基础上安装项目依赖,例如 fastapi、uvicorn、pypinyin、snownlp,并且我们用 .venv 来管理这些依赖。我们此前介绍过 requirements.txt,通过 pip install 是可以把依赖都安装回来的。包括 venv 也是 Python 官方自带的能力,所以这个问题也不大。我们已经知道如何使用 venv,也知道如何通过 requirements.txt 文件来 install 后端依赖。

4.代码和数据库

我们知道项目代码要完备,目前我们有 main.py、storage.py,代码运行的时候会自动创建 history.db。关于拉取代码到服务器,我们已经很熟悉——前端代码是怎么拉到服务器上的,后端代码就如何拉到服务器上,背后是 git 和 GitHub 在发挥作用。而关于数据库,如果是 MySQL 或者 PostgreSQL 这样的数据库,部署的时候可能会有一些麻烦,但是我们用的是 SQLite,后端代码运行的时候会自动创建的。这也是我们已经知道的。

5.启动后端服务

我们知道需要通过 Uvicorn 监听一个端口来提供 API 服务,我们在本地一直是使用 fastapi dev 命令来自动调用 Uvicorn,默认监听 8000 端口。

以上这些都是我们已经知道的。

我们会遇到哪些挑战

但是,服务器部署与本地运行毕竟还有显著差异(不然也不会有我们今天这节课了)。如果只凭我们目前已经知道的这些去部署,还会遇到下列的挑战:

挑战 1. 缺失前端配置文件

.env.local 文件不进 git,所以当我们把项目代码 pull 到服务器上之后,服务器上没有任何前端配置。

而且服务器上要的也不是 .env.local 这个名字——.local 的意思就是"本机私有”,它是给我们自己开发用的。线上构建要的是另一个名字,等下会讲到。总之前端的配置得在服务器上手动建一份,不然构建出来的前端不知道该去哪儿找后端。

这属于是配置文件的问题。

挑战 2. 后端配置不匹配

后端代码 main.py 中有一个 CORS 中间件(CORSMiddleware),其中有这样一行代码

allow_origins=["http://localhost:3000"],

这行代码在线上运行时候肯定会出问题,因为 localhost 的意思就是本地。我们的项目部署之后是需要用户通过互联网访问的,这行代码会“水土不服”。

讲道理,这种内容就不应该写在代码里,它应该写进单独的一个配置文件,因此这个和上一个前端问题一样,也属于是配置文件问题。

挑战 3. 仅支持本地访问的 fastapi dev

本地通过 fastapi dev 来启用 Uvicorn 监听端口没问题,但是它有一个问题在于它只接收本地的访问,也就是发请求的电脑也是运行程序的同一台电脑。按照我们现在的部署逻辑,用户要在自己的电脑上通过 js 向我们的服务器的 8000 端口发请求,所以这个方案会行不通。

但好在 FastAPI 的文档有介绍,如果面向公网提供服务,需要用 fastapi run 命令。用这个命令运行的时候,Uvicorn 就不止能够处理来自本地的请求了。

挑战 4. 终端窗口不敢关闭

我们在本地运行 zero-to-tech 项目的时候,前后端分别需要一个终端窗口,窗口关闭服务就停了。部署到服务器之后,前端可以不再依赖持续开着的终端窗口,因为前端的部署方案是构建为静态页面,由 Nginx 去提供服务。

但是后端就比较尴尬——后端的终端窗口要一直开着,一旦关闭,后端服务就停了。

挑战 5. 防火墙

我们的服务器在云平台上,前端和后端目前分别通过 80 端口和 8000 端口监听请求。80 端口已经开放了,我们在讲前端的时候就已经确认过这件事。但云平台的安全组 / 防火墙默认情况下不会开通 8000 端口。我们需要手动去给它放行。


方案定了,会撞上的问题也预判到了,接下来就是一个一个解决。把五条挑战归拢一下,其实是四件事:

  1. 处理配置文件(挑战 1、2)——这件得在本地办,上服务器之前就要办完;
  2. 换成 fastapi run(挑战 3)——一条命令的事;
  3. 开通防火墙(挑战 5)——去云平台控制台点一下;
  4. 让后端在后台常驻(挑战 4)——等前面都跑通了,最后办。

配置文件

先办第一件:挑战 1 和挑战 2。

这两条看着一个在前端、一个在后端,其实是同一类问题——都是"某个值不该写死,它得随着机器变"。而且它们都得在本地办完再推上去,所以放在上服务器之前。

什么是配置

同一份代码,跑在我们自己的电脑上和跑在服务器上,有些东西就是会不一样,比如说前端 / 后端地址。因为不同的服务器 IP 不同,地址自然也会不同。

除此之外,还有其他的一些信息值得放在配置中,包括:

  • 前后端地址
  • 数据库地址和连接配置(包括密码)
  • 第三方服务密钥(例如大模型 API key)
  • 服务监听哪个端口
  • 日志写到哪儿、要不要开调试模式

这些可以写在同一个配置文件中,用类似下面这种方式来写:

名称a=xxx
名称b=123

把这些保存为一个文件,代码就可以从中读取到运行所需的参数了。这个文件就叫做配置文件。

这些东西不属于代码。 代码回答的是"怎么做",而配置回答的是"这一次,对着谁做"。

代码和配置之间是相互配合的。同一份代码,如果配上不同的配置,就能服务不同的环境。我们这个项目里,有两处和地址相关的配置。

一处就是前端的 .env.local 文件:

# 前端根目录,.env.local
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000

这个文件里写了后端的访问地址,并赋值给一个常量 NEXT_PUBLIC_API_BASE_URL,前端在请求后端的时候就会顺着这个常量找到 http://localhost:8000 这个地址。

另一处在后端,就是前面提到的这行:

    allow_origins=["http://localhost:3000"],     # 5.5 加的

它本不应该写死在代码里,而是应该也抽取为一个配置文件。5.5 那一节的时候我们提过这件事,当时说 “后端这类配置,留到部署那一节一起处理”。

今天就是那一节。

配置文件的命名、读取

配置文件的命名有一些松散的规矩,基本上会叫做 .env,但之所以说松散,是因为有时候也可能不会这么命名,这取决于框架认不认它。

比如说我们现在项目中唯一的一个配置文件,就是前端的 .env.local

为什么是叫 .env.local 而不是 .env 呢?其实 Next.js 两个都认,但后缀在传递不同的意思:.local 表示本机私有(即别人机器上没有,也别提交)。

同时 Next.js 还认下面这几种写法:

  • .env.development.local
  • .env.development
  • .env.production

.production 表示生产环境构建时才用。5.5 那份配置本来就是本机私有的,所以叫 .env.local。

有余力的话,可以了解一下 Next.js 到底是怎么找配置文件的。

它不是"只认一个",而是按固定顺序找一串,先找到的值优先。以 npm run dev 为例,顺序是:

.env.development.local  →  .env.local  →  .env.development  →  .env

npm run build 时则把中间的 development 换成 production。

这个顺序解释了两件事:其一,.env 也是认的,只是优先级最低,所以适合放"各环境通用"的值;其二,.local 那几个排在前面,意味着本机的设置永远盖过公共设置——这正是"本机私有"该有的待遇。

上述说的是前端 Next.js 的配置文件。但是 Python 后端的要求就和 Next.js 不同,我们等会儿使用 python 代码来读取配置文件的时候,它认的就是 .env,这种情况下就不需要加其它后缀。

有了这些配置文件之后,只需要把它们放在项目的指定位置,在代码中通过约定的方式就可以读取到。比如说前端配置文件就放在前端项目的根目录,而后端配置文件就放在后端项目的根目录(在我们 zero-to-tech 项目中,后端配置文件需要放在 /backend 下)

此外,python 读取配置文件的时候,需要使用一个 python-dotenv 库,等会儿我们要安装一下。

为什么配置文件名称前面有个点?这是因为点开头的文件是隐藏文件,用隐藏文件写配置也是一种心照不宣的约定。

配置文件与 git

我们在 5.5 那一节讲前端配置文件的时候,提到过一个知识点——配置文件不进 git

这是因为 git 在很多时候会用来在不同的设备之间同步代码(比如说本地电脑与服务器之间),而配置文件又和具体的设备有关,所以它们不需要同步。

此外还有一个更重要的原因,那就是配置文件中很可能会包含密码、密钥这些信息。这些信息的保密程度显然比代码的保密程度要高,因此 绝对不能随着代码推送到远程仓库,尤其是 GitHub 的公开仓库!

这种情况下,我们就需要把配置文件写入到 .gitignore 中,明确要求 git 忽略它。

README 与 .example

让 git 忽略配置文件是出于安全考虑,但它会引起一个问题——别人拿到代码之后跑不起来,并且不知道需要补充哪些配置,也不知道配置文件应该叫什么名字、按什么格式写配置,就会“玩不转”这个项目。

这怎么办?

按照惯例,所有项目的根目录中,都需要有一个 README.md 文件,我们此前的章节中也多次遇到过这个文件。比如说在 GitHub 上创建一个新的仓库时有一行 “Initialize this repository with a README”,就是在询问是否需要建一个 README 文件;我们在判断一个开源的库靠不靠谱的时候也会去看它的 README 文件。

这个文件的作用就是告诉拿到这份代码的人这个项目是怎么回事,解决什么问题,代码结构是怎么样的,以及如何运行它。

有了 README 文件,就可以解决配置文件缺失的问题了——可以把配置文件的要求写在 README 里,人们拿到代码之后照着 README 中写的去创建配置文件就行了。

但是,关于配置文件,大家还会有一个心照不宣的约定,那就是写一份示例配置文件,叫做 example,放在项目中。

比如说,我们项目中依赖一个 .env 配置文件,这个文件不能进 git 代码仓库,那我就创建一个 .env.example 文件(也有人叫 .env.sample、.env.template,都是一回事)。

它和 .env 长得一模一样,键和名都有,只是值全是示例。因为没有真的值,所以就可以进 git 了。它在代码运行的时候也不会真的生效,它的作用就是给我们去看配置文件应该怎么写。

动手处理配置文件

后端配置目前还是写死在代码里,所以我们先处理它。需要干两件事。

  1. 把值搬出去——挪到一个专门放配置的文件里;
  2. 让代码去读它——让程序启动时自己去那个文件里取。

一件一件来。

第一件:把值搬出去。 在 backend/ 下新建一个 .env 文件:

# backend/.env
ALLOWED_ORIGINS=http://localhost:3000

实际的配置就一行(顶部那行 # 开头的是注释,不会被代码读取)。

按照前面说的,这个文件不能进 git。打开 .gitignore 确认有这一行:

.env*

没有就现在加上。然后 git status 看一眼——backend/.env 不该出现在待提交列表里。

这一步别跳过。“配置不进 git"不能靠"我记得应该被挡住了”,要靠亲眼看一次 git status。

第二件:让代码去读它。 需要改一下后端 python 代码 main.py:

在顶部先引入两个库:

import os
from dotenv import load_dotenv

紧接着,读取配置文件:

load_dotenv()                        # ← 读同目录下的 .env

ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS").split(",")

然后,修改中间件的 allow_origins 的值,不再使用写死的 http://localhost:3000,而是换成从配置文件中读取出来的动态的值:

app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWED_ORIGINS,   # ← 不再写死,从配置来
    allow_credentials=True,
    ...
)

注意,这个 dotenv 不是 python 标准库的成员,所以我们需要手动安装一下,并且记录到 requirements.txt 中

cd backend
source .venv/bin/activate
pip install python-dotenv
pip freeze > requirements.txt        # 老规矩,记账(6.1)

这个 dotenv 只干一件事:把 .env 里的值读进环境变量。

改完之后本地验证一下。 后端用 fastapi dev 跑起来,前端 npm run dev,文字实验室照常能用的话,就说明后端配置文件改好了。想更确定,把 .env 里那行改成一个别的地址、重启后端,再点分析——这次真被拦了。再改回来就可以了。

至此,后端配置就单独抽取成一个配置文件了。接下来我们创建前后端配置文件的示例文件。

创建配置示例文件

在项目根目录 ~/zero-to-tech/ 下创建 .env.example 文件:

NEXT_PUBLIC_API_BASE_URL=http(s)://[ip]:[port]

在项目的后端根目录 ~/zero-to-tech/backend 下创建 .env.example 文件:

ALLOWED_ORIGINS=http(s)://[ip]:[port]

注意键要写全,值给一个能用的示例,或者写有指引性的内容——这样别人 cp .env.example .env 之后,改值就行,不用再去猜键叫什么。

这样,两个 example 文件就建好了。

不过这里会有一个问题。我们的配置文件虽然不需要让 git 追踪,但是这两个 .example 文件还是要随着代码一起提交的,所以不能被忽略。现在 .gitignore 的写法是 .env*,会把这两个文件也拒之门外,所以我们需要允许它们被追踪,在 .gitignore 的 .env* 下面添加一行:

!.env.example

这样,就把示例配置排除在列表之外了,也就意味着它们可以随着代码提交了。

提交代码

到这里,本地的活基本全干完了——代码改了、配置样例建好了。一起推:

git add -A
git commit -m "CORS 名单改为从配置读取;补 .env.example"
git push

这次推上去的包括:main.py(读配置的代码)、requirements.txt(多了一个 python-dotenv)、两份 .env.example、还有改过的 .gitignore。


服务器部署

按照朴素的部署方案,接下来我们只需要把代码同步到服务器上,并且在服务器上准备所需的环境,然后前端构建,后端用 fastapi run 运行起来就可以了。至于终端窗口的后台运行和防火墙,我们执行到对应步骤的时候再介绍。

先拉取代码。


拉取代码

拉取代码动手就快,姿势要帅。先 SSH 登录到服务器上:

ssh 用户名@你的服务器IP
cd ~/zero-to-tech
git pull

前后端代码都在这一个 git 仓库中,所以这次 pull 就把前后端代码都拉到服务器了。

再 ls backend/ 查看,后端目录中应该会有这些内容:

backend/
  main.py
  storage.py
  requirements.txt
  .env.example

这里应该有代码文件,有配置示例和 requirements.txt,但没有 .venv/,没有 history.db,也没有 .env。这是我们自己定的规矩正在生效——代码走 Git,依赖和数据不随行。

准备 Python 环境

接下来要检查服务器的 Python 安装情况

python3 --version     # 有没有?版本够不够?
which python3         # 用的是哪一个?

Ubuntu 自带 Python 3,所以第一句多半直接给你一个版本号。但这两句都不能省,它们问的是两件不同的事:

  • --version 回答"有没有、够不够"。fastapi、snownlp 这些库都有各自的最低 Python 版本要求,太老的装不上——在装依赖之前发现版本不够,和装到一半才报错,是完全不同的两种体验;
  • which 回答"用的是哪一个"。一台机器里可能住着好几个 Python,python 和 python3 还各指各的。如果不是自己装的机器,这个问题会更常见。

如果是 python3.10 或更高的版本,一般就没问题。

如果 Python 版本过低,或者服务器上没有安装 Python,那就需要安装。本节课不再介绍 Python 安装相关的内容。

Node 呢? 不用装——4.6 部署前端的时候我们就在这台服务器上装过 Node 并且 build 过了。不放心的话敲一句 node -v 确认一下就好。


安装依赖

我们在本地用 venv 来管理 python 环境,服务器上也用 venv:

cd backend
python3 -m venv --prompt=zero-to-tech .venv
source .venv/bin/activate      # 提示符出现 (zero-to-tech)
pip install -r requirements.txt

建 venv 那一步 Ubuntu 可能会提示:venv 组件没装,需要先 sudo apt install python3-venv(或提示里给出的具体包名,如 python3.12-venv),如果出现则照提示装即可,装完再重跑 venv 命令。

执行 pip install 的时候,就会按照 requirements.txt 逐个安装 fastapi、uvicorn、pypinyin、snownlp 这些依赖了,可能会需要一些时间。


写配置文件

依赖装完了,下一步是准备配置文件

先写前端的配置文件

cd ~/zero-to-tech
cp .env.example .env.production
vim .env.production

在里面写

NEXT_PUBLIC_API_BASE_URL=http://服务器ip地址:8000

再写后端配置文件

cd ~/zero-to-tech/backend
cp .env.example .env         # 照抄一份
vim .env                     # 再改值

在里面写

ALLOWED_ORIGINS=http://服务器ip地址

注意这里没有端口号,别顺手写成 :3000,也不要写 :80

这两份配置值的变化规律并不一样,值得对照着看一眼:

配置本地线上变了什么
NEXT_PUBLIC_API_BASE_URLhttp://localhost:8000http://服务器IP:8000只换了地址
ALLOWED_ORIGINShttp://localhost:3000http://服务器IP地址换了,端口也没了

为什么后端这份连端口都变了?因为 ALLOWED_ORIGINS 登记的是"允许谁来找我",也就是前端页面所在的那个源。本地的前端跑在 3000 端口上,而线上的前端是 Nginx 在 80 端口提供的——80 是 http 的默认端口,浏览器发出的 Origin 里根本不带它。

所以配置值要跟着"那台机器上的事实"走,不能照着本地做字符串替换。 这也正是配置存在的意义:同一份代码,在不同机器上适配各自的实际环境。


跑起来

这一步顺手解决挑战 3。

跑起来的时候,要分别跑前端与后端。前端直接构建就可以了

cd ~/zero-to-tech
npm install
npm run build

后端正式上线用 fastapi run

cd ~/zero-to-tech/backend
fastapi run

启动日志(示意,以你实机为准):

FastAPI   Starting production server 🚀
 server   Server started at http://0.0.0.0:8000
   INFO   Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)

可以看到背后仍然是跑了 Uvicorn,并且是跑的地址是 0.0.0.0,不是 127.0.0.1,这是 run 和 dev 的一个重要区别:

fastapi devfastapi run
改代码自动重启有没有
默认监听127.0.0.1——只听本机0.0.0.0——听所有网卡
给谁用自己电脑上开发服务器上跑给全世界用
  • 127.0.0.1——只接受本机内部的连接。外面的世界连不进来;
  • 0.0.0.0——听所有网卡,外面也能连进来。

线上环境验证

服务跑起来了,接下来需要验证它运行正常。在通过互联网验证之前,需要首先确保服务器本地的运行是成功的。再开一个终端窗口、再 SSH 一次。

先验证后端接口访问正常:

curl localhost:8000/api/profile

如果能看到 JSON 返回,就说明后端服务已经起来了,正在监听 8000 端口并且能在服务器本地请求。

再验证数据库已经初始化。

ls ~/zero-to-tech/backend/

如果可以看到 history.db,则说明数据库已经初始化完成了。

如果要完整验证,还得用 curl 做一次分析、再取一次 history,但没必要。我们迫不及待想打开浏览器,从公网访问一次真正的页面了。


开通防火墙

轮到挑战 5。

我们的服务器在云平台上,前端和后端目前分别通过 80 端口和 8000 端口监听请求。80 端口已经开放了,我们在讲前端的时候就已经确认过这件事。但云平台的安全组 / 防火墙默认情况下不会开通 8000 端口。我们需要手动去给它放行。

这个方式和此前给 80 端口放行的方式是一样的。

8000 端口开启之后,用本地电脑访问 http://服务器IP:8000/api/profile,能获取到返回的 JSON,就说明 8000 端口已经通了。

测试

通过浏览器访问 http://服务器ip地址,就可以访问到前端页面了。此时访问文字实验室页面,对一句文字进行分析。这一步可以测试后端服务是否正常运行。

点击文字实验室的历史记录,查看是否有分析过的那句话。这一步可以测试数据库是否正常工作。

更换浏览器(或者开启无痕模式),换一句文字再做分析,然后查看历史记录——这一次应该看不到刚才那个浏览器分析过的内容,只有新浏览器自己打的那一句。这一步测试的是 6.6 做的会话机制:换了浏览器就是换了访客,各人只看得到自己的那一份。

如果都是正常的,说明我们已经成功把 zero-to-tech 部署上线了。我们现在不仅可以通过电脑来访问,甚至也可以通过手机浏览器来访问它。


服务端的后台运行

只剩最后一条了:挑战 4。

网站能用了,很想合上笔记本去睡觉。

先别急。 做个实验:直接关掉 SSH 窗口。

再刷新网站——页面还在(那是 Nginx 送的静态文件),但点分析按钮,转圈、报错。

前端没问题,但后端程序死了!

原因是 uvicorn 目前是个前台进程,它寄生在我们的 SSH 会话里,我们走了它就没了。

“会话"这个词又出现了。 我们和服务器之间那段连着的关系就是 SSH 会话,从登录那一刻开始,到退出那一刻结束。

我们希望 uvicorn 的运行不依赖会话,我们关掉终端之后它仍然可以持续在服务器上运行。对于这个需求 Linux 有现成的办法。

我们再次通过 SSH 登录远程服务器,但是这次不再直接执行 fastapi run 命令,而是这样:

cd backend
nohup .venv/bin/fastapi run > backend.log 2>&1 &

cd backend 我们已经很熟悉,关键是第二行:

nohup .venv/bin/fastapi run > backend.log 2>&1 &

一句话四个零件,逐个拆:

  • 末尾的 &——把这条命令丢到后台跑,终端不用干等着;
  • 开头的 nohup——”no hang up",别挂断。它让这个进程不再随着 SSH 断开而被杀掉。光有 & 还不够:& 只是不占着终端,人一走它照样陪葬;
  • > backend.log——把输出重定向进一个文件。以前日志直接打在终端上,现在没终端可打了,不接住就全丢了;
  • 2>&1——认个脸:把错误输出也一并塞进同一个文件。(1 是正常输出,2 是错误输出,这句的意思是"2 号跟着 1 号走"。)报错和正常日志混在一处,翻起来方便。

敲完回车,屏幕上会蹦出一个数字——那是进程号(PID)。

顺带留意这次写的是 .venv/bin/fastapi 这个完整路径,而不是先激活环境再跑 fastapi。因为丢到后台的进程不见得还带着我们 activate 出来的那套环境——指名道姓,最保险。

现在再来一遍那个实验:关掉 SSH 窗口,再次访问我们的网站,这一次应该就可以正常访问了。

回顾并写入 README.md

当所有的部署工作已经做完,我们就可以安心写 README 文件了。

注意这个顺序:我们不是先写好文档再照着做,而是做完了、验证过了,再把走通的那条路写下来。这两者的差别很大——前者写出来的是"我以为该这么做",后者写出来的才是"这么做真的可以"。

一份过时或者想当然的 README,比没有更糟。 没有的话,人家会来问你;写错了的话,人家会照着走进坑里。

打开项目根目录的 README.md,把这一节的成果补进去:

# 文字实验室

一个中文文本分析小工具:输入一段话,给出情感倾向评分和全文拼音,
并把每次分析的结果存下来,各人只看得到自己的那一份。

零到全栈课程的贯穿项目。

## 技术栈

- 前端:Next.js(静态导出)+ React
- 后端:FastAPI + uvicorn
- 分析:snownlp(情感)、pypinyin(注音)
- 存储:SQLite
- 线上:Nginx

## 本地跑起来

需要:Node.js 18+、Python 3.10+

**后端**

```bash
cd backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env          # 按下面「配置说明」填好
fastapi dev                   # → http://localhost:8000
```

**前端**(另开一个终端)

```bash
npm install
cp .env.example .env.local    # 按下面「配置说明」填好
npm run dev                   # → http://localhost:3000
```

## 部署到服务器

前提:服务器上已装好 Python 3.10+、Node.js 18+ 和 Nginx,
且 Nginx 的站点根目录已指向本项目的 `out/`、监听 80 端口。

**1. 拉取代码**

```bash
cd ~/zero-to-tech
git pull
```

**2. 前端:装依赖、写配置、构建**

```bash
npm install
cp .env.example .env.production   # 按下面「配置说明」填好
npm run build                     # 产物进 out/,由 Nginx 提供服务
```

**3. 后端:建环境、装依赖、写配置**

```bash
cd backend
python3 -m venv --prompt=zero-to-tech .venv   # 首次部署才需要
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env              # 按下面「配置说明」填好
```

**4. 后端:在后台跑起来**

```bash
nohup .venv/bin/fastapi run > backend.log 2>&1 &
```

`fastapi run` 是生产模式,监听 `0.0.0.0:8000`;`nohup ... &` 让它在
SSH 断开后继续运行,日志写进 `backend.log`。

查看日志、停止服务:

```bash
tail -f backend.log           # 看日志
ps aux | grep fastapi         # 找到进程号
kill 进程号                    # 停掉
```

**5. 放行 8000 端口**

去云平台控制台的安全组 / 防火墙,放行 8000 端口(80 端口应该已经放行)。

**6. 验证**

浏览器访问 `http://服务器IP`,打开文字实验室做一次分析,再看历史记录。
换一个浏览器(或无痕窗口)再试一次,两边的历史记录应该是互相看不到的。

## 配置说明

配置文件不进 Git,请照着 `.env.example` 自己建一份。

**前端**:开发用 `.env.local`,生产构建用 `.env.production`

| 键 | 说明 | 本地 | 线上 |
| --- | --- | --- | --- |
| `NEXT_PUBLIC_API_BASE_URL` | 后端接口地址 | `http://localhost:8000` | `http://服务器IP:8000` |

**后端**:`backend/.env`

| 键 | 说明 | 本地 | 线上 |
| --- | --- | --- | --- |
| `ALLOWED_ORIGINS` | 允许跨源访问的前端地址,多个用逗号隔开 | `http://localhost:3000` | `http://服务器IP`(不带端口) |

写完自己走一遍。 最好的办法是假装自己是第一次拿到这个项目——照着敲,不许凭记忆补任何一步。漏了哪句、少了哪个前提,当场就露馅了。

README 是活的,它跟着项目一起长。 现在这一版够用了:够一个陌生人把项目在本地跑起来,也够他照着部署到自己的服务器上。等下一节我们把部署方式换掉,再回来改它。


思考:现在部署方式好不好

站点上线了,全世界能访问。但它是用最朴素的办法凑出来的,存在一些缺点

第一:后端服务脆弱。nohup 确实让后端活过了 SSH 断开,但它只解决了这一件事,还有一些问题它没解决:

  • 服务器重启,它不会自己起来。服务器毕竟也是一台电脑,虽然它以稳定运行作为主要特征,但仍然还是会有关机重启的可能——云厂商维护、断电、我们操作的时候手滑重启,一旦发生这些事情,网站后端服务就悄无声息地没了;
  • 它崩了也不会自己起来。如果代码里有个 bug 把进程弄死了,就会一直死着,直到我们发现为止;
  • 管起来全靠手工。比如说我们更新了代码,想停掉它重启一次,会是一件比较麻烦的事情(得去找到进程号)。我们甚至想看它现在还是不是活着,都是一件比较麻烦的事儿。
  • 想看日志也比较麻烦,得自己记着日志文件在哪儿。

第二:裸奔的 8000 端口。 8000 这个端口现在一直对公网开着,而且是个裸端口:

  • 这个端口没有日志:谁来访问过 8000 端口、干了什么,无从查起(80 端口就有,Nginx 会记录);
  • 这个端口没有加密:内容明文来回跑。下一节我们要给网站上 HTTPS,而 HTTPS 是配在 Nginx 上的——走 8000 的那些请求,一个字都保护不到;
  • 这个端口没有任何管控:限流、转发规则,一概没有。

多开一扇门,就多一分风险,这也是为什么服务器的防火墙不会默认开那么多端口的原因。

第三:前后端跨源。 浏览器的 CORS 策略对于前后端有太多的限制,虽然我们在开发阶段已经逐个把我们遇到的问题解决了,但是后续我们要上域名、上 HTTPS 的时候,这些跨源问题还会持续困扰我们。我们是否能在部署环节让前后端变成同源的呢?

这三个问题,就是我们下一节要做的事情:

  • systemd 取代 nohup——开机自启、崩溃自愈、一句命令问状态、一句命令看日志;
  • Nginx 反向代理把 /api/ 接进 80 端口,8000 端口不再对外提供服务;
  • 前后端都使用 80 端口之后,就自然变成了同源,配置文件也就更简单了。

下一节,我们就把这套朴素做法一样样换掉。


← 上一节:模块 6.6 状态与会话 | 下一节:模块 7.2 常驻与同源——systemd 与 Nginx 反向代理 →