CloudFormationとOpsWorksでインフラを育てる

こんにちは。インフラエンジニアの光野です。 弊社ではネットワークの構築と管理にAWS CloudFormationAWS OpsWorksを導入しました。 本記事では、その効果と導入に際しての工夫について紹介いたします。

目次

  1. Before / After
  2. 効果
  3. CloudFormarionとは
    • CloudFormation or Terraform
  4. OpsWorksとは
    • SSH/sudo管理
  5. CloudFormationとOpsWorksの役割分担
  6. CloudFormationテンプレートの分離方針
  7. OpsWorksマルチレイヤーによるインスタンスの管理
  8. OpsWorksでのdry-runとdiff
  9. YAML版CloudFormationでOpsWorks(Chef)を定義する場合の注意点
  10. まとめ

Before / After

CloudFormationとOpsWorksを導入するまで、弊社のネットワーク管理は以下のようになっていました。 f:id:vasilyjp:20170831223922p:plain

ネットワークの構築やChefの実行は全て手作業です。これがCloudFormationとOpsWorksの導入後は以下のようになりました。

f:id:vasilyjp:20170831223939p:plain GitHubにpushすると、syntax checkが走ります。masterにマージされるとCloudFormationにchange setが作成されるため、変更内容を確認して反映させます。 各インスタンスの設定はOpsWorksで管理されており、任意のタイミングでChef実行の依頼を行います。リソース変更やChefコマンドの実行を手作業で行うことはありません。

なおchange setとはこれからどのリソースがどのように変更されるか(中断なし・一時的に中断・置換)を事前に知ることができるものです。 f:id:vasilyjp:20170831131145p:plain 意図しない変更を避けるために最後だけは人の目による変更承認フローを挟むようにしています。

効果

CloudFormationとOpsWorks導入で大きく3つの効果がありました。

  1. 再利用性の向上
  2. ノウハウの蓄積
  3. 履歴管理

再利用性の向上

OpsWorksごとCloudFormationを使って定義することで、ネットワークとインスタンスの構成管理が一枚のテキストで完結するようになりました。

Webコンソールが気を利かせてくれていた部分も含めてテキストで記述するので、イニシャルコストは大きいですが一度作ってしまえばその後は楽ができます。 アプリケーションは案件ごとに変わっても、ネットワーク要素は定形であることが多く、初期構築の時間を別のことへ回せるようになりました。

ノウハウの蓄積

感覚値として、EC2インスタンスを立てるくらいであれば手作業の方が若干早いです。 f:id:vasilyjp:20170831224040p:plain 上で挙げた通り、複製が簡単。初期構築のハードルが下がる。というのは事実ですが、CloudFormationに作業時間の短縮を期待していると意外に裏切られます。

導入によって得られる効果は、ノウハウの蓄積です。RDSやElastiCacheのパラメータグループのように、設定を忘れると後で痛い目をみる項目も全てテキストで管理されます。そのため、別の誰かへノウハウを伝えるということができるようになりました。

また、後で悩みがちなセキュリティグループやIAMのルールについては積極的にコメントで補足しています。

履歴管理

CloudFormation・OpsWorks共に実行履歴がすべて記録されます。これまではいつどの何を変更したのかを思い出すのに苦労していましたが、今は記録が残るので重宝しています。

CloudFormationとは

AWS CloudFormationは、AWSリソースをJSON/YAMLで表現したテンプレートを元に、その構成を自動で構築してくれるサービスです。2016年9月のアップデートでYAMLに対応し可読性と保守性が高まりました。

以下のYAMLは「VPC」「Subnet」「RouteTable」を作って関連付けるサンプルテンプレートです。テンプレートを元に作られるAWSリソースのグループをスタックと呼びます。

---
AWSTemplateFormatVersion: 2010-09-09
Parameters:
  VPCCidrBlock:
    Type: String
    Default: '10.0.0.0/16'
  PublicFirstCidrBlock:
    Type: String
    Default: '10.0.0.0/24'
