清羽AI正在绞尽脑汁想思路ING···
清羽のAI摘要
GLM-4-Flash

碎碎念

最近回了趟老家,结果发现老家的网络环境简直能把人逼疯,什么更新文章、写点小东西,统统没法搞,甚至聊天都得我弟的手机开热点,可能是手机硬件不行吧,那干脆就顺理成章地不写了,反正也没人追更。回来之后呢,更惨,直接进入了“开摆正道”,那种明知道该动手写点啥,却每天都在“明天一定”的状态,摆到最后竟然还摆出点小爽感——哎呀,真舒服,太舒服了,舒服到根本不想写。

但人不能一直摆下去,毕竟博客摆太久,自己看着也会心虚。再加上这段时间正好有朋友问我:我的应用商店是怎么实现自动更新的,既然有人抛话题,那我就顺势一接,正大光明地写一篇“看似干货,其实就是更新”的文章。反正更新嘛,不管怎么写,只要发出来,咕咕就算结束,万事大吉!

下个月月中,就该去工作咯,希望后面的路途一帆风顺!

PS: 八月二十九是我的生日哦!

介绍

先说说 1Panel。它也算是这两年最火的新一代Linux服务器运维管理面板了吧,主打一个“现代化 + 简洁好用”。不像某Python面板那样一股子历史包袱,1Panel直接走的就是容器化路线,基于Docker来管理应用,UI也比较清爽,该有的功能都安排得明明白白:建站、运维、监控、备份,一套搞定,比较适合既想偷懒又想装专业的用户。

1Panel的应用商店就是它的灵魂之一。简单来说,你可以把它理解成“手机应用商店”在服务器上的翻版:点一点就能把一个完整的服务(比如WordPressHaloRedis之类)拉起来,免去自己维护并更新的麻烦。官方应用商店本身已经挺丰富了,但嘛,跟GitHub这个无底洞比还是差点意思,经常会有一些项目你得自己手动折腾,想加进商店还得自己维护,这就给了像我这种强迫症患者“二次创作”的机会。

第三方应用商店有很多比较著名,比如下面,维护了海量的实用应用,基本上覆盖了全部应用,杂七杂八,你能想到的都有,但是这是他们的优点,也是他们的缺点,如果添加单独一个,会跟不上更新,如果全部添加,会非常冗余,甚至有很多重复应用。

为了更加直观,且不再冗余,自己维护一个应用商店也就有点用了起来。

仓库介绍

1Panel的资源库默认位置在/opt/1panel,其中我们安装过的所有应用在/opt/1panel/app中,这里涵盖了绝大部分资源,而我们的应用商店,则维护在/opt/1panel/resource/apps中,在其中有一个local文件夹,在其中添加同等格式的应用文件夹,则会被自动解析在应用商店的本地应用中,所以三方仓库的原理就是,将apps中的所有文件夹放在local文件夹中,定时刷新缓存,系统检测到缓存后,就会反馈到仓库中,最终实现推送更新。

我们可以看看okxlin提供的安装第三方应用的命令行:

1
2
3
git clone -b localApps https://ghp.ci/https://github.com/okxlin/appstore /opt/1panel/resource/apps/local/appstore-localApps
cp -rf /opt/1panel/resource/apps/local/appstore-localApps/apps/* /opt/1panel/resource/apps/local/
rm -rf /opt/1panel/resource/apps/local/appstore-localApps

其实就是实现了我们上面说的那些内容。

应用目录

我们再进入应用目录,以AllinSSL为例,目录如下:

1
2
3
4
5
6
7
. 📂 allinssl
└── 📂 1.0.7/
│ ├── 📄 data.yml
│ ├── 📄 docker-compose.yml
├── 📄 README.md
├── 📄 data.yml
└── 📄 logo.png

其中的readme.md,很明显,是说明文档,展示在安装的首页,还有logo.png,用于展示图标:

AllinSSL

除此之外,在一级目录下,有一个data.yml文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
name: AllinSSL
tags:
- SSL
- 证书管理
- 自动化运维
- DevOps
- 安全
title: SSL证书全流程管理工具,一站式证书生命周期解决方案
description: 一站式SSL证书生命周期管理工具,支持多家CA和多平台自动化部署,提供安全入口保护和证书状态监控。
additionalProperties:
key: allinssl
name: AllinSSL
tags:
- Tool
- DevOps
shortDescZh: 一站式SSL证书生命周期管理解决方案,支持多家CA与多平台自动化运维
shortDescEn: One-stop SSL certificate lifecycle management tool with multi-CA and platform support
type: website
crossVersionUpdate: true
limit: 0
website: https://github.com/allinssl/allinssl
github: https://github.com/allinssl/allinssl
document: https://github.com/allinssl/allinssl
description:
en: One-stop SSL certificate lifecycle management tool supporting multiple CAs and platforms, with automated issuance, renewal, deployment, and monitoring.
zh: 一站式SSL证书生命周期管理工具,支持多家证书颁发机构和多平台自动化部署,提供证书申请、续期、监控等功能。
zh-Hant: 一站式SSL憑證生命週期管理工具,支援多家憑證頒發機構及多平台自動化部署,提供憑證申請、續期、監控等功能。
ja: 複数のCAとプラットフォームに対応したワンストップSSL証明書ライフサイクル管理ツール。自動発行、更新、展開、監視を提供。
ms: Alat pengurusan kitar hayat sijil SSL sehenti yang menyokong pelbagai CA dan platform, dengan pengeluaran, pembaharuan, penyebaran, dan pemantauan automatik.
pt-br: Ferramenta de gerenciamento de ciclo de vida de certificado SSL tudo-em-um, suportando múltiplas CAs e plataformas, com emissão, renovação, implantação e monitoramento automatizados.
ru: Универсальный инструмент управления жизненным циклом SSL-сертификатов с поддержкой множества центров сертификации и платформ, автоматическим выпуском, обновлением, развертыванием и мониторингом.
ko: 여러 CA 플랫폼을 지원하는 원스톱 SSL 인증서 수명 주기 관리 도구로 자동 발급, 갱신, 배포 모니터링을 제공합니다.
architectures:
- amd64
- arm64

需要注意其中的key,这个值对应着文件夹名称,不容有错,其他的可以象征性的填写一下,tags标签有几个固定的值,如果写了其他的会不显示,但是不会报错,剩下的,建议gpt生成一下嘻嘻。

在一级目录下,还有一个以版本号命名的文件夹,这个文件夹名称就是我们安装时选择的版本号,一般文件夹内部的docker-compose.yml文件中的版本号需要和文件夹名称对应,非必要不要写latest

不能写latest的原因

这个涉及下一部分,应用商店不单单是维护一个仓库即可,如果应用数量较多,手动更新会非常费神,所以需要自动检测到更新,而latest标签的镜像始终指向最新的哈希值,所以无法检测到更新,导致应用没法推送更新,哪怕应用发布了新的应用。

在版本号文件下还有一个data.yml,这个和上面的根目录不同,根目录的data.yml维护的是应用元信息,而版本号下面的data,yml文件则维护的是安装字段信息,如下:

安装字段信息

1Panel会根据这个字段,在目录下创建.env文件,而目录下的docker-compose.yml中的信息也是使用的环境变量,在启动的时候会自动读取.env中维护的信息,从而实现安装,这就是整个安装的过程。

应用更新

这里更新使用的是renovate检测,该组件会定时检测更新,如果有更新则提交PR

这里下一章节讲解,我们先讲解一下1Panel中是怎么实现推送更新的。首先,应用中的文件夹更新,系统会根据版本号大小判断到,当前应用是否有更新,注意这里判断的是文件夹名称,而不是docker-compose.yml中的版本号。

当检测到更新后,系统会提示,更新,首先备份整个目录,由于在1Panel应用商店中,通常会将数据挂载到./data目录下,所以也不用担心。然后将新文件覆盖进来,由于数据文件夹中原始是没有文件的,所以这部分文件不需要担心覆盖。

覆盖完成后,系统会执行docker compose up -d命令,如果一切正常,最终则会正确更新,如果更新出现问题,也会自动回退。

升级前备份应用

至于在上面设置页面的自定义仓库,其实就是给正常仓库的apps文件夹打包为tar.gz压缩包,个人感觉没必要替换掉所有的应用商店,如果有这部分需求可以看以下视频自行学习,这里不再讲解。

所以难点就集中在怎么自动更新应用啦!下面我们就来讲解一下1Panel工作流中的一些原理!

更新工作流

Renovate

安装应用

Renovate可以说是1Panel自动更新的核心,首先克隆一个仓库,这里推荐克隆窝修改后的appstore应用,支持的功能和完整度会稍微高一些:

复刻完成后,添加应用,尝试打开Renovate,添加你个人的仓库,如果不出意外,会自动产生一个issue,用于实时观测应用状态:

实时状态

无需关闭该issue,他会自动打开的QAQ,别问我怎么知道的。

配置文件

应用安装好后,可以自行配置一下根目录中的配置文件,当然也可以保持默认,除非有部分应用超出范围。比如,第三方源。

打开下面站点,可以看到其中我添加了了一些三方源比如codeberg.org,这里我建议除了docker hub源,其余都按照规则添加进来,比如ghcrk8s

除了第一部分的源配置,下面我限定了更新的范围,比如不更新action,以稳定运行,不更新部分已经停更的应用,指定更新特殊版本号的应用,比如牢Umami,其余的你们自己看咯,完整的配置文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base"],
"gitIgnoredAuthors": ["githubaction@githubaction.com"],
"rebaseWhen": "never",
"prCreation": "immediate",

"hostRules": [
{
"hostType": "docker",
"matchHost": "codeberg.org",
"registryUrls": ["https://codeberg.org"]
},
{
"hostType": "docker",
"matchHost": "code.forgejo.org",
"registryUrls": ["https://code.forgejo.org"]
}
],

"packageRules": [
{
"matchManagers": ["github-actions"],
"enabled": false
},
{
"matchDatasources": ["docker"],
"matchFileNames": ["apps/meting-api/*/docker-compose.yml"],
"enabled": false
},
{
"matchDatasources": ["docker"],
"matchFileNames": ["apps/chatnio/*/docker-compose.yml"],
"enabled": false
},
{
"matchDatasources": ["docker"],
"matchFileNames": ["apps/*/*/docker-compose.yml"],
"versioning": "semver"
},
{
"matchDatasources": ["docker"],
"matchPackageNames": ["ghcr.io/umami-software/umami"],
"versionCompatibility": "^(?<compatibility>.*)-(?<version>.*)$",
"versioning": "semver"
}
]
}

按道理默认的够用了,但是万一你们有抽象的要求呢嘻嘻。

作用

Renovate会不定时开始检测,具体看其队列中的检测任务的时间,如果检测到更新,则会自动创建新分支,修改版本号后提交pr,修改docker-compose文件中的镜像版本为最新。

Renovate提交的pr信息

看第二部分配置文件部分,我匹配了apps文件夹下所有的镜像文件,做到不遗漏更新,但是根据第一部分的讲解,仅仅更新docker-compose文件无法推送更新,推送更新主要依赖于文件夹的版本号实现更新,这部分是renovate机器人无法做到的~

那就继续看第二部分!

更新版本

触发机制

在我们仓库的action工作流中,除了Renovate工作流触发器,还有第一个工作流,这个工作流才是整个系统的核心。

所有工作流

工作流内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
name: Update app version in Renovate Branches

on:
push:
branches: [ 'renovate/*' ]
workflow_dispatch:
inputs:
manual-trigger:
description: 'Manually trigger Renovate'
default: ''

jobs:
update-app-version:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Configure git
run: |
git config --local user.email "githubaction@githubaction.com"
git config --local user.name "github-action update-app-version"

- name: Get list of updated files by the last commit
id: updated-files
run: |
echo "files=$(git diff-tree --no-commit-id --name-only -r ${{ github.sha }} | tr '\n' ' ')" >> $GITHUB_OUTPUT

- name: Run renovate-app-version.sh on updated files
id: rename
run: |
set -e
chmod +x .github/workflows/renovate-app-version.sh

files="${{ steps.updated-files.outputs.files }}"
declare -a changed_apps=()

echo "Updated files: $files"

for file in $files; do
if [[ $file == *"docker-compose.yml"* ]]; then
echo "Processing file: $file"

app_name=$(echo $file | cut -d'/' -f 2)
old_version=$(echo $file | cut -d'/' -f 3)
echo "App name: $app_name, old version: $old_version"

