使用docker发布rails程序

  原文链接: http://steveltn.me/blog/2014/03/15/deploy-rails-applications-using-docker 原作者:Weiyan Shao  

什么是docker?

  Docker是一个用来打包,装载和运行应用程序的一个轻量级容器的开源项目。它的工作方式非常像虚拟机,包裹所有的东西(文件系统,进程管理,环境变量等)在一个容器内。跟虚拟机有所不同,它是使用LXC(Linux kernel container)来替代虚拟层。 LXC没有自己的内核,但是与主机和其他容器一起共享Linux内核的。 基于LXC,因此Docker是非常轻量级的,因此在运行应用程序的时候几乎没有性能损失。 Docker同样提供了聪明的方法去管理你的images镜像。通过Dockerfile和自己的缓存机制,任何人都可以容易重新发布一个更新过的镜像而不用重新传输大量的数据。  

为什么是Docker?

  因为你从来都不知道你的应用程序能否在服务器上正常运行,就算你在本地已经测试和正常运行过了。那是因为服务器上的环境跟你的本地并非完全一致。RVM的设置,ruby和gem的版本差异。如果我们在发布前进行本地测试的时候,我们就已经确认如果在本地正常运行那它也能在服务器上正常运行,那我们会节省一些在服务器上debug错误的时间。 对于巨量的发布,使用虚拟机镜像是相对比较容易的。你可以在几分钟内创建一个虚拟机实例和应用你的镜像让它进行工作。除了便利性,它也有如下一些问题:

  1. 如果你只做了一个很小的更新可不得不提交一个完整的新镜像
  2. 有很多的性能损失。
  3. 你的应用程序可能运行在一个虚拟环境的VPS上,所以你不能运行一个虚拟机在已有的虚拟环境之上。

