6

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

 1 year ago
source link: https://www.mokeyjay.com/archives/3256/comment-page-1
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
kubernetes

老博客迁移上云(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 的文档写的真是简陋🤬

自从 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<[email protected]>"
LABEL Version="2023.04.09"
LABEL Description="moke 博客运行环境"
Docker

我们先把这个镜像手动推到镜像仓库去,一会儿我们要让这个镜像在 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};
}
nginx

这样,当 Let’s encrypt 发起 http 请求来验证你的域名时,它的请求就会被旧机器转发到新机器上,最终在新机器上成功签发证书。亲测可行

最后,把域名的解析指向改为新机器 ip,大功告成!🎉

上云成功后,发现不挂梯子访问还是很慢😳

仔细一看,原来是 WP-Editor 调用了 jsdelivr 的静态资源,它在国内都被墙了,能不慢吗😅 改成依赖本地资源即可
其次还有 gravatar 头像的问题,我找到一个 Cravatar 作为替代,看起来挺靠谱,访问速度也不错

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

kubernetes

本站文章除注明转载外,均为原创文章。如需转载请注明出处:https://www.mokeyjay.com/archives/3256

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK