在Kubernetes集群上搭建一个测试用的电商网站demo

前言

上一篇文章: 搭建一个基于containerd的高可用Kubernetes集群中, 我们成功搭建了一个高可用的Kubernetes集群. 为了让这个实验环境充分发挥价值, 接下来我们就可以运行一个基于实际业务的demo了.

在本文我们将会搭建一个简单的电商页面, 可在页面上进行基本的类似商品信息浏览, 购物车, 结算功能:

overview

本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)
    • 服务发现

系统架构图:

diagram

各服务功能说明:

服务 技术栈 说明
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
# Dockerfile-multistage 
FROM maven:3.5-jdk-8-alpine as builder
COPY 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-alpine
RUN mkdir /app
COPY --from=builder /usr/src/app/target/product.jar /app
WORKDIR /app
EXPOSE 3000
# ENTRYPOINT ["java","-jar","product.jar"]

镜像构建和上传(后面其他几个服务都是差不多):

1
2
3
cd src/product
docker 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 build
WORKDIR /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 publish
RUN dotnet publish "order.csproj" -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim as final
WORKDIR /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/passport
npm 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-alpine
WORKDIR /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
# build
FROM golang:alpine3.15 AS builder
# ARG GOPROXY=https://goproxy.cn,direct
WORKDIR /src
COPY go.mod .
COPY go.sum .
RUN go mod download -x
COPY . /src
RUN go build -o main -x

# runtime
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/review
composer install

export APP_NAME=xyzshop
export 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 builder
WORKDIR /app/
COPY . /app/
RUN composer install

FROM php:8.1-fpm
# install mysql driver
RUN docker-php-ext-install mysqli pdo pdo_mysql
WORKDIR /app
COPY --from=builder /app/vendor /app/vendor
COPY ./ /app/
# permision fix
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.jsonscript节点部分:

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.

lens

namespace & service & domain

demo将会创建三个ns:

  • xyz
  • pdm
  • db

各个后台服务都会通过同名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-debian
COPY ./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
# mysql-restore.yaml
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了, 笔者把部分图片替换成一张空白图片, 但后台服务的功能都是正常的:

demo

总结

笔者偶尔在网上冲浪的时候都会看到一些关于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 builder
ENV NODE_OPTIONS=--openssl-legacy-provider
WORKDIR /app
COPY . /app
RUN npm install --force
RUN npm run build

FROM nginx:alpine
RUN mkdir -p /cos/mall
WORKDIR /cos/mall
COPY --from=builder /app/build/. /cos/mall/

镜像构建命令:

1
2
3
cd src/mall2
docker 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了.

验收一下改动:

demo2