使用 Vagrant、Chef 和 rbenv 设置 Ruby 开发虚拟机

一些关于我设置 Vagrant 虚拟机以帮助合作者使用我的 Web 发布工具链的经验笔记。我使用 Chef 来配置虚拟机,并使用 rbenv 来安装和控制正确的 Ruby 版本。

2014 年 9 月 4 日



我拥有自己的工具链来构建 martinfowler.com。我大约在 2000 年开始构建它,当时很少有类似的工具存在。静态网站在当时并不流行,但我更喜欢通过 rsync 部署我的网站到仅需 Apache 的服务器。随着时间的推移,工具链变得更加强大和复杂,但我喜欢它发展的方式,它是我工作和探索新想法的舒适的家。

近年来,我越来越多的同事和朋友在我的网站上使用该工具链撰写文章。为了与他们合作,我设置了我的核心网站仓库的简化副本,我们使用 git 进行协作。由于我的合作者大多是程序员,因此这种工作流程非常有效。

要运行所有这些,需要安装一些软件。我用于工具链的所有软件都是开源的,但最近出现了一些安装问题。特别是你会发现许多基本的 Ruby 安装都比较老旧,因此我们需要安装更新版本的 Ruby。由于工具链处理 XML,我使用 Nokogiri。这是一个很好的工具,但安装起来可能很麻烦。最近几个月,我遇到过几个合作者浪费了几个小时试图安装它。

一年前(或者两年?),Erik 告诉我应该设置一个安装了所有工具链的虚拟机实例,这样合作者就可以启动虚拟机并开始工作。我们越来越多的项目中使用 Vagrant 等工具来设置这样的虚拟开发环境。最后,由于最近合作者遇到的这些麻烦,我决定这样做。

总的来说,这比我预期的要难得多,而且我经常找不到太多可以帮助我的文档。因此,我在这里写下了我的经验,供任何想要做类似事情的人参考。请记住,我不将这些笔记写成权威文档 - 它们只是记录了我成功执行的操作。可能还有更好的方法,但我没有遇到,我对这些工具并不熟悉(而且实际上也不想成为专家)。这也很依赖时间,这些工具的后续版本可能会有所不同,因此如果你在文章日期很久以后尝试遵循我的线索,请谨慎行事。

使用 Vagrant 设置简单的虚拟机

首先要做的是启动一个简单的虚拟机。我的同事们说 Vagrant 是解决这个问题的最佳方法。对于客户机,我选择了 Ubuntu 14.04,因为它似乎是客户机系统的流行选择。巧合的是,我用来做这件事的盒子也运行着 14.04。但令人恼火的是,与 Ubuntu 14.04 打包在一起的 Vagrant 版本没有设置为运行 14.04 客户机,因此我不得不手动下载并安装最新版本的 Vagrant(1.6.3)。它被打包成一个 deb 文件,因此非常简单,但我确实在尝试使用早期版本让事情正常工作时四处摸索了一段时间,直到我意识到我需要这样做。

要运行一个基本的 Vagrant 盒子,你需要一个名为 Vagrantfile 的控制文件。对于一个简单的示例,这个控制文件可以只包含

VAGRANTFILE_API_VERSION = "2"

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
  config.vm.box = "ubuntu/trusty64"
end

这告诉 Vagrant 基于 64 位 ubuntu trusty(即 14.04)创建一个虚拟机。有了这个 Vagrantfile,你就可以使用 vagrant up 创建并启动虚拟机。机器创建并启动后,你可以使用 vagrant ssh 登录到它。你会注意到 Vagrant 创建了一个名为 vagrant 的用户,并使用 ssh 密钥登录。 [1] vagrant 用户能够在没有密码的情况下使用 sudo,并且是 Vagrant 用于控制机器管理的工具。

你可以使用 vagrant halt 停止机器,并使用 vagrant destroy 完全销毁机器。vagrant up 命令将启动现有机器,或者如果机器不存在,则创建并启动机器。Vagrant 为此使用默认机器,有一些方法可以处理多个机器,但我还没有探索过。

使用 Chef 进行配置

Vagrant 为我提供了一个裸机,但这样的机器需要配置软件,这样我才能用它做有用的事情。此操作的全部目的是使其尽可能自动化,以便合作者只需执行几个命令就可以让虚拟机准备好,而无需任何繁琐的安装说明。

一种方法是在虚拟机中运行安装脚本,但通常更好的方法是使用专为配置机器而设计的软件,例如 PuppetChefAnsible。我选择了 Chef,不是因为经过了详细的评估,而是因为我认识那里的一位员工,以防我需要一位有影响力的人。

不幸的是,Chef 的文档在这一点上非常无用,因为它针对的是管理数百台服务器的情况。关于像这样设置单个服务器的文档很少,我不得不四处寻找一段时间才能弄清楚该怎么做。

需要谷歌搜索的关键内容是 chef-solo,它是 Chef 的一个版本,用于处理这种单服务器场景。Vagrant 具有与 chef-solo 协同工作的必要钩子,因此两者非常适合在一起使用。 [2]

我用于设置 Vagrant 虚拟机的文件夹包含两个条目:Vagrantfilecookbooks,它是一个包含 Chef 指令的目录。(Chef 对烹饪的隐喻有点过分。)为了启动一个基本的服务器并使用 Chef 进行配置,我需要在 Vagrantfile 中添加以下内容。

Vagrantfile

  VAGRANTFILE_API_VERSION = "2"
  
  Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
    config.vm.box = "ubuntu/trusty64"
  
    config.vm.provision :chef_solo do |chef|
      chef.add_recipe "mfweb"
    end
  end

这告诉我将我的虚拟机映像基于 ubuntu trusty 64 位系统,并使用“mfweb”配方配置新机器。

mfweb 配方位于 cookbooks 文件夹中,展开后如下所示

cookbooks
└── mfweb
    ├── files
    │   └── default
    │       └── home-web
    │           └── …
    ├── libraries
    │   └── helpers.rb
    └── recipes
        └── default.rb

在 Chef 中,cookbook 是一个配置信息的集合。cookbook 中可以出现各种内容,但我需要三个主要部分

  • files:需要复制到虚拟机的各种文件和目录
  • libraries:配方辅助代码
  • recipes:指定虚拟机配置的代码

由于我非常熟悉 Ruby,因此 Chef(和 Vagrant)都使用 Ruby 作为它们的编程语言,这一点非常方便。Chef 配方使用 Ruby 内部 DSL,这对我来说非常有效。

然而,在不太令人高兴的一面,Chef 的整体结构很复杂 - 比我的单服务器示例需要的要复杂得多。使用 Chef 最难的部分是弄清楚我实际上需要理解的系统中的哪些小部分。对于 Chef 的主要受众来说,这种复杂性并非没有必要,但它确实让我感到棘手。

与许多配置工具一样,Chef 尽可能以声明式的方式工作。Chef 配方不是一个按顺序执行各种命令的配置脚本,而是试图描述机器的状态。然后,Chef 运行时将这种期望状态与机器的实际状态进行比较,并执行将机器带到期望状态所需的任何操作。

例如,假设我们希望文件“hello.txt”出现在 /home/vagrant 中。配方文件(cookbooks/recipes/default.rb)中的片段如下所示

file "/home/vagrant/hello.txt" do
  content "hello world"
end

该片段表示我希望该文件位于指定位置,并具有给定的内容。当我运行配方时,Chef 会查看该位置是否存在这样的文件,如果不存在,则会创建它。它还会查看内容是否正确,并在必要时进行更改。

这种声明式结构对于配置机器非常有意义。然而,缺点是如果出现问题,你需要进行调试,那么很难弄清楚正在按什么顺序执行什么。由于我并不想成为 Chef 专家,只是想让我的简单虚拟机配置好,这可能是一个问题。然而,总的来说,它在大多数情况下都能正常工作。当然,如果我再次进行常规的系统管理员工作,我会希望非常熟悉这样的工具。

