如何管理并自动更新 HomeLab 中的容器
经过一段时间的探索和优化,我已经找到一个特别适合我的方法,来自动化管理我 HomeLab 中的众多容器。一直想写一篇文章来记录一下,拖到今天才开始动笔。
首先交代一下背景,我的 HomeLab 中目前总共管理着 50 多个容器,分布在群晖 NAS 以及另外两台 NUC 的虚拟机中。从最初的直接通过 compose 文件手动管理,到后来使用了 Portainer,再到现在的基于 GitLab CI 的自动化管理方式,逐渐变得更自动化、更方便、也更不容易出错。
为什么要自动化管理
一开始上 Portainer,是为了解决手动管理多个设备中的容器,频繁 SSH 到不同设备中比较麻烦的问题。用 Portainer 确实可以方便快捷地管理多个设备中的容器,当容器数量比较少的时候,还是非常推荐的。
随着容器数量的变多,Portainer 上我遇到两个不太好解决的问题:
- 容器的自动更新,Portainer 的 Business 版是提供了自动更新的功能的,但可惜 CE 版没有
- 容器的配置文件管理、配置和 compose 文件的备份、版本记录等
第二个问题比较好解决,通过 git 管理 compose 文件和配置文件,结合 GitLab CI 在文件变更的时候自动 SSH 到对应宿主机上执行容器的更新操作。
第一个问题,是我在本博客项目上使用 renovate 来更新前端依赖的时候,从他们的文章中看到,renovate 也可以检测 Docker 镜像的更新,于是灵光一现,基于上一步所有 compose 文件都已经在 GitLab 中管理了,那再结合 renovate,就可以实现容器的自动更新了。
最终形态
中间摸索的步骤由于已经过了差不多几个月了,不太容易复盘了,就把最终的形态分享一下。
如上图,所有容器的配置文件、compose 文件都放在 config-files
这个项目中,该项目通过 GitLab 管理,renovate 在定期检测到镜像更新的时候,会自动提交一个 MR,可以通过一定规则配置成自动合并或手动合并,当然也支持手动修改配置文件或 compose 文件。MR 合并或配置文件修改后触发 GitLab CI,通过 SSH 登录对应宿主机上,执行对应的配置文件更新或容器更新的操作。
我的 config-files
目录结构:
.
├── aliyun
│ ├── gateway
│ └── hk
├── homelab
│ ├── scripts
│ ├── synology
│ ├── vm-rocky-01
│ └── vm-rocky-02
├── .gitlab-ci.yml
└── renovate.json
aliyun
目录中的 gateway
和 hk
是我在杭州和香港的两台服务器,部署一些外网项目,也是通过上述的方式管理。homelab
中的 scripts
是一些脚本,与本文无关,但也是通过 config-files
项目统一管理的。
我的 renovate.json
如下:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", "group:allNonMajor", ":semanticCommitTypeAll(chore)"],
"packageRules": [
{
"matchManagers": ["docker-compose"],
"matchPackageNames": ["sonatype/nexus3", "clickhouse/clickhouse-server"],
"enabled": false
},
{
"matchManagers": ["docker-compose"],
"matchPackageNames": ["gitlab/gitlab-runner"],
"versionCompatibility": "^(?<compatibility>.*)-(?<version>.*)$"
},
{
"matchManagers": ["docker-compose"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true,
"schedule": ["before 11am on Monday"]
}
],
"rangeStrategy": "bump",
"timezone": "Asia/Shanghai"
}
里面有一些自定义的规则,比如禁用了 sonatype/nexus3
和 clickhouse/clickhouse-server
的自动更新,以及适配了 gitlab/gitlab-runner
的不规则的版本号。
.gitlab-ci.yml
如下:
stages:
- pass
- deploy
pass:
stage: pass
script:
- echo "Current branch is $CI_COMMIT_BRANCH, pass."
except:
- main
deploy:
stage: deploy
before_script:
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
- mkdir -p ~/.ssh
- echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
script:
- MODIFIED_FILES=$(git diff --name-only --diff-filter=ACMRT $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA)
- DELETED_FILES=$(git diff --name-status --diff-filter=DR $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | awk '{print $2}')
- |
for FILE in $DELETED_FILES; do
echo "文件移除 $FILE"
# 如果文件是 compose.yaml,执行 docker compose down
if [[ "$FILE" == "*/compose.yaml" ]]; then
if [[ "$FILE" == "homelab/synology/*" ]]; then
DEST_DIR="/volume3/docker/$(dirname ${FILE#homelab/})"
echo "退出容器 synology:$(dirname ${FILE#homelab/synology/})"
ssh -p 5110 [email protected] "export PATH=\$PATH:/usr/local/bin && cd $DEST_DIR && docker-compose down"
fi
if [[ "$FILE" == "homelab/vm-rocky-01/*" ]]; then
DEST_DIR="/root/$(dirname ${FILE#homelab/vm-rocky-01/})"
echo "退出容器 vm-rocky-01:$(dirname ${FILE#homelab/vm-rocky-01/})"
ssh [email protected] "cd $DEST_DIR && docker compose down"
fi
if [[ "$FILE" == "homelab/vm-rocky-02/*" ]]; then
DEST_DIR="/root/$(dirname ${FILE#homelab/vm-rocky-02/})"
echo "退出容器 vm-rocky-02:$(dirname ${FILE#homelab/vm-rocky-02/})"
ssh [email protected] "cd $DEST_DIR && docker compose down"
fi
# if [[ "$FILE" == "aliyun/gateway/*" ]]; then
# DEST_DIR="/root/config-files/gateway/$(dirname ${FILE#aliyun/gateway/})"
# echo "退出容器 gateway:$(dirname ${FILE#aliyun/gateway/})"
# ssh [email protected] "cd $DEST_DIR && docker compose down"
# fi
fi
# 删除对应文件
if [[ "$FILE" == "homelab/synology/*" ]]; then
FILE_PATH="/volume3/docker/${FILE#homelab/}"
echo "从 Synology 删除文件: $FILE_PATH"
ssh -p 5110 [email protected] "rm -f $FILE_PATH"
fi
if [[ "$FILE" == "homelab/vm-rocky-01/*" ]]; then
FILE_PATH="/root/${FILE#homelab/vm-rocky-01/}"
echo "从 vm-rocky-01 删除文件: $FILE_PATH"
ssh [email protected] "rm -f $FILE_PATH"
fi
if [[ "$FILE" == "homelab/vm-rocky-02/*" ]]; then
FILE_PATH="/root/${FILE#homelab/vm-rocky-02/}"
echo "从 vm-rocky-02 删除文件: $FILE_PATH"
ssh [email protected] "rm -f $FILE_PATH"
fi
if [[ "$FILE" == "aliyun/gateway/*" ]]; then
FILE_PATH="/root/config-files/gateway/${FILE#aliyun/gateway/}"
echo "从 gateway.aliyun 删除文件: $FILE_PATH"
ssh [email protected] "rm -f $FILE_PATH"
fi
done
for FILE in $MODIFIED_FILES; do
echo "文件变更 $FILE"
if [ -e "$FILE" ]; then
if [[ "$FILE" == "homelab/synology/*" ]]; then
DEST_DIR="/volume3/docker/$(dirname ${FILE#homelab/})"
ssh -p 5110 [email protected] "mkdir -p $DEST_DIR"
echo "复制文件 $FILE 到 Synology: $DEST_DIR"
scp -O -P 5110 $FILE [email protected]:$DEST_DIR
fi
if [[ "$FILE" == "homelab/vm-rocky-01/*" ]]; then
DEST_DIR="/root/$(dirname ${FILE#homelab/vm-rocky-01/})"
ssh [email protected] "mkdir -p $DEST_DIR"
echo "复制文件 $FILE 到 vm-rocky-01: $DEST_DIR"
scp -O $FILE [email protected]:$DEST_DIR
fi
if [[ "$FILE" == "homelab/vm-rocky-02/*" ]]; then
DEST_DIR="/root/$(dirname ${FILE#homelab/vm-rocky-02/})"
ssh [email protected] "mkdir -p $DEST_DIR"
echo "复制文件 $FILE 到 vm-rocky-02: $DEST_DIR"
scp -O $FILE [email protected]:$DEST_DIR
fi
if [[ "$FILE" == "aliyun/gateway/*" ]]; then
DEST_DIR="/root/config-files/gateway/$(dirname ${FILE#aliyun/gateway/})"
ssh [email protected] "mkdir -p $DEST_DIR"
echo "复制文件 $FILE 到 gateway.aliyun: $DEST_DIR"
scp -O $FILE [email protected]:$DEST_DIR
fi
if [[ "$FILE" == "aliyun/hk/nginx/*" ]]; then
DEST_DIR="/etc/nginx/$(dirname ${FILE#aliyun/hk/nginx/})"
ssh [email protected] "mkdir -p $DEST_DIR"
echo "复制文件 $FILE 到 hk.aliyun: $DEST_DIR"
scp -O $FILE [email protected]:$DEST_DIR
fi
fi
done
for FILE in $MODIFIED_FILES; do
if [ -e "$FILE" ]; then
# 如果文件是 compose.yaml,执行 docker compose up -d
if [[ "$FILE" == "*/compose.yaml" ]]; then
if [[ "$FILE" == "homelab/vm-rocky-01/renovate/compose.yaml" ]]; then
echo "跳过部署 vm-rocky-01:renovate"
continue
fi
if [[ "$FILE" == "homelab/synology/*" ]]; then
DEST_DIR="/volume3/docker/$(dirname ${FILE#homelab/})"
echo "部署容器 synology:$(dirname ${FILE#homelab/synology/})"
ssh -p 5110 [email protected] "export PATH=\$PATH:/usr/local/bin && cd $DEST_DIR && docker-compose up -d"
fi
if [[ "$FILE" == "homelab/vm-rocky-01/*" ]]; then
DEST_DIR="/root/$(dirname ${FILE#homelab/vm-rocky-01/})"
echo "部署容器 vm-rocky-01:$(dirname ${FILE#homelab/vm-rocky-01/})"
ssh [email protected] "cd $DEST_DIR && docker compose up -d"
fi
if [[ "$FILE" == "homelab/vm-rocky-02/*" ]]; then
DEST_DIR="/root/$(dirname ${FILE#homelab/vm-rocky-02/})"
echo "部署容器 vm-rocky-02:$(dirname ${FILE#homelab/vm-rocky-02/})"
ssh [email protected] "cd $DEST_DIR && docker compose up -d"
fi
# if [[ "$FILE" == "aliyun/gateway/*" ]]; then
# DEST_DIR="/root/config-files/gateway/$(dirname ${FILE#aliyun/gateway/})"
# echo "部署容器 gateway.aliyun:$(dirname ${FILE#aliyun/gateway/})"
# ssh [email protected] "cd $DEST_DIR && docker compose up -d"
# fi
echo "容器重新部署完成"
continue
fi
# 如果文件是 homelab/synology/nginx/*, 重新启动 nginx
if [[ "$FILE" == "homelab/synology/nginx/*" ]]; then
echo "重新启动 synology:nginx 服务"
ssh -p 5110 [email protected] "export PATH=\$PATH:/usr/local/bin && docker exec nginx nginx -s reload"
fi
# 如果文件是 aliyun/gateway/nginx/*, 重新启动 nginx
if [[ "$FILE" == "aliyun/gateway/nginx/*" ]]; then
echo "重新启动 gateway.aliyun:nginx"
ssh [email protected] "nginx -s reload"
fi
# 如果文件是 aliyun/hk/nginx/*, 重新启动 nginx
if [[ "$FILE" == "aliyun/hk/nginx/*" ]]; then
echo "重新启动 hk.aliyun:nginx"
ssh [email protected] "nginx -s reload"
fi
# 如果文件是 rinetd.conf, 重新启动 rinetd
if [[ "$FILE" == "homelab/synology/rinetd/config/rinetd.conf" ]]; then
echo "重新启动 synology:rinetd 服务"
ssh -p 5110 [email protected] "export PATH=\$PATH:/usr/local/bin && cd /volume3/docker/synology/rinetd && docker-compose restart"
fi
# 如果文件是 gitlab.rb, 重新配置 gitlab
if [[ "$FILE" == "homelab/vm-rocky-01/gitlab/config/gitlab.rb" ]]; then
echo "重新配置 vm-rocky-01:gitlab"
ssh [email protected] "docker exec gitlab gitlab-ctl reconfigure"
fi
# 如果文件是 prometheus.yml, 重新启动 prometheus
if [[ "$FILE" == "homelab/vm-rocky-01/prometheus/config/prometheus.yml" ]]; then
echo "重新启动 vm-rocky-01:prometheus"
ssh [email protected] "cd /root/prometheus && docker compose restart"
fi
# 如果文件是 telegraf.conf, 重新启动 telegraf
if [[ "$FILE" == "homelab/synology/telegraf/conf/telegraf.conf" ]]; then
echo "重新启动 synology:telegraf"
ssh -p 5110 [email protected] "export PATH=\$PATH:/usr/local/bin && cd /volume3/docker/synology/telegraf && docker-compose restart"
fi
if [[ "$FILE" == "homelab/vm-rocky-01/telegraf/conf/telegraf.conf" ]]; then
echo "重新启动 vm-rocky-01:telegraf"
ssh [email protected] "cd /root/telegraf && docker compose restart"
fi
if [[ "$FILE" == "homelab/vm-rocky-02/telegraf/conf/telegraf.conf" ]]; then
echo "重新启动 vm-rocky-02:telegraf"
ssh [email protected] "cd /root/telegraf && docker compose restart"
fi
# 如果文件是 bots/config/clash/*.yaml, 更新 clash 配置
if [[ "$FILE" == "homelab/vm-rocky-02/bots/config/clash/*.yaml" ]]; then
echo "重新启动 vm-rocky-02:bots"
ssh [email protected] "cd /root/bots && docker compose restart"
echo "等待 10 秒"
sleep 10
echo "重新启动 synology:clash-premium"
ssh -p 5110 [email protected] "export PATH=\$PATH:/usr/local/bin && cd /volume3/docker/synology/clash-premium && docker-compose restart"
fi
# 如果文件是 artalk/data/artalk.yml, 重新启动 artalk
if [[ "$FILE" == "homelab/vm-rocky-02/artalk/data/artalk.yml" ]]; then
echo "重新启动 vm-rocky-02:artalk"
ssh [email protected] "cd /root/artalk && docker compose restart"
fi
fi
done
- echo "部署完成"
only:
- main
其中,SSH_KNOWN_HOSTS
和 SSH_PRIVATE_KEY
存储在 GitLab CI/CD 的变量中,用于 SSH 登录到对应的宿主机上。
当我需要改某个容器的配置文件时,例如当我需要修改 synology
上的 nginx
配置,我只需要去 config-files
项目中修改 homelab/synology/nginx/nginx.conf
文件,然后 commit,push,等待几秒钟,GitLab CI 就会自动帮我完成配置文件的更新以及 nginx
的重启。
每周一,renovate 会自动发起并合并更新容器镜像版本的 MR,然后 GitLab CI 会自动帮我更新容器。
对了,GitLab 、GitLab Runner、Renovate 也都是通过容器私有化部署在 HomeLab 中的,这些就不赘述。