Resources:
  # VPCを作成
  EC2VPC:
    Type: 'AWS::EC2::VPC'
    Properties:
      # [NOTE] 作成時にCidrBlockを指定することでテンプレートを使いまわすことができる
      CidrBlock: !Ref VPCCidrBlock
      EnableDnsSupport: true
      EnableDnsHostnames: true
      InstanceTenancy: 'default'
      # [NOTE] 組み込み変数を参照して定形の名前をつける
      Tags:
        - Key: 'Name'
          Value: !Sub '${AWS::StackName}-vpc'
  # RouteTableを作成
  EC2RouteTablePublic:
    Type: "AWS::EC2::RouteTable"
    Properties:
      VpcId: !Ref EC2VPC
      Tags:
        - Key: 'Name'
          Value: !Sub '${AWS::StackName}-route-table-public'

  # Subnetを一つ作成
  EC2SubnetPublicFirst:
    Type: "AWS::EC2::Subnet"
    Properties:
      # [NOTE] スタックを作るリージョンの0番目のAZにSubnetを作る。東京ならaz-a
      AvailabilityZone:
        Fn::Select:
          - 0
          - Fn::GetAZs: !Ref 'AWS::Region'
      CidrBlock: !Ref PublicFirstCidrBlock
      MapPublicIpOnLaunch: true
      Tags:
        - Key: 'Name'
          Value: !Sub '${AWS::StackName}-public-subnet-first'
      VpcId: !Ref EC2VPC

  # SubnetとRouteTableの対応付け
  EC2SubnetRouteTableAssociationPublicFirst:
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties:
      RouteTableId: !Ref EC2RouteTablePublic
      SubnetId: !Ref EC2SubnetPublicFirst

CloudFormation Designerというテンプレートを視覚的に表示・編集できるツールもあります。これは上のYAMLを読み込ませた例です。 f:id:vasilyjp:20170831131238p:plain

CloudFormation or Terraform

ネットワークの構成管理というとTerraform by HashiCorpが有名です。弊社でも導入前に検討を行いました。

CloudFormation Terraform
記述言語 JSON/YAML 独自のDSL
dryrun・変更内容の確認 ある(変更セット) ある(plan)
新機能への追従 数か月遅れ コミュニティ次第(自力でも何とかできる)
対応するサービス AWS 様々

Terraformが登場した2014年頃は「CloudFormationではできないことができる」という印象がありましたが、この数年でその差もだいぶ無くなりました。

最終的に、CloudFormation採用の決め手としたのはdryrunの安心感です。ドキュメントを読むとプロパティ1つずつに変更の反映方法が記載されています。 VPCを作成するというテンプレートを例にすると、CidrBlockInstanceTenancyは変更すると「置換」という更新が行われます。この場合、新しいリソースが作成されたあと、古いリソースが削除されます。副作用のある更新が事前にわかるのは大変重要です。

  EC2VPC:
    Type: 'AWS::EC2::VPC'
    Properties:
      CidrBlock: !Ref VPCCidrBlock # 置換
      EnableDnsSupport: true       # 中断なし
      EnableDnsHostnames: true     # 中断なし
      InstanceTenancy: 'default'   # 置換
      Tags:                        # 中断なし
        - Key: 'Name'
          Value: !Sub '${AWS::StackName}-vpc'

cf. AWSEC2VPC - AWS CloudFormation

一方で、CloudFormationはAWSの新機能への追従がそれほど速くありません。Regional WAFへの対応は1.5か月ほどでしたがこれは比較的早いという印象です。 cf. AWS WAF on ALB with Cloudformation - AWS Developer Forums

特に告知があるわけでもないため、新機能から数ヶ月が経ち、ふとドキュメントを見ると追加されているといったことがあります。

OpsWorksとは

AWS OpsWorksスタックとAWS OpsWorks for Chef Automateの2つがあり、弊社で利用しているのは前者です。

Stack > Layer >Instancesという階層構造でインスタンスを管理します。 f:id:vasilyjp:20170831224115p:plain

OpsWorksは管理下のインスタンスに対してChefを実行することで構成管理を行います。Chefの実行対象は1インタンスからスタック全台同時まで自由に選べます。 f:id:vasilyjp:20170831131342p:plain