# 获取所有服务名
services=$(yq '.services | keys | .[]' "$file")
service=""
image_line=""

for s in $services; do
# 通过awk获取服务下的image行(包含注释)
image_line=$(awk "/services:/{flag=0} /^\s*$s:/{flag=1} flag && /^\s*image:/{print; exit}" "$file")
echo "Service $s image line: $image_line"
if [[ "$image_line" != *"[ignore]"* ]]; then
service="$s"
break
else
echo "Skipping service $s due to [ignore]"
fi
done

if [[ -z "$service" ]]; then
echo "No valid service found in $file, skipping..."
continue
fi

# 提取image纯字符串,去除注释和多余空格
image=$(echo "$image_line" | sed -E 's/^\s*image:\s*([^ #]+).*/\1/')
echo "Selected service: $service"
echo "Extracted image: $image"

if [[ "$image" == *":"* ]]; then
new_version=$(cut -d ":" -f2- <<< "$image")
trimmed_version=${new_version/#"v"/}
echo "Parsed new version: $trimmed_version"
else
trimmed_version=""
echo "No version tag found in image."
fi

changed_apps+=("${app_name}:${old_version}:${trimmed_version}")
echo "Calling renovate-app-version.sh with: $app_name, $old_version, $trimmed_version"
.github/workflows/renovate-app-version.sh "$app_name" "$old_version" "$trimmed_version"
fi
done

echo "All changed apps: ${changed_apps[*]}"
echo "apps=$(IFS=, ; echo "${changed_apps[*]}")" >> $GITHUB_OUTPUT

- name: Commit & Push Changes
run: |
set -e
IFS=',' read -r -a apps <<< "${{ steps.rename.outputs.apps }}"
for item in "${apps[@]}"; do
app_name=$(cut -d':' -f1 <<< "$item")
old_version=$(cut -d':' -f2 <<< "$item")
new_version=$(cut -d':' -f3 <<< "$item")

if [[ -n "$app_name" && -n "$new_version" ]]; then
git add "apps/$app_name/*"
git commit -m "📈将应用 $app_name 的版本从 $old_version 升级到 $new_version [skip ci]" --no-verify || echo "无内容可提交"
fi
done

git push || echo "无内容可推送"

- name: Force merge PR after version bump
if: github.ref_name != 'main'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
branch_name=$(git rev-parse --abbrev-ref HEAD)
echo "Current branch: $branch_name"

# 获取 PR 编号
pr_number=$(gh pr list --state open --head "$branch_name" --json number -q '.[0].number')
if [ -z "$pr_number" ]; then
echo "No PR found for branch $branch_name"
exit 0
fi

echo "Found PR #$pr_number, force merging..."

# 强制合并,不管 mergeable 状态
gh pr merge "$pr_number" --merge --delete-branch --admin

可以看到触发方式中有,在分支renovate/*触发推送,则会进入到该工作流,恰好,在上一部分renovate中,自动更新创建的分支也是以这个为开头的,所以当renovate更新后,我们可以抓取到更新并触发该工作流。

更新文件夹

renovate机器人更新时,会在提交信息中给出一个规范信息,从xxx版本更新到了yyy版本都有记录,我们可以从该记录中提取到旧版本信息和新版本信息,再执行renovate-app-version.sh脚本,该脚本经过我大量简化,功能仅为输入应用名称,旧版本,新版本,即可实现文件夹的重命名。

具体提取版本号的过程,你们可以自行研究一下,这里不再细讲,能用即可。

自动合并

原版appstore到这里就结束了,而我实现的新版则会自动合并符合要求的更新PR,实现全自动化,由于我们的触发器是Push触发器,我们无法直接获取到PR的编号,所以这里我使用github API,检测PR编号,并自动强制合并。

最终实现的效果如下:

实现更新版本全流程

首先,renovate实现创建分支并提交修改,打开PRaction实现修改文件夹,最终检测PR编号,自动合并并删除多余分支。

镜像

由于我们所使用的镜像需要符合docker hubv2 API规范,才能正常通过renovate更新并检测,普通源倒是很多,但是譬如ghcr这种的镜像源,比较稳定的非常有限,ghcr.nju.edu.cn是南京大学官方维护的镜像,稳定,但是很遗憾,经过测试,无法直接作为镜像源添加在列表中,无法支持检测更新的功能。

但是嘛,我总不能每次安装手动改一次镜像地址吧,作为一个彻头彻尾的懒蛋,我是不能接受的,所以我写了一个脚本,用来替换相关的镜像源。

设计

起初我想通过直接维护一个允许api的镜像源,后面发现成本较高,并且暴露在公网,容易被滥用,毕竟反向代理这些被墙的站点,风险是众所周知的,所以我选择了将配置写在服务器本地,提供脚本实现替换并安装。

脚本

首先,更新本地应用的脚本设计如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#!/bin/bash
set -euo pipefail
IFS=$'\n\t'

GIT_REPO="https://cnb.cool/Liiiu/appstore"
TMP_DIR="/opt/1panel/resource/apps/local/appstore-localApps"
LOCAL_APPS_DIR="/opt/1panel/resource/apps/local"

trap 'rm -rf "$TMP_DIR"' EXIT

echo "📥 Cloning appstore repo..."
[ -d "$TMP_DIR" ] && rm -rf "$TMP_DIR"
git clone "$GIT_REPO" "$TMP_DIR"

echo "🔄 Mirroring apps..."
cd "$TMP_DIR"
if [[ -f ./mirror.sh ]]; then
chmod +x ./mirror.sh
./mirror.sh
else
echo "⚠️ mirror.sh not found, skipping mirroring"
fi
cd -

mkdir -p "$LOCAL_APPS_DIR"

for app_path in "$TMP_DIR/apps/"*; do
[ -d "$app_path" ] || continue
app_name=$(basename "$app_path")
local_app_path="$LOCAL_APPS_DIR/$app_name"

echo "🔁 Updating app: $app_name"
[ -d "$local_app_path" ] && rm -rf "$local_app_path"
cp -r "$app_path" "$local_app_path"
done

echo "✅ Sync completed."

在其中,会执行一个Mirror.sh脚本,该脚本实现的功能为,首先从本地找到配置文件,地址为/opt/mirror-config.env,内容示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# ====== GHCR (GitHub Container Registry) ======
# 是否经常被墙:是
GHCR_ENABLE=true
GHCR_MIRROR=ghcr.io.mirror

# ====== Quay.io (RedHat/Community images) ======
# 是否经常被墙:是
QUAY_ENABLE=false
QUAY_MIRROR=quay.io.mirror

# ====== GCR (Google Container Registry) ======
# 是否经常被墙:是
GCR_ENABLE=false
GCR_MIRROR=gcr.io.mirror

# ====== k8s.gcr.io (旧 Kubernetes 镜像仓库) ======
# 是否经常被墙:是
K8S_GCR_ENABLE=false
K8S_GCR_MIRROR=k8s.gcr.io.mirror

# ====== registry.k8s.io (新 Kubernetes 镜像仓库) ======
# 是否经常被墙:是
K8S_REG_ENABLE=false
K8S_REG_MIRROR=registry.k8s.io.mirror

在项目的根目录中,mirror.sh执行后,首先会检测本地的该路径的配置文件,如果存在,则会读取其中的配置,选择是否替换镜像和镜像地址,如果存在文件,且设置为true,则会按照根目录中,维护的.env文件,分辨哪些项目是对应的镜像,并检索目录进行替换。

最终拉取下来后,呈现在应用商店的即为镜像站点,并且由于版本检测并不在本地进行,所以只要可以拉取即可,是否支持api并不重要。

具体的文档也可以看到github

最终也是基本实现功能,并且保护了私有镜像站不会暴露。

总结

至此,整个流程就算是跑通啦!应用商店的首次维护需要我们手动生成相关的元信息,但后续更新就简单多了:直接交给 action 去跑,再配合一个定时任务,就能实现全自动更新,真正做到“无人参与”。前端点击一下更新,应用就能在商店里展示出来,不仅美观,还方便备份和维护,算是省心又好用。大家有兴趣的话也欢迎试试!

时间过得飞快,转眼暑假就结束了,又要开工了。以前最讨厌的九月一日,如今反倒没什么感觉——毕竟已经没有开学可怕的事了(笑),而是走上了职场的新阶段。希望接下来的日子一切顺利吧!

还有还有,今天是俺的生日!🎂

每日一图

图片来自哲风壁纸

生日快乐!