文章目录
项目部署整体流程
背景
本来是写业务的, 突然说有客户想要部署项目到自家的服务器上.
那我就懵了啊, 以前可没搞过这种SaaS服务啊.
于是结合网上以及之前部署的流程, 组织了一套原始的部署方式.
简介
主要流程为:
1. 项目由docker容器化部署
2. 使用docker-compose 进行容器服务编排
3. 后端项目中使用环境变量获取数据库信息
4. 环境变量由docker-compose进行注入
5. 由 .env 来组织docker-compose的环境变量配置
6. 由shell脚本聚合compose启动指令及其他操作.
主要涉及知识点:
- docker
- docker-compose
- nginx
- shell
具体流程
项目配置
nginx配置
主要想使用环境变量来替换nginx转发的服务器ip端口与地址, 利用了shell的envsubst 来替换文件中的变量名达成.
当然, docker中也有这种操作, 可以直接利用template来替换实现, 我这种方式相对迂回了一些.(待修改, 目前已经部署完了, 所以就不想再折腾了, 下次如果有部署再优化吧)
记录了在这边:
https://blog.csdn.net/fwzzzzz/article/details/119423811?spm=1001.2014.3001.5502
前端项目
不是很熟悉前端, 为了部署项目, 也只是看了一些相关的build知识.
我们的前端项目使用的是react构建的, 所以我着重了解了下基础, 以及运行方式.
在我看来, 这次的前端部署, 只要将前端代码build出来的静态文件放到服务器中的某个位置, 然后nginx进行路由就行了.
问题点:
一是, nginx使用docker启动, 而前端项目的build过程是在本地还是在容器中一起构建, 一起构建的话就需要采用容器的二次构建来减少镜像大小了.
第二个需要注意的是, 前端与后端的接口跨域问题, 这个也是需要用nginx来解决的, 具体步骤是通过匹配api路由来proxy转发一次.
- Dockerfile 文件
FROM nginx:1.20.0
COPY ./build /opt/web_dist
WORKDIR /opt/web_dist
EXPOSE 80
ENTRYPOINT nginx -g "daemon off;"
后端项目
后端项目需要更改的是配置文件中的环境变量获取, 因为我们项目中的数据库等连接在换了服务器的情况下, 一定会发生变化. 所以最好就是使用环境变量, 由外部来注入统一管理.
具体的语言, 获取环境变量的方法不一样, 可按需要修改.
由于后端也是采用docker启动, 所以我们只需要添加Dockerfile文件即可, 注意依赖等
- Dockerfile
FROM tiangolo/uvicorn-gunicorn-fastapi:python3.6
ADD requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.douban.com/simple
COPY . /usr/src/app
WORKDIR /usr/src/app
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
数据库配置
由于项目采用了mysql与redis, 所以这边使用了两个容器来分别启动服务
mysql
因为不确定客户后期是否会更换数据库的连接, 所以在docker-compose中没有使用服务名称来替换数据库的连接
启动mysql容器时, 采用了最简单的数据服务挂载到宿主机的方式, 为的是能快速部署(避免冷启动以及编写sql脚本插入数据).
- 解决冷启动问题
由于我们新部署的项目里面是没有任何数据的, 所以就需要避免冷启动带来的一些未知错误, 两种方式:
1. 利用shell脚本进行数据的写入
可以直接执行shell 脚本来插入数据
简单来说, 就是在宿主机的shell脚本中添加指令, 不进入容器执行:
docker exec -i msyql bash -c '/data/initdb/initdb.sh'
以上命令进入容器, 执行initdb.sh文件, 而此文件会连接mysql数据进行数据的插入:
mysql -u${MYSQL_USER} -p${MYSQL_PASSWORD} -e "source /data/dbinit/initdb.sql"
当然此处的 MYSQL_USER 等变量, 也都是由compose进行环境变量的注入
而执行的initdb.sql文件中, 是对数据库的插入数据操作:
INSERT INTO `user_role` VALUES (1, 'admin', '管理员:拥有后台全部权限');
.....
2. 直接本地生成好容器挂载出目录, 以及插入数据
这样, 在测试的宿主机文件中就出现了完整的数据, 之后就可以一同打包代码文件夹中, 到客户的服务器中进行部署了.
这样的操作比较简单, 方便.
我在哼哧哼哧的写完shell脚本之后, 还是选择了第二种方式.
哦对, 第一种方式运行时, 遇到了个问题. 是关于宿主机与容器之间的交互的:
目前正确的方式是: 宿主机的sh文件执行容器内的sh文件 -> 容器内sh文件执行mysql指令 -> 插入数据
刚开始使用的是: 宿主机sh文件直接执行 exec -c "mysql -u .... -e "source" " -> 宿主机内的sql文件. 这样就会导致找不到文件, 应该是宿主机与容器之间的文件系统的隔离问题, 我整了一会子, 就采用了上一个方式解决了.
redis
没什么好说的
注意环境变量
DockerFile编写规范
这里列出了收集到的以及在应用中踩坑发现的, dockerfile的一些编写规范.
- FROM 有线使用最小功能集的基础镜像
- 依赖具体的版本镜像, 禁用latest
- LABEL 增加镜像元数据
- 使用 WORKDIR 指定工作目录, 避免绝对路径扩散
- 不要使用 ENV与ARG传递敏感信息
- 敏感信息使用 mount secret 方式
- RUN 上下文依赖关系 需要在同一个RUN 内执行(缓存机制)
- 优先使用 COPY, 语义明了, 简洁
- 禁用 root 用户运行应用, 为应用创建用户与用户组
- 应用程序运行用户设置Shell为 /sbin/nologin
- 显式的使用EXPOSE指明端口与协议 EXPOSE 80/tcp
- 持久化数据 VOLUME
- 将标准日志与错误日志分别输出到stdout与stderr
- 使用ENTRYPOINT封装镜像的固定行为,使用CMD配合输入可变参数
- 使用ENTRYPOINT执行运行前准备工作
- 尽量选择体积的镜像(也可能会导致一些未知错误)
- 分阶段构建是控制镜像大小的一种方式
- 禁用 chmod -R
- dockerfile 书写顺序按更新频率升序排列(缓存机制)推荐将变化频度低的层尽量放上层,变化频度高的层放下层
- 相关度高一致的命令, 写在同一个RUN指令中
.env
配置文件
# 确定服务器ip
VMP_SERVER_IP=47.x.x.13
# 自定义服务开放端口
WEB_PORT=8111 # 前端端口
APP_PORT=8222 # app后端端口
API_PORT=8333 # api后端接口
# mysql服务
MYSQL_ROOT_PASSWORD=vmp_admin
MYSQL_HOST=47.x.x.13
MYSQL_PORT=8306
MYSQL_USERNAME=vmp
MYSQL_PASSWORD=dev_admin
MYSQL_DATABASE=vmp
# redis服务
REDIS_HOST=47.x.x.13
REDIS_PORT=8307
REDIS_PASSWORD=
# 容器名称
MYSQL_CONTAINER=vmp_mysql
REDIS_CONTAINER=vmp_redis
WEB_CONTAINER=vmp_web
SERVER_TS_CONTAINER=vmp_server_ts
SERVER_PY_CONTAINER=vmp_server_py
# 镜像名称
WEB_IMAGE=vmp_web_image
SERVER_TS_IMAGE=vmp_app_image
SERVER_PY_IMAGE=vmp_api_image
# 项目环境变量
PROJ_ENV=production
docker-compose 配置
version: "3.6"
services:
# mysql服务
mysql:
image: mysql:5.7
restart: always
container_name: ${MYSQL_CONTAINER}
ports:
- ${MYSQL_PORT}:3306
networks:
- vmp_service
volumes:
- ${PWD}/service/mysql:/var/lib/mysql
command: [
"--character-set-server=utf8mb4",
"--collation-server=utf8mb4_unicode_ci",
]
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_USER=${MYSQL_USERNAME}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- LANG=C.UTF-8
# redis服务
redis:
image: redis:alpine3.14
container_name: ${REDIS_CONTAINER}
ports:
- ${REDIS_PORT}:6379
networks:
- vmp_service
volumes:
- ${PWD}/service/redis:/data
# 前端服务
vmp_web:
image: ${WEB_IMAGE}
build: vmp_web
restart: always
container_name: ${WEB_CONTAINER}
ports:
- ${WEB_PORT}:80
networks:
- vmp_service
volumes:
- ${PWD}/service/nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- vmp_api
- vmp_app
links:
- vmp_app
- vmp_api
# ts后端应用
vmp_app:
image: ${SERVER_TS_IMAGE}
build: vmp_server
restart: always
container_name: ${SERVER_TS_CONTAINER}
networks:
- vmp_service
ports:
- ${APP_PORT}:2999
volumes:
- ${PWD}/server_logs/ts_app:/usr/src/app/logs
environment:
- MYSQL_HOST=${MYSQL_HOST}
- MYSQL_PORT=${MYSQL_PORT}
- MYSQL_USERNAME=${MYSQL_USERNAME}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- NODE_ENV=${PROJ_ENV}
- OSS_KEY=${OSS_KEY}
- OSS_SECRET=${OSS_SECRET}
- OSS_BUCKET=${OSS_BUCKET}
- OSS_REGION=${OSS_REGION}
- OSS_PATH=${OSS_PATH}
- PY_API_URL=${VMP_SERVER_IP}:${API_PORT}
depends_on:
- redis
- mysql
# py后端接口项目
vmp_api:
image: ${SERVER_PY_IMAGE}
build: vmp_server_py
restart: always
container_name: ${SERVER_PY_CONTAINER}
ports:
- ${API_PORT}:8000
networks:
- vmp_service
volumes:
- ${PWD}/server_logs/py_api:/usr/src/app/app/logs
- ${PWD}/service/py_pkg:/usr/src/app/package
environment:
- REDIS_HOST=${REDIS_HOST}
- REDIS_PORT=${REDIS_PORT}
- REDIS_PASSWORD=${REDIS_PASSWORD}
- MYSQL_HOST=${MYSQL_HOST}
- MYSQL_PORT=${MYSQL_PORT}
- MYSQL_USERNAME=${MYSQL_USERNAME}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- PY_ENV=${PROJ_ENV}
depends_on:
- redis
- mysql
networks:
vmp_service:
启动文件 start.sh
start.sh 文件中有很多是我没有来得及写的功能函数, 只是写了一些占位的输出(反正也没用到, 后续再优化吧)
主要的逻辑就是利用了getopts来实现输入的参数化启动, 然后再根据输入的参数执行不同的脚本命令, 这样把对docker-compose的一些指令封装到了shell脚本的指令后面.
格式问题
本来是在win下编写的脚本, 在上传到服务器中进行测试的时候, 异常报错提示是格式的问题, 需要进行手动的替换操作:
sudo sed -i "s/\r//" .start.sh
参数化启动
想要实现那种 -a -b 传递参数的形式, 看起来高端些, shell中可以使用 getopts 来实现这种方式:
# -a 用于指定执行动作; -t 指定容器名称, 默认全部; -h 查看帮助
while getopts "a:t::" opt;
do
case $opt in
a)
ACTION=$OPTARG
;;
t)
TARGET=$OPTARG
;;
\?)
echo "No such command...${OPTARG}"
usage
;;
esac
done
main ${ACTION} ${TARGET}
其中 `while getopts "a:t::" opt;` 表示接受两个参数, -a 与 -t
a是必填的(a:), t是选填(t::), 那我们使用的时候就可以直接:
start.sh -a install
或者
start.sh -a install -t msyql
看起来果然就高档了起来呢(dogs
start.sh 文件
#!/bin/bash
#=============================================================================
#
# Author: hhhhh
#
# Last modified: 2021-08-01 00:00
#
# Filename: start.sh
#
# Description:
#
#=============================================================================
source ./.env
INITDB_FILE_NAME="${PWD}/service/database/" # 初始化mysql数据库脚本位置
NGINX_CONF_FILE="${PWD}/service/nginx" # nginx conf 配置文件位置
# 打印方法
function echo_red() {
echo -e "\033[1;31m$1\033[0m"
}
function echo_green() {
echo -e "\033[1;32m$1\033[0m"
}
function echo_yellow() {
echo -e "\033[1;33m$1\033[0m"
}
function echo_done() {
echo "$(gettext 'complete')"
}
# 操作执行
function build_project()
{
echo $1
if [ ! $1 ]; then
echo_green "部署全部项目"
alter_nginx_conf
docker-compose build && docker-compose up -d
else
if [ $1 == 'vmp_web' ]; then
alter_nginx_conf
fi
echo_green "部署单独的项目: $1"
docker-compose build $1 && docker-compose up -d $1
fi
}
function remove_project()
{
if [ ! $1 ]; then
echo_red "删除全部项目容器"
echo "docker-compose stop && docker-compose rm -f"
else
echo_red "删除指定容器: $1"
echo "docker-compose stop $1 && docker-compose rm -f $1"
fi
}
function restart_project()
{
if [ ! $1 ]; then
echo_green "重启全部项目容器"
alter_nginx_conf
docker-compose restart
else
if [ $1 == ${WEB_CONTAINER} ]; then
alter_nginx_conf
fi
echo_green "重启指定容器: $1"
docker-compose restart $1
fi
}
# 保存容器镜像
function save_project_image()
{
echo $1
if [ ! $1 ]; then
echo_green "保存全部容器镜像: mysql, redis, web, ts, py"
echo "docker save -o mysql.tar mysql:5.7"
echo "docker save -o redis.tar redis:alpine3.14"
echo "docker save -o ${WEB_IMAGE}.tar ${WEB_IMAGE}:latest"
echo "docker save -o ${SERVER_TS_IMAGE}.tar ${SERVER_TS_IMAGE}:latest"
echo "docker save -o ${SERVER_PY_IMAGE}.tar ${SERVER_PY_IMAGE}:latest"
else
echo_green "重启指定容器及版本号: $1"
echo "docker save -o $1.tar $1"
fi
}
# 恢复容器镜像
function load_project_image()
{
echo $1
if [ ! $1 ]; then
echo_green "保存全部容器镜像: mysql, redis, web, ts, py"
echo "docker load -i mysql.tar"
echo "docker load -i redis.tar"
echo "docker load -i ${WEB_IMAGE}.tar"
echo "docker load -i ${SERVER_TS_IMAGE}.tar"
echo "docker load -i ${SERVER_PY_IMAGE}.tar"
else
echo_green "重启指定容器及版本号: $1"
echo "docker save -o $1.tar $1"
fi
}
function init_db {
# 迁移数据库脚本文件至容器内
echo "docker cp ${INITDB_FILE_NAME} ${MYSQL_CONTAINER}:/data/dbinit/"
docker cp ${INITDB_FILE_NAME} ${MYSQL_CONTAINER}:/data/dbinit/
# 执行数据的插入
echo_green "insert into db"
echo "docker exec -i ${MYSQL_CONTAINER} bash -c '/data/dbinit/initdb.sh'"
docker exec -i ${MYSQL_CONTAINER} bash -c '/data/dbinit/initdb.sh'
if [ "$?" -ne 0 ];then
echo_red "数据初始化失败"
exit 1
else
echo_green "执行初始化成功"
exit 0
fi
}
# 对nginx.conf 文件进行修改
function alter_nginx_conf {
export NGINX_VMP_SERVER="http://${VMP_SERVER_IP}:${APP_PORT}/"
envsubst '${NGINX_VMP_SERVER}' < ${NGINX_CONF_FILE}/default.conf.template > ${NGINX_CONF_FILE}/default.conf
unset NGINX_VMP_SERVER
}
# help
function usage {
cat << EOF
Desc: 用于管理项目容器的简易脚本
Author: hzinsights
Options:
-a action 程序执行指令(build|restart|init_db)
build: 部署容器
restart: 重启容器
init_db: 插入数据
save: 保存镜像
load: 恢复镜像
help 查看帮助
-t target 容器项目名称(可选)
Examle:
$0 -a build
EOF
exit 0
}
function main {
case "${ACTION}" in
build)
echo_green "进行项目部署..."
build_project ${TARGET}
;;
uninstall)
echo_green "进行项目删除..."
remove_project ${TARGET}
;;
restart)
echo_green "重启项目服务..."
restart_project ${TARGET}
;;
save)
echo_green "保存项目镜像..."
save_project_image ${TARGET}
;;
load)
echo_green "恢复项目镜像..."
load_project_image ${TARGET}
;;
init_db)
echo_green "进行数据插入操作"
init_db
;;
help)
echo_green "查看帮助"
usage
;;
*)
echo_red "No such command: ${action}"
usage
;;
esac
}
# -a 用于指定执行动作; -t 指定容器名称, 默认全部; -h 查看帮助
while getopts "a:t::" opt;
do
case $opt in
a)
ACTION=$OPTARG
;;
t)
TARGET=$OPTARG
;;
\?)
echo "No such command...${OPTARG}"
usage
;;
esac
done
main ${ACTION} ${TARGET}
启动执行部署
我们会在本地先执行测试一遍, 然后连接mysql数据库插入一些基础的数据, 测试功能没问题了, 把这些代码以及挂载的目录一起打包发给客户.
有客户进行上传到服务器上, 登录ssh准备部署
解压安装包, 进入目录
chmod +x ./start.sh # 赋予可执行权限
./start.sh -a build # 执行容器的部署
然后等待镜像拉取与部署, 中间可能会出现镜像的拉取过慢, 可以使用阿里云的镜像加速服务.
遇到的问题集
从开始学习部署到结束部署的时间:
0726 - 0812
三周时间
第一周:
0726 - 0730
- 前端项目的启动, build 等技术学习
- 前端的部署就是在nginx上暴露静态文件的server
- 前端项目部署到服务器中去.
- 前后端的部署联调
- 学习dockerfile 的编写优化
- 后端与数据库容器的部署
- 学习shell基本知识
- nginx的部署相关问题
- server 相关
- 转发的问题
第二周:
0802 - 0806
- 根据项目中设置的修改并部署nginx的相关配置
- nginx 进行端口转发解决跨域问题
- nginx 转发失败, 还是没法显示页面
- 项目的orm与数据库字段不一致导致
- 优化 .sh 文件
- shell脚本的语言逻辑的优化
- 使用getopt可以达成参数化输入
- 解决冷启动问题
- 使用sh文件进行数据sql的自动写入
1. 第一种方案是:
在宿主机的sh文件中, 执行 exec -c 不进入容器执行命令, 这条命令是在mysql容器内执行 -e source sql 文件来插入数据
但是出现问题, 怎么也找不到文件, 无论是写宿主机的路径还是容器路径
2. 第二种方案
必须要进入容器执行 sh 脚本, sh脚本再调用容器内的命令来执行sql写入指令
宿主机的sh文件 -> 容器内的sh 文件 -> 执行容器内的mysql指令 -> 插入数据
- 直接挂在宿主机数据目录
最简单, 可以本地执行完, 然后打包代码文件夹一起发给客户
- 解决linux 与 win 文件格式的不同, 导致的问题
sudo sed -i "s/\r//" ./.env
- nginx 的配置问题:
nginx 的端口转发, 需要的ip地址肯定是不一样的, 客户的机器不一样, ip也不一样, 所以需要定制化修改
想通过修改 .env 文件来统一管理, 那就只能搞一搞看了.
- docker有专门的命令来替换参数(envsubst)
但是我们使用的compose与dockerfile一起用的, 所以有点问题
- 第二种方案:
使用了shell 脚本执行 envsubst 来在本地替换, 最后挂载至容器内
- 部署时的项目修改兼容
前端项目的不一致修改
后端的环境变量获取的修改
py项目兼容客户的py包
第三周:
0809 - 0812
- 前端突然出现问题
build 出错, 解决档案使用本地编译, 上传到服务器中
- py 包的编译问题
- 客户的服务部署
内网服务不可访问外网数据库
- 优化后端的整体部署
- 修改ts中固定的代码
- 修改py中的代码
- 修改compose文件
- 修改 .env 文件
- 部署结束 (一共花了 14天....)