kubernetes

老博客迁移上云(kubernetes)全过程 + 踩坑实录

k8s
2434
5
2023-05-05

🥰 当你看到这篇文章,就说明我的博客上云成功啦~

前言

上一次迁移服务器时,感觉 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 上

镜像仓库

阿里云和腾讯云都有免费不限容量(只限制命名空间和仓库数量)的个人版可用,就省得自己搭建。我这里就选腾讯云了

迁移计划

  1. 写镜像实现博客容器化
  2. 让博客成功在 k8s 上跑起来
  3. 搭建流水线实现自动构建、部署
  4. 签发 ssl 证书
  5. 修改域名解析

开工 😤

博客镜像

我将博客代码和 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 自身和插件更新都会修改代码文件,我该怎样把这些变更纳入版本管理呢?
目前想到的方案就是直接更新,然后手动进入容器把代码打包拉下来提交😂
如果有更好的方案,还请大家评论留言

昵称
邮箱
网址
springwood的头像 2023-06-04 11:07

其实梯子什么的没事,现在为了迎接openai,谁家不备梯子

ysicing的头像 2023-05-06 16:32

drone-kube方式也不错呀, https://docs.drone.io/runner/kubernetes/overview/

mokeyjay的头像 2023-05-06 20:30
mokeyjay 博主

我是用 docker 跑的 drone runner。不过即便换成 kube 应该也避免不了那些坑吧🥲

月子喵的头像 2023-05-05 23:26

可以给blog的deployment加个更新策略, 防止在容器替换的时候服务不可用

mokeyjay的头像 2023-05-06 12:06
mokeyjay 博主

看了一下目前已经是 RollingUpdate 了,可能是 k3s 做了点优化把它设为默认值了?