Replit 是在浏览器里写代码、运行和发布应用的平台。很多应用在 Preview 里好好的,一发布就失败,原因多半是端口、监听地址或启动命令。 这篇做一个午餐投票的小后端(只用标准库),配一份符合发布要求的
.replit,再写check_replit.py:解析.replit,检查端口配置、监听地址、发布命令,并实际请求首页测响应时间(发布前的健康检查超过 5 秒可能失败)。
先看清成品
- 应用在 box 上真实运行:监听 0.0.0.0:8000,首页 9 ms,投票接口正常,非法选项返回 400。
- 真实踩到:照文档把
run = [...]和[run.env]同时写,TOML 解析失败。- 检查脚本在 3 个错误配置和一个 6 秒首页上都报出问题;3 项 pytest 通过。
合规提醒:用自己的 Replit 账号,不共享账号;用量和发布费用以 replit.com/pricing 为准,本文不写价格。公开项目的代码任何人都能看到,密钥只放 Secrets。收集用户数据的应用要遵守隐私法规;工作区策略禁止公开发布时,不要把代码挪到个人空间绕过(官方排错文档也这样要求)。
运行说明:我没有在 Replit 上创建项目或发布(没有产生费用),box 上也无法运行 Replit 的环境。真实运行过的是:本文的应用和检查脚本在 box 本机上的运行结果(2026-10-21,UTC+8)。Replit 端的行为全部来自官方文档。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
main.py |
投票应用(标准库) |
.replit |
运行和发布配置 |
check_replit.py |
发布前检查 |
slow_home.py |
6 秒首页演示 |
broken/ |
3 个错误配置 |
test_90a.py |
3 项测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Replit 账号 | 在 https://replit.com 注册 |
| Python | 3.11 以上(用到 tomllib),pip install pytest |
关键事实
| 事实 | 来源 |
|---|---|
.replit 是 TOML;run 可以是字符串或数组;需要环境变量时写成 [run] 表的 args 加 [run.env] |
官方 Configuration |
[deployment] 的 run 是发布后容器启动时执行的命令,build 在之前执行,ignorePorts = true 时不要求端口检查 |
同上 |
[[ports]] 用 localPort 和 externalPort;第一个打开的端口默认对外为 80;额外对外端口只能是 3000、3001、3002、3003、4200、5000、5173、6000、6800、8000、8008、8080、8081 |
官方 Ports |
| Autoscale 和 Reserved VM 只支持一个对外端口,且对应的内部端口不能是 localhost | 同上 |
| 服务必须监听 0.0.0.0;写了 ports 后自动端口检测关闭,必须有 externalPort = 80;首页超过 5 秒健康检查可能超时 | 官方 发布排错 |
| 部署类型:Autoscale(默认)、Static、Reserved VM、Scheduled | 官方 Deployment types |
文件结构
90-replit-A-src/
├── main.py
├── .replit
├── check_replit.py
├── slow_home.py
├── broken/
│ ├── toml/ # run 和 [run.env] 冲突
│ ├── ports/ # internalPort + 两个对外端口
│ └── localhost/ # 监听 127.0.0.1
└── test_90a.py
第 1 步:.replit
# modules 和 [nix] channel 保留 Replit 模板生成的值即可,这里不写死版本
entrypoint = "main.py"
# run 要带环境变量时必须写成 [run] 表(见官方 Configuration 文档);
# 不能同时写顶层 run = [...] 和 [run.env],否则 TOML 解析失败
[run]
args = ["python3", "main.py"]
[run.env]
PORT = "8000"
[deployment]
run = ["python3", "main.py"]
deploymentTarget = "cloudrun"
[[ports]]
localPort = 8000
externalPort = 80
第 2 步:应用
"""午餐投票的最小后端(标准库,无第三方依赖),按 Replit 发布的要求写:
- 监听 0.0.0.0(只监听 localhost/127.0.0.1 的服务发布后无法访问);
- 端口来自环境变量 PORT,默认 8000,并与 .replit 里 [[ports]] 的 localPort 一致;
- 首页要快:发布前 Replit 会请求首页做健康检查,超过 5 秒可能导致发布失败;
- 长期运行,不能跑完就退出。"""
import json, os, threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
VOTES = {"兰州拉面": 0, "黄焖鸡": 0, "沙拉": 0}
LOCK = threading.Lock()
PAGE = """<!doctype html><meta charset="utf-8"><title>午餐投票</title>
<h1>今天中午吃什么?</h1><div id="v"></div>
<script>
async function load(){const r=await fetch('/api/votes');const d=await r.json();
document.getElementById('v').innerHTML=Object.entries(d).map(([k,n])=>`<p><button onclick="vote('${k}')">${k}</button> ${n} 票</p>`).join('')}
async function vote(k){await fetch('/api/vote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:k})});load()}
load()
</script>"""
class H(BaseHTTPRequestHandler):
def _send(self, code, body, ctype="application/json; charset=utf-8"):
data = body.encode() if isinstance(body, str) else body
self.send_response(code); self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data))); self.end_headers(); self.wfile.write(data)
def do_GET(self):
if self.path == "/":
return self._send(200, PAGE, "text/html; charset=utf-8")
if self.path == "/healthz":
return self._send(200, '{"ok": true}')
if self.path == "/api/votes":
with LOCK:
return self._send(200, json.dumps(VOTES, ensure_ascii=False))
self._send(404, '{"error": "not found"}')
def do_POST(self):
if self.path != "/api/vote":
return self._send(404, '{"error": "not found"}')
try:
n = int(self.headers.get("Content-Length", 0))
name = json.loads(self.rfile.read(min(n, 4096)) or b"{}").get("name")
except (ValueError, json.JSONDecodeError):
return self._send(400, '{"error": "bad json"}')
with LOCK:
if name not in VOTES:
return self._send(400, json.dumps({"error": "unknown option"}))
VOTES[name] += 1
return self._send(200, json.dumps(VOTES, ensure_ascii=False))
def log_message(self, fmt, *args):
print("%s %s" % (self.address_string(), fmt % args), flush=True)
def serve(host="0.0.0.0", port=None):
port = int(port or os.environ.get("PORT", "8000"))
s = ThreadingHTTPServer((host, port), H)
print(f"listening on {host}:{port}", flush=True)
return s
if __name__ == "__main__":
serve().serve_forever()
第 3 步:检查脚本
"""发布前检查 .replit 和应用代码。规则来自 docs.replit.com:
Configuration(.replit 是 TOML;带环境变量的 run 要写成 [run] 表)、Ports([[ports]] 用 localPort/externalPort;
Autoscale 和 Reserved VM 只支持一个对外端口,且不能是 localhost;额外对外端口只能用列表里的那些)、
发布排错(服务必须监听 0.0.0.0;写了 ports 后自动端口检测关闭,必须有 externalPort = 80;首页超过 5 秒可能导致健康检查失败)。"""
import argparse, re, sys, time, tomllib, urllib.request
from pathlib import Path
EXTRA_EXTERNAL = {3000, 3001, 3002, 3003, 4200, 5000, 5173, 6000, 6800, 8000, 8008, 8080, 8081}
def check_config(path=".replit"):
errs, warns = [], []
try:
cfg = tomllib.loads(Path(path).read_text(encoding="utf-8"))
except tomllib.TOMLDecodeError as e:
return [f".replit 不是合法 TOML:{e}"], warns, {}
dep = cfg.get("deployment", {})
if not dep.get("run"):
errs.append("[deployment] 没有 run,发布时不知道怎么启动")
ports = cfg.get("ports", [])
for i, p in enumerate(ports):
if "internalPort" in p:
errs.append(f"ports[{i}] 用了 internalPort(文档排错段的写法),配置项应为 localPort")
if "localPort" not in p:
errs.append(f"ports[{i}] 缺少 localPort")
ext = p.get("externalPort")
if ext is not None and ext != 80 and ext not in EXTRA_EXTERNAL:
errs.append(f"ports[{i}] externalPort={ext} 不在支持的端口列表里")
exposed = [p for p in ports if "externalPort" in p]
if ports and not any(p.get("externalPort") == 80 for p in ports):
errs.append("写了 [[ports]] 后自动端口检测会关闭,必须有一个 externalPort = 80")
if len(exposed) > 1:
errs.append(f"有 {len(exposed)} 个对外端口;Autoscale/Reserved VM 只支持一个,发布会失败")
for p in exposed:
if p.get("exposeLocalhost"):
warns.append(f"localPort {p.get('localPort')} 设置了 exposeLocalhost;发布时对外端口不能是 localhost 服务")
return errs, warns, cfg
def check_code(cfg, root="."):
errs = []
entry = Path(root) / cfg.get("entrypoint", "main.py")
if not entry.exists():
return [f"入口文件 {entry} 不存在"]
src = entry.read_text(encoding="utf-8")
if re.search(r"""(host\s*=\s*|\(\s*)["'](127\.0\.0\.1|localhost)["']""", src):
errs.append(f"{entry.name} 里监听的是 localhost/127.0.0.1,发布后无法访问,应为 0.0.0.0")
m = re.search(r"""PORT["']\s*,\s*["']?(\d+)""", src)
local = [p["localPort"] for p in cfg.get("ports", []) if "externalPort" in p and "localPort" in p]
if m and local and int(m.group(1)) not in local:
env_port = cfg.get("run", {}).get("env", {}).get("PORT") if isinstance(cfg.get("run"), dict) else None
if not env_port or int(env_port) not in local:
errs.append(f"代码默认端口 {m.group(1)} 和 .replit 的 localPort {local} 不一致")
return errs
def check_live(url, limit=5.0):
t = time.time()
try:
with urllib.request.urlopen(url, timeout=limit + 1) as r:
code = r.status
except Exception as e:
return [f"请求 {url} 失败:{e}"], None
dt = time.time() - t
errs = [] if code == 200 else [f"首页返回 {code}"]
if dt > limit:
errs.append(f"首页用了 {dt:.1f}s,超过 {limit:.0f}s,发布时健康检查可能超时")
return errs, dt
def main(argv=None):
ap = argparse.ArgumentParser(); ap.add_argument("root", nargs="?", default=".")
ap.add_argument("--url", help="本地已启动的服务地址,例如 http://127.0.0.1:8000/")
a = ap.parse_args(argv)
errs, warns, cfg = check_config(Path(a.root) / ".replit")
if cfg: errs += check_code(cfg, a.root)
if a.url:
e, dt = check_live(a.url); errs += e
if dt is not None: print(f"首页响应 {dt * 1000:.0f} ms")
for e in errs: print("✗", e)
for w in warns: print("!", w)
print("✓ 本地检查通过(不代表发布一定成功)" if not errs else f"{len(errs)} 个问题需要先修")
return 1 if errs else 0
if __name__ == "__main__":
sys.exit(main())
第 4 步:测试
import json, threading, urllib.request
import check_replit as C, main
def test_good_config():
errs, warns, cfg = C.check_config(".replit")
assert errs == [] and C.check_code(cfg, ".") == []
def test_broken():
assert "TOML" in C.check_config("broken/toml/.replit")[0][0]
e = " ".join(C.check_config("broken/ports/.replit")[0])
assert "internalPort" in e and "2 个对外端口" in e
_, _, cfg = C.check_config("broken/localhost/.replit")
assert "0.0.0.0" in C.check_code(cfg, "broken/localhost")[0]
def test_server_roundtrip():
s = main.serve(host="127.0.0.1", port=0); port = s.server_address[1]
threading.Thread(target=s.serve_forever, daemon=True).start()
try:
base = f"http://127.0.0.1:{port}"
assert json.load(urllib.request.urlopen(base + "/healthz")) == {"ok": True}
req = urllib.request.Request(base + "/api/vote", data=json.dumps({"name": "沙拉"}).encode(), method="POST")
assert json.load(urllib.request.urlopen(req))["沙拉"] >= 1
errs, dt = C.check_live(base + "/")
assert errs == [] and dt < 5
finally:
s.shutdown()
第 5 步:运行
$ PORT=8000 python main.py &
listening on 0.0.0.0:8000
# 2026-10-21 22:08 UTC+8
$ python check_replit.py . --url http://127.0.0.1:8000/
首页响应 9 ms
✓ 本地检查通过(不代表发布一定成功)
$ curl -s localhost:8000/healthz
{"ok": true}
$ curl -s -X POST localhost:8000/api/vote -H 'Content-Type: application/json' -d '{"name":"沙拉"}'
{"兰州拉面": 0, "黄焖鸡": 0, "沙拉": 1}
$ curl -s -X POST localhost:8000/api/vote -d '{"name":"火锅"}'
{"error": "unknown option"}
$ ss -ltn | grep 8000
LISTEN 0 5 0.0.0.0:8000 0.0.0.0:*
$ python check_replit.py broken/toml
✗ .replit 不是合法 TOML:Cannot declare ('run', 'env') twice (at line 16, column 9)
1 个问题需要先修
exit 1
$ python check_replit.py broken/ports
✗ ports[0] 用了 internalPort(文档排错段的写法),配置项应为 localPort
✗ ports[0] 缺少 localPort
✗ 有 2 个对外端口;Autoscale/Reserved VM 只支持一个,发布会失败
✗ 代码默认端口 8000 和 .replit 的 localPort [5173] 不一致
4 个问题需要先修
exit 1
$ python check_replit.py broken/localhost
✗ main.py 里监听的是 localhost/127.0.0.1,发布后无法访问,应为 0.0.0.0
1 个问题需要先修
exit 1
$ python check_replit.py . --url http://127.0.0.1:8001/ # slow_home.py
首页响应 6010 ms
✗ 首页用了 6.0s,超过 5s,发布时健康检查可能超时
1 个问题需要先修
exit 1
$ python -m pytest -q
3 passed
怎么检查结果?
- 在 Replit 里打开隐藏文件(Library → Files → Show hidden files)可以看到并编辑
.replit。 modules和[nix]保留 Replit 模板生成的值;本文的.replit只写了需要改的部分。- 发布前在 Publishing 面板确认 run 命令、部署类型(这个应用用 Autoscale),并把需要的 Secrets 加进去(见 B 篇)。
- 投票数据保存在内存里,Autoscale 缩到 0 或重启后会清空;要持久化请用数据库(见 C 篇)。
调试示例
1. .replit 解析失败:Cannot declare ('run', 'env') twice。 我第一版是这样写的:
run = ["python3", "main.py"]
...
[run.env]
PORT = "8000"
用 Python 的 tomllib 一读就报错:Cannot declare ('run', 'env') twice (at line 16, column 9)。原因是 run 已经是一个数组了,[run.env] 又要把它当表用。官方 Configuration 文档正好讲了这一点:要给 run 加环境变量,得把它改成 [run] 表、命令放进 args,并且要挪到所有顶层属性之后。改完以后能正常解析。检查脚本第一步就是用 tomllib 解析,这类问题在本地就能发现。
2. 文档里有两种端口写法。 Ports 页的配置说明和示例都是 localPort,但同一页“Debugging”一节的例子写的是 internalPort = 3000。按配置说明,[[ports]] 的字段是 {localPort, externalPort, exposeLocalhost},所以检查脚本把 internalPort 当作错误,提示改成 localPort。
3. 首页慢会让发布在最后一步失败。 文档说发布前会请求首页,超过 5 秒可能超时。我用 slow_home.py 模拟一个首页要 6 秒的服务,脚本测到 6010 ms 并报错。实际项目里常见原因是首页请求时才去加载大文件或调用外部 API,应该放到启动时或缓存起来。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 发布后访问不到 | 只监听了 localhost | 监听 0.0.0.0 |
| 发布在 promote 阶段失败 | 写了 ports 但没有 externalPort = 80 | 加上,或删掉 ports 让它自动检测 |
| 多个服务只能访问一个 | Autoscale 只支持一个对外端口 | 合并成一个服务或换部署方式 |
| .replit 改了不生效 | TOML 格式错误 | 用 tomllib 或检查脚本先解析 |
模板规则
- 监听 0.0.0.0,端口来自环境变量。
- 只暴露一个对外端口 80。
- 首页要快,重活不放在首页。
- 发布前本地跑一遍检查。
项目资源
| 用途 | 地址 |
|---|---|
| Replit 官方文档 | https://docs.replit.com |
| Replit App 配置 .replit(官方) | https://docs.replit.com/features/project-setup/configuration |
| Replit 端口(官方) | https://docs.replit.com/features/project-setup/ports |
| Replit 部署类型(官方) | https://docs.replit.com/features/publishing/deployment-types |
| Replit 发布排错(官方) | https://docs.replit.com/build/troubleshooting |
| Replit 定价 | https://replit.com/pricing |
Preview 能跑只说明开发环境没问题,发布前把端口、地址和启动命令再确认一遍。