初始代码 如果买的服务器时按使用量计费的,用完记得停机并且销毁机器实例,单独停机还会扣费

复习前回内容:密钥管理

  • 开发环境 master.keycredentials.yml.enc
  • 生产环境 production.keyproduction.yml.enc
  • 原则:机密不得提交到 git,不得复制给实习生

【山竹记账后端】6.部署到云服务器


大纲链接 §

[toc]


继续部署上线-云服务器

1. 一键部署到云服务器

上云的前提条件,需要同时满足

  1. 已经在本地成功部署
  2. 已经准备好 production.keyproduction.yml.enc
  3. 云服务器已经正确安装了 Docker
  4. 云服务器已经开通了 3000 端口
  5. Docker 开发环境可以通过 ssh 命令登录云服务器(使用 mangosteen 用户)

容器项目登录终端

1
2
3
4
5
6
ssh mangosteen@server1
docker ps
docker run hello-world
docker network create network1
...
# mangosteen@server1:

映射云服务器 ip

  • 找到服务器实例,复制公网ip
  • docker 容器项目中的终端运行 vim /etc/hosts 编辑
  • 也可以直接运行添加命令 echo "xxx.xxx.xxx.xx server1" >> /etc/hosts
  • 可以查看 cat /etc/hosts

    1
    2
    
    ...
    xxx.xxx.xxx.xx server1
  • 之后就可以使用 ssh mangosteen@server1 登录云服务器

  • 注意销毁机器后 ip 会改变,流程跑通后,可以将按量付费服务器换成按年购买的服务器公网ip


云服务器部署

回顾宿主机部署步骤

  1. Docker 里运行 pack_for_host.sh
  2. Windows/Mac 里运行 setup_host.sh

部署到宿主机 v.s. 部署到云机器

  • 差别不大

云服务器部署步骤

  1. Docker 里运行 pack_for_remote.sh
  2. 没了

远程打包脚本 bin/pack_for_remote.sh

创建 bin/pack_for_remote.sh

 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
# 注意修改 user 和 ip(云服务器公网)
# user=mangosteen
# ip=121.196.236.94
# ip=server1 #已映射
# 带默认值的声明
user=${user:-mangosteen}
ip=${ip:-server1}
# 时间作为唯一的部署版本号
time=$(date +'%Y%m%d-%H%M%S')
# 容器部署临时缓存目录
cache_dir=tmp/deploy_cache
# 压缩包文件名:容器中压缩包的完整输出路径
dist=$cache_dir/mangosteen-$time.tar.gz
# bin 获取当前脚本所在的绝对或相对路径(通常是容器项目 bin 目录)
current_dir=$(dirname $0)
# 远程部署目标:容器中部署目录的完整路径,time 为版本号
deploy_dir=/home/$user/deploys/$time
# 依赖配置文件:容器中 Gemfile 的完整路径
gemfile=$current_dir/../Gemfile
gemfile_lock=$current_dir/../Gemfile.lock
# 本地缓存的依赖包目录:容器中 vendor/cache 目录的完整路径
vendor_cache_dir=$current_dir/../vendor/cache

function title {
  echo 
  echo "###############################################################################"
  echo "## $1"
  echo "###############################################################################" 
  echo 
}

title '打包源代码为压缩文件'
mkdir $cache_dir # 创建本地缓存目录
bundle cache # 将项目依赖的 Gem 包下载并缓存到 vendor/cache 目录,确保依赖不变时,远程机器无需再次联网下载
tar --exclude="tmp/cache/*" --exclude="tmp/deploy_cache/*" -czv -f $dist *

