大量サーバの yum update を考える | サイバーエージェント 公式エンジニアブログ
こんにちは、サイバーエージェントでサーバ・ネットワークを担当している前田拓(まえだたく)といいます(twitterは t9mdでやっています)。

さっそく本題ですが、今回は私が日頃業務で使用しているCentOSのパッケージ管理について書きたい思います。
※ 今回の内容は、CentOS を例に書いていますが、CentOS の元になっている RedHat Enterprise Linux でもおそらく適用できると思います。

50台以上の大量のサーバーを管理しており、環境も 「開発環境」、「テスト環境」、「本番環境」といった様に複数ある場合、各サーバの RPM パッケージのバージョンをちゃんと管理し、アップデートしていく為にはそれなりの仕組みが必要になります。

パッケージアップデートは 1.開発環境 → 2.テスト環境 → 3.本番環境 の順番で行いますが、1~3 の流れの途中で各サーバが参照するリポジトリのパッケージリストが変わってしまっては困ります。
「テスト環境で動いていたのに、本番では特定のプログラムが動かない。」というような事態になった時の切り分けが大変です。

今回は、計画的にサーバ群のパッケージアップデートを行う為の仕組みを考えてみましたので、紹介します。参考になればありがたいです。

■ 基本的な知識
最初に、CentOS のリポジトリの基本となる知識を簡単に説明しておきます。

まずバージョンですが、メジャーバージョンと、マイナーバージョンで構成されおり、CentOS 5.X の 5 がメジャーバージョン、Xの部分がマイナーバージョンになります。
メジャーバージョンが同じなら、マイナーバージョンは yum update することで 5.2 → 5.3 → 5.4 → 5.5 という形で上がっていきます。

もう少し具体的に説明します。

/etc/yum.repos.d/ 配下を確認すると、インストール直後のリポジトリ定義ファイルは 「mirrorlist=http://mirrorlist.centos.org/?release=$releasever&arch=$basearch&repo=updates 」の様に mirrorlist を参照しており、この URL にアクセスして得られたURL からパッケージを取得します。
$relasever には メジャーバージョン番号(5.Xの場合は5)、arch には i386 や X86_64 が、repo には os や updates が入ります。
$releasever はメジャーバージョン番号が入ると書きましたが、詳細に説明すると CentOS の場合は centos-release パッケージのバージョン番号が入ります。
このバージョン番号は下記のコマンドで確認できます。


rpm -q centos-release --queryformat '%{version}\n'


結果的にアクセスする URL は「 http://mirrorlist.centos.org/?release=5&arch=x86_64&repo=updates 」 の様になります。
curl コマンドでアクセスしてみると下記ような URL のリストが返されました。


$ curl 'http://mirrorlist.centos.org/?release=5&arch=x86_64&repo=updates'
http://www.ftp.ne.jp/Linux/packages/CentOS/5.5/updates/x86_64/
http://ftp.jaist.ac.jp/pub/Linux/CentOS/5.5/updates/x86_64/
http://ftp.yz.yamagata-u.ac.jp/pub/linux/centos/5.5/updates/x86_64/
http://ftp.iij.ad.jp/pub/linux/centos/5.5/updates/x86_64/
http://rsync.atworks.co.jp/centos/5.5/updates/x86_64/
http://ftp.tsukuba.wide.ad.jp/Linux/centos/5.5/updates/x86_64/
http://mirror.nus.edu.sg/centos/5.5/updates/x86_64/
ftp://ftp.oss.eznetsols.org/linux/centos/5.5/updates/x86_64/
http://centos.ustc.edu.cn/centos/5.5/updates/x86_64/
http://centos.tt.co.kr/5.5/updates/x86_64/
$


クライアントは返された URL のどれかにアクセスして、パッケージを取得します。
上記では 5.5 が返っていますが、5.5の部分は、その時点での最新のバージョン番号が入ります。
このように、query parameter としては同じメージャー番号しか渡していないので、5.2 のクライアントだろうが、5.4 のクライアントだろうが、返される URL は最新のバージョンのものになり、同じパッケージリストを参照します。
この仕組みにより、yum update を続けることで、5.2 → 5.3 → 5.4 と順に上がっていきます。

■ やりたいこと
簡単に基本的なバージョニングと5.x → 5.y へ上がっていく仕組みを説明しました。
上記のような仕組みは便利な半面、計画的に複数台のサーバーをアップデートしていくには不便な場合があります。
参照するパッケージリストが変わるタイミングを、環境やサーバーの種類によってコントロールしたい場合がある為です。

