Gin Blog III
在之前的步骤中,我们完成了基本的文章增删改查功能,接下来就将其部署到 Docker 上
之前已经在 nginx 上部署了一个 vuepress 项目,这次就在另一个 nginx 上部署本项目
前端项目通过反向代理实现在同一域名下不同路径访问不同的应用:
localhost/: 为 vuepress bloglocalhost/app/: 为本应用
后端项目通过创建一个负载均衡 nginx 来转发前端的请求
1. Fontend deploy
1.1 配置目录
创建资源文件夹,这里 conf 文件夹不用手动创建,后面从容器中复制一份出来
MyDockerApps
└── blog-app
    ├── app
    │   ├── conf
    │   ├── data
    │   └── logs
    └── blog
        ├── conf
        ├── data
        └── logs
blog-app: 应用根路径app: 本应用blog: vuepress blog 应用conf: nginx 配置data: 资源路径logs: nginx 日志
启动 nginx 并复制配置文件
$ docker run -d --rm --name test-nginx nginx
$ docker container cp test-nginx:/etc/nginx/ ./app
$ docker container cp test-nginx:/etc/nginx/ ./blog
$ mv ./app/nginx ./app/conf
$ mv ./blog/nginx ./blog/conf
--rm: stop 容器时,容器自动删除docker container cp container:dir destDir: 将容器内的文件复制到指定目录
1.2 配置反向代理
修改./blog/conf/conf.d/default.conf, 建议将 default 改为自定义的域名 servername
server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;
    access_log  /var/log/nginx/host.access.log  main;
    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }
    location /app/ {
         proxy_pass http://app/;
    }
    
    error_page  404 /404.html;
    # redirect server error pages to the static page /50x.html
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}
location /app/: URI 匹配,proxy_pass将/app/的请求代理到指定地址 需要注意的是http://app/和http://apphttp://app/: 将会把 URI 去除前缀后追加至代理路径 例如:htpp://localhost:/app/article将被代理至http://app-nginx/articlehttp://app: 将完整匹配的 URI 追加至代理路径 例如:htpp://localhost:/app/article将被代理至http://app-nginx/app/article
./blog/conf/nginx.conf
user  nginx;
worker_processes  auto;
error_log  /var/log/nginx/error.log notice;
pid        /var/run/nginx.pid;
events {
    worker_connections  1024;
}
http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    access_log  /var/log/nginx/access.log  main;
    sendfile        on;
    #tcp_nopush     on;
    keepalive_timeout  65;
    #gzip  on;
    include /etc/nginx/conf.d/*.conf;
    upstream app {
        server app-nginx:80;
    }
}
upstream: 配置上游服务app: 名称server: 指定地址,app-nginx为部署本应用的容器名,在容器加入同一网络之后,其将作为容器的域名
./app/conf/conf.d/default.conf
server {
    listen       80; 
    listen  [::]:80;
    server_name  localhost;
    access_log  /var/log/nginx/host.access.log  main;
    location / { 
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }   
    error_page  404              /404.html;
    # redirect server error pages to the static page /50x.html
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }   
}
1.4 Docker Network
两个 nginx 容器需要使用 docker network 互联,创建 network
$ docker network create -d bridge blog-app-net
-d: 网络模式,bridge为桥接模式
1.3 简单测试
现在简单测试下反向代理的效果,为了方便可以编写启动容器的脚本
start-app.sh
#!/usr/bin/env sh
# app-nginx 
docker run -d \
        --name app-nginx \
        -v $HOME/MyDockerApps/blog-app/app/conf:/etc/nginx:ro \
        -v $HOME/MyDockerApps/blog-app/app/logs:/var/log/nginx \
        -v $HOME/MyDockerApps/blog-app/app/data/dist:/usr/share/nginx/html \
        -v /etc/localtime:/etc/localtime:ro \
        --network blog-app-net \
        --restart always \
        nginx 
# blog-nginx
docker run -d \
        --name blog-nginx \
        -p 18080:80 \
        -v $HOME/MyDockerApps/blog-app/blog/conf:/etc/nginx:ro \
        -v $HOME/MyDockerApps/blog-app/blog/logs:/var/log/nginx \
        -v $HOME/MyDockerApps/blog-app/blog/data/dist:/usr/share/nginx/html \
        -v /etc/localtime:/etc/localtime:ro \
        --network blog-app-net \
        --restart always \
        nginx 
-v /etc/localtime:/etc/localtime:ro: 同步宿主机时区
recreate-app.sh
#!/usr/bin/env sh
docker rm -f  blog-nginx app-nginx
./start-app.sh
restart-app.sh
#!/usr/bin/env sh
docker restart app-nginx blog-nginx 
写入测试用的页面:
$ mkdir ./blog/data/dist ./app/data/dist
$ echo 'HELLO BLOG' >> ./blog/data/dist/index.html
$ echo 'HELLO APP' >> ./app/data/dist/index.html
启动容器,并测试
$ ./start-app.sh
$ curl 'http://localhost:18080'
HELLO BLOG
$ curl 'http://localhost:18080/app/'
HELLO APP
1.4 Deploy
测试反向代理成功后就可以部署前端应用了,vuepress 的项目可以使用上述的 ./blog/data/dist/index.html 替代
1.4.1 配置Public Path
Vue CLI 默认认为应用部署在域名根路径,若需要部署在非根目录则需要修改 publicPath 为对应的路径, 以及 router 和 axios 的baseurl
我们将不同的变量放在环境配置中,这样就可以根据环境使用不同的配置了,比如:dev 环境使用 publicPath: '/' 即可而部署是可以改为 ‘/app/’
修改.env.production ,添加 PUBLIC_PATH 并修改 server 参数
# public path
PUBLIC_PATH = '/app/'
将vue.config.js 的 publicPath 修改为环境变量
const pulicPath = process.env.PUBLIC_PATH
module.export {
	publicPath: pulicPath,
    // ...
}  
const baseURL = process.env.PUBLIC_PATH
const createRouter = () =>
  new Router({
    base: baseURL,
    // mode: 'history', // require service support
    scrollBehavior: () => ({ y: 0 }),
    routes: constantRoutes
  })
const baseURL = process.env.PUBLIC_PATH + process.env.VUE_APP_BASE_API
// create an axios instance
const service = axios.create({
  baseURL: baseURL, // url = base url + request url
  withCredentials: true, // send cookies when cross-domain requests
  timeout: 5000 // request timeout
})
1.4.2 部署脚本
在前端项目根目录创建deploy/deploy.sh
#!/usr/bin/env sh
set -e
yellowFront='\e[33m'
redFront='\e[31m'
greenFront='\e[32m'
restoe='\e[0m'
echo -e $yellowFront'Start Deploy'$restoe
    yarn run build:prod
    distPath='dist'
    appPath=$HOME/MyDockerApps/blog-app/app/data
    echo -e $greenFront' Deploying resources ...'$restoe
        if [[ -d  $appPath/dist ]]
        then
            echo -e $redFront'  Deleting exists resources ...'$restoe
            rm -rf $appPath/dist
        fi
        cp -r $distPath $appPath
    echo -e $greenFront' Restart' `docker restart app-nginx`$restoe
echo -e $yellowFront'Deploy Done'$restoe
简单流程如下:
- 构建项目生成
dist目录 - 删除原有的静态资源(若存在),将
dist目录复制到资源目录 - 重启容器
 
在package.json中添加指令
"deploy": "sh ./deploy/deploy.sh"
执行部署脚本
$ yarn run deploy
浏览器访问http://localhost:18080/app/可以成功进入 Dashboard 页面
现阶段项目的构建都是在本地环境的,但是对于每个人来说本地环境不尽相同,不能够保证构建结果一致
更好的做法是采用多阶段构建(mulit-stage build)在使用 docker 镜像进行编译构建以保证环境的一致性
2. Load Balancer
本节将添加一个 nginx 作为负载均衡服务器并采用轮询策略将请求转发至三个后台服务
和上面类似,将配置文件挂载到本地
start-load-banlancer.sh
#!/usr/bin/env sh
# load-balancer-nginx
docker run -d \
        -p 19090:80 \
        --name load-balancer-nginx \
        -v $HOME/MyDockerApps/blog-app/load-balancer/logs:/var/log/nginx \
        -v $HOME/MyDockerApps/blog-app/load-balancer/conf:/etc/nginx \
        -v /etc/localtime:/etc/localtime \
        --restart always \
        --network blog-app-net \
        nginx
修改配置文件./load-balancer/conf/conf.d/localhost.conf
server {
    listen       19090;
    listen  [::]:19090;
    server_name  localhost;
    access_log  /var/log/nginx/host.access.log  main;
    location / { 
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }   
    error_page  404              /404.html;
}
配置负载均衡./load-balancer/conf/nginx.conf
http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    access_log  /var/log/nginx/access.log  main;
    sendfile        on;
    #tcp_nopush     on;
    keepalive_timeout  65;
    #gzip  on;
    include /etc/nginx/conf.d/*.conf;
    upstream go-server {
        server blog-app-server-01:19091;
        server blog-app-server-02:19092;
        server blog-app-server-03:19093;
    }
}
upstream go-server: 设置了三个服务,默认是权重为 1 的加权轮询策略
至此,负载均衡服务就完成了,接下来部署后台服务
3. Mysql Deploy
类似的,创建资源目录
blog-app
└── mysql
    ├── conf
    └── data
conf: mysql 配置data: 存储 mysql 数据
start-mysql.sh
#!/usr/bin/env sh
  
# blog-app-mysql
docker run -d \
    --name blog-app-mysql \
    -v $HOME/MyDockerApps/blog-app/mysql/conf:/etc/mysql/conf.d \
    -v $HOME/MyDockerApps/blog-app/mysql/data:/var/lib/mysql \
    -v /etc/localtime:/etc/localtime:ro \
    -e MYSQL_ROOT_PASSWORD=pass \
    --network blog-app-net \
    --restart always \
    mysql
-v $HOME/MyDockerApps/blog-app/mysql/conf:/etc/mysql/conf.d: 挂载自定义配置-v $HOME/MyDockerApps/blog-app/mysql/data:/var/lib/mysql: 挂载数据库文件-e MYSQL_ROOT_PASSWORD=pass: 设置 ROOT 用户密码
3.1 初始化数据库
启动容器后,执行 sql 脚本
$ docker exec -i blog-app-mysql sh -c 'mysql -uroot -p${MYSQL_ROOT_PASSWORD}' < blog.sql
sh -c:-c后面的字符串讲作为 shell 的命令执行
可以直接进入 容器访问 mysql
$ docker exec -it blog-app-mysql bash
mysql -u root -p
也可以启动一个同一 docker network 的临时 mysql 实例作为 client 进行访问
$ docker run -it --rm --network blog-app-net mysql mysql -hblog-app-mysql -uroot -p
4. Backend Deploy
4.1 本地编译
部署之前,先在本地编译好可执行文件
$ CGO_ENABLED=0 GOOS=linux go build -o ./bin/blog-app-server main.go
CGO_ENABLED=0: 禁用 CGO 动态链接,采用静态链接库,因为运行环境在 scratch 镜像中GOOS: 声明程序构建环境的目标操作系统
4.2 Dockerfile
创建 Dockerfile,这里使用 scratch 镜像,这样构建的镜像会非常小
FROM scratch
WORKDIR /app/config
COPY ./bin/blog-app-server /app
WORKDIR /app
ENV APP_ENV=docker
EXPOSE 9090
ENTRYPOINT ["/app/blog-app-server"]
注意 scratch 中没有 shell ,无法直接使用 mkdir 创建目录
可以使用 WORKDIR 来创建目录, 或者在多阶段构建中从其他容器中复制过来
需要注意的是在 WORKDIR /app/config 之后再次使用 WORKDIR /app 将工作目录切换到了 /app
若切换的话,启动容器时应用会到/app/config/config下寻找配置文件,和预期不符
构建镜像并查看
$ docker build -t blog-app-server:local
$ docker image ls
REPOSITORY                 TAG       IMAGE ID       CREATED          SIZE
blog-app-server            local    93827d1581c0   7 seconds ago   	 18MB
golang                     alpine    6f9d081b1170   10 days ago      315MB
golang                     latest    d939cc1fb139   5 weeks ago      941MB
这里可以对比下 golang 官方和 goalng:alpine 的大小,使用 scratch 镜像非常小
因为这些官方镜像包含了各种编译环境、库和工具等所以大小会比较大
也可以使用官方教程中使用的 gcr.io/distroless/static 或 gcr.io/distroless/base 参见 Documentation for gcr.io/distroless/base and gcr.io/distroless/static, 相比于 scratch 有着更好的安全性
4.3 资源目录
blog-app
└── server
    └── conf
创建配置文件 ./conf/config.docker.yaml
server:
  port: 9090
  readTimeout: 10s
  readHeaderTimeout: 10ms
  writeTimeout: 10s
mysql:
  host: blog-app-mysql
  port: 3306
  username: root
  password: pass
  database: gin_blog
  charset: utf8
gorm:
  tablePrefix: blog_
  maxIdleConns: 10
  maxOpenConns: 100
  logLevel: error
host: blog-app-mysql: 容器和 mysql 容器在同一网络下,容器名将作为域名logLevel: warn: 生产环境可以提高 log level
4.3 启动 Container
#!/usr/bin/env sh
  
serverName="blog-app-server0"
port=19090
  
for (( i=1; i<4; i++  ))
do
    let port++
    docker run -d \
        --name $serverName$i \
        -p $port:9090 \
        -v $HOME/MyDockerApps/blog-app/server/conf:/app/config \
        -v /etc/localtime:/etc/localtime \
        -e APP_ENV=docker \
        --network blog-app-net \
        --restart always \
        blog-app-server:v1
done
上述脚本将启动三个后端容器
5. Muli-stage builds
在上面的例子中,我们都是采用本地编译/构建然后将资源部署至本地挂载的目录中。但是这样有一个弊端,不能保证不同的环境编译或构建的结果,因为每个人的编译环境不尽相同不能保证其编译结果;若是个人项目的话只在单个设备上运行问题不大,若是需要部署到不同的环境中则需要保证结果的一致性。
多阶段构建 (Multi-stage build) 不仅可以解决上述问题,还可以有效的降低镜像的大小 (将构建结果直接复制到极小的镜像中运行,不必带入构建过程中的需要的工具,库等)
下面就来将前端和后端项目采用 Multi-stage 来构建镜像
5.1 Front end
创建 .dockerignore
**/dist
**/node_modules
yarn.lock
忽略掉不需要的文件和目录
创建Dockerfile
#
# Build
#
FROM node:17 AS build-env
COPY ./ /app
WORKDIR /app
ENV NODE_OPTIONS=--openssl-legacy-provider
RUN yarn config set registry https://registry.npm.taobao.org \
    && yarn config set proxy http://127.0.0.1:7890 \
    && yarn config set https-proxy http://127.0.0.1:7890 \
    && yarn install \
    && yarn run build:prod
