用生产配置启动的门户后端一直没有就绪
目标
像生产环境一样启动一个真实的 Backstage 后端。用 --config 叠加两个配置文件,通过环境变量传入机密,开启 CORS 以便浏览器界面可以调用,并把数据库迁出内存,使重启后数据仍然保留。评判依据不是文件,而是运行中进程的行为(监听的端口、就绪状态、401/200、响应头、重启后留存的数据)。
为什么重要
在本地能正常工作的后端一上生产,就可能在错误的端口监听,或者进程在运行却没有就绪,或者只有界面发起的 API 调用被拦截,或者每次重启都会丢失已注册的内容。原因大多不在代码,而在于配置叠加的顺序、缺失的环境变量、来源不同的客户端与服务器,以及内存数据库。在脑子里知道合并规则,与在运行中的进程上实际测量它的结果,是两回事。
VM 首次启动大约需要 4 分钟,第 1 步的安装需要从互联网下载,约 30 秒。这台 VM 上没有 docker 和 podman,因此不构建容器镜像(command -v docker 的输出为空)。前端应用也不构建,而是用带 Origin 请求头的 curl 代替浏览器来确认 CORS 响应。
步骤
- 在
/usr/local/cba-prod中以固定版本安装后端软件包。 - 用
--config叠加基础配置和生产配置两个文件来启动,并观察颠倒顺序后会有什么不同。 - 观察缺少
${PORTAL_API_TOKEN}时启动的后端无法就绪,然后通过环境变量传入机密。 - 用
backend.cors.origin只允许门户界面的源。 - 用
APP_CONFIG_环境变量在不修改文件的情况下扩大允许的源。 - 记录在
:memory:数据库上重启后,已注册的 location 会消失。 - 把数据库迁到目录中,使其在重启后仍然保留。
- 用当前进程中测得的值编写生产配置检查清单。
参考
- 生产端口是 7300。就绪检查:
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:7300/.backstage/health/v1/readiness - 日志追加写入
/usr/local/cba-prod/backend.log。每次启动都以Loading config from行开头,NODE_ENV=production 时其后每行是一条 JSON。 - CORS 检查:
curl -s -D - -o /dev/null -H 'Origin: http://portal.example.test:3000' http://127.0.0.1:7300/.backstage/health/v1/readiness - 查看进程的环境变量:
tr '\0' '\n' < /proc/$(pgrep -f 'node index.js' | head -1)/environ | grep -E 'NODE_ENV|APP_CONFIG|PORTAL' - 编写配置文件(--config 顺序、APP_CONFIG_、${VAR}):https://backstage.io/docs/conf/writing
- 供外部调用的静态令牌(externalAccess):https://backstage.io/docs/auth/service-to-service-auth
- 插件数据库配置(better-sqlite3):https://backstage.io/docs/tutorials/configuring-plugin-databases
- 生产部署概述:https://backstage.io/docs/deployment/
- 以 Docker 镜像部署(本 VM 中不涉及):https://backstage.io/docs/deployment/docker
- 架构概述(前端应用与后端):https://backstage.io/docs/overview/architecture-overview
- backend.cors 配置 schema(config.d.ts):https://github.com/backstage/backstage/blob/master/packages/backend-defaults/config.d.ts
以固定版本下载用于测试生产配置的后端
在 /usr/local/cba-prod 中创建 npm 项目(包含 package.json),并以不带范围符号的精确版本安装三个软件包。npm 缓存设为 npm_config_cache=/usr/local/cba-prod/.npmcache。这三个软件包是 @backstage/backend-defaults@0.17.8、@backstage/plugin-catalog-backend@3.9.1 和 better-sqlite3@12.4.1。
先执行 npm init -y,再执行 npm install --save-exact ...。package.json 不只是依赖的记录,也是后端启动时查找项目根目录的依据(没有它,启动会因 NoPkgJsonFound 而崩溃)。
叠加生产文件后,它在另一个端口监听
创建 /usr/local/cba-prod/app-config.yaml(基础)和 /usr/local/cba-prod/app-config.production.yaml(生产)。基础配置:app.baseUrl: http://localhost:3000、backend.baseUrl: http://localhost:7007、backend.listen.port: 7007,数据库为 better-sqlite3、':memory:',catalog.rules: [{allow: [Component, Location]}],location 为 file、./catalog/portal.yaml(Component 为 portal-web)。生产配置:只有 backend.baseUrl: http://localhost:7300 和 backend.listen.port: 7300。创建只包含 catalog 插件的 index.js,以及把后端以 NODE_ENV=production 用 node index.js --config app-config.yaml --config app-config.production.yaml 重新启动的 /usr/local/cba-prod/start.sh(输出追加写入 /usr/local/cba-prod/backend.log),然后运行它。为了对比,请把 --config 的顺序颠倒后启动一次,查看监听的端口,在 /root/cba-prod/order.txt 中留下一行 reversed_listen_port=<그때 포트>(占位符为当时的端口号),再用 start.sh 恢复。
只要给出一个 --config,自动加载默认文件的行为就会关闭,文件按给出的顺序叠加,后面的生效。日志第一行 Loading config from MergedConfigSource{...} 中能看到实际读取的文件及顺序。用 ss -ltnp 确认监听的端口。start.sh 用 setsid nohup ... >> backend.log 2>&1 < /dev/null & 启动,这样 shell 结束后进程仍然存活。
缺少机密就启动的后端不会就绪
在生产文件中通过 backend.auth.externalAccess 添加 type 为 static、token 为 ${PORTAL_API_TOKEN}、subject 为 ops-cli 的条目。先不设置环境变量启动,查看 /.backstage/health/v1/readiness 返回什么,并把日志中含有 Missing required config value 的消息保存到 /root/cba-prod/missing-env.txt。然后在 /root/cba-prod/secrets.env 中写入 PORTAL_API_TOKEN=<직접 만든 24자 이상 무작위 값>(占位符为自己生成的 24 位以上随机值),权限设为 600,修改 start.sh 让它读取该文件并作为环境变量传入,再重新启动。不带令牌访问 /api/catalog/entities 应返回 401,带上 Authorization: Bearer <그 값>(占位符为该令牌值)则应返回 200,且令牌值不能出现在任何 YAML 中。
如果 ${VAR} 替换所用的环境变量不存在,这个值就会整个消失,读取必填值的服务会在启动时失败。进程不会退出,端口仍处于打开状态,于是出现“在运行却不能用”的状态。NODE_ENV=production 的日志是 JSON,用 jq -r .message 读起来比较方便。值可以用 openssl rand -hex 16 或 node -p "require('crypto').randomBytes(24).toString('base64')" 生成。
只有浏览器中的门户界面可以调用后端
在生产文件中添加 app.baseUrl: http://portal.example.test:3000 和 backend.cors.origin: http://portal.example.test:3000,并用 start.sh 重新启动。带上 Origin: http://portal.example.test:3000 请求头发起请求时,响应中应出现值相同的 Access-Control-Allow-Origin;而用 Origin: http://evil.example.test 时不应有该响应头。再发送一次 OPTIONS 预检请求(Access-Control-Request-Method: GET),查看响应码。
前端应用在浏览器中以 app.baseUrl 运行,并调用 backend.baseUrl 的 API。如果两者的源(协议、主机、端口)不同,浏览器会根据后端的 CORS 响应头决定是否允许。curl 不会强制执行 CORS,所以请通过响应头是否出现来确认:curl -s -D - -o /dev/null -H 'Origin: ...' URL。
不修改文件,只在本次部署中扩大 Origin
让本地开发用的界面 http://localhost:3000 也能调用这个后端,但不要修改 YAML。在 start.sh 中把 JSON 数组 ["http://portal.example.test:3000","http://localhost:3000"] 放入 APP_CONFIG_backend_cors_origin 环境变量后重新启动。两个 Origin 都应收到 Access-Control-Allow-Origin,而 http://evil.example.test 仍然不应收到。
APP_CONFIG_ 之后的名称中,_ 会被替换为 . 而成为配置键,值会先按 JSON 解析。环境变量的优先级高于所有配置文件。请观察日志第一行的 EnvConfigSource{count=...} 变成了多少。shell 引号内必须保留 JSON 的双引号。
重启之后,已注册的 location 消失了
用令牌向 POST /api/catalog/locations 发送 {"type":"url","target":"https://git.example.test/portal/catalog-info.yaml"} 进行注册(201),统计 GET /api/catalog/locations 的数量,然后用 start.sh 重启并再统计一次。在 /root/cba-prod/memory.txt 中留下 id=<등록 응답의 location.id>、before_restart=<개수>、after_restart=<개수> 三行(占位符依次为注册响应中的 location.id、重启前的数量和重启后的数量)。
即使注册响应是 201,真正读取该 url 也是后面处理阶段的事(这台主机地址不存在,读取会失败——这里只看注册记录是否留下)。请回想一下数据库配置是什么。
把数据库迁到磁盘,重启后数据仍然保留
在生产文件中添加 backend.database.connection.directory: /usr/local/cba-prod/db(client 会与基础文件中的 better-sqlite3 合并)。用 start.sh 重新启动,再次注册同一个 url,把该 POST 响应 JSON 保存到 /root/cba-prod/persist.json,然后再重启一次,确认 GET /api/catalog/locations/<id> 返回 200。
对象按键逐层深度合并,所以生产文件中只需写 connection。指定 directory 后,每个插件都会生成自己的 SQLite 文件——用 ls 查看生成了哪些文件。生产环境通常使用 PostgreSQL。
用当前进程填写生产配置检查清单
在 /root/cba-prod/report.md 的前五行写入当前运行的后端上测得的值——listen_port=(node 监听的端口)、base_port_open=(能否连接 7007,yes/no)、evil_origin_allowed=(http://evil.example.test 是否收到 ACAO,yes/no)、env_config_count=(最后一次启动日志中的 EnvConfigSource count)、locations_now=(GET /api/catalog/locations 的数量)。在其下写明浏览器界面(app.baseUrl)与后端(backend.baseUrl)是如何通信的、NODE_ENV=production 下日志中出现的警告,以及在这台 VM 上没有构建 Docker 镜像的原因。
数字和 yes/no 是当前测得的值,而不是凭记忆写的。可以用 ss -ltnp、curl、grep 'Loading config from' backend.log | tail -1。警告请在 JSON 日志中查找含有 "level":"warn" 的行。用 command -v docker 确认是否有该工具。