はじめに
SRE1課の石井です。 最近WAFを大量作成する業務をboto3で進めてたのですが、途中からCloudFormationに浮気しました。
今回の記事は私がCloudFormationを作成したときに使ったjinja2にフォーカスして記事を書いてみます。
対象者
- jinja2の使い方を味見したい方
- WAFの大量作成をCloudFormationで実施したい方
- pythonをある程度理解している方
jinja2とは
python用のテンプレートエンジン、公式はこちら。
テンプレートとして記載したファイルに対して、pythonから変数を渡してファイルを生成します。
webページなど、似たような構成のページを作る際にjinja2で生成する時に使用する使い方がメジャーなようですが、私はansibleから入ったため、taskファイルやplaybookの大量生成する時のテクとして覚えました。
百聞は一見にしかずなので、簡単なサンプルを元に挙動を確認しましょう。
下記のサンプルはmdファイルのテンプレートにpython上で定義したdictを渡してどう出力されるか見てみましょう。
#fruits.j2 {%- for fruit in Fruits %} * {{ loop.index }} {{ fruit }} この果物について記事を書きます {%- for n in range(1, 4) %} * {{ fruit }}は果物 {%- endfor %} {%- endfor %}
#sample_gen.py import jinja2 fileSystemLoader = jinja2.FileSystemLoader(searchpath="./") env = jinja2.Environment(loader=fileSystemLoader) FDict={ 'Fruits': ['apple', 'grape' ] } template = env.get_template('fruits.j2') print(template.render(FDict))
上記sample_gen.pyとfruit.j2を同じディレクトリに保存してsample_gen.pyを実行すると下記が出力されます。
* 1 apple この果物について記事を書きます * appleは果物 * appleは果物 * appleは果物 * 2 grape この果物について記事を書きます * grapeは果物 * grapeは果物 * grapeは果物
fruit.j2にdictで渡した内容がmdのテンプレートに展開され、テンプレート内のforが作用して同じ記載内容を二回出力しているのがわかると思います。
尚、上記サンプルはdictをコード内で記載していますが、実際はymlファイルで別に出してあげると使いやすくなります。
CloudFormationのmapping関数じゃダメなの?
実は最初mapping関数で実現しようとしたのですが、mapping関数だと同じ内容を手動でコピペする量が多くなってしまったので断念しました。 また、メンテする際もpython上のymlのパラメータをいじるだけにしたいなーと思いjinja2を採用しました。
テンプレートの概要
今回作成したテンプレートは 以下の要件に沿って作成しています。
- 作成するWebAclは全て同じマネージドルールを付与する。
- マネージドルールは全てのルールをCountかBlockで実装する。
- IPSetsを用いてWAFでIPベースの接続元制限を実装する。
- IPSetsを使用した場合、信頼されているIPしか接続できないため、マネージドルールは付与しない。
- WAFのログは全てS3に保存する。
テンプレート作成
早速WAFを作成するCloudFormationのテンプレートを作ってみます。
ディレクトリ構成とパッケージインストール
下記のような感じでディレクトリとファイルを配置してください。
86ba369e39bc:/workspaces/CFn-generate# tree . ├── CFngenerate.py ├── conf │ └── data.yml ├── dist │ └── templates └── WAF.j2
jinja2とyamlを使うため下記のコマンドでパッケージをインストールします。
pip install Jinja2 pip install pyyaml
各種ファイルについて説明:
data.yml
- テンプレートに渡すdictが定義されたファイル。
WAF.j2
- 肝となるテンプレートファイル。
dist
- テンプレートから出力されるファイルを保存するディレクトリ
CFngenerate.py作成
まずはテンプレートを呼び出すpythonを作成します。
import jinja2 import yaml o_filepath='./dist/webacl-cfn.yml' i_filepath='./conf/data.yml' fileSystemLoader = jinja2.FileSystemLoader(searchpath="./templates") env = jinja2.Environment(loader=fileSystemLoader) with open(i_filepath) as file: data = yaml.safe_load(file) for i in data: template = env.get_template(i+'.j2') yml = template.render(data) print(yml) with open(o_filepath, "w", encoding='utf-8') as f: f.write(yml)
前段はファイルの場所の設定やyamlの読み込みを行なっています。 重要な部分は後段の次の2行です。
template = env.get_template(i+'.j2') #iはymlの最上位のkey要素(WAF)が入ります。
yml = template.render(data) #dataはyamlから読み込んだdict
実際にテンプレートに内容を貼り付けを行なっているのはtemplate.renderとなります。
data.yml作成
次に可変な部分となる要素のymlを作成します。
--- WAF: rules: - AWS-AWSManagedRulesCommonRuleSet webacls: - service_name: Web env: stg role: ecs number: 01 override: Null scope: REGIONAL s3location: arn:aws:s3:::aws-waf-logs-hoge - service_name: Web env: prod role: ecs number: 01 override: count scope: REGIONAL s3location: arn:aws:s3:::aws-waf-logs-fugo - service_name: Web env: prod role: ecs number: 02 override: count scope: REGIONAL s3location: arn:aws:s3:::aws-waf-logs-fugo IPSets: - 192.168.1.0/24 - 192.168.2.1/32 # - # service_name: Web # env: prod # role: ecs-cf # override: Null # scope: CLOUDFRONT
上記ymlの可変部分について説明します。 rules:各種webaclに付与するマネージドルールです。 続くwebaclsで定義する各webaclにrulesで定義した内容が紐づけられます。
- webacls:作成されるwebaclを記載します。
- service_name,env,role,numberは作成される名前に関係します。使用するプロジェクトに合わせて変更してください。
override:マネージドルールをカウント設定で投入するかを制御します。Null または countのどちらかを定義できます。(ymlでchoiceっぽい書き方知ってる方教えてください...)
- Null:マネージドルールをBLOCK状態で投入します。
- count:マネージドルールをCount状態で投入します。
scope:REGIONALまたはCLOUDFRONTを入力します。CLOUDFRONTはCloudFormationのリージョンをus-east-1に設定して実行してください。
s3location:WAFのログ保存する場所を指定してください。なお、WAFログを直接S3に出力する場合「aws-waf-logs-」のプレフィックスが必要です。
IPSets:WAFをIPベースで接続元を絞る場合に指定してください。なお、IPSetsを有効化した場合、マネージドルールは付与されません。
WAF.j2の作成
最後に今回の肝となるjinja2のファイルを記載します。
## Jinja2 Resources: {%- for webacl in WAF.webacls %} WAFv2WebACL{{ webacl.service_name| capitalize }}{{ webacl.env| capitalize }}{{ webacl.role| capitalize }}{{ '%02d' % webacl.number }}: Type: "AWS::WAFv2::WebACL" Properties: Name: webacl-{{ webacl.service_name }}-{{ webacl.env }}-{{ webacl.role }}-{{ '%02d' % webacl.number }} Description: webacl-{{ webacl.service_name }}-{{ webacl.env }}-{{ webacl.role }}-{{ '%02d' % webacl.number }} DefaultAction: {%- if webacl.IPSets %} Block: {} {%- else %} Allow: {} {%- endif %} VisibilityConfig: SampledRequestsEnabled: true CloudWatchMetricsEnabled: true MetricName: webacl-{{ webacl.service_name }}-{{ webacl.env }}-{{ webacl.role }}-{{ '%02d' % webacl.number }} Scope: {{ webacl.scope }} Rules: {%- if webacl.IPSets %} - Name: {{ webacl.service_name }}-{{ webacl.env }}-{{ webacl.role }}-{{ '%02d' % webacl.number }}_IPrestriction Priority: 0 Action: Allow: {} Statement: IPSetReferenceStatement: ARN: !GetAtt WAFv2IPSet{{ webacl.service_name| capitalize }}{{ webacl.env| capitalize }}{{ webacl.role| capitalize }}{{ '%02d' % webacl.number }}.Arn VisibilityConfig: SampledRequestsEnabled: true CloudWatchMetricsEnabled: true MetricName: {{ webacl.service_name }}-{{ webacl.env }}-{{ webacl.role }}-{{ '%02d' % webacl.number }}_IPrestriction {%- else %} {%- for rule in WAF.rules %} - Name: {{ rule }} Priority: {{ loop.index -1 }} OverrideAction: {%- if webacl.override %} Count: {} {%- else %} None: {} {%- endif %} Statement: ManagedRuleGroupStatement: VendorName: "AWS" Name: {{ rule | replace('AWS-','') }} VisibilityConfig: SampledRequestsEnabled: true CloudWatchMetricsEnabled: true MetricName: {{ rule }} {%- endfor %} {%- endif %} {%- if webacl.IPSets %} DependsOn: WAFv2IPSet{{ webacl.service_name| capitalize }}{{ webacl.env| capitalize }}{{ webacl.role| capitalize }}{{ '%02d' % webacl.number }} {%- endif %} WAFv2LoggingConfiguration{{ webacl.service_name| capitalize }}{{ webacl.env| capitalize }}{{ webacl.role| capitalize }}{{ '%02d' % webacl.number }}: Type: "AWS::WAFv2::LoggingConfiguration" Properties: ResourceArn: !GetAtt WAFv2WebACL{{ webacl.service_name| capitalize }}{{ webacl.env| capitalize }}{{ webacl.role| capitalize }}{{ '%02d' % webacl.number }}.Arn LogDestinationConfigs: - {{ webacl.s3location }} {%- if webacl.IPSets %} WAFv2IPSet{{ webacl.service_name| capitalize }}{{ webacl.env| capitalize }}{{ webacl.role| capitalize }}{{ '%02d' % webacl.number }}: Type: "AWS::WAFv2::IPSet" Properties: Name: ipsets-{{ webacl.service_name }}-{{ webacl.env }}-{{ webacl.role }}-{{ '%02d' % webacl.number }} Description: ipsets-{{ webacl.service_name }}-{{ webacl.env }}-{{ webacl.role }}-{{ '%02d' % webacl.number }} IPAddressVersion: "IPV4" Addresses: {%- for ipset in webacl.IPSets %} - {{ ipset }} {%- endfor %} Scope: "REGIONAL" {%- endif %} {%- endfor %} ##############################################################################################
かなりごつい内容になってしまっていますが、うっすらCloudFormationの内容がチラチラ見えると思います。 書いてある内容としては「テンプレートの概要」の項に書いた内容及び、「data.yml作成」の項で記載した内容に沿っています。
変数へのアクセスはマスタッシュ記法と呼ばれる形式で実施します。 マスタッシュ記法は {{ hoge }} といった記載で実施し、サンプルではdata.ymlの要素に{{ webacl.role }}といった記載でアクセスしています。
ただし、下記はjinja2特有の書き方のため記載しておきます。
- capitalize:パイプで渡された単語の頭文字を大文字に変換。
- replace:パイプで渡された単語を置換。
- loop.index:for実行中のインデックス番号。
- '%02d' % webacl.number:数字を二桁に整えます。pythonの書き方ですが、jinja2でもそのまま使えます。
実行
CFngenerate.pyを実行してみましょう。 うまくいけばdistディレクトリ配下に「webacl-cfn.yml」というファイルができているはずです。 以下は出力結果のサンプルです。
## Jinja2 Resources: WAFv2WebACLWebStgEcs01: Type: "AWS::WAFv2::WebACL" Properties: Name: webacl-Web-stg-ecs-01 Description: webacl-Web-stg-ecs-01 DefaultAction: Allow: {} VisibilityConfig: SampledRequestsEnabled: true CloudWatchMetricsEnabled: true MetricName: webacl-Web-stg-ecs-01 Scope: REGIONAL Rules: - Name: AWS-AWSManagedRulesCommonRuleSet Priority: 0 OverrideAction: None: {} Statement: ManagedRuleGroupStatement: VendorName: "AWS" Name: AWSManagedRulesCommonRuleSet VisibilityConfig: SampledRequestsEnabled: true CloudWatchMetricsEnabled: true MetricName: AWS-AWSManagedRulesCommonRuleSet WAFv2LoggingConfigurationWebStgEcs01: Type: "AWS::WAFv2::LoggingConfiguration" Properties: ResourceArn: !GetAtt WAFv2WebACLWebStgEcs01.Arn LogDestinationConfigs: - arn:aws:s3:::aws-waf-logs-hoge WAFv2WebACLWebProdEcs01: Type: "AWS::WAFv2::WebACL" Properties: Name: webacl-Web-prod-ecs-01 Description: webacl-Web-prod-ecs-01 DefaultAction: Allow: {} VisibilityConfig: SampledRequestsEnabled: true CloudWatchMetricsEnabled: true MetricName: webacl-Web-prod-ecs-01 Scope: REGIONAL Rules: - Name: AWS-AWSManagedRulesCommonRuleSet Priority: 0 OverrideAction: Count: {} Statement: ManagedRuleGroupStatement: VendorName: "AWS" Name: AWSManagedRulesCommonRuleSet VisibilityConfig: SampledRequestsEnabled: true CloudWatchMetricsEnabled: true MetricName: AWS-AWSManagedRulesCommonRuleSet WAFv2LoggingConfigurationWebProdEcs01: Type: "AWS::WAFv2::LoggingConfiguration" Properties: ResourceArn: !GetAtt WAFv2WebACLWebProdEcs01.Arn LogDestinationConfigs: - arn:aws:s3:::aws-waf-logs-fugo WAFv2WebACLWebProdEcs02: Type: "AWS::WAFv2::WebACL" Properties: Name: webacl-Web-prod-ecs-02 Description: webacl-Web-prod-ecs-02 DefaultAction: Block: {} VisibilityConfig: SampledRequestsEnabled: true CloudWatchMetricsEnabled: true MetricName: webacl-Web-prod-ecs-02 Scope: REGIONAL Rules: - Name: Web-prod-ecs-02_IPrestriction Priority: 0 Action: Allow: {} Statement: IPSetReferenceStatement: ARN: !GetAtt WAFv2IPSetWebProdEcs02.Arn VisibilityConfig: SampledRequestsEnabled: true CloudWatchMetricsEnabled: true MetricName: Web-prod-ecs-02_IPrestriction DependsOn: WAFv2IPSetWebProdEcs02 WAFv2LoggingConfigurationWebProdEcs02: Type: "AWS::WAFv2::LoggingConfiguration" Properties: ResourceArn: !GetAtt WAFv2WebACLWebProdEcs02.Arn LogDestinationConfigs: - arn:aws:s3:::aws-waf-logs-fugo WAFv2IPSetWebProdEcs02: Type: "AWS::WAFv2::IPSet" Properties: Name: ipsets-Web-prod-ecs-02 Description: ipsets-Web-prod-ecs-02 IPAddressVersion: "IPV4" Addresses: - 192.168.1.0/24 - 192.168.2.1/32 Scope: "REGIONAL" ##############################################################################################
WAF以外のリソースの追加
WAF以外のリソースもjinja2で生成する場合、data.ymlの最上位のkeyに(WAFと同じレイヤー)に新しいリソース名(例えばEC2とか)を記載し、templatesディレクトリに対応するテンプレートを作成すれば同じ要領で作成出来ます。
最後に
個人的にIPSetsのテンプレートは別ファイルでよかったかなと思ってます。 テンプレートにforやifをたくさん記載すると、j2ファイルが難解になってきて、今回のサンプルも結構読みづらくなってしまいました。
そのため、文字整形やテンプレートに渡すdictは基本的にpython側で完結していた方が断然おすすめです。
また、jinja2ではデバッグでステップ実行や、変数のウォッチが出来ないためrenderで渡す変数はシンプルにして、テンプレート内で辺に変数を組み立てて使わない方がデバッグもやりやすいです。
懺悔
今回、この仕組みでどれくらい楽になったかというと・・・手動でやるより作業ミスは無いよね、ぐらいの嬉しさしかないです。
今回実施したWAF大量作成の業務は単純にリソースを大量作成すればいいだけの話ではなかったため、boto3でもCloudFormationでも、どの手法を採用してもキツかったと思います。
作業の段取りと情報整理で結構時間使ったので、やはりリソースの情報のアクセスのしやすさは大事だなぁとしみじみと思いました。
追加情報
PE部の橋本さんから情報もらったのですが bashにenvsubstというコマンドがあり、このコマンドもjinja2と同じ働きをしてくれるそうです。 bashだけで完結するなら、envsubstの方がお手軽かなと思っており、次回はこのenvsubstで記事書きたいなと思ってます。