云端部署

用 linode-cli 一条脚本开一台 Linode

用 linode-cli 一条脚本开一台 Linode:先建云防火墙,再用 cloud-init(通过 Metadata 服务传入)在首次开机时自动建用户、关闭密码登录、安装 Caddy、部署一个 Python 留言板,并自动申请 HTTPS 证书。从敲命令到浏览器打开 https://你的域名,中间不用手动登录服务器配置。

BINBHO · 图文教程

用 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-cli 5.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 拉取再重启服务
想防垃圾留言 示例没有验证码 加简单的频率限制

项目资源

用途 地址
linode-cli 入门 https://techdocs.akamai.com/cloud-computing/docs/getting-started-with-the-linode-cli
Metadata 服务 https://techdocs.akamai.com/cloud-computing/docs/overview-of-the-metadata-service
云防火墙 https://techdocs.akamai.com/cloud-computing/docs/cloud-firewall
创建实例 API https://techdocs.akamai.com/linode-api/reference/post-linode-instance
cloud-init 示例 https://cloudinit.readthedocs.io/en/latest/reference/examples.html
Caddy 自动 HTTPS https://caddyserver.com/docs/automatic-https
价格 https://www.linode.com/pricing/

建议先在本地跑通留言板并校验 cloud-init 和 Caddyfile,再用最小的套餐创建实例。

确认整个流程能自动跑通后,把这套 cloud-config.yaml 当成你自己的服务器初始化模板。