説明を簡単にするため、下記のような前提、要件を例に考えます。

■ 前提と要件

1. 回線の無駄遣いをしたくないので、各クライアントはインターネットへ直接アクセスせずにアップデートしたい。

いちいちインターネットへアクセスしていてはダウンロードの時間もかかります。
またインターネットへアクセス出来ない(させない)クライアントもあります。

2. パッケージアップデートはテスト環境、→ 本番環境 の流れで行う。
3. yum update は毎日1回、深夜に自動的に行う。

大量のサーバを管理しているので、自動でアップデートしたい。

4. アップデートサイクルの過程では全クライアントが同じパッケージリストを参照したい。

本番と、テスト環境でインストールされているパッケージが違ってしまうと、テスト環境の意味が薄れる。

5. 各サーバの /etc/yum.repos.d/ 配下のリポジトリ定義ファイルをいちいち更新したくない。
変更の手間を考えると、クライアント側のリポジトリ定義はなるべく変更したくない。

■ 解決方法

1. 回線の無駄遣いをしたくないので、各クライアントはインターネットへ直接アクセスせずにアップデートしたい。

yum リポジトリのローカルミラーを作成すれば解決します。

2. パッケージアップデートはテスト環境、→ 本番環境 の流れで行う。
3. yum update は毎日1回、深夜に自動的に行う。
4. アップデートサイクルの過程では全クライアントが同じパッケージリストを参照したい。

アップデートサイクルの開始時点でリポジトリのスナップショットを作成し、パッケージ内容を固定します。
クライアントは次回のアップデートサイクルまでは、この固定されたリポジトリを参照します。

5. 各サーバの /etc/yum.repos.d/ 配下のリポジトリ定義ファイルをいちいち更新したくない。

各サーバが参照するリポジトリは、サーバー側がIPアドレスを元に判断するようにし、クライアント側の設定には依存しないようにしました。


ポイントを絞って具体的に説明していきます。

■ リポジトリのローカルミラーを作成する

公開ミラーのURLを定期的に rsync 等を使用して同期したディレクトリを、http や ftp で公開するだけです。
すでに色々なところで説明されているので省きます。
下記リンクが参考になります。

CentOS Wiki:Creating Local Mirrors for Updates or Installs

■ リポジトリのスナップショットの作成

リポジトリのミラー先ディレクトリが /var/www/html/CentOS だとした場合、スナップショットの作成は下記の様になります。


$ cd /var/www/html/CentOS
$ cp -al 5.5 r5.5-01


この文章を書いている時点での最新のバージョンである、5.5 は定期的に rsync で同期しているディレクトリで、随時内容が更新されていくので、別のディレクトリ名でコピーを作成して内容を固定します。
CentOS から提供されるパッケージを自分達で直接変更することはないので、cp に '-l' オプションを付けて、各ファイルを物理的にコピーする代わりにハードリンクを作成しています。※ ディレクトリはハードリンクされません。念のため。
こうすることで、リビジョンも一瞬で作ることが出来ますし、ディスク容量も消費しません。
もちろん、上記の操作で作成したディレクトリの内容は絶対に更新してはいけません。※ スナップショットの意味がなくなってしまいます。

■ 各サーバ( yum client ) のリポジトリ定義ファイル

例えば、[update] リポジトリの定義ファイルは、 下記の様にしています。


$ cat /etc/yum.repos.d/CentOS-update.repo
[update]
name=CentOS - Updates
gpgcheck=1
gpgkey=http://p-repo01/CentOS/RPM-GPG-KEY-CentOS-5
enabled=1
mirrorlist=http://p-repo01/cgi-bin/repo.cgi?arch=$basearch&repo=updates
priority=1
$



■ 各サーバが参照するリポジトリのリビジョンの対応表( repo_map.conf )

各サーバは mirrolist に指定されたURLにアクセスし、返されたURLからパッケージを取得しますが、この時アクセスされる repo.cgi で、各サーバに返す URL を設定ファイルを元に決めます。
設定ファイルは下記のようになっています。

■ repo_map.conf

$ cat repo_map.conf
# Production
p-web01 r5.4-02
※ <省略> 実際は p-web は 01-100 までの100台あるとする。
p-web100 r5.4-02
p-db01 r5.4-01
p-db02 r5.4-01

# Testing
t-web01 r5.4-02
t-web02 r5.4-02
t-db01 r5.4-01

# Development
d-web01 r5.4-02
d-db01 r5.4-01
d-test 5 # 常に最新を参照


■ 対応表を元に サーバーに応じた リポジトリの URL を返す CGI スクリプト