而在docker中,是各自独立的: 1. 你不必再次更新整个镜像。Docker是基于AuFS(http://aufs.sourceforge.net/),它将追踪整个文件系统的差异。 2. 由于是运行在主机的kernel之上,所以性能损失忽略不计。 3. 你可以将Docker运行在一个虚拟机上,因为Docker本身不是一个虚拟机。

分层的魔法

  当然你可以像使用虚拟机镜像一样来使用Docker容器: 上传你的容器,启动它以及要更新的它时候替换整个镜像。但是这不是最好的方法。去理解Dockerfile是如何工作的,让我们一步步进行试验。你可能想知道如下的东西:

  1. 一个镜像就是文件系统的镜像,就像磁盘镜像一样。
  2. 一个容器就是一个镜像的实例,就像虚拟机实例一样。
  3. Docker需要运行在root权限下。

 

Helloworld

首先,得到一台Linux机器并在上面安装Docker。为了让事情变的更容易点,我使用了DigitalOcean每月5辽币的实例“含有Docker0.8的ubuntu 13.10”. 这样可以节省时间安装Docker。 为了打印出”Helloworld”字符,我们需要一个工作中的docker容器。得到它最容易的方式是从Docker官方的仓库中取得。对于docker镜像来说一个docker仓库非常像github。 运行:

1
host# docker run ubuntu /bin/echo hello world 

其他:

  • ubuntu是一个官方仓库里一个镜像的名字。通常你可以使用 USERNAME/TAG 来定义一个仓库里的镜像。但是如果是你跳过USERNAME。它就意味着是官方版本的Docker镜像
  • /bin/echo hello world 是在容器里运行的一个命令

它的输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
host# docker run ubuntu /bin/echo hello world 
Unable to find image 'ubuntu' (tag: latest)
locally Pulling repository ubuntu 9f676bd305a4:
Download complete 9cd978db300e:
Download complete eb601b8965b8:
Download complete 5ac751e8d623:
Download complete 9cc9ea5ea540:
Download complete 511136ea3c5a:
Download complete 6170bb7b0ad1:
Download complete 321f7f4200f4:
Download complete f323cf34fd77:
Download complete 1c7f181e78b9:
Download complete 7a4f87241845:
Download complete hello world

Download complete的每行都是为了一个层 (http://docs.docker.io/en/latest/terms/layer/). Docker不会写数据进一个镜像。它会在现有的镜像上创建一个包含有你对文件系统修改内容的新层。从文件系统原先的状态迁移到最新的状态其实就是应用一个或者多个层在老的镜像指向,就像给文件打补丁一样。 当一个容器已经停止,就可以提交它(layer)。提交一个容器就是在基础镜像上创建一个额外的层。如想象的,一个官方的ubuntu镜像就是很多的layer创建出来的。 当下载完镜像后,docker从镜像里启动一个容器,运行命令并打印出结果。在我们这个例子中说打印的结果就是最后一行的“hello world”。  

修改你的镜像并提交它

  让我们尝试修改一个镜像并进行提交。 首先,进入一个交互式shell:

1
host# docker run -i -t ubuntu /bin/bash 

注意这个shell的弹出是很快的。开启shell的时间消耗在于2个部分: 下载时间和启动时间。由于我们在做"helloworld"的时候已经下载过了镜像,docker已经缓存了它。 启动时间是可以忽略的,因为它不像一个虚拟机,一个Docker容器不需要启动内,也不用启动系统服务等等。

现在让我们随便安装一个软件,比如nginx,在容器的shell执行以下命令:

1
2
3
root@54e8da3b1db0:/# apt-get update 
root@54e8da3b1db0:/# apt-get install -y nginx
root@54e8da3b1db0:/# exit

现在你已经回到你主机的shell中了。运行:

1
host# docker ps -l 

使用上面命令可以列出你的容器。你将看到如下内容:

1
2
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 
54e8da3b1db0 ubuntu:12.04 /bin/bash 5 minutes ago Exit 0 clever_einstein

显然你的更改并没有被提交。没有提交,你就不能把它作为基础镜像。使用如下命令进行提交:

1
2
root@docker-toy-machine:~# docker commit -m "Install Nginx" 
54e8 steveltn:nginx 4e66300102f4218b312fb4352221682ff42614351b18506e51792491e432111d

54e8是容器ID的前面几个字符。通过下面命令来进行检查镜像的有效性:

1
2
3
4
5
6
7
8
9
10
11
12
13
root@docker-toy-machine:~# docker images 
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
steveltn nginx 4e66300102f4 4 seconds ago 237.6 MB ubuntu 13.10
9f676bd305a4 5 weeks ago 178 MB ubuntu saucy 9f676bd305a4 5 weeks ago 178 MB ubuntu 13.04
eb601b8965b8 5 weeks ago 166.5 MB ubuntu raring
eb601b8965b8 5 weeks ago 166.5 MB ubuntu 12.10
5ac751e8d623 5 weeks ago 161 MB ubuntu quantal
5ac751e8d623 5 weeks ago 161 MB ubuntu 10.04
9cc9ea5ea540 5 weeks ago 180.8 MB ubuntu lucid
9cc9ea5ea540 5 weeks ago 180.8 MB ubuntu 12.04
9cd978db300e 5 weeks ago 204.4 MB ubuntu latest
9cd978db300e 5 weeks ago 204.4 MB ubuntu precise
9cd978db300e 5 weeks ago 204.4 MB

你将看到nginx镜像已经被创建。你可以把它作为基础用在新的镜像中,你现在也可以运行其他命令在这上面。但是现在让我们运行一个服务在这个镜像上面。  

在一个容器中运行nginx

  通常当你在终端中运行nginx之后,它会产生几个进程同时master进程就会退出。在这里,docker将终止它的容器。为了阻止它,我们将修改nginx配置文件以便产生其他进程后保留master进程。

1
2
3
4
5
6
7
8
9
10
11
host# docker run 4e66300102f4 /bin/bash -lc 'echo "daemon off;" >> /etc/nginx/nginx.conf' 
host# docker ps -l
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
9352f354c4ab steveltn:nginx /bin/bash -lc echo " 42 seconds ago Exit 0 romantic_curie

host# docker commit -m "Turn off nginx daemon mode"
9352f354c4ab steveltn:nginx bc94dfbdc83a894e8837769ea8a52d93a4fa8628bf3a1b8748e3a5ffbfd9a760

host# docker images
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
steveltn nginx bc94dfbdc83a 14 seconds ago 237.6 MB

你可以看到新的image ID跟我们提交的结果一样。就像你看到的一样, 修改一个镜像的方式就是在在一个镜像上运行一个命令(就是从一个镜像上创建一个容器), 然后提交容器成为一个新的镜像。通过做这些,一个新的layer就被增加到一个镜像上。 下一步是运行nginx作为一个服务。

1
docker run -p 80:80 bc94dfbdc83a /usr/sbin/nginx 

-p 80:80 转发host的80端口到容器的80端口. bc94dfbdc83a是镜像的ID. 现在访问主机的80端口你将看到熟悉的”Welcome to nginx!”. 你可以使用”docker ps”看到所有的Docker进程,也可以使用”docker kill”来终止它们。  

使用Dockerfile

到现在我们做任何事情都是一步步手动进行的。 这不是我们想要的自动发布。为了实现自动发布,我们将要介绍Dockerfile。 一个Dockerfile就是一个Docker的配置文件,它定义如何创建自定义的容器从一个基础镜像中。用来替代一行行的命令执行,我们只需要写入这些命令到Dockerfile中。比如我们之前那个运行nginx的容器,我们可以像如下这样创建Dockerfile:

1
# Dockerfile for installing and running Nginx # Select ubuntu as the base image FROM ubuntu # Install nginx RUN apt-get update RUN apt-get install -y nginx RUN echo "daemon off;" >> /etc/nginx/nginx.conf # Publish port 80 EXPOSE 80 # Start nginx when container starts ENTRYPOINT /usr/sbin/nginx 

正如你所猜的那样:

  • FROM 意味着从仓库中选择一个基础镜像
  • RUN 意味着在容器中执行一个命令
  • EXPOSE 表示映射一个端口到主机中
  • ENTRYPOINT 是当容器运行时的一个初始化命令

你可以把这个Dockerfile放到服务器上然后从所在的目录中进行build。但是Docker也提供更好的方法。如果你提供一个github仓库地址,它会clone这个仓库,作为上下文进行使用,然后在仓库的根目录进行加载Dockerfile。

1
2
3
4
5
6
7
host# docker build -t steveltn/nginx github.com/steveltn/toy-rails-project-for-docker 
Step 0 : FROM ubuntu ---> 9cd978db300e
Step 1 : RUN apt-get update ---> Running in 7395bc0c6b70 === suppressed === ---> 6ad2ab717026
Step 2 : RUN apt-get install -y nginx ---> Running in c7e1df6ef59a === suppressed === ---> 6ce63dc3c19d
Step 3 : RUN echo "daemon off;" >> /etc/nginx/nginx.conf ---> Running in fdc4954637a4 ---> 41144f01b920
Step 4 : EXPOSE 80 ---> Running in e31a205de745 ---> 15d5c1e287c2
Step 5 : ENTRYPOINT /usr/sbin/nginx ---> Running in 17a5e24835d9 ---> 7d09c4e0c09c Successfully built 7d09c4e0c09c

如用你运行”docker ps –l”可以看到所有的容器,你将发现最新build的那个。由于我们通过”-t steveltn/nginx”把它进行标记,我们可以使用tag作为一个索引。下一步,让我们启动一个容器。

1
host# docker run -p 80:80 steveltn/nginx 

访问你80端口,你可能看到跟之前一样的nginx页面。 你可以使用”-d”把容器作为一个守护进程运行。 现在让我们再build一个容器。build命令将在一秒内完成! 为什么会那么快? 这是因为Docker会缓存每个在Dockerfile中“RUN”命令的中间状态(以layer的形式)。当你在相同容器中执行相同的命令,Docker仅仅是再次应用最后运行这个命令所创建的层来替换现在所运行的在当前的文件系统中。这就是为什么“apt-get update”和”apt-get install -y nginx”那么快执行完成。这个layer magic是由AuFS实现,它的核心就是像git这样的版本控制系统(没有forking和merging)。  

发布一个Rails项目

为了让事情比较简单,我就仅仅创建一个空的rails项目。为了模拟通常的环境,我使用Unicorn作为Rack server。 我们把Dockerfile放到Rails项目的根目录下,然后增加一个目录/config/container 来存储容器内服务的配置文件,比如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
The Dockerfile looks like this: 
# Dockerfile for a Rails application using Nginx and Unicorn
# Select ubuntu as the base image
FROM ubuntu
# Install nginx, nodejs and curl
RUN apt-get update -q
RUN apt-get install -qy nginx
RUN apt-get install -qy curl
RUN apt-get install -qy nodejs
RUN echo "daemon off;" >> /etc/nginx/nginx.conf
# Install rvm, ruby, bundler
RUN curl -sSL https://get.rvm.io | bash -s stable RUN /bin/bash -l -c "rvm requirements"
RUN /bin/bash -l -c "rvm install 2.1.0"
RUN /bin/bash -l -c "gem install bundler --no-ri --no-rdoc"
# Add configuration files in repository to filesystem
ADD config/container/nginx-sites.conf /etc/nginx/sites-enabled/default
ADD config/container/start-server.sh /usr/bin/start-server
RUN chmod +x /usr/bin/start-server
# Add rails project to project directory
ADD ./ /rails
# set WORKDIR
WORKDIR /rails
# bundle install
RUN /bin/bash -l -c "bundle install"
# Publish port 80
EXPOSE 80
# Startup commands
ENTRYPOINT /usr/bin/start-server

增加的目录为为了从上下文(你build镜像的github仓库目录)复制文件到容器文件系统的路径。在增加文件后运行命令,Docker就会根据增加的文件是否被修改来判断是否使用cached layer. config/container/nginx-sites.conf 文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# nginx-sites.conf 
server {
root /rails/public;
server_name 95.85.4.231 _;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_set_header Host $http_host;
if (!-f $request_filename) {
proxy_pass http://localhost:8080;
break;
}
}
}

start-server.sh:

1
# start-server.sh #!/bin/bash cd /rails source /etc/profile.d/rvm.sh bundle exec unicorn -D -p 8080 nginx 

现在我们所有的东西都已经准备好了。Docker已经安装了所有的服务,运行它吧

1
2
host# docker build -t steveltn/nginx github.com/steveltn/toy-rails-project-for-docker 
host# docker run -p 80:80 steveltn/nginx

这2个命令会分别进行build和运行image。  

技巧和思考

 

  • 我只是简单的在一个development环境中运行一个Rails应用。作为一个完全的Rails应用,你需要使用production环
  • 由于我们在重新发布的时候只是替换了镜像,所以databases是不能运行在Docker容器中。
  • 在我们的例子中,我们使用的是官方的ubuntu镜像,所有的修改都是在此之上进行的。你可以安装任何你想要的,并推送这个镜像进入Docker索引中,然后使用你自己的镜像作为基础镜像。这会降低一点灵活性,但是增加了很多便利。你肯定不希望使用”bundle install”在镜像中,因为可能会在项目中更改相应的Gemfile, 但是使用”apt-get install”是一个好方法。(但是很多时候bundle install很好用啊,但是在ruby中必须要指定好版本。)
  • 如果你不想共享你的镜像,你可以创建自己的仓库或者使用像Quay这样的私有仓库。
  • 你可以使用”Capistrano”(http://capistranorb.com/来自动发布程序
  • 在修改任何文件后避免使用”bundle install”从scratch, 参考这篇文章(http://ilikestuffblog.com/2014/01/06/how-to-skip-bundle-install-when-deploying-a-rails-app-to-docker/)
  • 我不知道如何做0 downtime的发布。
  • Docker现在只支持Linux。 如果你像我一样使用OS X, 你可以使用Vagrant来测试你的build在发布前。

As mentioned my @nonsens3, “You can have a zero downtime deployment with a proxy like hipache. It would route traffic to the new container when its ready.” Posted by SteveLTN Mar 15th, 2014 Linux, Rails, Technology