#
# Deploy
#
FROM nginx:alpine
WORKDIR /app
COPY  /app/dist /app
ENV NODE_OPTIONS=--openssl-legacy-provider 用于解决 Error:0308010C, 详情参见Error message "error:0308010C:digital envelope routines::unsupported"
构建镜像
$ docker build --network host -t app-blog:v1 .
5.2 Backend
创建 .dockerignore
bin/
config/
blog.sql
README.md
Dockerfile
#
# Build
#
FROM golang:alpine AS build-env
# Set proxy
ENV GOPROXY https://goproxy.cn,direct
COPY . /app
WORKDIR /app
RUN go mod download \
    && CGO_ENABLED=0 GOOS=linux go build -o /blog-app-server
#
# Deploy
#
FROM gcr.io/distroless/static:latest
WORKDIR /app/config
COPY  /blog-app-server /app
WORKDIR /app
EXPOSE 9090
ENTRYPOINT ["/app/blog-app-server"]
构建镜像
$ docker build --network host -t blog-app-server:v1 .
5.3 编写启动脚本
在上面的步骤中我们都是一个个启动容器的,比较繁琐,现在我们优化一下启动脚本
在 blog-app 目录下创建 scripts,并将之前的启动脚本放在这里并编写新的脚本一次启动所有容器
start-apps.sh
#!/usr/bin/env sh
  
