使用 Vagrant、Chef 和 rbenv 設定一個 Ruby 開發 VM

我在設定一個 Vagrant VM 時的一些經驗筆記,以幫助合作者使用我的網路出版工具鏈。我使用 Chef 準備 VM,並使用 rbenv 安裝和控制正確版本的 Ruby。

2014 年 9 月 4 日



我有一個用於建置 martinfowler.com 的工具鏈。我大約在 2000 年開始使用它,當時類似的工具很少。在那些日子裡,靜態網站並不流行,但我滿喜歡透過 rsync 將我的網站部署到只需要 Apache 的伺服器上。隨著時間的推移,工具鏈變得更強大、更複雜,但我喜歡它開發的方式,它是一個讓我可以舒適地工作和探索新想法的家。

近年來,我有更多同事和朋友使用工具鏈在我的網站上撰寫文章。為了與他們合作,我設定了一個精簡版的核心網站儲存庫,我們使用 git 進行協作。由於我的合作者大多是程式設計師,因此這個工作流程相當有效。

要執行所有這些,有必要安裝一些軟體。我為工具鏈使用的所有軟體都是開源的,但最近出現了一些安裝問題。特別是,你會發現許多基本的 Ruby 安裝都很舊,所以我們需要安裝一個較新的 Ruby 版本。由於工具鏈會處理 XML,所以我使用 Nokogiri。這是一個很好的工具,但安裝起來可能很麻煩。在最近幾個月,我有幾個合作者浪費了數小時試圖安裝它。

一年前(或兩年前),Erik 告訴我,我應該設定一個已安裝並準備就緒的工具鏈 VM 執行個體。這樣,合作者就可以啟動一個 VM 並開始工作。我們越來越多地使用像 Vagrant 這樣的工具在我們的專案上設定虛擬開發環境。最後,在這些最新的合作者問題中,我決定這麼做。

總而言之,這比我預期的要困難得多,而且我經常找不到太多可以幫助我的文件。因此,我寫下了我在這裡的經驗筆記,給任何想要做類似事情的人。請記住,我寫這些筆記並非作為權威文件——它們只是記錄了我所做的事情。可能還有更好的方法是我沒有遇到的,我對這些工具沒有太多經驗(而且確實沒有什麼野心)。這也是非常特定於時間的,這些工具的後續版本可能會以不同的方式運作,因此如果你在文章日期之後很久才試著追隨我的麵包屑,請小心這一點。

使用 Vagrant 設定一個簡單的 VM

首先要做的就是讓一個簡單的 VM 啟動並執行。我的同事告訴我,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) 的 VM。有了這個 Vagrantfile,您就可以使用 vagrant up 建立並啟動一個 VM。一旦機器建立並啟動,您就可以使用 vagrant ssh 登入。您會注意到 Vagrant 已經建立了一個稱為 vagrant 的使用者,並使用 ssh 金鑰讓您登入。 [1] vagrant 使用者可以在沒有密碼的情況下使用 sudo,而 Vagrant 使用它來控制機器管理。

您可以使用 vagrant halt 停止機器,並使用 vagrant destroy 完全銷毀機器。 vagrant up 指令會啟動現有的機器,或者在尚未建立機器的情況下建立並啟動機器。Vagrant 使用一個預設機器,有多種方法可以處理多個機器,但我尚未探索它們。

使用 Chef 準備

Vagrant 讓我有了一個裸機器,但這樣的機器需要使用軟體配置,這樣我才能用它做有用的事情。這個操作的重點是讓它盡可能自動化,因此合作者只需執行幾個指令,就可以讓 VM 準備就緒,而不需要尷尬的安裝說明。

執行此操作的方法之一可能是執行 VM 中的安裝指令碼,但通常更好的方法是使用專門用於配置機器軟體,例如 PuppetChefAnsible。我選擇 Chef,不是因為任何詳細的評估,而是因為我認識在那裡工作的人,以防我需要一位有影響力的朋友。

不幸的是,Chef 的文件在這個時候沒有什麼幫助,因為它是為您管理數百台伺服器的情況而編寫的。關於只設定單一伺服器幾乎沒有文件,我必須搜尋一陣子才能找出我該做什麼。

Google 的關鍵字是 chef-solo,這是 Chef 的一種版本,用於處理這種單一伺服器情境。Vagrant 有必要的掛鉤可與 chef-solo 搭配使用,因此兩者可搭配得很好。 [2]

我用來設定 Vagrant VM 的資料夾包含兩個項目: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

這告訴我,將我的 VM 映像建立在 ubuntu trusty 64 位元系統上,並使用「mfweb」食譜來提供新機器與 chef-solo。

mfweb 食譜位於 cookbooks 資料夾中,展開後如下所示

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