また同一スタック間に所属するインスタンス間では/etc/hostsが常に同期されておりSSHするときに大変便利です。

$ hostname
gateway1
$ cat /etc/hosts
# This file was generated by OpsWorks
# any manual changes will be removed on the next update.

# OpsWorks Layer State
127.0.0.1 localhost.localdomain localhost
127.0.1.1 gateway1.localdomain gateway1

<private ip> gateway1.localdomain gateway1
<public ip> gateway1.localdomain-ext gateway1-ext
<private ip> web1.localdomain web1
<private ip> web2.localdomain web2

SSH/sudo管理

OpsWorksが提供するのはChefの実行だけではありません。OSがLinuxであれば、IAMユーザとLinuxのユーザを紐付けて管理することができます。

公開鍵を登録すると、IAMユーザに対応したSSHユーザがスタック単位で作られます。 f:id:vasilyjp:20170831131452p:plain

あとは、チェックボックスでSSH許可・sudo許可を選択するだけでそのユーザがインスタンス側に作成されます。 f:id:vasilyjp:20170831131519p:plain

CloudFormationとOpsWorksの役割分担

CloudFormationでもヘルパースクリプトを使うと、インスタンスに対してパッケージのインストールなどを行うことができます。

しかし、継続的に運用するのであれば、diffを見たりdry-runをしたり何が起こるのかを事前に把握してから変更を加えたくなります。そのため、インスタンス内の構成管理は全てOpsWorks(Chef)に集約する形を取りました。OpsWorks自体の設定はCloudFormationで行えるため、いずれの場合でも設定が散逸することはありません。

  OpsWorksLayerMemcached:
    Type: "AWS::OpsWorks::Layer"
    Properties:
      # ...
      CustomRecipes:
        Setup:
          - 'memcached'
      CustomJson:
        memcached:
          cachesize:
            6554
          listen: 0.0.0.0
          maxobjsize: 1m
      # ...

CloudFormationでOpsWorksレイヤーと、Chefレシピに渡すattributesを定義しています。

CloudFormationテンプレートの分離方針

CloudFormationを使う上で悩ましいのは、テンプレートをどのように分離するかということです。 同一テンプレート内であれば、組み込み関数を使って互いを参照することが可能です。

f:id:vasilyjp:20170831224139p:plain そのため、可能な限り1つのテンプレートでリソース定義を行う方が依存を解決しやすくなります。 一方で、1つのテンプレートに全てを記述すると再利用性を損ねる可能性があります。たとえば個人ごとのIAMユーザは、あるアカウント内で1つにしておきたいものです。

そこで、IAMなどをまとめたテンプレートとVPCを基準に分離するテンプレートの2つに分けて定義することにしました。 便宜上、前者をインフラテンプレート、後者をサービステンプレートと呼んでいます。

f:id:vasilyjp:20170831224559p:plain 個人ごとのIAMユーザやグループ、ACMの証明書やCloudTrailはインフラテンプレートにまとめます。 一方、VPCやS3、EC2インスタンスやIAMインスタンスプロファイルはサービステンプレートにまとめます。 開発に伴ってステージング環境や負荷検証用の環境が欲しい場合は、プロパティを変えつつ同じサービステンプレートからスタックを量産します。

また、この時インフラテンプレートで宣言した要素はクロススタック参照を使って、サービステンプレートで参照させています。

# インフラテンプレート
Resources:
  # ...
  IAMRoleRdsEnhancedMonitoring:
    Type: "AWS::IAM::Role"
    Properties:
      # ...
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole'
# ...
Outputs:
  OutputIAMRoleRdsEnhancedMonitoringArn:
    Value: !GetAtt IAMRoleRdsEnhancedMonitoring.Arn
    Export:
      Name: !Sub "${AWS::StackName}-iam-role-rds-enhanced-monitoring-arn"

# サービステンプレート
Parameters:
  IAMRoleRDSEnhancedMonitoringArn:
    Type: String
    Default: '<stack name>-iam-role-rds-enhanced-monitoring-arn'
Resources:
  # ...
  RDSDBInstance:
    Type: "AWS::RDS::DBInstance"
    Properties:
      # ...
      MonitoringRoleArn:
        Fn::ImportValue: !Ref IAMRoleRDSEnhancedMonitoringArn # インフラテンプレートの要素を参照

参照先をべた書きせず組み込みのRef関数で解決したいというのが主な理由ですが、不用意な要素の削除を禁止するという狙いがあります。 クロススタック参照で参照された値は、すべての参照が消えるまで要素の削除が禁止されます。

この例の場合だと、サービステンプレート中でIAMRoleRdsEnhancedMonitoringArnが消えない限り、インフラテンプレートからIAMRoleRdsEnhancedMonitoringを消すことができません。

OpsWorksマルチレイヤーによるインスタンスの管理

OpsWorksでは1つのインスタンスを複数のレイヤーに所属させることが可能です。弊社では、これを積極的に使っており共通の設定をまとめたレイヤーを作成し、全てのインスタンスは必ずそこへ所属させています。この共通設定をまとめたレイヤーは便宜上コモンレイヤーと表記します。

コモンレイヤーの目的は3つです。

  • Chefの記述をDRYに行うため
  • レイヤー同士の関係性を明らかにするため
  • CloudWatch Logsの設定を簡易にするため

Chefの記述をDRYに行うため

元々弊社では、設定をDRYに行うことを目的にChefのロールに親子関係を持たせて管理していました。

base configure # カーネルパラメータや監視設定
 -> API configure # Rubyやnginxの設定
   -> サービスAのAPI configure # ホスト名やサービス依存の設定
   -> サービスBのAPI configure

同様のことをOpsWorksで実現するため、base configureに相当する部分をコモンレイヤーの設定にまとめ、全インスタンスを所属させています。

レイヤー同士の関係性を明らかにするため

次に、レイヤー同士の関係を明らかにするためです。これはOpsWorksにすることで前々からの課題が解決されました。 例えば、APIとBATCHで同じ親を持っているとします。

f:id:vasilyjp:20170831224654p:plain 2階層であれば憶えられますが、階層が深くなってくると思わぬところで知らない依存ができている可能性があります。 OpsWorksではデプロイ画面でインスタンスを選択すると所属するすべてのレイヤーでチェックがつくため、これが視覚的に分かりやすくなりました。 f:id:vasilyjp:20170831131842p:plain

ただし、OpsWorksのレイヤー自体に親子関係という考え方はありません。親と子に相当するレイヤーをインスタンスに割り当てる部分は、自らで管理する必要があります。後から変更することもできますが、インスタンスが再起動するので気軽には行なえません。

CloudWatch Logsの設定を簡易にするため

最後はCloudWatch Logsとの連携を簡易にするためです。

OpsWorksではレイヤー単位で任意のログを指定して、CloudWatch Logsへ集約することができます。

f:id:vasilyjp:20170831131857p:plain これはレイヤー単位で設定するものなので、「全台のauth.logを回収したい」という時は、全レイヤーにその設定をして回る必要があります。この項目はCloudFormationで設定できないので手作業です。コモンレイヤーがあれば、一度設定してやるだけで全台で回収してくれます。

OpsWorksとdryrunとdiff

OpsWorksによるChefの実行は大変便利なのですが、2つ難点があります。

  • dryrun(ChefではWhy-runと呼ぶ)ができないこと
  • 変更の差分が見えないこと

実行ログにこういったdiffも出力されないため、成功しても何がどう変わったのかがわかりません。とても不安です。 image.png

そこでdryrunとdiffが利用できるようにChefレシピを書いてコモンレイヤーで読み込むことにしました。

dryrun

こちらは簡単です。

Chef::Config[:why_run] = node['opsworks_helper']['whyrun']

Chef::Config[:why_run]trueの時、Chefはdryrunモードになります。 OpsWorksはChef実行時に追加でattributesを渡せるので、そこにtrue/falseを入れてやります。

{
  "opsworks_helper": {
    "whyrun": true
  }
}

diff

こちらは少し面倒です。 結論から言えば、OpsWorksが使うChefラッパーを書き換えることで解決します。