在 Vagrant 的上下文中,配置行为可以在不同的时间点发生。如果你从头开始创建机器,它会在创建时进行配置。如果你有一个正在运行的机器,并且想要重新配置它而不重新启动它,你可以使用 vagrant provision 来完成。要在 Vagrant 中重新启动,可以使用 vagrant reload,它还会重新加载 Vagrantfile 中的任何更改。但是,重新加载不会运行配置配方,除非你使用 vagrant reload --provision 命令告诉它这样做。

配置的重要部分之一是加载软件,而使用 Ubuntu 加载软件的主要方法是使用它的打包系统。在 Chef 中,你可以使用 package 命令安装软件包

package 'nodejs'

由于 Chef 配方是 Ruby,因此如果需要,我也可以使用 Ruby 的语言特性。因此,如果我有多个软件包要安装,我喜欢使用它轻松定义和使用单词数组的能力

%w[build-essential openssl libreadline6 libreadline6-dev].each {|p| package p}

创建新用户

尝试让这个虚拟机正常工作的一个难题是处理不同的 Ruby 版本。vagrant 帐户用于管理,我担心在其中包含不同的 Ruby 版本会导致配置问题。因此,我创建了一个单独的用户来进行编程工作。事后看来,我不确定所有这些都有帮助,我可能会在将来将其删除以简化虚拟机的设置,但以下是我执行的操作。

创建新用户从在配方文件中定义用户开始。

cookbooks/mfweb/recipes/default.rb

  user "web" do
    home '/home/web'
    shell '/bin/bash'
  end

但这只是创建用户并指定其主目录,我需要做更多工作才能真正创建主目录。

default.rb

  remote_directory "/home/web" do
    user 'web'
    files_owner 'web'
    source 'home-web'
  end

我在 Chef 中使用 remote_directory 资源将源目录 cookbooks/mfweb/files/default/home-web 的内容放入虚拟机的 home 目录。虚拟机上不在源目录中的任何文件都不会被触碰(有一个选项可以清除这些文件)。然后,我可以将 .bashrc 和其他方便的文件放入源目录,并在每次配置虚拟机时将它们复制到机器上。

这些步骤创建了用户和 home 目录,但没有提供登录方法。使用与 vagrant 用户相同的 ssh 机制是有意义的,因此将 vagrant 用户的 .ssh 目录复制过来似乎是明智之举。我考虑过使用 Chef 的 file 资源(因为它是非安全密钥),但无论我使用哪种方法,都需要处理所有权和权限模式,因此我求助于 Chef 执行 shell 命令的能力。

default.rb

  execute "copy-ssh" do
    command "cd ~web ; cp -r ~vagrant/.ssh . && chmod 700 .ssh && chown -R web .ssh"
  end

完成这些操作后,我现在可以使用以下命令登录到新帐户

vagrant ssh -- -l web

使用助手消除重复

这很好地创建了用户和目录,但在用户和文件夹名称中存在重复,随着我编写更多配方文件,这种重复会变得更糟。我可以通过使用常量来避免很多重复,例如

USER = 'web'
HOME_DIR = File.join('/home', USER)
user USER do 
  home HOME_DIR
  shell '/bin/bash'
end

但我决定走另一条路,定义一个辅助对象。我用辅助对象设置了一些它需要的数据,然后在配置中看到重复代码时使用它。辅助对象位于 cookbooks/mfweb/libraries 中 - 似乎任何位于那里的 ruby 文件都会被自动加载并可供配方使用。

helper.rb

  module Mfweb
    class Helper
      attr_reader :user, :ruby_version
  
      def initialize ruby_version, user
        @ruby_version = ruby_version
        @user = user
      end
      def home *args
        File.join("/home", @user, *args)
      end

然后我可以像这样使用它

default.rb

  helper = Mfweb::Helper.new("2.1.2", 'web')
  
  user helper.user do
    home helper.home
    shell '/bin/bash'
  end
  remote_directory helper.home do
    user helper.user
    files_owner helper.user
    source 'home-web'
  end
  execute "copy-ssh" do
    command  "cd #{helper.home} ; cp -r ~vagrant/.ssh . && chmod 700 .ssh && chown -R #{helper.user} .ssh"
  end