在 Chef 中,食譜是一組提供資訊。各種資訊會出現在食譜中,但我需要三個主要部分

  • 檔案:需要複製到 VM 的各種檔案和目錄
  • 函式庫:食譜的輔助程式碼
  • 食譜:指定 VM 設定的程式碼

由於我非常熟悉 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 專家,而只是想設定我的簡單 VM,因此這可能會是個問題。然而,總體而言,它在大部分時間都能正常運作。當然,如果我再次執行常規系統管理工作,我希望能非常熟悉這樣的工具。

在 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}

建立一個新使用者

嘗試讓此 VM 運作的其中一個磨難是處理不同的 ruby 版本。vagrant 帳戶用於管理,而我擔心在其中擁有不同的 ruby 版本會導致配置產生複雜性。因此,我為程式設計工作建立了一個獨立的使用者。事後看來,我不確定這是否有幫助,我可能會在未來移除它以簡化 VM 的設定,但以下是我如何執行的步驟。

建立新使用者從在指令檔案中定義使用者開始。

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 的內容放入 VM 的家目錄。VM 上不在來源目錄中的任何檔案都不會受到影響(有一個選項可以清除這些檔案)。然後,我可以將 .bashrc 和其他方便的檔案放入來源目錄,並在每次配置時將它們複製到機器上。

這些步驟會建立使用者和家目錄,但不會提供我們任何登入的方式。使用與 vagrant 使用者相同的 ssh 機制是有道理的,因此複製 vagrant 使用者的 .ssh 目錄似乎是合理的。我考慮使用 Chef 的檔案資源(因為它是非安全的金鑰),但無論我使用哪種方法,都需要調整所有權和權限模式,所以我訴諸 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

使用輔助工具而不是常數現在已成為我的習慣。我比較喜歡在函式中保留任何字串處理,而且比較喜歡使用函式而不是常數,因此我可以輕鬆地隨意用函式取代簡單的常數。將函式綑綁到物件中,讓我可以將狀態與函式一起保存在明確的命名空間中。我通常不喜歡「輔助工具」作為類別的名稱,因為它暗示的只是一個任意的函式和資料集合,但在這種情況下,它完美地描述了它的角色。

同步開發樹狀結構

為了能夠建構任何東西,我們需要將各種來源放入 VM。由於我將來源保存在 git 中,因此執行此操作的方法之一是使用 git 在 VM 中複製儲存庫。但是,儘管我想使用 VM 來執行建構,但我寧願在我的主機上進行所有編輯。幸運的是,Vagrant 可以輕鬆地在主機和 VM 之間共用目錄,只要您進行變更,就會同步它們。為此,您將宣告 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,它會正常啟動機器。我可以透過先執行新的機器,並將同步資料夾組態註解掉,然後重新載入它,來解決這個問題。

HTML 輸出

建構的輸出是網站,因此將 apache 加入 VM 是有道理的,這樣我們才能看到結果。

default.rb

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

遺憾的是,我必須在此處使用執行資源。Chef 有連結資源來設定連結,但不會覆寫 apache 安裝建立的現有目錄項目。

有了它,我就可以將 VM 上的埠 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)。由於 VM 僅用於單一目的,因此有一個很好的論點,完全不使用切換器 - 我可以像在生產系統中那樣,將適當的 ruby 版本安裝為系統 ruby。但我決定使用 rbenv,因為這樣它會反映那些像我一樣在沒有 VM 的情況下直接在他們的機器上執行工具的系統。

我安裝 rbenv 及其相關的 ruby-build 的第一個方法是使用 Chef 食譜 [3]。但在與它們奮戰了幾個小時後,我無法讓它們正常工作。我無法找出如何將 rubies 安裝到 ~/rbenv 而不是 /usr/local,而且我陷入了一個困境,我會安裝一個 gem,但它不會顯示在 gem list 中。所以我放棄了 Chef 食譜。

我的下一個嘗試是使用 Chef 執行資源,以便安裝可以在配置期間執行。但是,我在讓腳本使用正確版本執行的過程中遇到了麻煩。我無法讓執行命令使用環境來執行,因此它會選擇正確的 rbenv shim 來執行正確版本的 Ruby。因此,最後放棄在配置期間執行 Ruby 安裝,而是盡可能在配置期間執行,然後使用需要在 VM 內手動執行的引導腳本。

所有這些的第一步是使用 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

要執行它,VM 的使用者需要登入網路帳戶並執行

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 coffee-script@1.6.3"
  end
  
  # annoyingly mac and ubuntu use different commands for node
  link "/usr/bin/node" do
    to  "/usr/bin/nodejs"
  end

我沒有尋找 npm 的 Chef 食譜,執行選項似乎運作良好。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 日:首次發布