# run all container
./app.sh
./mysql.sh
./server.sh
./load-balancer.sh
这样就能一次性启动所有容器了
6. Docker Compose
之前的步骤中我们使用了脚本来一次启动所有容器,但是如果需要重新启动,重新构建,配置网络等步骤需要编写更加复杂的脚本。当需要启动的容器数量越多,管理就越复杂
此时就需要用到 Docker Compose 来管理多个容器所构成的应用,具体介绍可以参见Docker Compose
6.1 Compose File
在项目资源目录创建/blog-app/docker-compose.yaml, 不建议将其放在项目根目录,因为 Docker Compose 是用于编排和管理多个容器,不应和单个项目糅合在一起
version: "3.9"
services:
  blog-nginx:
    image: nginx:alpine
    ports:
      - "18080:80"
    volumes:
      - type: bind
        source: "${BLOG_NGINX_PATH}/conf"
        target: /etc/nginx 
        read_only: true
      - type: bind
        source: "${BLOG_NGINX_PATH}/logs"
        target: /var/log/nginx 
      - type: bind 
        source: "${BLOG_NGINX_PATH}/data/dist"
        target: /usr/share/nginx/html
      - type: bind
        source: /etc/localtime
        target: /etc/localtime
        read_only: true
    restart: always
    depends_on:
      - app-nginx
  app-nginx:
    image: nginx:alpine
    volumes:
      - type: bind
        source: "${APP_NGINX_PATH}/conf"
        target: /etc/nginx 
        read_only: true
      - type: bind
        source: "${APP_NGINX_PATH}/logs"
        target: /var/log/nginx 
      - type: bind 
        source: "${APP_NGINX_PATH}/data/dist"
        target: /usr/share/nginx/html
      - type: bind
        source: /etc/localtime
        target: /etc/localtime
        read_only: true
    restart: always
    depends_on:
      - load-balancer-nginx
  load-balancer-nginx:
    image: nginx:alpine
    ports:
      - "19090:80"
    volumes:
      - type: bind
        source: "${LOAD_BALANCER_NGINX_PATH}/conf"
        target: /etc/nginx 
        read_only: true
      - type: bind
        source: "${LOAD_BALANCER_NGINX_PATH}/logs"
        target: /var/log/nginx 
      - type: bind
        source: /etc/localtime
        target: /etc/localtime
        read_only: true
    restart: always
    depends_on:
      - blog-app-server
  server:
    build: "${BACKEND_PATH}/"
    ports:
      - "9090"
    volumes:
      - type: bind
        source: "${SERVER_PATH}/config"
        target: /app/config
      - type: bind
        source: /etc/localtime
        target: /etc/localtime
        read_only: true
    restart: always  
    depends_on:
      - msyql-db
  mysql-db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
    volumes: 
      - type: bind
        source: "${MYSQL_PATH}/conf"
        target: /etc/mysql/conf.d 
        read_only: true
      - type: bind
        source: "${MYSQL_PATH}/data"
        target: /var/lib/mysql 
      - type: bind
        source: /etc/localtime
        target: /etc/localtime
        read_only: true
      - type: bind
        source: "${MYSQL_PATH}/init"
        target: /docker-entrypoint-initdb.d
    restart: always
