老博客迁移上云(kubernetes)全过程 + 踩坑实录
🥰 当你看到这篇文章,就说明我的博客上云成功啦~
前言
上一次迁移服务器时,感觉 vultr 韩国还不错。过了大概一年多发现访问量大减,还以为是我摸鱼更新太慢导致的流失(当然这也是很重要的原因)
偶然有一次没挂梯子访问自己的博客,发现慢的吓人。一测,好家伙,这延迟、这丢包、这带宽……没救了没救了
我的网站 PHP 运行环境从最早的虚拟主机、lnmp.org 再到 宝塔,一直都是本地运行。kubernetes 其实在工作中已经接触过一段时间了,但都只是学了点皮毛基础,用用腾讯云搭建好的环境、在网页后台点点鼠标而已。作为一个后端开发自是不满足于此,我准备自己从头搭建 k8s 集群,把博客以及手头的几个服务全都容器化上云,把 CI/CD 搞起来😤
选型
Kubernetes
首先来搭建环境。各大云服务厂商都有提供现成的服务,就像工作中用到的那样,简单写几个配置文件点点鼠标就能用,我当然是不愿意选这种的(才不是因为贵)
自己从头搭建如何呢?我在家里的 NAS 上尝试过三开虚拟机练习手动搭建集群,跑是跑起来了,但是过程真的累,而且后面还有很多麻烦的东西等着我。此外,三台服务器对于我这种没啥访问量的小站长来说属实是大材小用了,这开销完全没必要
这种时候,就要请出轻量级的 Kubernetes 发行版 —— K3s 啦~🎉
相比 k8s ,不仅资源占用大大减少,而且它原生就支持单机部署,对于我这种想玩 k8s 又只舍得租一台机的人来说再合适不过了
💡 本文部分内容仅适用于单机 k3s 集群。如果你有多台节点,请根据实际情况自行变通
安装非常简单,官方不仅提供了中文文档,甚至还提供了国内加速镜像,太贴心了😘
不过有个细节值得注意,现在的云厂商几乎都不会把公网 ip 直接绑定到机器上了(即无法通过 ip addr
看到)
这种情况下,安装 k3s 时需要多带个参数 --tls-san
。例如:
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC='server --tls-san {你的公网 ip}' sh -
否则的话,k3s 生成的证书只有内网 ip,你也就无法通过公网连接集群了
CI/CD
在我的工作中使用的流水线是腾讯内网的蓝盾,其对外开源版本叫蓝鲸。看了下配置需求高得吓人🤯(貌似是 4c8g 起)
gitlab ci 的口碑貌似不错,但是他们 saas 版本之前翻车人尽皆知。自建的话我又对自己的运维容灾能力实在是没有信心
那 github action 如何呢?我在 pixiv 小部件那个项目用过,蛮不错的。但它对于私有项目是有每个月的运行时长限制的(免费账户每月 2000 分钟,Pro 用户 3000 分钟),要是用完就麻烦了😖
尽管 action 支持自托管 runner(无运行时长限制),但在一番折腾后我还是放弃了。官方的 runner 并不支持容器化运行,而第三方打包的 runner 容器又有各种各样的已知问题。算了
最终,在朋友果仁的安利下选择了 Drone。实际上也不止是他,我在许多地方搜索 轻量 ci 等关键词都能看到网友提起它
安装并不难,装个控制面板再装个 runner,没什么坑。但不得不吐槽一句,Drone 的文档写的真是简陋🤬
Git
自从 github 被微软收购之后开放无限私有项目,我几乎没有理由不把代码放到 github 上
镜像仓库
阿里云和腾讯云都有免费不限容量(只限制命名空间和仓库数量)的个人版可用,就省得自己搭建。我这里就选腾讯云了
迁移计划
- 写镜像实现博客容器化
- 让博客成功在 k8s 上跑起来
- 搭建流水线实现自动构建、部署
- 签发 ssl 证书
- 修改域名解析
开工 😤
博客镜像
我将博客代码和 nginx 配置文件打包在镜像里,以便利用上 k8s 的回滚功能。这是我的 Dockerfile,供大家参考:
# 博客用的 minty 主题已停更多年。当初 8.0 发布我自行修补一番才兼容上来,懒得继续升版本了
FROM php:8.0.28-fpm-buster
# 腾讯云的镜像源,加快构建
RUN mv /etc/apt/sources.list /etc/apt/sources.list.bak
ADD .docker/images/php/sources.list /etc/apt/
# 装几个常用的小工具方便我 debug 啥的,个人习惯
RUN apt-get update \
&& apt-get install -y git iputils-ping procps wget curl vim iproute2 \
&& echo 'alias ll="ls -lha"' > /root/.bashrc
# 安装 php 扩展
RUN docker-php-ext-install -j$(nproc) bcmath calendar exif gettext sockets dba pcntl pdo_mysql shmop sysvmsg sysvsem sysvshm iconv
# 这是我很喜欢的一个工具,可以快速安装一些原本很麻烦的扩展
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions mcrypt zip mysqli xmlrpc gmp gd opcache
# 将代码和配置文件打包到镜像里
COPY . /code
COPY .docker/config/php.ini /usr/local/etc/php/conf.d/php.ini
# 镜像信息
LABEL Author="mokeyjay<i@mokeyjay.com>"
LABEL Version="2023.04.09"
LABEL Description="moke 博客运行环境"
我们先把这个镜像手动推到镜像仓库去,一会儿我们要让这个镜像在 k8s 中成功跑起来。后面再做流水线实现自动构建、部署
在 k8s 上跑起来
搭建 mysql
我的东西不多,懒得每个项目独立一个 mysql。因此我创建一个 database
命名空间,让所有项目使用这个共享数据库
我喜欢按照服务来划分 yaml,这样维护起来会方便一些。我写了一些注释来帮助你了解它们的作用
# 创建 secret 存储 mysql root 密码
apiVersion: v1
kind: Secret
metadata:
name: mysql-root-password
namespace: database
data:
# 密码需要 base64 编码一下才能放进 secret 里
password: c2FkZjc4OXNhN2Y5YXNkN2Y5OGFzZGY=
---
# 创建一个 PVC 来持久化 mysql 数据
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql
namespace: database
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path # 存在节点本地。毕竟我就一台机器
resources:
requests:
storage: 10Gi
---
# mysql 部署
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql
namespace: database
spec:
replicas: 1
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
# 把刚才申请的 pvc 放进来,命名为 data
volumes:
- name: data
persistentVolumeClaim:
claimName: mysql
containers:
- name: mysql
image: mysql:5.7.41
env:
# 从 secret 获取 root 密码
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-root-password
key: password
args:
# 一些 mysql 的配置项
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --sql-mode=
# 将 data 挂载到这个路径下来持久化数据
volumeMounts:
- name: data
mountPath: /var/lib/mysql
---
# 添加一个 service 供 database 命名空间内访问以及节点外部环境访问
apiVersion: v1
kind: Service
metadata:
name: mysql
namespace: database
spec:
selector:
app: mysql
type: NodePort
ports:
- port: 3306
targetPort: 3306
nodePort: 30306 # 如果不需要外部连接就不要这一行。添加了这一行就一定要通过安全组之类的措施来确保安全
name: mysql
---
# 供集群内跨命名空间访问的服务
apiVersion: v1
kind: Service
metadata:
name: mysql-cluster-connect
namespace: database
spec:
type: ExternalName
# 在其他命名空间内使用这个域名就能访问 mysql 数据库
externalName: mysql.database.svc.cluster.local
kubectl apply
一下,不出意外是能够正常跑起来的
💡 值得注意的是,
local-path
实际上并不会遵循 PVC 的容量限制,也就是说上面分配的 10G PVC 空间实际上是无限大,直到节点硬盘被塞满
搭建博客
咱们的博客镜像由于包含了代码文件,被我设成私有了。因此我们需要先创建一个镜像仓库的凭据,才能用这个凭据拉取到私有镜像
kubectl create secret docker-registry {secret 名称} \
--docker-server={镜像仓库域名} \
--docker-username={用户名} \
--docker-password={密码} \
--docker-email={邮箱} \
-n {命名空间}
💡 本文示例中,镜像仓库凭据名称为 qcloud-docker-registry
此外,我还希望所有 http 请求都自动跳转到 https。这里我们需要写一个 ingress 中间件来实现
apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
name: force-https
namespace: ingress
spec:
redirectScheme:
scheme: https
permanent: true
接下来我们创建博客的其他部分
# 创建一个 pvc 来持久化 wordpress 媒体库里的文件
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wp-content-uploads
namespace: old-blog
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 10Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: old-blog
namespace: old-blog
spec:
replicas: 1
selector:
matchLabels:
app: old-blog
template:
metadata:
labels:
app: old-blog
spec:
volumes:
# 媒体库里的文件
- name: wp-content-uploads
persistentVolumeClaim:
claimName: wp-content-uploads
# 博客代码文件(空卷)
- name: code
emptyDir: {}
# nginx 配置文件(空卷)
- name: nginx-conf
emptyDir: {}
containers:
- name: php
image: hkccr.ccs.tencentyun.com/old_blog/php:latest # 刚才咱们手动推送到镜像仓库的博客镜像
volumeMounts:
- name: code
mountPath: /var/www/html
- name: wp-content-uploads
mountPath: /var/www/html/wp-content/uploads
- name: nginx-conf
mountPath: /nginx-conf
lifecycle:
# 利用 pod 生命周期回调,在容器启动后执行下列代码
postStart:
exec:
# 将容器中的 /code 和 nginx.conf 拷贝到上面挂载的空卷中
command: ["/bin/sh", "-c", "cp -r /code/* /var/www/html/ && cp /code/.docker/config/nginx.conf /nginx-conf/default.conf"]
- name: nginx
image: nginx
volumeMounts:
# 上面我们将代码和 nginx 配置文件都拷贝到卷中
# 现在让 nginx 容器也挂载这些卷,实现静态资源、配置文件在两个容器中共享
- name: nginx-conf
mountPath: /etc/nginx/conf.d
- name: code
mountPath: /var/www/html
- name: wp-content-uploads
mountPath: /var/www/html/wp-content/uploads
# 上面提到的镜像仓库的凭据 secret
imagePullSecrets:
- name: qcloud-docker-registry
---
apiVersion: v1
kind: Service
metadata:
name: old-blog
namespace: old-blog
spec:
selector:
app: old-blog
type: NodePort
ports:
- port: 80
targetPort: 80
name: nginx
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: old-blog
namespace: old-blog
annotations:
# 应用上面写的中间件
traefik.ingress.kubernetes.io/router.middlewares: ingress-force-https@kubernetescrd
spec:
rules:
# 监听 www 和根域名。其中根域名会根据 nginx 配置 302 到 www(别问,问就是历史遗留)
- host: www.mokeyjay.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: old-blog
port:
number: 80
- host: mokeyjay.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: old-blog
port:
number: 80
将域名解析(或 hosts)到这台机器的公网 ip 上。不出意外的话,就能正常访问到了
搭建流水线
这里的坑可就多了去了,耽误我好几天😭
镜像构建时无法正确解析腾讯云镜像域名
我的 NAS 和 runner 都可以正确解析 mirrors.tencent.com
,唯独构建过程报错无法解析。通过添加 custom_dns
参数指定公共 dns 解决
奇怪的是,在后续构建中即便我删掉这个参数,也能正常解析了。它是自己整了一个构建过程专用的 dns 缓存?
每次构建从 0 开始无法复用层缓存
docker 的 layer cache 能大大加快重复构建的过程。但 Drone 并没有正常使用这个缓存,貌似是因为其容器化的插件机制导致的
网上能搜到的解决方案大多已十分古老,我就没找到一个有效的
最后也仅仅只是在 drone 的官方 docker 插件中找到一个 cache_from
参数。指定一个镜像地址,drone 每次构建会把这个镜像拉取下来作为缓存源。如果存在相同的层则不再重复构建
这是缓存吗?是,但根本不是我想要的。每次构建都要拉一个几百 M 的镜像下来检查缓存,要命🤮
解决方案是将宿主机的 docker.sock 挂载到 runner 中,通过宿主机的 docker 来构建镜像。好处是能正常使用缓存,构建速度大大加快。代价是存在一定的安全风险,考虑到这个私有 runner 仅仅处理我自己的私有项目,勉强能够接受吧
🤔 文章写到这里突然想到,我其实应该在本地搭建一个镜像仓库(例如 harbor)来承担这个缓存的角色。这样
cache_from
每次构建时拉取几百 M 的镜像所需的耗时也基本可以忽略不计了
更新镜像报错
一直报我 token 错误,又是一阵苦苦折腾
最后发现这个插件已经好几年没更新了,issue 里原作者也表示已经不再维护
唉,累了累了😰在另一位朋友的安利下换成了阿里云效,免费版每月 1800 分钟将就用吧
更换为 github action
后来经过对比我发现,云效的免费时长用完后,额外时长价格较高(¥618/年,不限时长)。而 github pro 价格便宜($4/月,3000 分钟)还支持自托管 runner。实在不够用我就在 NAS 里起个虚拟机跑 runner 好了
要使用 action 很简单,在项目根目录创建 .github/workflows
,在里面写些 yaml 即可
放出我的供大家参考:
name: 构建镜像并推送上线
on:
push:
branches:
- "main"
jobs:
build:
name: 构建镜像并推送上线
runs-on: ubuntu-latest
steps:
- name: 拉取代码
uses: actions/checkout@v3
- name: 登录到仓库
uses: docker/login-action@v2
with:
registry: hkccr.ccs.tencentyun.com
username: ${{ secrets.QCLOUD_REGISTRY_USERNAME }}
password: ${{ secrets.QCLOUD_REGISTRY_PASSWORD }}
- name: 使用 buildx 作为构建器
uses: docker/setup-buildx-action@v2
- name: 构建并推送
uses: docker/build-push-action@v3
with:
context: .
file: .docker/images/php/Dockerfile
push: true
# 镜像要打上 action 执行编号(从 1 开始自增)和 latest 标签
# 用自增数字做标签可以方便你查看当前容器的镜像版本
tags: hkccr.ccs.tencentyun.com/old_blog/php:${{github.run_number}}, hkccr.ccs.tencentyun.com/old_blog/php:latest
platforms: linux/amd64
# 跟上面提到的 drone 插件的 cache_from 参数是一个意思,这是为了启用层缓存以加快构建速度
cache-from: type=registry,ref=hkccr.ccs.tencentyun.com/old_blog/php:latest
cache-to: type=inline
- name: 部署到集群
uses: ghostzero/kubectl@v1
env:
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG }}
with:
# 更新容器镜像
args: set image --record -n old-blog deployment/old-blog php=hkccr.ccs.tencentyun.com/old_blog/php:${{github.run_number}}
- name: 检查部署结果
uses: ghostzero/kubectl@v1
env:
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG }}
with:
args: rollout status -n old-blog deployment/old-blog
配置 ssl 证书
我希望使用 let's encrypt 提供的免费 ssl 证书。恰好 k3s 内置的 Traefik 集成了对 ACME 的支持,我可以很方便地实现这点
首先,我们需要 安装 cert-manager
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.11.0/cert-manager.yaml
kubectl get pods -n cert-manager # 看到三个 RUNING 就说明安装成功了
然后需要创建一个 Issuer
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
# 如果证书即将过期且自动续期失败,他们会发提醒邮件到你的这个邮箱
email: {邮箱}
privateKeySecretRef:
name: prod-issuer-account-key
server: https://acme-v02.api.letsencrypt.org/directory
solvers:
- http01:
ingress:
class: traefik
selector: {}
apply 之后执行一下 kubectl describe clusterissuer letsencrypt
,如果看到 Type: Ready
的字样就是 OK 的
最后,在你的 Ingress 配置项中添加如下内容:
metadata:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- secretName: {存储证书内容的 secret 名称}
hosts:
- {域名}
以我的博客域名为例,最终 Ingress 是这样的:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: old-blog
namespace: old-blog
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.middlewares: ingress-force-https@kubernetescrd
spec:
rules:
- host: www.mokeyjay.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: old-blog
port:
number: 80
tls:
- secretName: tls-www.mokeyjay.com
hosts:
- www.mokeyjay.com
如果你的域名是直接解析到这台机器公网 ip 上的,那么在你 apply 之后,证书的签发就会全自动完成。你可以通过
kubectl describe certificate
来检查是否签发成功
这时候可能有朋友就要问了:我的域名目前指向旧机器 ip,我想要尽可能降低服务的不可用时间,也就是希望在新机器上把 ssl 证书签好再把域名解析改到新机器上,这该怎么搞呢?
哎,那这位朋友跟我的情况是一样的。在我咨询过万能的网友之后,最终决定采用 nginx 转发来实现。在你的旧机器 nginx 上添加如下内容:
location ~ \.well-known{
allow all;
proxy_set_header Host $host;
proxy_pass http://{新机器 ip};
}
这样,当 Let's encrypt 发起 http 请求来验证你的域名时,它的请求就会被旧机器转发到新机器上,最终在新机器上成功签发证书。亲测可行
最后,把域名的解析指向改为新机器 ip,大功告成!🎉
后记
上云成功后,发现不挂梯子访问还是很慢😳
仔细一看,原来是 WP-Editor 调用了 jsdelivr 的静态资源,它在国内都被墙了,能不慢吗😅 改成依赖本地资源即可
其次还有 gravatar 头像的问题,我找到一个 Cravatar 作为替代,看起来挺靠谱,访问速度也不错
还有一个问题,就是 WordPress 自身和插件更新都会修改代码文件,我该怎样把这些变更纳入版本管理呢?
目前想到的方案就是直接更新,然后手动进入容器把代码打包拉下来提交😂
如果有更好的方案,还请大家评论留言
其实梯子什么的没事,现在为了迎接openai,谁家不备梯子
drone-kube方式也不错呀, https://docs.drone.io/runner/kubernetes/overview/
我是用 docker 跑的 drone runner。不过即便换成 kube 应该也避免不了那些坑吧🥲
可以给blog的deployment加个更新策略, 防止在容器替换的时候服务不可用
看了一下目前已经是 RollingUpdate 了,可能是 k3s 做了点优化把它设为默认值了?