2025-05-02 15:42:00
上一篇博客提到我在使用 Dokploy 部署网站服务,但 Dokploy 不支持定时任务,于是只能创建普通服务,并在内部使用脚本定时执行命令。最近发现,将这些定时任务放在函数计算中执行可能是更好的选择。
我的服务都跑在阿里云上,下面介绍的也是阿里云函数计算。
要创建一个函数,在阿里云函数界面后台,点击创建函数按钮即可,如下图所示:
在随后的界面中,选择“任务函数”类型。
然后,函数代码部分可根据需要选择类型,比如可以使用 ACR 中的 Docker 镜像。
需要注意的是,无论是上传代码还是使用 Docker 镜像,都要确保对应的代码能提供一个 HTTP 服务,因为定时任务执行的入口即是这个 HTTP 服务。
其他还有环境变量等配置,根据你的实际情况填写即可。
使用函数计算的定时任务,需要你的代码提供一个 HTTP 服务,定时任务执行时,会以 POST 的方式请求 /invoke
路径,即类似下面这样的请求:
curl -X "POST" "http://localhost:8050/invoke" \
-H 'Content-Type: application/json' \
-d $'{
"payload": "YOUR_PAYLOAD",
"triggerName": "trigger-name",
"triggerTime": "2025-04-27T03:12:45Z"
}'
当然,真实的请求还有很多 HTTP 头信息。
你需要在代码中实现 /invoke
接口,并在其中执行定时任务。在函数计算后台,可以设置超时时间等属性。
注意其中的 payload
字段,后面在设置定时触发器时,可以自定义传入的 palyload
信息。
添加函数之后,即可在配置界面设置触发器。
函数计算支持多种触发器,在这儿,我们选择定时触发器即可。
其中最后一个字段“触发消息”,其中填写的内容即是上面 payload
参数的值。注意这儿传递的是普通字符串,而不是 JSON,收到之后可根据需要做一个解析。
如果你在同一个函数中有多个用途不同的触发器,可以通过 payload
参数进行区分。
设置好之后,在函数详情界面可以看到类似下面的图示。
如果一切顺利,定时任务就添加成功了,稍后可以在日志页面看到执行记录。
如果你的函数使用的是 ACR 中的 Docker 镜像,当推送了新的镜像时,函数计算的版本不会自动更新,需要你登录网站后台手动修改,或者调用函数计算的 API 进行设置。
每次手动修改是一件很麻烦的事,建议使用 API,以便和现有的发布流程结合起来。你可以先安装 aliyun-cli 命令行工具,然后执行类似下面的命令:
aliyun fc PUT /2023-03-30/functions/YOUR_FC --region cn-shanghai --header "Content-Type=application/json;" --body "{\"tracingConfig\":{},\"customContainerConfig\":{\"image\":\"registry-vpc.cn-shanghai.aliyuncs.com/XXX/YYY:1.2.3\"}}"
请注意将其中的参数值替换为你的项目中的值。
有一些定时任务(比如清理老数据、备份用户数据等)比较耗费资源,将它们迁移到函数计算中可以减少主服务器的负担,是一个不错的实践。
函数计算是按量收费的,多数情况下,定时任务使用函数计算应该比专门买一台服务器划算,不过也不要大意,请做好优化,同时注意关注每日的用量。
2025-04-20 21:41:00
之前的几年我一直在使用 K3s + Rancher 的组合来管理网站服务,不过前段时间迁移到了 Dokploy,在这儿记录一下要点。
K3s + Rancher 的组合挺好,几年来一直运行稳定,不过对像我这样的非专业运维来说还是有点太复杂了,事实上几年来,我一直只在使用这个组合的一些最基础的功能。
去年看到有人介绍 Dokploy,了解了一下之后,发现它非常适合我的使用场景,同时又足够简单,于是花了一点时间做了研究,并最终决定迁移到 Dokploy。
除了 Dokploy 之外,还有 Coolify 等产品也不错,而且功能更多一些,读者朋友如果有需要也可以试一试。
Dokploy 提供了云服务,订阅之后可通过他们的云服务管理自己的服务器。
云服务听起来是个不错的选择,可以减少自己运维的时间成本,我也花了 $4.5 订阅了一个月体验了一番。不过 Dokploy 的云服务在海外,我的服务器在国内,两者之间通讯不畅,因此体验并不是很好。
最后,我选择了自托管服务,将 Dokploy 和网站服务安装在同一个网络中。
Dokploy 的安装很简单,在一台干净的服务器上运行以下命令即可:
curl -sSL https://dokploy.com/install.sh | sh
为了确保 Dokploy 能顺利运行,这台服务器建议至少要 2 CPU + 2 G 内存。
如果你的服务器在国内,安装时可能耗时较长,可以添加国内的 docker 镜像,比如修改 /etc/docker/daemon.json
文件,添加以下内容:
{
"registry-mirrors": [
"https://docker.1ms.run"
]
}
安装完成之后,即可通过 http://{服务器 IP}:3000
的形式访问 Dokploy 后台。
Dokploy 成功安装后,马上就可以开始创建应用。不过,这时创建的应用会和 Dokploy 安装在同一台服务器上,你也可以在 Dokploy 后台添加新的服务器,并将应用添加到新服务器上。
个人建议用一台服务器专门运行 Dokploy,然后在 Remote Servers 面板中添加其他服务器。
添加服务器之后,还需要在 Actions 菜单中点击 Setup Server,并根据提示进行设置。
其中 Deployments 那个步骤可能耗时会很长,可以考虑点击 Modify Script,将脚本复制到对应的服务器上手动执行。
添加完服务器之后,就可以添加项目,随后在项目中添加服务了。
添加服务这儿,最重要的一个设置是 Provider,即设置代码的来源。
Dokploy 支持多种常见的源,比如 Github,配置好之后只需向指定仓库和分支推送代码,Dokploy 就会自动拉取并构建代码,就像 Vercel 一样。
对小项目来说,这样的方式自然是很方便的,不过也可以用 Docker 作为 Provider,并使用第三方镜像服务。这样主要有两个好处:
我使用的是阿里云的容器镜像服务,填写方式类似下图:
Dokploy 提供了丰富的 API,几乎所有操作都可以通过 API 完成。当某个服务需要更新时,可以登录网站手动修改相关值,也可以使用 API 更新。
比如,如果一个服务的 Provider 是 Docker,可以用类似下面的请求进行修改:
curl -X "POST" "https://your-dokploy/api/application.saveDockerProvider" \
-H 'x-api-key: $YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d $'{
"applicationId": "$APP_ID",
"dockerImage": "$DOCKER_URL"
}'
有几个注意点:
x-api-key: xxx...
,而不是常见的 Authorization: Bearer xxx...
。applicationId
的值在 URL 中,在界面上暂时没有显示。比如某个服务的地址是 https://your-dokploy/dashboard/project/aaa/services/application/bbb
,地址最后的 bbb
就是 applicationId
。
通过 API 的方式,可以很方便地将服务的发布、回滚等操作集中到一处管理,或者与你现有的服务集成。
使用 Dokploy 已经有一段时间了,整体而言还是很满意的,相对其他方案它很容易上手,且足够稳定,可用于生产环境。
不足是暂时还不支持定时任务,不过可以通过启动一个普通服务并在其中运行定时脚本的方式解决。
如果你有类似的需求,不妨也试一试 Dokploy。
2024-11-28 21:38:00
最近在产品中用到了 Electron 中的 Kiosk 模式,记录一下要点。
Kiosk 模式是一种专门为限制用户操作而设计的应用运行模式,通常用于构建锁定的全屏应用程序,禁止用户访问系统其他功能或退出应用。在这种模式下,应用程序占据整个屏幕,并且用户无法通过常见的方式(如键盘快捷键、窗口控制按钮等)退出或切换到其他应用。
Kiosk 模式的主要用途是为用户提供一个专注且受限的操作环境,避免对系统的其他部分产生干扰。
Kiosk 模式被广泛应用于以下场景:
当然,我在开发的是日常效率软件,并不属于以上场景。我用到 Kiosk 模式的场景主要如下。
我开发并维护着一个截图软件图几,它有三种截图模式:全屏截图、窗口截图、区域截图。
其中区域截图的交互方式是:用户点击截图按钮(或按下截图快捷键),先生成当前屏幕的截图,随后显示一个全屏无边框窗口,在窗口中显示将刚刚生成的屏幕截图,同时允许用户在窗口上进行框选等操作。
这个无边框窗口就需要使用 Kiosk 模式,以免用户无意中切换窗口。当然,等用户完成或取消截图时,需要再退出或关闭对应的 Kiosk 窗口。
WonderPen 写作软件最近添加了小黑屋模式,进入这种模式后,软件将全屏显示,屏蔽一切干扰,在完成预设的写作目标之前,将无法退出或切换到其他软件。
这个禁止退出的小黑屋,自然也使用了 Kiosk 模式。
在 Electron 中,将一个窗口设为 Kiosk 模式非常简单,在创建窗口时设置 kisok
属性为 true
即可。
有时,我们的窗口在创建时需要以普通模式显示,然后再在一定条件下切换为 Kiosk 模式,只需用类似下面的代码切换即可:
win.setKiosk(flag)
其中 flag
是一个布尔值。
你还可以使用 win.isKiosk()
方法判断当前窗口是否为 Kiosk 模式。
在实践过程中,我发现很多时候只设置 Kiosk 属性还不太够,还需要设置 frame
等属性。以下是一个示例:
const win = new BrowserWindow({
// 其他属性...
closable: false,
maximizable: false,
minimizable: false,
resizable: false,
fullscreen: false,
fullscreenable: false,
frame: false,
skipTaskbar: true,
alwaysOnTop: true,
useContentSize: true.
autoHideMenuBar: true.
movable: false.
thickFrame: false.
titleBarStyle: 'default',
paintWhenInitiallyHidden: false,
roundedCorners: false,
enableLargerThanScreen: true,
acceptFirstMouse: true,
kiosk: true,
// 其他属性...
})
即使这样设置之后,在 macOS 上有时仍会出现 Docker 栏和顶部系统菜单栏出现在 Kiosk 窗口上方的情况,因此还需要进一步设置 alwaysOnTop 的属性为 screen-saver
,代码如下:
win.setAlwaysOnTop(true, 'screen-saver', 1)
在 Windows 和 macOS 中,alwaysOnTop 的窗口有多种极别,按层级由低到高分别是:
如果只是简单地 win.setAlwaysOnTop(true)
,则窗口的级别只是 floating
,仍有可能被其他系统组件遮挡。
另外需要注意,在 macOS 下,太高的级别会挡住系统自带输入法的候选字窗口,如果你的 Kiosk 窗口需要用户输入,并且可能使用系统自带输入法的话,这个级别不能高于 modal-panel
。
Kiosk 模式只对当前窗口有效,一个窗口只能覆盖一个屏幕,若用户有多个显示器,则需先检测显示器数量,然后创建多个 Kiosk 窗口分别覆盖。
设置 Kiosk 模式后,用户仍可以使用 Cmd+Q
这样的快捷键退出应用,因此需要在代码中监听窗口的 close
事件,并检查是否处在 Kiosk 状态,如是则阻止退出。代码类似下面这样:
win.on('close', async (e: Electron.Event) => {
if (win.isKiosk()) {
e.preventDefault()
return
}
// 其他逻辑
}
Windows 下退出 Kiosk 模式后,窗口的大小可能会变成全屏大小,如希望退出时恢复原大小,可以在进入 Kiosk 模式之前先记住窗口大小,退出后再设置为原大小。
Kiosk 模式并不能阻止用户重启计算机。如果希望重启计算机后能自动恢复 Kiosk 状态,可以将软件设置为随系统启动,并且启动时自动进入 Kiosk 模式。
2024-09-23 10:57:00
之前很长一段时间,这个博客一直在用云服务商提供的免费 SSL 证书,那个证书有一年有效期,也即一年只需要申请部署一次,因此全手动操作也不算麻烦,但现在免费 SSL 证书的有效期统一缩短为 3 个月了,意味着每 3 个月就要操作一次,这就让手动申请和部署变得麻烦起来了。
最近,我尝试了一下使用 acme.sh 申请 SSL 证书的方法,确实方便了不少,在这里记录一下。
acme.sh 是一个实现 ACME 协议的脚本,主要用途是申请或更新免费 SSL 证书。运行以下命令即可安装:
curl https://get.acme.sh | sh -s [email protected]
更多安装方式可见官方文档:https://github.com/acmesh-official/acme.sh。
acme.sh 会被安装在 ~/.acme.sh
目录下。
安装好 acme.sh 后,可以用以下命令申请证书:
acme.sh --issue --dns -d mydomain.com -d "*.mydomain.com" --yes-I-know-dns-manual-mode-enough-go-ahead-please
记得把其中的 mydomain.com
换成你自己的域名。
上面的代码中,我申请了泛域名证书,所以同时添加了 mydomain.com
和 *.mydomain.com
域名。需要注意的是,*.mydomain.com
不包含 mydomain.com
,如果你希望证书除了包含 www.mydomain.com
这样的二级域名,也包含 mydomain.com
的话,记得把 mydomain.com
也加上。
另外,*.mydomain.com
也不包含更深的层级,比如它包含 home.mydomain.com
,但不包含 app.home.mydomain.com
。如果你需要更深层级的泛域名,需要把对应的域名也填上。
还需要注意的是最后一个参数 --yes-I-know-dns-manual-mode-enough-go-ahead-please
。acme.sh 更希望用户使用自动申请证书的方式(见下一小节),如果你确实需要手动申请,需加上这个参数,否则命令不会正常执行。
如果一切顺利,acme.sh 命令会输出两段 TXT 信息,需要你手动添加到对应域名的 DNS 解析中,以验证你确实对这个域名拥有权限。在证书申请完成之后,可以删除对应的 TXT 记录。
登录域名服务商(比如阿里云)后台,在域名解析中添加上对应的 TXT 记录,然后再运行以下命令,即可生成证书:
acme.sh --renew -d mydomain.com -d "*.mydomain.com" --yes-I-know-dns-manual-mode-enough-go-ahead-please
证书会被保存在 ~/.acme.sh/
目录下,包含以下四个文件:
mydomain.com.cer
证书mydomain.com.key
密钥ca.cer
fullchain.cer
全链路证书其中在网站场景主要使用 fullchain.cer
文件和 mydomain.com.key
文件。
可以看到,上面手动申请的步骤,主要的手动操作就是要为域名添加 TXT 记录以验证域名权限,acme.sh 支持让这个步骤自动化,即自动添加 TXT 记录,并在验证完成之后自动删除对应的记录。
以阿里云为例(如果你的域名是在阿里云注册并解析的),首先需要去阿里云控制台获取一个 AccessKey,建议专门设置一个 RAM 用户,只开通 DNS 权限。
得到 AccessKey 之后,在命令行中执行以下命令:
export Ali_Key="key"
export Ali_Secret="secret"
随后再执行以下命令,即可自动申请或更新证书了:
acme.sh --issue --dns dns_ali -d mydomain.com -d "*.mydomain.com"
注意 --dns
参数后面的值为 dns_ali
。
一切顺利的话,证书申请会自动完成,并被保存在 ~/.acme.sh/
目录下。
其他各大域名服务商的自动申请方式类似,具体可参见官方文档。
如果你使用了自动申请,AccessKey 会被明文保存在 ~/.acme.sh/account.conf
文件内,如果介意,可在申请完之后修改这个文件并删除对应的 AccessKey。
另外,使用自动申请后,acme.sh 会添加一条定时任务,每天自动检查证书是否需要更新。可运行以下命令查看当前系统的定时任务列表:
crontab -l
现在 acme.sh 默认使用的证书颁发机构是 ZeroSSL,还有一些其他可选机构,比如 Let's Encrypt。可以用 --set-default-ca
修改默认证书颁发机构,比如:
acme.sh --set-default-ca --server letsencrypt
我没有修改 CA,在使用默认的 ZeroSSL 的证书,目前来看暂时没有遇到什么问题。
除了自动申请证书外,大部分网络服务商也支持自动上传 SSL 证书,不过这部分我还没有研究,后续如果觉得值得记录,会另外写文分享。
2024-08-09 16:31:00
最近完善了一下产品的购买流程,其中的一项工作是处理来自苹果 App Store 平台的 CONSUMPTION_REQUEST 消息,在这儿记录一下要点。
App 如果使用了苹果的内购(IAP),每当发生用户购买、续费、退款等操作时,苹果服务器都会向开发者指定的地址发送一条消息,不同的消息有不同的 notificationType
值,其中 CONSUMPTION_REQUEST 消息的意思是用户为应用内购买发起了退款请求,App Store 请求开发者服务器提供用户的消费数据,用于协助 App Store 决定是否给用户退款。
开发者可以忽略 CONSUMPTION_REQUEST 消息,也可以根据需要,在 12 小时内回应 App Store。
要回应 CONSUMPTION_REQUEST 消息,只需向指定的地址发一个 PUT 请求即可。具体细节可见官网文档。
这个 PUT 消息的要点主要有两个:
我们先看 Body 中的数据内容。
根据文档,数据字段以及含义大致如下:
{
"accountTenure": 0, // 用户年龄段,0 表示未知
"appAccountToken": "", // 用户 uuid,由于之前没有设置,此处留空
"consumptionStatus": 0, // 消费状态,0:未知,1:未消费,2:部分消费,3:全部消费
"customerConsented": True, // 用户是否同意提供消费数据
"deliveryStatus": 0, // 交付状态,0:已成功交付
"lifetimeDollarsPurchased": 0, // 用户在应用内购买的总金额,0 表示未知
"lifetimeDollarsRefunded": 0, // 用户在应用内退款的总金额,0 表示未知
"platform": 1, // 平台,0:未知,1:苹果平台,2:其他平台
"playTime": 0, // 用户在应用内的总时间,0 表示未知
"refundPreference": 1, // 商家对退款的意见,0:未知,1:支持,2:不支持,3:不确定
"sampleContentProvided": True, // 是否已经提供了示例内容
"userStatus": 1, // 用户账号状态,0:未知,1:活跃,2:暂停,3:关闭,4:受限
}
你可以根据需要,修改对应字段的值。
请求 Header 中有两个必填的自定义字段,分别是:
application/json
Bearer $jwt_token
其中 jwt_token
必须要正确填写,否则请求会返回 401 错误。
jwt_token
的具体生成说明可见官方文档,大致格式类似下面这样:
Header:
{
"kid": "ZA12345678",
"alg": "ES256",
"typ": "JWT"
}
Payload:
{
"iss": "your_uuid",
"iat": 1723173620,
"exp": 1723183620,
"aud": "appstoreconnect-v1",
"bid": "your_bundle_id"
}
其中 kid
、iss
,以及生成 JWT 时所需的私钥等几项,需要去 App Store Connect 后台生成。
如果你之前还没有生成过对应的私钥,可以前往 App Store Connect 后台的“用户和访问” → “集成” → “App 内购买项目”页面生成,如下图所示:
生成之后,可以在这个页面下载 .p8 格式的私钥。注意这个私钥只能下载一次,下载之后请妥善保存,如果不慎遗失,只能删除再重新生成一个。
上面生成 JWT 所需的 kid
对应上图中的“密钥 ID”,iss
对应“Issuer ID”,私钥即上面下载的 .p8 文件中的内容。
然后就可以用类似下面的方法生成 JWT 了:
import jwt
jwt_token = jwt.encode(
payload,
private_key,
algorithm="ES256",
headers=headers,
)
最后,将得到的 jwt_token
以 Bearer $jwt_token
的形式包含在请求头的 Authorization
中,发起 PUT 请求即可。
如果请求返回 202 状态码,表示请求成功了。如果是其他值,可根据错误状态再仔细检查处理。
2024-06-27 14:34:00
最近在开发 Flutter 项目,其中 iOS 版 App 账号登录时,需要适配 1Password 等密码管理器,即需要告诉 1Password 等密码管理器当前 App 的登录特征信息(域名),以及应该填写界面上的哪些表单项。在这儿记录一下要点。
我们首先要处理的,是让 App 和某个域名(通常是官网域名)关联,这样在 App 中唤起 1Password 填写密码时,1Password 才知道应该显示哪些账号。
这儿主要有三个步骤。
在 Apple 开发者后台的 Certificates, Identifiers & Profiles 页面,记得要选中 Associated Domains 选项,如下图所示:
接下来,要在 Xcode 中为你的 App 添加关联域名,如下图所示:
在 Domains 那一栏,添加 webcredentials:你的域名
即可,比如你的域名是 test.com,那么添加 webcredentials:test.com
就行。
最后,还需要在你的域名对应的网站上添加一个认证文件,证明指定 App 确实和当前域名相关。这个文件的文件名固定为 apple-app-site-association
,可以放在网站的根目录,或者 .well-known
目录下,确保可以通过网络访问到。
这个文件的用途很多,可能还会包含一些其他字段,和密码管理器相关的主要是以下内容:
{
"webcredentials": {
"apps": [
"TeamId.BundleId"
]
}
}
确保你的 apple-app-site-association
文件包含 webcredentials
字段,并将其中 apps 中的 TeamId、BundleId 换成你的真实 ID。
为了得到更好的登录体验,Flutter 中也要做一些设置,主要是告诉 1Password 等密码管理器需要填写哪些字段,以及各个字段分别对应什么内容。
关键代码如下:
@override
Widget build(BuildContext context) {
// ...
return Container(
body: Center(
child: AutofillGroup(
child: Column(
children: [
TextField(
autofillHints: const [AutofillHints.email],
decoration: InputDecoration(
labelText: 'Email',
),
),
TextField(
autofillHints: const [AutofillHints.password],
decoration: InputDecoration(
labelText: 'Password',
),
),
ElevatedButton(
onPressed: () {
// Submit the form
},
child: Text('Submit'),
),
],
),
),
),
);
}
其中最关键的有两处,一是需要自动填写的表单部分,需要用 AutofillGroup
组件包起来,这样 1Password 就知道哪些字段是需要自动填写的。二是 Email、用户名、密码等需要填写的字段,需要添加 autofillHints
属性,比如 autofillHints: const [AutofillHints.email]
,这样 1Password 才知道当前字段应该填什么内容。
完成这些设置之后,App 登录时就应该能正常适配 1Password 了。