depends_on: 仅表示依赖关系,但容器并不会等待依赖容器完全启动后再启动
 6.2 .env file
编写.env 文件设置上面出现的环境变量
BLOG_NGINX_PATH=sample path
APP_NGINX_PATH=sample path
LOAD_BALANCER_NGINX_PATH=sample path
SERVER_PATH=sample path
MYSQL_ROOT_PASSWORD=root pass
MYSQL_PATH=sample path
6.3 启动服务
$ docker compose -p ba up -d --scale server=3
-p: 指定 project 名称,默认为当前目录的名称(例如 blog-app),启动的容器名格式为project-service-No., 例如ba-server-1-d: detached, 服务在后台运行--scale service=num: 指定启动服务容器的数量,这里启动了三个后台服务
至此完整的服务部署就完成了,在浏览器中访问 http://localhost:18080/app/ 即可访问
Reference
- nginx nginx docs
 - docker nginx docker hub
 - config vue-cli docs
 - history mode vue-router docs
 - HTTP Load Balancing nginx docs
 - Documentation for 
gcr.io/distroless/baseandgcr.io/distroless/static - docker mysql docker hub
 - Use multi-stage builds docker docs
 - Error message "error:0308010C:digital envelope routines::unsupported" stackoverflow
 - Docker Compose Docker docs
 
