前言 在上一篇文章: 搭建一个基于containerd的高可用Kubernetes集群 中, 我们成功搭建了一个高可用的Kubernetes集群. 为了让这个实验环境充分发挥价值, 接下来我们就可以运行一个基于实际业务的demo了.
在本文我们将会搭建一个简单的电商页面, 可在页面上进行基本的类似商品信息浏览, 购物车, 结算功能:
本Demo中涉及到的源代码和相关的kubernetes yaml文件, 笔者已上传到github, 大家可以自便:
https://github.com/RondoChen/k8s-demo.git
demo后台结构介绍 要完成这个demo, 你需要:
一个可用的Kubernetes系统, 并熟悉kubectl
如果有一个图形化的Kubernetes管理工具就更方便了, 笔者使用的是Lens
一个可用的容器镜像仓库, 本文使用的是DockerHub
熟悉Docker的基本操作
对demo中涉及到的开发技术栈有基本的了解
能熟练使用像postman, curl, 浏览器的开发者功能等工具排查网站或者API问题
在过程中, 我们会经历以下几个运维工作日常会遇到的场景:
在Kubernetes中管理容器的持久化存储
通过容器化的方式部署MySQL数据库集群, 实现读写分离
在容器中运行Redis
为各个使用不同开发技术栈的服务编写Dockerfile
使用multistage build完成容器镜像的构建
在Kubernetes中完成容器的编排:
job, statefulset, deployment
负载均衡
动态扩缩容(replica set)
健康检查
配置分离(config map)
服务发现
系统架构图:
各服务功能说明:
服务
技术栈
说明
mall
js: react SPA
商城前端页面
order
c#: dotNet core + MySQL
订单管理
product
java: Springboot + MySQL
商品管理
passport
nodejs: eggjs + MySQL
账号管理
review
php: Lavarel + MySQL
商品评论
shopcart
go: echo + redis
购物车
镜像构建 前面提到, 这个demo是由各个使用不同技术栈的服务组合而成, 我们需要对各个服务进行镜像的构建工作.
按照传统的部署思路, 我们往往需要一个编译环境去完成, 但在容器化的思路下, 我们直接在容器里面完成编译就可以了. 这样最起码有两个好处, 既节省了维护编译环境的工作量, 也顺便把编译步骤规范起来了.
Multi-stage builds
笔者已完成了相关镜像的构建, 并上传到dockerhub:
1 2 3 4 5 6 rondochen/product:1.0 rondochen/review:1.0 rondochen/order:1.0 rondochen/shopcart:1.0 rondochen/passport:1.0 rondochen/mysql-data:1.0
Dockerfile的内容没有经过认真的优化, 但因为不是本文的重点, 就暂时满足于能跑就行. :-)
product 这是使用java spingboot框架开发的服务, 在项目路径src/product/src/main/resources/application.yml
中已预设好必要的配置内容, 其中数据库的连接可以通过环境变量实现修改.
如需本地运行, 可以参考:
1 2 3 4 5 6 export DB_HOST='127.0.0.1' export DB_PASSWORD='P@ssword123' export DB_USERNAME='xyzshop_user' export DB_DATABASE='xyzshop' mvn clean compile mvn exec :java
Dockerfile:
1 2 3 4 5 6 7 8 9 10 11 12 FROM maven:3.5 -jdk-8 -alpine as builderCOPY src /usr/src/app/src COPY pom.xml /usr/src/app RUN mvn -f /usr/src/app/pom.xml clean package FROM openjdk:8 -jdk-alpineRUN mkdir /app COPY --from=builder /usr/src/app/target/product.jar /app WORKDIR /app EXPOSE 3000
镜像构建和上传(后面其他几个服务都是差不多):
1 2 3 cd src/productdocker build -t rondochen/product:1.0 . -f Dockerfile-multistage docker push rondochen/product:1.0
order 这是订单管理服务, 本地运行方式为:
1 2 3 4 5 dotnet restore dotnet run
关于dotnet平台的容器构建, 可以浏览微软官网获取更多内容:
https://aka.ms/containerfastmode
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 FROM mcr.microsoft.com/dotnet/core/sdk:3.1 -buster AS buildWORKDIR /src COPY ["./order.csproj" , "order/" ] RUN dotnet restore "order/order.csproj" WORKDIR "/src/order" COPY . . RUN dotnet build "order.csproj" -c Release -o /app/build FROM build AS publishRUN dotnet publish "order.csproj" -c Release -o /app/publish FROM mcr.microsoft.com/dotnet/core/aspnet:3.1 -buster-slim as finalWORKDIR /app EXPOSE 7000 COPY --from=publish /app/publish . ENTRYPOINT ["dotnet" , "order.dll" ]
passport 这是使用eggjs框架开发的服务, 本地运行方法:
1 2 3 4 5 6 7 8 9 10 cd src/passportnpm install export DB_HOST='127.0.0.1' export DB_PASSWORD='P@ssword123' export DB_USERNAME='xyzshop_user' export DB_DATABASE='xyzshop' node index.js
Dockerfile:
1 2 3 4 5 6 7 FROM node:13 -alpineWORKDIR /app COPY package*.json ./ RUN npm i --production COPY . . EXPOSE 5000 CMD [ "node" , "index.js" ]
shopcart 这是使用Golang编写的服务, 依赖Redis, 启动方法为:
1 2 3 4 5 6 export SERVICE_PASSPORT='http://127.0.0.1:5000' export SERVICE_PRODUCT='http://127.0.0.1:3000' export REDIS_ADDRESS='xxx' export REDIS_PASSWORD='xxx' export REDIS_DB='xxx' go run main.go
第一次构建的时候, 要下载大量的依赖文件, 可能需时较长, Dockerfile:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 FROM golang:alpine3.15 AS builderWORKDIR /src COPY go.mod . COPY go.sum . RUN go mod download -x COPY . /src RUN go build -o main -x FROM alpine:3.17 WORKDIR /app COPY --from=builder /src/main /app/ COPY ./config/config.yaml /app/config/config.yaml EXPOSE 6000
review 这是用PHP语言开发的服务, 本地运行方法:
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 cd src/reviewcomposer install export APP_NAME=xyzshopexport DB_HOST='127.0.0.1' export DB_PASSWORD='P@ssword123' export DB_USERNAME='xyzshop_user' export DB_DATABASE='xyzshop' export SERVICE_PASSPORT='http://127.0.0.1:5000' cat <<EOF > ./.env APP_NAME=${APP_NAME} APP_KEY=base64:QHVH+p7eTaKkYNtJI5+0koXGH1FdXfFrLdj6N3KPdbM= APP_DEBUG=true APP_ENV=local APP_URL=http://localhost:9000 LOG_CHANNEL=stack LOG_LEVEL=debug DB_CONNECTION=mysql DB_HOST=${DB_HOST} DB_PORT=3306 DB_DATABASE=${DB_DATABASE} DB_USERNAME=${DB_USERNAME} DB_PASSWORD=${DB_PASSWORD} SERVICE_PASSPORT=${SERVICE_PASSPORT} BROADCAST_DRIVER=log CACHE_DRIVER=file FILESYSTEM_DRIVER=local QUEUE_CONNECTION=sync SESSION_DRIVER=file SESSION_LIFETIME=120 EOF php artisan serve --port=9000
PHP是脚本语言, 不需要编译, 但我们可以用multi-stage build去完成composer install
步骤:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 from composer:2.3 .10 as builderWORKDIR /app/ COPY . /app/ RUN composer install FROM php:8.1 -fpmRUN docker-php-ext-install mysqli pdo pdo_mysql WORKDIR /app COPY --from=builder /app/vendor /app/vendor COPY ./ /app/ RUN sed -ri 's/^www-data:x:82:82:/www-data:x:1000:50:/' /etc/passwd RUN chown -R www-data:www-data /app
mall mall服务是一个前端页面, 在生产环境下, 我们倾向把项目托管到对象存储(COS), 并且在npm run build
的时候把页面的资源文件连接也指向对象存储的地址.
对象存储 在这个demo中, 笔者使用Nginx在内网搭建一个HTTP服务, 模拟成一个K8s集群外的COS服务, 并创建一个域名cos.rondochen.com
, 指向Nginx服务所在的内网IP地址.
其中, Nginx的配置文件如下:
1 2 3 4 5 6 7 server { listen 80; server_name cos.rondochen.com; root /data/cos ; access_log /var/log/nginx/mall_access.log; error_log /var/log/nginx/mall_error.log; }
构建前端项目 修改src/mall/package.json
script节点部分:
1 2 3 4 5 6 7 8 ... "scripts" : { "start" : "export PORT=8000 && react-app-rewired start" , "build" : "GENERATE_SOURCEMAP=false PUBLIC_URL=http://cos.rondochen.com/mall/ react-app-rewired build" , "test" : "react-app-rewired test" , "eject" : "react-scripts eject" }, ...
也可以使用docker容器完成构建:
1 docker run --rm --env NODE_OPTIONS=--openssl-legacy-provider -v $(pwd )/mall:/mall node:19.1 sh -c 'cd /mall && npm install --force && npm run build && echo "finish!"'
构建完成后, 按照上面Nginx的配置, 我们把npm build的结果src/mall/build
里面的内容放到Nginx服务器的/data/cos/mall
即可, 如果在同一个服务器实例下, 即是:
1 rsync --delete -avrz src/mall/build/ /data/cos/mall/
如果是在生产环境中, 就是把src/mall/build
的内容上传到相应的COS路径中.
前端项目的资源往往有较高的访问量, 把静态资源投放到COS可以很轻易地配合CDN加速实现访问速度的优化.
部署过程 代码编译和构建都完成后, 相当于是做完了CI, 接下来就轮到CD了.
因为架构比较简单, 而且大多数都是无状态服务, 总的来说, 先启动数据库, 再启动后台服务就可以了.
在部署的过程中, 可能会在数据库和前端部署的步骤比较容易出问题, 有一定运维基础的朋友应该都可以根据日志报错或者是浏览器上的开发者工具定位到问题.
另外, 部署过程中可能需要多次查看集群内的各种内容, 有一个图形化工具会舒服一点, 笔者使用Lens.
namespace & service & domain demo将会创建三个ns:
各个后台服务都会通过同名service接入, 当服务创建完成后, 各个服务的接入域名就是:
product.pdm
review.pdm
mall.xyz
order.xyz
passport.xyz
shopcart.xyz
mysql.db
mysql-read.db
redis.db
数据库 在生产环境下, 数据库都是单独部署, 不会运行在K8s集群上, 但在本demo中, 也顺便演示一下在K8s中运行Mysql和Redis的方法.
其中, Redis不考虑数据持久化, 所以不需要申请存储空间, 而对于MySQL, 会搭建一个读写分离的MySQL集群.
官网文档有更详细的说明: 运行一个有状态的应用程序
为数据库创建Persistent Volume 关于K8s集群中的持久化存储, 在实际工作中, 笔者更多地都是使用云平台中现成的产品或者方法, 比如说腾讯云TKE可以直接购买云硬盘作为POD的存储空间.
在本Demo中, 为了简化配置, 笔者将会直接使用K8s服务器的本地存储空间为Pod提供持久化存储. 需要强调的是, 在生产环境下, 为了避免单点故障, 以及基于性能的考虑, 不建议这样操作.
根据规划, 我们将会创建三个Mysql Pod, 所以也需要创建三个PV. 笔者把三个PC都创建在节点k8s-w-1节点上, 下面这个是第一个PV的配置, 其余的两个只是序号不同:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 apiVersion: v1 kind: PersistentVolume metadata: name: k8s-volume-1 labels: type: local spec: storageClassName: local-storage persistentVolumeReclaimPolicy: Retain capacity: storage: 2Gi volumeMode: Filesystem accessModes: - ReadWriteOnce local: path: "/data/k8s-volume-1" nodeAffinity: required: nodeSelectorTerms: - matchExpressions: - key: kubernetes.io/hostname operator: In values: - k8s-w-1
对新手来说, 可能需要加强一下PV, PVC, storageclass, accessmode这些概念的理解, 考虑到官网都解释得很清楚了, 这里就不啰嗦了.
用容器运行MySQL集群 在本demo中, MySQL集群的配置有几个要点:
使用ConfigMap做好mysqld
的配置
使用读写分离架构
需要为MySQL pod设置好服务
使用StatefullSet创建3个MySQL pod
每一个MySQL pod都需要各自挂载上一步创建的存储空间, 而且因为存储空间都使用节点k8s-w-1
的本地存储创建的, 所以在创建pod的时候要通过affinity配置把pod都创建在k8s-w-1
节点上
使用initContainers
(初始化容器)功能根据POD名称的序列号和hostname
生成MySQL服务器ID
在初始化容器中使用开源工具Percona XtraBackup
完成数据克隆
数据初始化 当MySQL集群都正常运行后, 我们创建一个Job完成数据的初始化, 导入本Demo运行需要的数据.
为了方便, 我们可以先提前做一个用于数据初始化的镜像, mysql-data:1.0
:
1 2 FROM mysql:5.7 -debianCOPY ./dump.sql /data/
当我们需要初始化MySQL集群里面的数据的时候, 只需要运行这个yaml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 apiVersion: batch/v1 kind: Job metadata: name: mysql-restore namespace: db spec: template: spec: containers: - name: mysql-client image: rondochen/mysql-data:1.0 command: - bash - "-c" - | mysql -uroot -pP@ssword1234_ -hmysql-0.mysql --default-character-set=utf8mb4 < /data/dump.sql restartPolicy: Never backoffLimit: 2 ttlSecondsAfterFinished: 100
运行Redis 因为不考虑Redis数据的持久化, 而且是单节点运行, 所以会比MySQL简单很多.
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 apiVersion: v1 kind: Service metadata: name: redis namespace: db spec: ports: - name: tcp-redis port: 6379 selector: app: redis --- apiVersion: apps/v1 kind: Deployment metadata: name: redis namespace: db spec: replicas: 1 selector: matchLabels: app: redis template: metadata: labels: app: redis spec: containers: - name: redis image: redis:latest imagePullPolicy: Always env: - name: REDIS_PASSWORD valueFrom: secretKeyRef: name: app-config key: REDIS_PASSWORD command: - sh - -c - | redis-server --requirepass $REDIS_PASSWORD ports: - containerPort: 6379 protocol: TCP resources: limits: cpu: 200m memory: 128Mi requests: cpu: 200m memory: 128Mi
Configmap 数据库密码和NginX的配置文件, 都使用Configmap记录, 在容器启动的时候, 会通过挂载Configmap实现配置内容的读取.
后台服务 服务的配置文件都交给Configmap或者是环境变量完成了. 像product没有挂载configmap, 是因为镜像里面的配置文件已经使用默认值+变量的方式完成了埋点, 在yaml文件中设置好环境变量就可以正常启动.
因为都是使用短链接的无状态服务, 当镜像都正确完成构建, 后台服务的部署就大体不会有什么问题了, 总的来说, 就是下面几点:
使用Deployment完成Pod的创建
为各个服务都创建相应的Service
使用Configmap管理Pod的配置内容
通过API/healthz/ready
实现健康检查
前端服务mall以及NginX mall服务作为这个demo的入口, 是需要允许集群外访问的, 所以mall-service需要设置成NodePort
.
1 2 3 4 5 6 7 8 9 10 11 12 apiVersion: v1 kind: Service metadata: name: mall namespace: xyz spec: type: NodePort ports: - name: http-mall port: 8000 selector: app: mall
另外, mall是一个静态页面, 我们在前面已经把mall的静态资源托管到COS, 所以我们只需要运行一个Nginx服务, 做好路径转发的设置就可以了.
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 # mall-nginx-config server { listen 8000; index index.html; location = /favicon.ico { access_log off; log_not_found off; } location = /robots.txt { access_log off; log_not_found off; } location / { proxy_http_version 1.1; proxy_pass http://cos.rondochen.com; rewrite ^/(.*)$ /mall/index.html break; } location /healthz/ready { access_log off; return 200 "ok\n"; } location ^~/api/review/ { root /app/public; rewrite ^/api/review/(.*)$ /$1 break; try_files $uri $uri/ /index.php?$args; } location ~ \.php$ { set $newurl $request_uri; if ($newurl ~ ^/api/review(.*)$) { set $newurl $1; root /app/public; } fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_pass review.pdm:9000; fastcgi_index index.php; include fastcgi_params; fastcgi_param REQUEST_URI $newurl; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_intercept_errors off; fastcgi_buffer_size 16k; fastcgi_buffers 4 16k; } location ^~/api/passport/ { proxy_http_version 1.1; proxy_pass http://passport.xyz:5000/; } location ^~/api/product/ { proxy_http_version 1.1; proxy_pass http://product.pdm:3000/; } location ^~/api/shopcart/ { proxy_http_version 1.1; proxy_pass http://shopcart.xyz:6000/; } location ^~/api/order/ { proxy_http_version 1.1; proxy_pass http://order.xyz:7000/; } }
部署结果 查看Service的Nodeport是31919
, 那我们用http://192.168.0.220:31919
就可以访问这个demo了.
1 2 3 4 5 6 [rondo@k8s-m-1 ~]$ kubectl get service -n xyz NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE mall NodePort 10.1.29.62 <none> 8000:31919/TCP 13d order ClusterIP 10.1.127.176 <none> 7000/TCP 13d passport ClusterIP 10.1.148.47 <none> 5000/TCP 13d shopcart ClusterIP 10.1.212.84 <none> 6000/TCP 13d
这是一个一年前开发出来的demo, 现在里面图片都已经404了, 笔者把部分图片替换成一张空白图片, 但后台服务的功能都是正常的:
总结 笔者偶尔在网上冲浪的时候都会看到一些关于docker和K8s的教程, 也粗略地看过一些. 看到的大多数视频都是在教命令的使用方法或者是讲解概念的, 基本上不会有带着代码跑一个demo的, 估计得交钱上培训班才能搞到一个练手的demo吧(笑).
对运维来说, 在学习运维技术的过程中, 尤其是关于CI/CD的技术, 如果没有现成的业务代码, 我们往往只能止步于官方文档中的hello world
示例. 而这个demo, 从运维的角度来看, 已经很接近一些真正上线运营的项目了.
在搭建的过程中, 可以深刻了解到涉及容器化改造, 容器编排的诸多概念. 另外也不难看出, 对于这种微服务的架构, 如果使用传统的部署方法, 维护和管理起来需要花不少的功夫, 而通过容器化改造, 我们只需要关注节点的数量和各自的运行状况就可以了, 因为CI/CD过程已经通过容器化完成了标准化.
后面可以再用这个Demo做更多的实验, 我们拭目以待.
另外关于服务容器化改造的思路, 可以参考一下笔者之前的文章: 使用minikube模拟基于Kubernetes平台的容器化改造
拾遗 mall服务的简化部署方案 考虑到前端部分的部署有点复杂, 其实还可以简化一点:
修改npm的相关设置, 把构建出来的静态文件指向本地
用multistage-build, 把mall的nodejs代码经过npm run build
之后直接复制到一个NginX镜像中
运行这个NginX镜像作为mall的container, 在NginX的配置文件中直接使用root
指向本地文件
这样, 我们就可以省略COS这个步骤了.
笔者在源代码中克隆出了一个mall2, 并且做好了相应的yaml文件调整, 可以和原来的mall入口同时使用, 对比一下效果.
修改package.json 我们省略了cos这个组件, 选择直接代理静态文件, 需要修改package.json
中的配置, 把原来的build
部分的PUBLIC-URL
配置删除:
1 2 3 4 5 diff mall/package.json mall2/package.json 24c24 < "build": "GENERATE_SOURCEMAP=false PUBLIC_URL=http://cos.rondochen.com/mall/ react-app-rewired build", --- > "build": "GENERATE_SOURCEMAP=false react-app-rewired build",
Dockerfile 如上所述, 使用multistage-build完成代码的构建并复制到NginX中:
1 2 3 4 5 6 7 8 9 10 11 FROM node:19.1 as builderENV NODE_OPTIONS=--openssl-legacy-providerWORKDIR /app COPY . /app RUN npm install --force RUN npm run build FROM nginx:alpineRUN mkdir -p /cos/mall WORKDIR /cos/mall COPY --from=builder /app/build/. /cos/mall/
镜像构建命令:
1 2 3 cd src/mall2docker build -t rondochen/mall:1.0 . -f Dockerfile-multistage docker push rondochen/mall:1.0
Nginx配置 我们在上一个步骤可以看到, mall经过npm构建之后, 把静态文件存放到/cos/mall
中, 那NginX的localtion配置文件, 就应该这样调整:
1 2 3 4 5 6 7 8 9 10 ... location / { proxy_http_version 1.1; root /cos; rewrite ^/(.*)$ /mall/index.html break; } location ^~/static/ { root /cos/mall; } ...
之所以这样调整, 是为了不改动源代码做出的妥协:
在mall的前端代码中, 路由都是以/mall
开头的, 所以我们把静态文件存放到/cos/mall
的同时, 把NginX的root设置到/cos
当NignX直接代理静态文件的时候, 静态文件的路径是/static/css/xxxxx.css
, 所以我们需要单独对静态资源指定一个root到cos/mall
yaml文件的调整 正如上面提到的, 我们是在维持原有的mall服务的情况下多创建一个mall2的服务, 对原来mall服务的相关yaml改动如下:
Configmap创建一个app-config2
, 修改里面的mall-nginx-config
部分
创建一个mall2 deployment, 修改标签和镜像
创建一个mall2 service, 类型依然是NodePort
, selector是app:mall2
最后, 我们获得了一个新的入口:http://192.168.0.220:32677
1 2 3 [rondo@k8s-m-1 kubernetes]$ kubectl get service -n xyz mall2 NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE mall2 NodePort 10.1.178.13 <none> 8000:32677/TCP 143m
mall2部署结果 说起来比较复杂, 但如果对前端部署比较熟悉的同学应该不难理解.
在不改动代码的前提下, 依据需求制定不同的部署方案, 也算是运维的日常之一了(笑).
mall2相关的代码和部署内容也同步到github了.
验收一下改动: