めもめも

このブログに記載の内容は個人の見解であり、必ずしも所属組織の立場、戦略、意見を代表するものではありません。

アプリケーション環境構築の自動化をまじめに考えてみる(1)

2012/09/16 追記
本記事で紹介しているツール「virt-construct.py」は、本記事の公開後にもろもろアップデートしています。最新バージョンについては、こちらの記事を参照してください。

師宣わく

「環境管理の鍵は環境構築を完全に自動化されたプロセスで行うことにある。環境を新しく作るほうが古い環境を直すよりも常に安上がりであるべきなのだ。」
---『継続的デリバリー』 Jez Humble, David Farley.

すばらしい。本当にこれが実現できれば、作りに作りこんだ仮想マシンの巨大なディスクイメージファイルを後生大事に貯めこむ必要はもうなくなります。誰かが勝手に設定変更して動かなくなったサーバを前に呪いの言葉を吐き続けることもなくなります。

IaaSが普及して、OS環境は5分で用意できるようになりましたが、その上のアプケーション環境を今までどおりに手作業で構築、メンテナンスしていてはクラウドのメリットも半減です。そろそろ「クラウド環境ならではのアプリケーションデプロイメント手法」がまじめに議論されてもいい気がします。(もちろん、まじめに議論している方々もたくさんいると思いますが、まだまだ世間の注目を集めるエリアにはなっていないという事です。)

ということで、個人的には、Aeolusみたいなツールも試しているのですが、それなりに仕掛けが大きいので、いろいろな思いつきをぱぱっと試してみるにはちと大変です。(これはこれで面白いツールなのですが。もちろん。)

で、もうちょっとプロトタイプ的に簡単に試行錯誤できる環境を作ってみよう!ということで、自動化スクリプトを駆使して、手元のRHEL/KVMサーバ1台で類似のことを実現することにチャレンジしてみることにしました。

事前の考察

アプリケーションを自動インストールするだけなら、ばりばりスクリプトをかけばいいだけですが、実運用を前提にすると考慮点がいくつか出てきます。まず、自動化する対象は、次の3つに分けて考えます。

(1) OSのインストールと初期設定
(2) アプリケーションのインストールと環境固有設定のリストア
(3) アプリケーションデータのリストア

(1)については既存のIaaSでまかなえる部分もありますが、既存のIaaSの場合は、

・手作業で作りこんだVMをイメージ化して保存しておき、それをコピーすることで自動構築する

という運用が多く、結局、イメージ化したものの中身がいまどうなっているのかだんだん分からなくなる、あたらしくイメージを作ろうとすると手作業が再発生する、などの問題が起きてきます。(1)についても「メタデータ」から完全に同じ環境を何度でも自動構築できるようにするべきだと考えています。あたらしいイメージを作る際は、「メタデータ」を修正して、「構築ボタン」を押すだけになっているべきです。

つぎに(2)については、環境固有の設定をどう保存/リストアするかが肝になりそうです。アプリケーションのインストールそのものは単純に自動化できても、その後の環境固有の設定は、いろいろなバリエーションがでてきます。さすがにこの部分は、運用中に手作業でチューニングする可能性もあります。

そこで、「設定ファイル」をバージョン管理できる環境を用意することにします。漠然としたイメージですが、アプリケーションの設定一式がGithubに登録されてバージョン管理されているとしましょう。設定変更した際は、即座にそれをcommitしておけば、後から他のマシンで同じ設定を再現することも簡単です。設定がまずければ、以前の状態に戻ることもコマンド一つです。

最後の(3)については・・・・。すいません。ここはまだ頭が回りきっていないので、まずは、(1)(2)を作ってから考えていきます。

OSインストールと初期設定の自動化

この部分は、RHEL/KVM環境であれば、virt-installとkickstartの組み合わせでOKでしょう。この際、virt-installの長いオプション指定やらks.cfgの準備やらが面倒なので、1つの設定ファイルで全部まかなえる自動インストールツールを作ってしまいます。

・・・

できました。

※RHEL6にEPELからpython-argparseを追加した環境で検証しています。

virt-construct.py

#!/usr/bin/python
# -*- coding: utf-8 -*-
#
#   virt-construct.py: Automation tool for virt-install and kickstart
#
#   2012/08/22 ver1.0
#
# Copyright (C) 2012 Etsuji Nakai
# 
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import os, sys, re, tempfile, stat, time, argparse
import libvirt

class Config():
    def __init__( self ):
        # Instance variables
        self.virtInstallOpts = ""
        self.ksContentsList = []

    def parse( self, configFile ):
        configList = configFile.readlines()
        variableDict = self.__parseVariables( configList )
        self.virtInstallOpts = self.__parseVirtInstOpts(
                                            configList, variableDict )
        self.ksContentsList = self.__parseKickstart(
                                            configList, variableDict )

    def __replaceVariables( self, source, variableDict ):
        for variable in variableDict:
            source = source.replace(
                    '${' + variable + '}', variableDict[ variable ] )
        return source

    def __parseKickstart( self, configList, variableDict ):
        _ksContentsList = []
        sectionRe = re.compile( '^\[\S*\]' )
        kickstartSectionRe = re.compile( '^\[kickstart\]' )
        pos = 'pre'
        for line in configList:
            line = line.strip()   # We don't cut comment
            if pos == 'pre':
                if kickstartSectionRe.match( line ): pos = 'in'
                continue
            if pos == 'in':
                if sectionRe.match( line ): break # next section
                line = line.replace( '\[', '[' )   # Escaped section tag
                _ksContentsList.append(
                    self.__replaceVariables( line, variableDict ) + '\n' )
                continue
        return _ksContentsList

    def __parseVirtInstOpts( self, configList, variableDict ):
        _virtInstallOpts = ""
        sectionRe = re.compile( '^\[\S*\]' )
        vartInstSectionRe = re.compile( '^\[virt-install\]' )
        pos = 'pre'
        for line in configList:
            line = line.strip().split( '#' )[0]   # Cut comment
            if pos == 'pre':
                if vartInstSectionRe.match( line ): pos = 'in'
                continue
            if pos == 'in':
                if sectionRe.match( line ): break # next section
                _virtInstallOpts = ' '.join( ( _virtInstallOpts, line ) )
                continue
        return self.__replaceVariables( _virtInstallOpts, variableDict )

    def __parseVariables( self, configList ):
        _variableDict = {}
        sectionRe = re.compile( '^\[\S*\]' )
        variablesSectionRe = re.compile( '^\[variables\]' )
        nameAndVariableRe = re.compile( '^(\S+)\s*=\s*(\S+)\s*$' )
        pos = 'pre'
        for line in configList:
            line = line.strip().split( '#' )[0]   # Cut comment
            if pos == 'pre':
                if variablesSectionRe.match( line ): pos = 'in'
                continue
            if pos == 'in':
                if sectionRe.match( line ): break # next section
                pair = nameAndVariableRe.search( line )
                if pair: _variableDict[ pair.group( 1 ) ] = pair.group( 2 )
                continue
        return _variableDict

def parseArgs():
    parser = argparse.ArgumentParser(
        description='Build KVM virtual machine' )
    parser.add_argument( '-c', '--conf', type=argparse.FileType('r'),
        default=sys.stdin, help='Config file' )
    parser.add_argument( '-d', '--ksDir',
        default='/var/www/html/ks', help='directory to place ks.cfg' )
    parser.add_argument( '-b', '--ksBaseurl',
        default='http://192.168.122.1/ks', help='baseurl for ks.cfg' )
    args = parser.parse_args()
    return args 

def parseOpts():
    args = parseArgs()
    ksBaseurl = args.ksBaseurl.rstrip( '/' )
    ksDir = args.ksDir.rstrip( '/' )
    config = Config()
    config.parse( args.conf )
    return ( ksBaseurl, ksDir, config.virtInstallOpts, config.ksContentsList )

def checkVmStatus( vmName, status ):
    conn = libvirt.open( 'qemu:///system' )
    if status == 'running':
        runningVms = map( conn.lookupByID, conn.listDomainsID() )
        if vmName in [ vm.name() for vm in runningVms ]:
            return True
        else:
            return False

    if status == 'stopped':
        stoppedVms = conn.listDefinedDomains()
        if vmName in stoppedVms:
            return True
        else:
            return False

def waitVmStatus( vmName, status, timeoutSec ):
    count = timeoutSec
    while count > 0:
        print "Waiting %s to become %s (%d/%d)..." % (
                vmName, status, count, timeoutSec )
        if checkVmStatus( vmName, status ):
            print "Done."
            return True
        time.sleep( 10 )
        count -= 10
    return False

if __name__ == '__main__':
    __debug = 1
    __dryrun = 0

    ( ksBaseurl, ksDir, virtInstallOpts, ksContentsList ) = parseOpts()

    # create ks.cfg and start virt-install
    with tempfile.NamedTemporaryFile( dir=ksDir, mode='w+t' ) as ksFile:
        ksFile.writelines( ksContentsList )
        ksFile.flush()
        os.chmod( ksFile.name, 0644 )
        ksFile_url = ksFile.name.replace( ksDir, ksBaseurl )
        virtInstallCmd = ' '.join(
                    ( 'virt-install', virtInstallOpts,
                      '--noautoconsole', '--noreboot',
                      '--extra-args="ks=' + ksFile_url + '"' ) )
        if __debug:
            print "== ks.cfg contents"
            os.system( 'cat ' + ksFile.name )
            print "== virt-install command"
            print virtInstallCmd
        if not __dryrun:
            os.system( virtInstallCmd )

        vmNameRe = re.compile( '--name\s+(\S+)' )
        vmName = vmNameRe.search( virtInstallOpts ).group( 1 )
        if not waitVmStatus( vmName, 'running', timeoutSec=60 ):
            sys.exit( "Timeout to start installing vm %s." % vmName )
        if not waitVmStatus( vmName, 'stopped', timeoutSec=3600 ):
            sys.exit( "Timeout to finish installing vm %s." % vmName )

設定ファイルの例はこちら

vm01.conf

[variables]
#
# Generic variables referenced as ${variable}
#
vmname=vm01
hostname=vm01
diskpath=/var/lib/libvirt/images/vm01.img
ip=192.168.122.199

vcpus=2
ram=1024
disksize=8
network=network:default
os-variant=rhel6
url=http://my_repository_server.example.com/RHEL63/x86_64/
netmask=255.255.255.0
gateway=192.168.122.1
nameserver=192.168.122.1

# PostgreSQL deployment data
pgsql_deploy=http://192.168.122.1/scripts/deploy_pgsql.sh
pgsql_gitrepo=https://github.com/enakai00/pgsql_configs.git
pgsql_gittag=development_1.0

[virt-install]
#
# virt-install options except ks.cfg
# --noautoconsole --noreboot is automatically added
#
--name ${vmname}
--vcpus ${vcpus}
--ram ${ram}
--disk path=${diskpath},size=${disksize},sparse=false
--network ${network}
--os-variant ${os-variant}
--location ${url}

[kickstart]
#
# ks.cfg contents
#
url --url=${url}
lang ja_JP.UTF-8
keyboard jp106
network --onboot yes --device eth0 --bootproto static --ip ${ip} --netmask ${netmask} --gateway ${gateway} --nameserver ${nameserver} --hostname ${hostname}
rootpw passw0rd
timezone --isUtc Asia/Tokyo
bootloader --location=mbr
zerombr
clearpart --initlabel --drives=vda
part /boot --fstype=ext4 --size=500
part swap --size=1024
part / --fstype=ext4 --grow --size=200
reboot
%packages
@base
@core
@japanese-support
git	# mandatory to manage application configs
%end

%post --log=/root/anaconda-post.log
set -x
cat <<EOF > /etc/yum.repos.d/base.repo
\[baseos]
name="Repository for base OS"
baseurl=${url}
gpgcheck=0
enabled=1
EOF

## PostgreSQL deployment
#curl ${pgsql_deployscript} -o /tmp/tmp$$
#. /tmp/tmp$$ ${pgsql_gitrepo} ${pgsql_gittag}
#rm /tmp/tmp$$
%end

はじめの[variables]セクションは、設定ファイル内で利用できる一般的な変数を定義しています。このセクションで定義した「name」という変数の値で、他のセクションの「${name}」の部分が置換されます。ちょこっとした変更なら、この変数を書き換えればOKです。

つぎの[virt-install]セクションは、virt-installコマンドに与えるオプションです。kickstart用の設定と--noautoconsole(インストール中にコンソールを表示しない)、--noreboot(インストール後はVMを停止する)は自動的に付加されるので、ここには記載しません。

最後の[kickstart]は、ks.cfgの中身になります。「\[baseos]」という行の頭の「\」はTypoではありません。設定ファイルのセクション名と解釈されないためのエスケープ処理です。実際に使用されるks.cfgでは消えます。最後のコメントアウトされている部分(「PostgreSQL deployment」以下)は、この後で、アプリケーションのデプロイメントまで自動化する際に使用します。ここでは無視してください。(要は外部のWebサーバから設定スクリプトを取ってきて実行するわけですが。。。引数にGitRepoとTagを指定しているあたりで何か感じますね。あなた。)

これを利用する前提の環境はこんな感じです。

・RHEL6.xのKVMホスト
・デフォルトの仮想ネットワーク「default」が生きている
・KVMホスト上でhttpdが動いていて、/var/www/html/ks/以下(ks.cfg置き場)が公開されている
・インストール用のレポジトリは外部のWebサーバに用意されている(変数「url」で指定)

実行例はこんな感じ。

$ sudo ./virt-construct.py -c vm01.conf 
== ks.cfg contents
#
# ks.cfg contents
#
url --url=http://binaries.nrt.redhat.com/contents/RHEL/6/3/x86_64/default/
lang ja_JP.UTF-8
keyboard jp106
network --onboot yes --device eth0 --bootproto static --ip 192.168.122.199 --netmask 255.255.255.0 --gateway 192.168.122.1 --nameserver 192.168.122.1 --hostname vm01
rootpw passw0rd
timezone --isUtc Asia/Tokyo
bootloader --location=mbr
zerombr
clearpart --initlabel --drives=vda
part /boot --fstype=ext4 --size=500
part swap --size=1024
part / --fstype=ext4 --grow --size=200
reboot
%packages
@base
@core
@japanese-support
git	# mandatory to manage application configs
%end

%post --log=/root/anaconda-post.log
set -x
cat <<EOF > /etc/yum.repos.d/base.repo
[baseos]
name="Repository for base OS"
baseurl=http://binaries.nrt.redhat.com/contents/RHEL/6/3/x86_64/default/
gpgcheck=0
enabled=1
EOF

## PostgreSQL deployment
#curl http://192.168.122.1/scripts/postgresql/deploy.sh -o /tmp/tmp$$
#. /tmp/tmp$$ https://github.com/enakai00/pgsql_configs.git v0
#rm /tmp/tmp$$

%end
== virt-install command
virt-install      --name vm01 --vcpus 2 --ram 1024 --disk path=/var/lib/libvirt/images/vm01.img,size=8,sparse=false --network network:default --os-variant rhel6 --location http://my_repository_server.example.com/RHEL63/x86_64/  --noautoconsole --noreboot --extra-args="ks=http://192.168.122.1/ks/tmpJi55ct"

Starting install...
ファイル vmlinuz を読出中...                     | 7.6 MB     00:00 ... 
ファイル initrd.img を読出中...                  |  58 MB     00:02 ... 
割り当て中 'vm01.img'                            | 8.0 GB     00:00     
ドメインを作成中...                              |    0 B     00:01     
Domain installation still in progress. You can reconnect to 
the console to complete the installation process.
Waiting vm01 to become running (60/60)...
Done.
Waiting vm01 to become stopped (3600/3600)...
Waiting vm01 to become stopped (3590/3600)...
(以下略)

スクリプト内の「__debug」フラグを立てているので、ks.cfgの内容と実行するvirt-installコマンドが表示されます。その後、実際にこのvirt-installコマンドが実行されてOSのインストールが行われます。インストールが完了してVMが停止すると、スクリプトも終了します。

とりあえずこれだけでも、いろんなバージョン/構成のOSが自由に自動構築できるので、結構便利ではないでしょうか? Linuxまわりの技術サポート系の仕事をしている方は、あらゆるバージョンのRHELをとっかえひっかえ用意していると思いますが、あらゆるバージョンのRHELのイメージをディスクに溜め込んだりしていませんか? 以前のテストで設定変更したことを忘れて、変な設定のまま違うテストをしてしまって陰鬱な気分になったことはありませんか? これがあれば、いつでも気分一新、新規インストール状態のRHELをさくっと用意することができますね。

次は、アプリケーションの導入と設定ファイルのバージョン管理に進みたいと思います。しばしお待ち下さい。。。。