最後は、repo_map.conf を参照して実際のリポジトリの URL を返す CGI( reoo.cgi ) です。
アクセスしてきたクライアントのIPアドレス( @cgi.remote_addr ) からホスト名を得て、それを reop_map.conf と照合してバージョンを決めています。
このような方法以外にも、クライアントが query parameter としてホスト名を渡す方法も考えましたが、その場合、repo.cgi に hostname を渡すために、yum が用意している YUM0~9 の環境変数を使用する必要があります。
YUM0 等に値を常に設定しておくには、シェルの環境設定ファイルを変更する必要がありますし、puppetd(agent) のような yum を使用する daemon の起動スクリプトも変更する必要があり、面倒なので止めました。
大量にサーバーがある状況では、変更しないでよい設定ファイルはなるべくそのままで使用したいので。

※ 下記の CGI では上記説明に反して、hostname query parameter が渡された場合は、IPアドレスよりも優先して使用し、リポジトリのURLを判断しています。
※ これは cobbler での自動インストールの過程でもローカルミラーを参照しているのですが、自動インストールの過程ではIPアドレスは DHCP で払い出しており、IPアドレスから一意にホスト名を特定できない為、機能として残してあります。

■ repo.cgi

#!/usr/bin/ruby
require 'cgi'
require 'resolv'

#---------------------------------------------
# Setting
#---------------------------------------------
CONF_FILE = File.dirname(File.expand_path(__FILE__)) + "/repo_map.conf"
REPO_LIST=%w(extras centosplus install addons fasttrack contrib updates os)

#---------------------------------------------
# Method
#---------------------------------------------
# 設定ファイルを読み込み、{ 'hostname' => 'version',... } 形式のハッシュを返す。
def load_config(file)
h = {}
File.open(file).each do |line|
line.chomp!
next if line =~ /(^\s*$)|(^\s*#)/
hostname, repo_revision = line.split.values_at(0,1)
h[hostname] = repo_revision
end
h
end

# hostname query parameter があればそれを採用、なければIPアドレスからホスト名を解決する。
def hostname(addr)
result = if not @cgi['hostname'].empty?
@cgi['hostname']
else
Resolv::Hosts.new.getnames(addr).pop
end
result
end

#---------------------------------------------
# Main
#---------------------------------------------
REPO_SERVER = "p-repo01"
revision_table = load_config CONF_FILE

@cgi = CGI.new
print @cgi.header("type" => "text/plain", "charset" => "UTF-8")

hostname = hostname @cgi.remote_addr
arch = @cgi['arch']
repo = @cgi['repo']

unless REPO_LIST.include?(repo)
puts "Bad repo - not in list - #{REPO_LIST.join(" ")}"
exit
end

unless revision_table.has_key?(hostname)
puts "your hostname #{hostname} not found"
exit
end

repo_revision = revision_table[hostname]

puts "http://#{REPO_SERVER}/CentOS/#{repo_revision}/#{repo}/#{arch}"


■ 実運用
最後に、アップデート の 具体例を紹介します。
web サーバ群を 最新のバージョンにアップデートする場合を考えます。
web サーバ群は現在 開発、テスト、本番環境全て、r5.4-02 のリポジトリを参照しています。
最新の 5.5 からリポジトリのスナップショットを作成します。


cp -al 5.5 r5.5-r01


1 アップデートサイクル開始

開発環境の d-web01 をまず update する。
repo_map.conf を編集する。
ログイして手動で yum update後、色々確認する。

2. 問題なければ t-web01, t-web02 を上げる。

数が少ないので手動で update。

3. 問題なければ本番を update する。

repo_map.conf を編集し、p-web01, 02 のみを r5.5-01へ上げる。深夜に自動アップデート。
数日様子を見て問題なければ、p-web03-50 までを r5.5-01 へ上げる( repo_map.conf を編集 )。深夜に自動アップデート。
問題なければ残りを全て上げる。

■ 最後に
なるべく具体的な内容を書こうとしたつもりですが、「 深夜にアップデートするのは怖くないか?」とか、「 kernel update があった時の reboot が抜けてるやないか 」みたいなツッコミどころもあるかと思います。
この辺の具体的な運用は環境によりけりです。
実際にこの仕組を適用する場合は、リビジョン名のつけ方や、repo_map.conf のファイルのフォーマットは環境に応じて工夫してみて下さい。

■ 参考にしたURL
[CentOS] local centos repository upgrade from 5.3 to 5.4
Yum: Caching remote data for multiple computers