title '创建远程目录'
ssh $user@$ip "mkdir -p $deploy_dir/vendor/cache"  # 在服务器上递归创建部署目录和依赖缓存目录
title '1.上传源码压缩包和依赖配置文件'
scp $dist $user@$ip:$deploy_dir/
yes | rm $dist # 强制删除本地生成的压缩包,释放空间
scp $gemfile $user@$ip:$deploy_dir/  # 上传 Gemfile
scp $gemfile_lock $user@$ip:$deploy_dir/ # 上传 Gemfile.lock
scp -r $vendor_cache_dir $user@$ip:$deploy_dir/vendor/ # 递归上传所有缓存的依赖包 #待改进为上传压缩包后再解压
title '2.上传 Dockerfile'
scp $current_dir/../config/host.Dockerfile $user@$ip:$deploy_dir/Dockerfile # 上传并重命名 Docker 配置文件
title '3.上传 setup 脚本'
scp $current_dir/setup_remote.sh $user@$ip:$deploy_dir/ # 上传在远程服务器执行的安装脚本
title '4.上传版本号'
ssh $user@$ip "echo $time > $deploy_dir/version" # 在远程目录中写入当前版本号文件
title '执行远程脚本'
ssh $user@$ip "export version=$time; /bin/bash $deploy_dir/setup_remote.sh"

解释命令:

  • title: 辅助函数。用于在终端输出显眼的分割线和大标题,提升日志可读性
  • scp 是基于 ssh 的拷贝
    • cp a.txt b.txt:本地复制文件
    • scp a.txt $user@$ip:/b.txt:上传文件到远程机器
    • scp -r a.txt $user@$ip:/b.txt:上传目录到远程机器
  • 远程执行命令 ssh $user@$ip "echo xxx"
    • 远程执行脚本 export version=$time; /bin/bash $deploy_dir/setup_remote.sh"
    • 环境变量
      • export version=$time;:设置环境变量 versiontime
      • /bin/bash 使用 bash 执行
  • tar 命令参数解释:
    • --exclude:打包时排除本地的缓存和临时文件,减小压缩包体积。
    • -czv:create(创建压缩包)、zip(gzip压缩)、verbose(显示过程)。
    • -f $dist *:将当前目录下的所有内容打包到指定路径。
  • 无需手动再登录远程机器去执行 setup_remote.sh 脚本命令了,比在宿主机中少一步
  • 需要添加执行权限 chmod +x bin/pack_for_remote.sh

参考


远程部署脚本 setup_remote.sh

创建 bin/setup_remote.sh

 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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
user=mangosteen
# 容器中部署目录 $version 由环境变量传递
root=/home/$user/deploys/$version
# 应用容器名称
container_name=mangosteen-prod-1
# 数据库容器名称
db_container_name=db-for-mangosteen

# 设置环境变量到 ~/.bashrc
function set_env {
  name=$1
  hint=$2
  [[ ! -z "${!name}" ]] && return
  while [ -z "${!name}" ]; do
    [[ ! -z "$hint" ]] && echo "> 请输入 $name: $hint" || echo "> 请输入 $name:"
    read $name
  done
  sed -i "1s/^/export $name=${!name}\n/" ~/.bashrc
  echo "${name} 已保存至 ~/.bashrc"
}

function title {
  echo 
  echo "###############################################################################"
  echo "## $1"
  echo "###############################################################################" 
  echo 
}

title '设置远程机器的环境变量'
set_env DB_HOST
set_env DB_PASSWORD
set_env RAILS_MASTER_KEY

title '创建数据库'
if [ "$(docker ps -aq -f name=^${DB_HOST}$)" ]; then
  echo '已存在数据库'
else
  docker run -d --name $DB_HOST \
            --network=network1 \
            -e POSTGRES_USER=mangosteen \
            -e POSTGRES_DB=mangosteen_production \
            -e POSTGRES_PASSWORD=$DB_PASSWORD \
            -e PGDATA=/var/lib/postgresql/data/pgdata \
            -v mangosteen-data:/var/lib/postgresql/data \
            postgres:14
  echo '创建成功'
fi

