用 linode-cli 一条脚本开一台 Linode:先建云防火墙,再用 cloud-init(通过 Metadata 服务传入)在首次开机时自动建用户、关闭密码登录、安装 Caddy、部署一个 Python 留言板,并自动申请 HTTPS 证书。从敲命令到浏览器打开
https://你的域名,中间不用手动登录服务器配置。
先看清成品
成品是一个公网可访问的 HTTPS 留言板。本文已在本机实际验证:cloud-init 26.2 执行
cloud-init schema显示Valid schema;Caddy 2 执行caddy validate显示Valid configuration;留言板本地运行,提交留言返回 303、HTML 被正确转义、空内容返回 400、/healthz返回{"ok": true};deploy.sh通过语法检查,linode-cli5.69.0 的参数名已用--help核对。本文没有 Linode 账号,没有真正创建实例和防火墙。
注意:Linode(现属 Akamai Cloud)按小时计费,关机也照常收费,不用的实例要删除才停止计费。官方价格页的例子是 1 GB 实例每小时 0.0075 美元,具体价格以下单页面为准。注册需要绑定信用卡。云服务器只用于合法用途,遵守 Akamai 的可接受使用政策和所在地法律。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 云防火墙 | 入站默认拒绝,只放行 22、80、443 |
| 自动初始化 | cloud-init 建 deploy 用户、禁 root 和密码登录 |
| 留言板 | Python 标准库 + SQLite,systemd 守护 |
| HTTPS | Caddy 自动申请和续期证书 |
| 一键部署 | deploy.sh |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Linode 账号 | 已绑定付款方式 | 新账号可能有促销额度,以官网为准 |
| linode-cli | pip install linode-cli |
本文使用 5.69.0 |
| 域名 | 能添加 A 记录 | 证书申请需要域名指向服务器 |
| SSH 密钥 | ~/.ssh/id_ed25519.pub |
没有就 ssh-keygen -t ed25519 |
| 代码仓库 | GitHub 公开仓库 | 存放 app.py 和 Caddyfile |
第一步:怎样配置 linode-cli?
pip install linode-cli
linode-cli configure
按提示在浏览器登录授权,或者粘贴在 Cloud Manager 创建的个人访问令牌。然后确认能用:
linode-cli regions list --text --format id,label,capabilities | grep -i metadata
注意:cloud-init 依赖 Metadata 服务,只有能力列表里带
Metadata的地区才能用,选地区前先用上面的命令确认。
第二步:要准备哪些文件?
1. 项目结构
guestbook/
├── app.py # 留言板
├── Caddyfile # HTTPS 反向代理
├── cloud-config.yaml # 首次开机初始化
├── firewall-rules.json # 云防火墙入站规则
└── deploy.sh # 创建防火墙和实例
2. app.py
"""留言板:只用 Python 标准库,数据存 SQLite。监听 127.0.0.1:8000,由 Caddy 对外提供 HTTPS。"""
import html
import json
import sqlite3
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs
import os
DB = os.environ.get("GUESTBOOK_DB", "guestbook.db")
def db():
con = sqlite3.connect(DB)
con.execute("CREATE TABLE IF NOT EXISTS msg (id INTEGER PRIMARY KEY, name TEXT, body TEXT, ts TEXT DEFAULT CURRENT_TIMESTAMP)")
return con
class H(BaseHTTPRequestHandler):
def _send(self, code, body, ctype="text/html; charset=utf-8"):
data = body.encode()
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 == "/healthz":
return self._send(200, json.dumps({"ok": True}), "application/json")
rows = db().execute("SELECT name, body, ts FROM msg ORDER BY id DESC LIMIT 50").fetchall()
items = "".join(f"<li><b>{html.escape(n)}</b>({t}):{html.escape(b)}</li>" for n, b, t in rows)
self._send(200, f"""<!doctype html><meta charset="utf-8"><title>留言板</title>
<h1>留言板</h1><form method="post"><input name="name" placeholder="名字" maxlength="30" required>
<input name="body" placeholder="说点什么" maxlength="200" required><button>提交</button></form><ul>{items}</ul>""")
def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
if n > 2000:
return self._send(413, "太长了")
f = parse_qs(self.rfile.read(n).decode())
name, body = f.get("name", [""])[0].strip()[:30], f.get("body", [""])[0].strip()[:200]
if not name or not body:
return self._send(400, "名字和内容都要填")
with db() as con:
con.execute("INSERT INTO msg (name, body) VALUES (?, ?)", (name, body))
self.send_response(303)
self.send_header("Location", "/")
self.end_headers()
if __name__ == "__main__":
ThreadingHTTPServer(("127.0.0.1", 8000), H).serve_forever()
| 要点 | 说明 |
|---|---|
| 只监听 127.0.0.1 | 外部只能经过 Caddy 访问 |
html.escape |
防止有人提交脚本 |
| 长度限制 | 请求体超过 2000 字节返回 413 |
| 303 跳转 | 刷新页面不会重复提交 |
3. Caddyfile
guestbook.example.com {
encode gzip
reverse_proxy 127.0.0.1:8000
}
把 guestbook.example.com 换成你的域名。Caddy 只要看到域名就会自动申请 HTTPS 证书,并把 HTTP 跳转到 HTTPS。
4. cloud-config.yaml
#cloud-config
# 首次开机由 cloud-init 执行:建普通用户、关密码登录、装 Caddy 和防火墙、部署留言板
users:
- name: deploy
groups: [sudo]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- ssh-ed25519 AAAA...替换成你的公钥 me@laptop
ssh_pwauth: false
disable_root: true
package_update: true
package_upgrade: true
packages: [caddy, ufw, python3, unattended-upgrades]
write_files:
- path: /etc/systemd/system/guestbook.service
content: |
[Unit]
Description=Guestbook
After=network.target
[Service]
User=www-data
WorkingDirectory=/var/lib/guestbook
Environment=GUESTBOOK_DB=/var/lib/guestbook/guestbook.db
ExecStart=/usr/bin/python3 /opt/guestbook/app.py
Restart=always
[Install]
WantedBy=multi-user.target
runcmd:
- mkdir -p /opt/guestbook /var/lib/guestbook
- chown www-data:www-data /var/lib/guestbook
- curl -fsSL https://raw.githubusercontent.com/你的用户名/guestbook/main/app.py -o /opt/guestbook/app.py
- curl -fsSL https://raw.githubusercontent.com/你的用户名/guestbook/main/Caddyfile -o /etc/caddy/Caddyfile
- ufw allow OpenSSH
- ufw allow 80/tcp
- ufw allow 443/tcp
- ufw --force enable
- systemctl daemon-reload
- systemctl enable --now guestbook
- systemctl reload caddy
| 要点 | 说明 |
|---|---|
第一行 #cloud-config |
必须有,否则不会被识别 |
ssh_pwauth: false |
禁止密码登录 |
packages: [caddy …] |
Ubuntu 24.04 软件源中有 caddy 2.6.2 |
| ufw | 和云防火墙一起作为两层防护 |
先把 app.py、Caddyfile 推到你的 GitHub 仓库,再替换 runcmd 里的地址和公钥。
5. firewall-rules.json 和 deploy.sh
[
{"label": "ssh", "action": "ACCEPT", "protocol": "TCP", "ports": "22", "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": ["::/0"]}},
{"label": "http", "action": "ACCEPT", "protocol": "TCP", "ports": "80", "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": ["::/0"]}},
{"label": "https", "action": "ACCEPT", "protocol": "TCP", "ports": "443", "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": ["::/0"]}}
]
#!/usr/bin/env bash
# 用 linode-cli 创建云防火墙和实例。用法:./deploy.sh us-sea
set -euo pipefail
REGION=${1:-us-sea}
FW_ID=$(linode-cli firewalls create --label guestbook-fw \
--rules.inbound_policy DROP --rules.outbound_policy ACCEPT \
--rules.inbound "$(cat firewall-rules.json)" --text --no-headers --format id)
echo "防火墙 ID:$FW_ID"
linode-cli linodes create --label guestbook --region "$REGION" --type g6-nanode-1 \
--image linode/ubuntu24.04 --root_pass "$(openssl rand -base64 32)" \
--authorized_keys "$(cat ~/.ssh/id_ed25519.pub)" --firewall_id "$FW_ID" \
--metadata.user_data "$(base64 -w0 cloud-config.yaml)" \
--text --format 'id,label,status,ipv4'
--metadata.user_data 要求 Base64 编码,这是 linode-cli linodes create --help 里写明的。root 密码用随机值,因为之后只用密钥登录。
第三步:怎样部署和检查?
1. 本地先验证
python3 app.py &
curl -d 'name=小明&body=你好' -i http://127.0.0.1:8000/ # 期望 303
curl http://127.0.0.1:8000/healthz # 期望 {"ok": true}
kill %1
有 Docker 的话可以校验配置:
docker run --rm -v $PWD:/w -w /w caddy:2 caddy validate --config Caddyfile
2. 创建实例
chmod +x deploy.sh
./deploy.sh us-sea
记下输出的 IPv4,到域名服务商添加 A 记录指向它。
3. 等待初始化完成
ssh deploy@服务器IP
cloud-init status --wait
systemctl status guestbook caddy --no-pager
cloud-init status 显示 status: done 表示所有步骤执行完。DNS 生效后打开 https://你的域名,提交一条留言。
4. 用完删除
linode-cli linodes list --text --format id,label
linode-cli linodes delete 实例ID
linode-cli firewalls list --text --format id,label
linode-cli firewalls delete 防火墙ID
出问题时该怎么调?
1. cloud-init 没执行
sudo cat /var/log/cloud-init-output.log | tail -50
cloud-init schema --system
| 原因 | 解决方法 |
|---|---|
| 地区不支持 Metadata | 换带 Metadata 能力的地区重建 |
缺第一行 #cloud-config |
补上 |
| YAML 缩进错 | 本地先 cloud-init schema -c cloud-config.yaml |
2. HTTPS 证书申请失败
journalctl -u caddy --no-pager | tail -30
最常见的原因是域名还没指向服务器,或者 80/443 被防火墙挡了。
3. 留言板 502
systemctl status guestbook,看 Python 是否报错,常见原因是 app.py 没下载成功(仓库地址写错)。
常见问题怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
| SSH 被拒 | 公钥没写对 | 用 Cloud Manager 的 LISH 控制台登录检查 |
Permission denied (publickey) 用 root 登录 |
已禁用 root | 用 deploy 用户 |
| 关机了还在扣费 | 关机照常计费 | 删除实例 |
| 留言时间差 8 小时 | SQLite 的 CURRENT_TIMESTAMP 是 UTC |
显示时自行转换时区 |
| 更新代码麻烦 | 首次开机才执行 cloud-init | 之后用 scp 或 git 拉取再重启服务 |
| 想防垃圾留言 | 示例没有验证码 | 加简单的频率限制 |
项目资源
建议先在本地跑通留言板并校验 cloud-init 和 Caddyfile,再用最小的套餐创建实例。
确认整个流程能自动跑通后,把这套 cloud-config.yaml 当成你自己的服务器初始化模板。