现在,使用辅助对象而不是常量已经成为我的习惯。我更喜欢将任何字符串操作放在函数中,并且更喜欢使用函数而不是常量,这样我就可以轻松地用函数替换简单的常量。将函数捆绑到对象中,使我能够在清晰的命名空间中将状态与函数保持在一起。我通常不喜欢将“helper”作为类的名称,因为它只意味着任意函数和数据的集合,但在这种情况下,它很好地描述了它的作用。

同步开发树

为了能够构建任何东西,我们需要将各种源代码导入到虚拟机中。由于我将源代码保存在 git 中,因此一种方法是使用 git 在虚拟机中克隆仓库。但是,虽然我想使用虚拟机来运行构建,但我更愿意在我的主机上进行所有编辑。幸运的是,Vagrant 使在主机和虚拟机之间共享目录变得很容易,并在您进行更改时同步它们。为此,您需要在 Vagrantfile 中声明同步的文件。

Vagrantfile

  Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
    config.vm.synced_folder("..", "/home/web/mfcom", :owner => 'web')
    # other steps in configuration …

我将 vagrant 工作的源代码放在整个项目仓库中的一个文件夹中,因此同步的文件夹是父文件夹。

在这样做的时候,我遇到了一个恼人的问题。当我第一次创建一个新机器时,它拒绝创建同步的文件夹,并给我一个错误,说“vboxsf”文件系统不可用。但是,如果我随后执行 vagrant reload,它就会正常启动机器。我可以通过首先使用注释掉的 synced_folder 配置运行一个新机器,然后在它存在的情况下重新加载来解决这个问题。

HTML 输出

构建的输出是一个网站,因此将 apache 添加到虚拟机中以便我们可以查看结果是有意义的。

default.rb

  package "apache2"
  
  execute "set-html_dir" do
    command "rm -r /var/www/html; ln -s #{helper.html} /var/www/html"
  end

不幸的是,我必须在这里使用 execute 资源。Chef 有一个 link 资源来设置链接,但不会覆盖 apache 安装创建的现有目录条目。

有了它,我就可以将虚拟机上的端口 80 映射到主机上的端口。

Vagrantfile

  Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
      # other config …
      config.vm.network :forwarded_port, guest: 80, host: 2929
  end

使用 rbenv 安装 Ruby 2.1.2

像许多 ruby 用户一样,我在笔记本电脑上使用一个切换器来切换 ruby 版本。我更喜欢 rbenv,因为我不喜欢 rvm 操作我的 shell 的方式(用 shell 函数替换 cd)。由于虚拟机只用于单一目的,因此完全不在其上使用切换器是一个很好的论据 - 我可以像在生产系统中那样,将适当的 ruby 版本安装为系统 ruby。但我决定使用 rbenv,因为这样它将反映那些像我一样直接在他们的机器上运行工具而没有虚拟机的人使用的系统。

我安装 rbenv 及其关联的 ruby-build 的第一个方法是使用 Chef cookbooks [3]。但在与它们搏斗了几个小时后,我无法让它们正常工作。我无法弄清楚如何将 ruby 安装到 ~/rbenv 而不是 /usr/local,并且我陷入了一个混乱,我安装了一个 gem,它不会在 gem list 中显示。所以我放弃了 Chef cookbooks。

我的下一个尝试是使用 Chef execute 资源,以便安装可以在配置期间运行。但我在那里陷入了让脚本以正确的版本运行的困境。我无法让 execute 命令在这样的环境中工作,以至于它会拾取正确的 rbenv shims 来运行正确的 ruby 版本。因此,我最终放弃了在配置期间进行 ruby 安装,而是尽可能地在配置期间进行,然后使用一个需要在虚拟机中手动运行的引导脚本。

所有这一切的第一步是使用 git 下载 rbenv 仓库。

default.rb

  package 'git'
  
  git(helper.rbenv_home) do
    repository 'https://github.com/sstephenson/rbenv.git'
    user helper.user
    revision 'v0.4.0'
  end