title 'docker build'
docker build $root -t mangosteen:$version
# 判断是否已存在运行应用,存在就删除
if [ "$(docker ps -aq -f name=^mangosteen-prod-1$)" ]; then
  title 'docker rm'
  docker rm -f $container_name
fi
# 运行应用容器
title 'docker run'
docker run -d -p 3000:3000 \
           --network=network1 \
           --name=$container_name \
           -e DB_HOST=$DB_HOST \
           -e DB_PASSWORD=$DB_PASSWORD \
           -e RAILS_MASTER_KEY=$RAILS_MASTER_KEY \
           mangosteen:$version

echo
echo "是否要更新数据库?[y/N]"
read ans
case $ans in
    y|Y|1  )  echo "yes"; title '更新数据库'; docker exec $container_name bin/rails db:create db:migrate ;;
    n|N|2  )  echo "no" ;;
    ""     )  echo "no" ;;
esac

title '全部执行完毕'
  • 核心功能函数 set_env: 交互式环境变量检查与保存
    • 首先检查系统是否已存在该环境变量,若存在则直接跳过([[ ! -z "${!name}" ]] && return
    • 若不存在,则进入 while 循环,提示并强制要求用户手动输入
    • 使用 sed -i "1s/^/.../" ~/.bashrc 将变量以 export KEY=VALUE 的形式插入到 ~/.bashrc 文件的第一行
    • 确保持久化,下次登录依然有效
    • 若用户输入不为空,则将输入保存至 ~/.bashrc

第一步:配置环境变量

  • 设置远程机器的环境变量,这些变量是不能写到 git 中的;部署时,添加到运行命令前的环境变量
    • set_env DB_HOST 数据库容器名或地址(后续创建数据库容器时,会直接以此变量名作为容器名)
    • set_env DB_PASSWORD 数据库密码
    • set_env RAILS_MASTER_KEY Rails 生产环境解密凭证所必需的密钥

第二步:创建并运行数据库容器

  • 使用 docker ps -aq -f name=^${DB_HOST}$ 精确查找是否已存在同名的数据库容器
  • 如果不存在:创建并后台运行一个基于 postgres:14 的容器
    • 加入自定义网络 --network=network1,确保应用容器后续能通过 DB_HOST 域名直接访问数据库。
    • 通过 -v 将数据挂载到外部数据卷 mangosteen-data,保证数据库重构时数据不丢失。

第三步:构建并运行应用容器

  • docker build $root -t mangosteen:$version:前往 $root 目录,读取那里的 Dockerfile,构建出带有版本标签的镜像 mangosteen:$version
  • 清理旧容器:检查是否存在正在运行或残留的旧应用容器(mangosteen-prod-1
    • 如果存在则强制删除(docker rm -f),为新容器腾出端口和名字
  • docker run:启动新应用容器,指定端口映射、网络、容器名、环境变量、镜像标签
    • 映射本地 3000 端口到容器 3000 端口。
    • 加入相同的网络 network1
    • 将第一步获取的环境变量注入到应用容器中,供 Rails 程序读取

第四步:可选的数据库迁移

  • 脚本使用 read ans 读取用户输入,提示用户是否需要更新数据库
  • 如果用户输入 y、Y 或 1:脚本会通过 docker exec 进入刚刚启动的应用容器
    • 执行 Rails 内置命令 bin/rails db:create db:migrate 来创建表结构或执行数据迁移
  • 若输入其他内容或直接回车,则跳过此步

需要添加执行权限 chmod +x bin/setup_remote.sh

参考


执行部署脚本顺序

确保脚本都已添加执行权限 chmod +x bin/*.sh

  • 容器项目终端 bin/pack_for_remote.sh:打包应用代码到远程机器
    • 远程服务器自动运行脚本 /home/$user/deploys/$time/setup_remote.sh:在远程机器上部署应用

运行结果

  • 全部执行完毕
    • docker build 成功
    • docker rm 成功删除原 mangosteen-prod-1
    • docker run 成功显示一串哈希 58be87xxxxx...

验证

  • 浏览器访问公网ip http://8.155.132.238:3000,确认应用已启动
  • 使用命令 curl --noproxy "*" http://localhost:3000 -v ,确认应用已启动

坑点1: 网络问题,可能报错

1
2
Retrying fetcher due to error (2/4):Bundler:HTTPError Could not fetch specs from https://gems.ruby-china.com/ 
due to underlying error <timed out (https://gems.ruby-china.com/specs.4.8.gz)>
  • 解决方法1,提前切换镜像源

host.Dickerfile

1
2
3
#...
RUN bundle config mirror.https://rubygems.org https://mirrors.tuna.tsinghua.edu.cn/rubygems
#...
  • 解决方法2,在容器项目中运行 bundle cache 缓存依赖
    • bundle cache 会将所有依赖缓存到自动生成的 vendor/cache 目录下,后续构建时会直接从缓存中读取,避免重复下载。
  • 并且需要修改 host.Dickerfile 中的命令 RUN bundle install 改为 RUN bundle install --local
    • 这样在后续构建时,会直接从缓存中读取依赖,避免重复下载。
  • 修改 bin/pack_for_remote.sh,在 tar 压缩前添加 bundle cache

host.Dickerfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
FROM ruby:3.1.2

ENV RAILS_ENV=production
RUN mkdir /mangosteen
RUN bundle config mirror.https://rubygems.org https://mirrors.tuna.tsinghua.edu.cn/rubygems
WORKDIR /mangosteen
ADD mangosteen-*.tar.gz ./
RUN bundle config set --local without 'development test'
RUN bundle install --local
ENTRYPOINT ["bundle", "exec", "puma"]

坑点2:报错 EOF 脚本添加最后一行回车空行

pack_for_remote.sh 报错 EOF

1
2
bin/pack_for_remote.sh:line 38:unexpected EOF while looking for matching
bin/pack_for_remote.sh:line 39:syntax error:unexpected end of file

2. 优化 Dockerfile 加速 bundle build

微调 host.Dockerfile

  • 目的:如果依赖没变,就不需要重新安装依赖,复用之前的
  • 步骤
    • 先添加 GemfileGemfile.lockvendor/cache
    • 再运行 bundle install --local
    • 最后做其他
  • 实际上改完代码后运行改的是 ADD mangosteen-*.tar.gz ./ 这行命令
    • 调整顺序,先安装依赖,再去运行依赖
    • 之前在 pack_for_remote.sh 脚本中已经上传 Gemfile 等文件

host.Dockerfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
FROM ruby:3.1.2

ENV RAILS_ENV=production
RUN mkdir /mangosteen
RUN bundle config mirror.https://rubygems.org https://mirrors.tuna.tsinghua.edu.cn/rubygems
WORKDIR /mangosteen
ADD Gemfile /mangosteen
ADD Gemfile.lock /mangosteen
ADD vendor/cache /mangosteen/vendor/cache
RUN bundle config set --local without 'development test'
RUN bundle install --local

ADD mangosteen-*.tar.gz ./
ENTRYPOINT ["bundle", "exec", "puma"]
  • 这是 docker 的一个特性: layer 层复用
    • 第n次运行镜像
    • ADD Gemfile /mangosteenADD Gemfile.lock /mangosteenADD vendor/cache /mangosteen/vendor/cache 等都不变
    • 之后的 RUN bundle install --local 也不需要变化,直接使用上一次 docker build 的结果

已修改 pack_for_remote.sh

1
2
3
4
5
6
7
8
9
#...
# 本地缓存的依赖包目录:容器中 vendor/cache 目录的完整路径
vendor_cache_dir=$current_dir/../vendor/cache
#...
title '创建远程目录'
ssh $user@$ip "mkdir -p $deploy_dir/vendor/cache"  # 在服务器上递归创建部署目录和依赖缓存目录
#...
scp -r $vendor_cache_dir $user@$ip:$deploy_dir/vendor/ # 递归上传所有缓存的依赖包 #待改进为上传压缩包后再解压
#...
  • 注意拷贝文件路径需要添加参数 scp -r

重新再打包运行镜像时就走缓存逻辑了

  • 第一次还需要安装,之后就直接使用缓存

    1
    2
    3
    4
    
    ...
    Step 10/12 RUN bundle install --local
    --->Using cache
    ...

提交代码忽略 vendor/cache

 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
# See https://help.github.com/articles/ignoring-files for more about ignoring files.
#
# Temporary files generated by your text editor or operating system
# belong in git's global ignore instead:
# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore`

# Ignore bundler config.
/.bundle

# Ignore all environment files (except templates).
/.env*
!/.env*.erb

# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
/tmp/*.gz
!/log/.keep
!/tmp/.keep

# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/
!/tmp/pids/.keep

# Ignore storage (uploaded files in development and any SQLite databases).
/storage/*
!/storage/.keep
/tmp/storage/*
!/tmp/storage/
!/tmp/storage/.keep

# Ignore master key for decrypting credentials and more.
/config/master.key

# IDE
.idea
.vscode
# plugin
.ai
.junie
.rubymate
.ruby-lsp

/config/credentials/production.key
/vendor/cache

3.云服务器部署命令总结

 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
# 【容器项目终端-云服务器】映射云服务器公网ip
ssh mangosteen@xxx.xxx.xxx.xx
vim /etc/hosts
echo "xxx.xxx.xxx.xx server1" >> /etc/hosts
cat /etc/hosts
# 【容器项目终端-云服务器】登录已经映射公网ip的云服务器
ssh mangosteen@server1

# 已添加脚本 pack_for_remote.sh 和 setup_remote.sh
# 【容器项目】添加可执行权限
chmod +x bin/*.sh
# 【容器项目】打包到云服务器
# bin/pack_for_remote.sh
# 【容器项目】打包到云服务器 - 传参公网ip
ip=xxx.xxx.xxx.xx bin/pack_for_remote.sh
# 云服务器自动运行 setup_remote.sh

# 创建网络
docker network create network1
# 启动数据库
docker start db-for-mangosteen
# 首次输入(不能提交到git中的内容)
# 请输入 DB_HOST:
db-for-mangosteen
# 请输入 DB_PASSWORD:
123456
# 请输入 RAILS_MASTER_KEY(在项目 production.key 中):
240c3dcce0e4e35f99e1c107042ec5a8

常见报错

DEBUG 示例:部署时更新数据库失败

  • 运行 ip=x.xxx.xxx.xxx bin/pack_for_remote.sh 部署

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    ...
    ###############################################################################
    ## 更新数据库
    ###############################################################################
    
    could not translate host name "db-for-mangosteen" to address: Temporary failure in name resolution
    Couldn't create 'mangosteen_production' database. Please check your configuration.
    bin/rails aborted!
    ActiveRecord::ConnectionNotEstablished: could not translate host name "db-for-mangosteen" to address: Temporary failure in name resolution
    
    
    Caused by:
    PG::ConnectionBad: could not translate host name "db-for-mangosteen" to address: Temporary failure in name resolution
    
    Tasks: TOP => db:create
    (See full trace by running task with --trace)

或者报错

处理方法

  • 可删除容器项目中的依赖缓存 tmp/deploy_cache
  • 登录远程 ssh root@8.155.132.238
  • 运行 docker ps -a 看看 db-for-mangosteen 容器是否在运行
  • 启动数据库容器 docker start db-for-mangosteen
  • 然后重新运行 ip=8.155.132.238 bin/pack_for_remote.sh

运行 bin/pack_for_remote.sh 报错:更新数据库失败

1
Error response from daemon: Container xxx is not running
  • 出现这种报错一般是数据库容器启动失败,应该在远程机器运行 docker ps -a 看看 db-for-mangosteen 容器是否在运行
  • 如果没有运行就用 docker logs db-for-mangosteen 看看数据库的报错信息,然后分析报错

运行 bin/pack_for_remote.sh 报错

1
2
3
4
> bin/pack_for_remote.sh
scp: realpath /home/mangosteen/deploys/20220906-210905/vendor/cache: No such file
scp: upload "/home/mangosteen/deploys/20220906-210905/vendor/cache": path canonicalization failed
scp: failed to upload directory bin/../vendor/cache to /home/mangosteen/deploys/20220906-210905/vendor/cache
  • 出错原因是远程机器上的 vendor/cache 目录没有创建

解决办法是把 pack_for_remote.sh 中的

1
2
title '创建远程目录'
ssh $user@$ip "mkdir -p $deploy_dir/vendor"

改为

1
2
title '创建远程目录'
ssh $user@$ip "mkdir -p $deploy_dir/vendor/cache"

调整 config/host.Dockerfile 以适配本地宿主机部署

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
FROM ruby:3.1.2

ENV RAILS_ENV=production

# 2. 提前升级镜像中的 bundler 版本,使其与你的 Gemfile.lock (2.3.7) 一致
# RUN gem install bundler:2.3.7
RUN bundle config mirror.https://rubygems.org https://mirrors.tuna.tsinghua.edu.cn/rubygems

RUN mkdir /mangosteen
WORKDIR /mangosteen
ADD Gemfile /mangosteen
ADD Gemfile.lock /mangosteen
ADD vendor/cache /mangosteen/vendor/cache
RUN bundle config set --local without 'development test'
RUN bundle install --local

ADD mangosteen-*.tar.gz ./
ENTRYPOINT ["bundle", "exec", "puma"]

大坑:镜像源失效

远程部署云服务器时报错:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
...
#2 [internal] load metadata for docker.io/library/ruby:3.1.2
#2 ERROR: failed to do request: Head "https://registry-1.docker.io/v2/library/ruby/manifests/3.1.2": dial tcp 118.193.240.37:443: i/o timeout
------
 > [internal] load metadata for docker.io/library/ruby:3.1.2:
------
Dockerfile:1
--------------------
   1 | >>> FROM ruby:3.1.2
   2 |     
   3 |     ENV RAILS_ENV=production
--------------------
ERROR: failed to build: failed to solve: DeadlineExceeded: ruby:3.1.2: failed to resolve source metadata for docker.io/library/ruby:3.1.2: failed to do request: Head "https://registry-1.docker.io/v2/library/ruby/manifests/3.1.2": dial tcp 118.193.240.37:443: i/o timeout

#... 或者报错
#2 [internal] load metadata for docker.io/library/ruby:3.1.2
#2 ERROR: failed to do request: Head "https://registry-1.docker.io/v2/library/ruby/manifests/3.1.2": dial tcp 118.193.240.37:443: i/o time
  • Docker 无法在 Docker Hub 上找到或下载你指定的 ruby:3.1.2 镜像
  • 常见的原因是网络连接限制、未登录 Docker Hub(或凭据过期)或者 Docker 守护进程的 DNS 配置故障
  • 网络连接超时问题。因为移除了加速镜像,Docker 开始直接连接官方的 registry-1.docker.io,但在国内直连该地址目前几乎是 100% 被墙或严重丢包的

只能换镜像源

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "registry-mirrors": [
    "https://docker.xuanyuan.me",
    "https://docker.1ms.run",
    "https://docker.m.daocloud.io",
    "https://docker.mirrors.ustc.edu.cn/",
    "https://o3b9ba2s.mirror.aliyuncs.com",
    "https://reg-mirror.qiniu.com",
    "https://hub-mirror.c.163.com/"
  ]
}

参考


·未完待续·

参考文章

相关文章


  • 作者: Joel
  • 文章链接:
  • 版权声明
  • 非自由转载-非商用-非衍生-保持署名