以下が、実際に使っているパッチです。

--- chef_command_wrapper.sh.org  2017-07-26 19:39:01.000000000 +0900
+++ chef_command_wrapper.sh   2017-07-26 19:48:45.000000000 +0900
@@ -1,4 +1,4 @@
-#! /bin/bash
+#!/bin/bash
 
 # Wrapper for chef to catch STDOUT and STDERR into a file,
 # necessary because this is run with sudo
@@ -92,9 +92,8 @@
     echo -e "$LOG_LINE_TO_PREPEND" >> "$CHEF_LOG_FILE"
 fi
 
-exec &> >(tee -a "$CHEF_LOG_FILE") 2>&1
-RUBYOPT="$_RUBYOPT" "$CHEF_CMD" -j "$JSON_FILE" -c "$CHEF_CONFIG" $RUN_LIST
-CHEF_RETURN_CODE=$?
+RUBYOPT="$_RUBYOPT" "$CHEF_CMD" -j "$JSON_FILE" --format doc -l warn -c "$CHEF_CONFIG" $RUN_LIST 2>&1 | tee -a "$CHEF_LOG_FILE"
+CHEF_RETURN_CODE=${PIPESTATUS[0]}
 
 if [ -n "$LOG_LINE_TO_APPEND" ]
 then

重要なのは、execの代わりにパイプを使うことと、PIPESTATUSを使う部分です。

OpsWorksがChefを実行する場合、chef_command_wrapper.shを使います。このスクリプトの実体は/opt/aws/opsworks/current/bin/chef_command_wrapper.shにあり、Chefを実行する直前に標準出力をexecでteeに流しています。ここのexecがdiffを闇に葬っています。これを回避するため上のパッチではexecを消しパイプでログを受け取るよう変更します。

そして、パイプにしたことで$?ではChefのExitコードが受け取れなくなりました。OpsWorksはCHEF_RETURN_CODEで成功・失敗を判断しているため、常に成功扱いになってしまいます。そのためPIPESTATUSを使ってパイプで繋いだChefのExitコードを取得するようにします。

このパッチもコモンレイヤーのChefレシピに含まれており、サーバ起動後2回目のChef実行からdiffが有効になります。1回目の実行はまっさらなインスタンスが初期化されるだけなので問題になりません。

YAML版CloudFormationでOpsWorks(Chef)を定義する場合の注意点

YAMLが使えるようになったことで、人間的な設定ファイルを書くことができるようになりました。 ただし、YAMLを使ってCloudFormationでOpsWorks(Chef)を設定する場合は注意する必要があります。 attributesがすべて文字列になってしまうという点です。

例えばmackere-agentが勝手に起動しないよう、レシピでstart_on_setupを与えたいと思います。

  OpsWorksLayerCommon:
    Type: "AWS::OpsWorks::Layer"
      # ...
      CustomJson:
        mackerel-agent:
          start_on_setup: false

しかしこれは期待通りに動作しません。JSONとして次のように評価されます。

{
  "mackerel-agent": {
    "start_on_setup": "false"
  }
}

falseが文字列です。レシピの中で"false"は真の扱いとなり、start_on_setup=trueになってしまいます。これは他のレシピでも起こりえます。 そのため、場合によってはYAMLの中でJSONを記述してやる必要があります。

  OpsWorksLayerCommon:
    Type: "AWS::OpsWorks::Layer"
      # ...
      CustomJson: |
        {
          "mackerel-agent": {
            "start_on_setup": false
          }
        }

まとめ

弊社ではネットワークの構築と管理にAWS CloudFormationとAWS OpsWorksを導入しました。 その結果、手作業で行っていた作業の大部分がなくなり、作業履歴の記録やノウハウの蓄積が可能になりました。

また、CloudFormationとOpsWorksを使ってより効率的に管理するためには幾つかの工夫が必要でした。 本記事が利用を検討している方の一助となれば幸いです。

さいごに

弊社では既存の枠組みにとらわれず、理想の形に向かって挑戦できるインフラエンジニアを大募集しています。 興味をもっていただけましたら、是非Wantedlyからご応募お願いいたします。