关于该片段的几件事。首先,您会注意到我指定了一个特定的标签来签出和使用。我这样做是因为拥有一个 可重复构建 非常重要。这样,如果出现问题,我可以使用我的 Vagrant 设置的 git 历史记录来帮助跟踪问题。其次,我在辅助对象上编写了另一个方法来获取 rbenv 安装位置。

helper.rb

  class MfCom::Helper
      def rbenv_home *args
        home('.rbenv', *args)
      end
      …

我还想安装 ruby-builder,它是 rbenv 的姊妹项目,用于安装新的 ruby。我将其安装到 rbenv 的 plugins 目录中,以便我可以使用 rbenv 的 install 命令。

default.rb

  directory(helper.rbenv_home('plugins')) do
    user helper.user
  end
  
  git(helper.rbenv_home('plugins/ruby-build')) do
    repository 'https://github.com/sstephenson/ruby-build.git'
    user helper.user
    revision 'v20140702'
  end

Chef 还可以安装 ruby 编译所需的各种库。我从互联网上的某个地方获得了这个列表,其中一些可能不需要。

default.rb

  %w[build-essential bison openssl libreadline6 libreadline6-dev
  zlib1g zlib1g-dev libssl-dev libyaml-dev libsqlite3-0
  libsqlite3-dev sqlite3 libxml2-dev libxslt1-dev autoconf
  libc6-dev ssl-cert subversion].each do |p|
    package p
  end

所有这些都为安装正确的 Ruby 做好了准备。为了完成这项工作,我在 cookbooks/mfweb/files/default/home-web 中包含了一个引导脚本

read -r VERSION < mfcom/.ruby-version
if [ -f .rbenv/versions/${VERSION}/bin/ruby ]; then
  echo "ruby ${VERSION} is already installed"
else
  rbenv install $VERSION
fi
cd mfcom
gem install bundler --no-rdoc --no-ri
rbenv rehash
bundle install --without=mac

要运行它,虚拟机的用户需要登录到 web 帐户并运行

sh bootstrap

引导需要一段时间才能运行,因为它会编译并安装由 rbenv 管理的正确 ruby 版本。然后它还会安装 bundler 并使用它来安装开发所需的所有 gem。

安装 coffeescript

除了 ruby 之外,我还需要在开发环境中使用 coffeescript。幸运的是,这很容易安装。

default.rb

  %w[nodejs npm].each {|p| package p}
  
  execute "node-packages" do
    command "npm install -g [email protected]"
  end
  
  # annoyingly mac and ubuntu use different commands for node
  link "/usr/bin/node" do
    to  "/usr/bin/nodejs"
  end

我没有寻找 npm 的 Chef cookbook,execute 选项似乎工作得很好。coffee 的版本是我笔记本电脑上当前的版本,我应该考虑升级它。

值得吗?

总的来说,所有这些都花费了比我预期的要长得多的时间,并且消耗了我大约一周的写作时间。如果它在将来为我的合作者节省时间,或者这篇文章在做类似事情时为其他人节省了一些时间,那么它将是值得的。如果我在开始之前就知道这篇文章的内容,我肯定会做得更快。

如果您发现我在这里谈论的任何事情是错误的,请告诉我。更新我现在的设置可能不值得,但我至少可以在这篇文章中添加一些警告和指向其他方法的指针。


进一步阅读

Pete Hodgson 谈论了 在您的项目仓库中设置一个单一的“go”脚本的价值,以设置您的开发环境。

致谢

Danilo Sato 在我尝试做所有这些事情时帮助解决了几个问题。

脚注

1: 这是一个不安全的密钥,私钥与 Vagrant 一起提供。对于只能通过主机访问的简单机器(如本例所示),这很好。

2: Chef 文档说您应该使用 chef-client 的本地模式而不是 chef-solo。但是,我没有找到关于如何使用它的文档,并且 chef-solo 似乎是 Vagrant 喜欢的,至少目前是这样。

3: 这些是 chef-rbenvchef-ruby_build

重大修订

2014 年 9 月 4 日: 首次发布