試行錯誤のおと

日々の試行錯誤した結果です。失敗することが多い記録、それだけでっす!

unbound の prefetch 動作が複雑で難しい

DNS による名前解決のオーバヘッドを解決するために DNS キャッシュサーバを導入しても、その上流や権威サーバに対して問合せが頻繁にある場合、応答の遅延が発生する状況があります。 DNS による名前解決がボトルネックとなっている環境のチューニングについて考えるとき、キャッシュサーバのキャッシュにあるレコードの TTL が切れる前にキャッシュサーバが自動的にレコードを更新してくれる機能があれば解決しそうです。

多くのプロバイダでフルリゾルバとして導入されている unbound には prefetch の機能があります。 prefetch という名前からして、キャッシュにあるレコードの TTL が切れる前に unbound 自ら自動的にレコードを更新してくれそうです。

prefetch: <yes or no> (1.4.2-)
    yesのときには、キャッシュを最新に保つために期限切れにする前に、メッセージ キャッシュの要素がプリフェッチされます。デフォルトはnoです。有効にすると、マシンに約10パーセントのトラフィックと負荷を与えますが、人気のある項目はキャッシュから期限切れになりません。

unbound.conf(5) – 日本Unboundユーザー会 には上記の通り、設定項目の説明が書かれています。しかし、ドキュメントから実際の挙動については読み取れません。 prefetch の機能を検討する際に、検証からソースコードリーディングを行ったので、そのまとめを記事にまとめました。検証した unbound のバージョンは 1.6.4 です。 結論だけ知りたい方は最後のまとめだけを読んでいただければと思います。

unbound のクエリ処理と prefetch が行われる条件

prefetch を有効にした unbound に対して、クエリを投げたとき、クエリに対する応答がキャッシュされ、レスポンスが返されます。 キャッシュされた応答のレコードが TTL を迎えても unbound はそのレコードを prefetch してキャッシュを更新しようとしません。 どのタイミングで prefetch されるかは unbound のソースコードを入り口から追っていくと実装が理解できます。

以下は unbound の worker の動作を示したコードであり、unbound はクエリを受け取ると以下の worker_handle_request() 関数にしたがってクエリを処理します。

daemon/worker.c:990

int 
worker_handle_request(struct comm_point* c, void* arg, int error,
    struct comm_reply* repinfo)
{
...
lookup_cache:
    /* Lookup the cache.  In case we chase an intermediate CNAME chain
    * this is a two-pass operation, and lookup_qinfo is different for
    * each pass.  We should still pass the original qinfo to
    * answer_from_cache(), however, since it's used to build the reply. */
    if(!edns_bypass_cache_stage(edns.opt_list, &worker->env)) {
        h = query_info_hash(lookup_qinfo, sldns_buffer_read_u16_at(c->buffer, 2));
        if((e=slabhash_lookup(worker->env.msg_cache, h, lookup_qinfo, 0))) {
            /* answer from cache - we have acquired a readlock on it */
            if(answer_from_cache(worker, &qinfo, 
                cinfo, &need_drop, &alias_rrset, &partial_rep,
                (struct reply_info*)e->data, 
                *(uint16_t*)(void *)sldns_buffer_begin(c->buffer), 
                sldns_buffer_read_u16_at(c->buffer, 2), repinfo, 
                &edns)) {
                /* prefetch it if the prefetch TTL expired.
                * Note that if there is more than one pass
                * its qname must be that used for cache
                * lookup. */
                if((worker->env.cfg->prefetch || worker->env.cfg->serve_expired)
                    && *worker->env.now >=
                    ((struct reply_info*)e->data)->prefetch_ttl) {
                    time_t leeway = ((struct reply_info*)e->
                        data)->ttl - *worker->env.now;
                    if(((struct reply_info*)e->data)->ttl
                        < *worker->env.now)
                        leeway = 0;
                    lock_rw_unlock(&e->lock);
                    reply_and_prefetch(worker, lookup_qinfo,
                        sldns_buffer_read_u16_at(c->buffer, 2),
                        repinfo, leeway);
                    if(!partial_rep) {
                        rc = 0;
                        regional_free_all(worker->scratchpad);
                        goto send_reply_rc;
                    }
                } else if(!partial_rep) {
                    lock_rw_unlock(&e->lock);
                    regional_free_all(worker->scratchpad);
                    goto send_reply;
                }
                /* We've found a partial reply ending with an
                * alias.  Replace the lookup qinfo for the
                * alias target and lookup the cache again to
                * (possibly) complete the reply.  As we're
                * passing the "base" reply, there will be no
                * more alias chasing. */
                lock_rw_unlock(&e->lock);
                memset(&qinfo_tmp, 0, sizeof(qinfo_tmp));
                get_cname_target(alias_rrset, &qinfo_tmp.qname,
                    &qinfo_tmp.qname_len);
                if(!qinfo_tmp.qname) {
                    log_err("unexpected: invalid answer alias");
                    regional_free_all(worker->scratchpad);
                    return 0; /* drop query */
                }
                qinfo_tmp.qtype = qinfo.qtype;
                qinfo_tmp.qclass = qinfo.qclass;
                lookup_qinfo = &qinfo_tmp;
                goto lookup_cache;
            }
            verbose(VERB_ALGO, "answer from the cache failed");
            lock_rw_unlock(&e->lock);
        }
        if(!LDNS_RD_WIRE(sldns_buffer_begin(c->buffer))) {
            if(answer_norec_from_cache(worker, &qinfo,
                *(uint16_t*)(void *)sldns_buffer_begin(c->buffer), 
                sldns_buffer_read_u16_at(c->buffer, 2), repinfo, 
                &edns)) {
                regional_free_all(worker->scratchpad);
                goto send_reply;
            }
            verbose(VERB_ALGO, "answer norec from cache -- "
                "need to validate or not primed");
        }
    }

この関数中の以下の箇所には、 prefetch が有効 (または serve_expired が有効) かつ現在の時間がキャッシュされたレコードの prefetch_ttl 以上の場合という条件の if 文があります。

                if((worker->env.cfg->prefetch || worker->env.cfg->serve_expired)
                    && *worker->env.now >=
                    ((struct reply_info*)e->data)->prefetch_ttl) {

if 文の上のコメントにはコメントがあり、コメントには prefetch_ttl が切れたタイミングで prefetch が行われると書かれています。 if 文中には reply_and_prefetch() 関数があり、この関数で prefetch の処理が行われていそうです。

prefetch の処理は後で追うとして、 prefetch_ttl がどのように付与されるか見ていきます。

unbound は DNS キャッシュサーバであるため、 受け取ったクエリに対して unbound 内にキャッシュがあればキャッシュをそのままクライアントに対して返すのですが、初回の問合せでは unbound 内にキャッシュは存在せず、設定に記述された stub-zone や forward-zone などのルールにしたがってクエリを処理し、応答をクライアントに返します。その際、次回から同一のクエリをクライアントから受け取ったときにキャッシュを参照して応答を返せるようレコードをキャッシュしますが、 prefetch が有効であると prefetch_ttl を付加してレコードをキャッシュします。

prefetch_ttl がどう計算されているかは worker_handle_request() の下部のキャッシュが存在しない場合の処理である mesh_new_client() 関数を追って読んでいくとよいでしょう。

 /* grab a work request structure for this new request */
    mesh_new_client(worker->env.mesh, &qinfo, cinfo,
        sldns_buffer_read_u16_at(c->buffer, 2),
        &edns, repinfo, *(uint16_t*)(void *)sldns_buffer_begin(c->buffer));

prefetch_ttl が実際に計算され処理はクライアントに対する応答を構築している以下の箇所です。

./util/data/msgreply.c:426:

 rep->prefetch_ttl = PREFETCH_TTL_CALC(rep->ttl);

PREFETCH_TTL_CALC マクロは以下で定義されています。 ./util/data/msgreply.h:63:

#define PREFETCH_TTL_CALC(ttl) ((ttl) - (ttl)/10)

この式から計算すると例えば TTL が 60 の mackerel.io のようなレコードの場合、 prefetch_ttl は 54 秒になります。

実際に prefetch の機能を有効にした unbound で検証した結果は以下です。

$ dig @127.0.0.1 mackerel.io A; sleep 54; dig @127.0.0.1 mackerel.io A; sleep 6; dig @127.0.0.1 mackerel.io A
# 1 回目のクエリ
...
;; ANSWER SECTION:
mackerel.io.        60 IN  A   52.69.239.167
...
# 2 回目のクエリ
...
;; ANSWER SECTION:
mackerel.io.        6  IN  A   52.69.239.167
...
# 3 回目のクエリ
...
;; ANSWER SECTION:
mackerel.io.        54 IN  A   13.113.31.156
...

2 回目のクエリが返された後のタイミングで prefetch が行われたように見えます。このとき、 tcpdump したり unbound の verbosity を 5 にすると、 unbound がキャッシュされた応答をクライアントに返した後に prefetch している様子がわかります。

unbound のログ

[1503937596] unbound[15412:0] info: incoming scrubbed packet: ;; ->>HEADER<<- opcode: QUERY, rcode: NOERROR, id: 0                                                                                                                                             
;; flags: qr aa ; QUERY: 1, ANSWER: 1, AUTHORITY: 4, ADDITIONAL: 0                                                                                                                               
;; QUESTION SECTION:                                                                                                                                                                                                                                           
mackerel.io.    IN      A                                                                                                                                                                        
                                                                                                                                                                                                                                                               
;; ANSWER SECTION:                                                                                                                                                                               
mackerel.io.    60      IN      A       52.69.239.167                           
...
[1503937650] unbound[15412:0] info: incoming scrubbed packet: ;; ->>HEADER<<- opcode: QUERY, rcode: NOERROR, id: 0                                                                                                                                             
;; flags: qr aa ; QUERY: 1, ANSWER: 1, AUTHORITY: 4, ADDITIONAL: 0                                                                                                                                                                                             
;; QUESTION SECTION:                                                                                                                                                                                                                                           
mackerel.io.    IN      A                                                                                                                                                                                                                                      
                                                                                                                                                                                                                                                               
;; ANSWER SECTION:                                                                                                                                                                                                                                             
mackerel.io.    60      IN      A       13.113.31.156                                                                                                                                
...    

prefetch は キャッシュのTTL の残りが 10% のときから、キャッシュが切れるまでの間にクエリを受信したときに行われることがわかりました。

prefetch はどのようにして行われるのか

prefetch がどのようにトリガされるかは先の検証とコードリーディングでわかりました。残りでは、もう少し prefetch の挙動を深堀りします。prefetch の具体的な中身について、先に取り上げた reply_and_prefetch() 関数を見ていきます。

reply_and_prefetch() 関数はユーザにクエリに対する応答を返し、統計情報の prefetch のカウントをインクリメントした後に、 mesh_new_prefetch() 関数をコールします。mesh_new_prefetch() は mesh と呼ばれる unbound のジョブキューのような構造体に prefetch のジョブを作成、更新する関数です。最後の引数の leeway + PREFETCH_EXPIRY_ADD は残りの TTL + 60 (定数) の計算であり、キューにこの値を入れ込みます。

/daemon/worker.c:764

/** Reply to client and perform prefetch to keep cache up to date.
 * If the buffer for the reply is empty, it indicates that only prefetch is
 * necessary and the reply should be suppressed (because it's dropped or
 * being deferred). */
static void
reply_and_prefetch(struct worker* worker, struct query_info* qinfo,
        uint16_t flags, struct comm_reply* repinfo, time_t leeway)
{
        /* first send answer to client to keep its latency 
         * as small as a cachereply */
        if(sldns_buffer_limit(repinfo->c->buffer) != 0)
                comm_point_send_reply(repinfo);
        server_stats_prefetch(&worker->stats, worker);
        
        /* create the prefetch in the mesh as a normal lookup without
         * client addrs waiting, which has the cache blacklisted (to bypass
         * the cache and go to the network for the data). */
        /* this (potentially) runs the mesh for the new query */
        mesh_new_prefetch(worker->env.mesh, qinfo, flags, leeway +
                PREFETCH_EXPIRY_ADD);
}

mesh_new_prefetch() の最後の mesh_run() でモジュールによって mesh に格納されているキューのクエリを実行します。 iterator モジュールであれば、iterator/iterator.c:3174 にて定義されている process_request() によって通常のクエリと同様に処理されます。

services/mesh.c:536

void mesh_new_prefetch(struct mesh_area* mesh, struct query_info* qinfo,
        uint16_t qflags, time_t leeway)
{
        struct mesh_state* s = mesh_area_find(mesh, NULL, qinfo,
                qflags&(BIT_RD|BIT_CD), 0, 0);
#ifdef UNBOUND_DEBUG
        struct rbnode_type* n;
#endif
        /* already exists, and for a different purpose perhaps.
         * if mesh_no_list, keep it that way. */
        if(s) {
                /* make it ignore the cache from now on */
                if(!s->s.blacklist)
                        sock_list_insert(&s->s.blacklist, NULL, 0, s->s.region);
                if(s->s.prefetch_leeway < leeway)
                        s->s.prefetch_leeway = leeway;
                return;
        }
        if(!mesh_make_new_space(mesh, NULL)) {
                verbose(VERB_ALGO, "Too many queries. dropped prefetch.");
                mesh->stats_dropped ++;
                return;
        }

        s = mesh_state_create(mesh->env, qinfo, NULL,
                qflags&(BIT_RD|BIT_CD), 0, 0);
        if(!s) {
                log_err("prefetch mesh_state_create: out of memory");
                return;
        }
#ifdef UNBOUND_DEBUG
        n =
#else
       (void)
#endif
        rbtree_insert(&mesh->all, &s->node);
        log_assert(n != NULL);
        /* set detached (it is now) */
        mesh->num_detached_states++;
        /* make it ignore the cache */
        sock_list_insert(&s->s.blacklist, NULL, 0, s->s.region);
        s->s.prefetch_leeway = leeway;
        
    if(s->list_select == mesh_no_list) {
        /* move to either the forever or the jostle_list */
        if(mesh->num_forever_states < mesh->max_forever_states) {
            mesh->num_forever_states ++;
            mesh_list_insert(s, &mesh->forever_first, 
                &mesh->forever_last);
            s->list_select = mesh_forever_list;
        } else {
            mesh_list_insert(s, &mesh->jostle_first, 
                &mesh->jostle_last);
            s->list_select = mesh_jostle_list;
        }
    }
    mesh_run(mesh, s, module_event_new, NULL);

prefetch_leeway は最終的には iter_dns_store() によってキャッシュとして格納されます。

iterator/iterator.c:2236

static int
processQueryResponse(struct module_qstate* qstate, struct iter_qstate* iq,
    int id)
{
...
        if(!qstate->no_cache_store)
            iter_dns_store(qstate->env, &iq->response->qinfo,
                iq->response->rep, 0, qstate->prefetch_leeway,
                iq->dp&&iq->dp->has_parent_side_NS,
                qstate->region, qstate->query_flags);

iterator/iter_utils.c:503

void
iter_dns_store(struct module_env* env, struct query_info* msgqinf,
    struct reply_info* msgrep, int is_referral, time_t leeway, int pside,
    struct regional* region, uint16_t flags)
{
    if(!dns_cache_store(env, msgqinf, msgrep, is_referral, leeway,
        pside, region, flags))
        log_err("out of memory: cannot store data in cache");
}

関数の説明によると、 prefetch_leeway は prefetch 中の TTL の更新にかかる待ち時間を表すもののようです。これにより複数レコードのセットを持つレコードのタイムアウトを早くに行うことができるようです(具体的な動作を追うのは飽きてあきらめました)。

services/cache/dns.h:60

 * @param leeway: during prefetch how much leeway to update TTLs.
 *  This makes rrsets (other than type NS) timeout sooner so they get
 *  updated with a new full TTL.
 *  Type NS does not get this, because it must not be refreshed from the
 *  child domain, but keep counting down properly.

まとめ

  • prefetch を有効にしても unbound が TTL が切れるタイミングで自動的にキャッシュのレコードを更新するわけではない
  • TTL が残り 10% から TTL が切れるタイミングでクエリを受信したときに prefetch が行われる。 (10% はハードコート)
  • 負荷が 10% 向上すると書かれているのは、TTL が切れる 10% 前のタイミングでクエリを受信し続けるワーストケース
  • prefetch_leeway は特に prefetch 自体できにしなくてよさそう

Conky で Github Grass Graph を rsvg で描画する

Linux Desktop を使っていない人や興味のない人にはほとんど旨味のない記事です。

毎日コードを書こうと思っていても、モチベーションを維持するのは難しいことが多いなと最近思ってる。 Github の Grass Graph を必然的に見るようにしたかったので、 conky に表示させてみた。

簡単に書くとシェルスクリプトと imlib で画像表示するだけで終わる。

Github の Grass Graph を取得して png に変換するスクリプト

#!/bin/sh
set -eu

ID="kizkoh"
curl https://github.com/${ID} | awk '/<svg.+class="js-calendar-graph-svg"/,/svg>/' > grass.svg
convert grass.svg grass.png

.conkyrc のサンプル

own_window_type conky
${image ./grass.png -p 0,0 -s 470x120 -f 3600}

ただ、ハックに物足りなさを感じたので、ちょっと Lua で頑張ってみた。

使用しているディストリで Conky の Lua rsvg モジュールを有効にする。 Lua rsvg は /usr/lib/conky/librsvg.so に入るので有効かどうかはここを見るとわかるみたい。 Conky のモジュールとしてインストールされるけど、 librsvg に依存しているだけなので普通に Lua からも使える。 Gentoo だと app-admin/conkylua-rsvg USE フラグを有効にしてビルドすると、 Lua rsvg が入る。

Lua rsvg はドキュメントがないので、ソースコードを読んで関数の役割を把握する。 といっても toluapp でバインディングが作成されているだけなので、関数を参照して rsvg 本家のドキュメントと照らし合わせればよいので簡単。 (最初は Lua 知識ゼロ だったので readelf でシンボルを読んでいた><)

それで書いたコードは以下のリンク。Cairo で描画しているので動作には Cairo モジュールも必要。

手元の Conky はこんな感じで表示されている。

f:id:kizkoh:20170620215925p:plain

出来上がったコードは Github に置いてある。
dotfiles/github_grass.lua at master · kizkoh/dotfiles · GitHub

いつも目がいくところ (Google Calendar とかの予定を表示させたりしている) にあるので、コードを書かないとなと気づきを与えてくれたり、芝生を育てるのが目的になってコードを書くモチベーションを維持できるようになったのでよい。 (最近うまくいってなくて説得力がないのが玉に瑕)

一つ欠点があって .conkyrc を書き換えたり、 SIGUSR1 をプロセスに送って設定をリロードさせると以下のようなエラーが発生する。

conky: received SIGHUP or SIGUSR1. reloading the config file.
conky: Syntax error (/home/kizkoh/.conkyrc:2: '=' expected near 'conky') while reading config file. 
conky: Assuming it's in old syntax and attempting conversion.
(process:15389): GLib-GObject-WARNING **: cannot register existing type 'RsvgHandle'

(process:15389): GLib-CRITICAL **: g_once_init_leave: assertion 'result != 0' failed

こうなると SIGKILL を送るしかなくてすごく面倒。はじめは Lua の書き方が悪いのかと思っていて調べていたけれど、それほど困ってはいなかった。けれどもどうしてこのようなエラーが発生するのかは気になっていたので時間が空いたときに調べていくと Conky の問題だと分かるようになってきた。

話題がすっかり変わりそうなのと深入りして長くなりそうなので、この記事はひとまずこのへんにして問題の調査の過程は次回の記事に書くとします。

私的 CloudFormation ベストプラクティス

最近 CloudFormation を触っていて、よくある初期構築のベストプラクティスについて意見がほしいので自分の考える CloudFormation の設計や使い方についての考えを書いた。

CloudFormation のメリット

CloudFormation を利用するためメリットはリソースの参照を簡単に記述できることと、べき等性の保証だと思っている。

以前、 EC2 インスタンスのプロビジョニングを Ansible で書いたことがある。

べき等性を確保したく、 サブネットの作成、 EBS のマウント、アンマウント、 EIP の付け替えなどの変更操作を Ansible だけで操作したい + その後のプロビジョニングの操作も Ansible にお任せしたいということを考えたときに、 Ansible の YAML ではなく以下のような Python のコードを書いた。

# ansible_playbook は data に playbook のデータを取ってタスクを実行する関数
data = [
    {
        'connection': 'local',
        'tasks': [
            {'ec2_vpc_subnet': {
                'vpc_id': 'vpc-xxxxxxxx',
                'state': 'present',
                'az': 'ap-northeast-1a',
                'cidr': '10.0.0.0/24',
                'resource_tags': {
                    'Name': 'jessie'
                }
            }, 'name': 'create subnet'}],
        'hosts': 'localhost'
    }
]
ansible_playbook(data=data)

subnets = get_all_subnets(filters={"vpcId": vpc_id,
                                     "tag:Name": "jessie"})
subnet_id = subnets[0].id

data = [
    {
        'connection': 'local',
        'tasks': [
            {
                'ec2':
                {
                    'assign_public_ip': 'yes',
                    'count': 1,
                    'ebs_optimized': False,
                    'image': 'ami-dbc0bcbc',
                    'instance_type': "t2.micro",
                    'key_name': 'kizkoh',
                    'monitoring': 'no',
                    'placement_group': False,
                    'private_ip': '10.0.0.4',
                    'tenancy': 'default',
                    'vpc_subnet_id': subnet_id,
                    'wait': 'yes'
                },
                'name': 'run jessie'
            }
        ],
        'hosts': 'localhost'
    }
]

ansible_playbook(data=data)

こんなコードを書いたのは Ansible の Playbook では YAML で EC2 API の実行の応答結果を扱えないためだ。 たとえば、インスタンスを作成した時に得られるインスタンス ID を引数に他の API を実行することができない。

CloudFormation はテンプレートと呼ばれるファイルに記述することでそれぞれのマネージドサービスのプロパティを記述することができる。 マネージドサービスのプロパティでは他のマネージドサービスのプロパティを参照できる。 先の Python のコードを CloudFormation のテンプレートに書き換えると以下のようになる。

AWSTemplateFormatVersion: 2010-09-09
Description: jessie stack
Resources:
  Subnet:
    Type: "AWS::EC2::Subnet"
    Properties:
      AvailabilityZone: ap-northeast-1a
      CidrBlock: 10.0.0.0/24
      Tags:
        - Key: Name
          Value: jessie
      VpcId:
        Ref: vpc-xxxxxxxx
  Instance:
    Type: "AWS::EC2::Instance"
    Properties:
      EbsOptimized: False
      ImageId: ami-dbc0bcbc
      InstanceType: t2.micro
      KeyName: kizkoh
      Monitoring: False
      NetworkInterfaces: 
        - AssociatePublicIpAddress: "true"
          DeviceIndex: "0"
          SubnetId: 
            Ref: !Ref Subnet
      Tenancy: default

依存関係ごとにスタックを分けて、記述するのが個人的なベストプラクティスだと思っている。 特に依存関係がないものはマネージドサービスごとにスタックを分けていて、以下のようなフォルダ構成を取っている。

$ tree
.
├── cloudtrail
│   ├── parameters
│   │   ├── production.json
│   │   └── staging.json
│   └── template.yaml
├── iam
│   └── template.yaml
├── README.md
...

ステージング環境と本番環境で一部のパラメタを変えて API の引数を変えているのでオペレーションを環境間でそのまま適用できるのも CloudFormation の魅力だと思っている。 スタックの動作について環境間の差分としてパラメータ以外は変えることはできず If 文がサポートされていないのもそういった用途のマッチしていて、環境間で整合性をとるための方法としてユースケースを限定してくれているのがよいなという印象を持った。

リソースの参照以外にも最新の API への対応がすぐにされるあたりは公式ならではだと思う。 Terraform などのサードパーティ製ツールを使うとどうしても API への対応が遅れることがあるので、公式がサポートしている恩恵も大きい。 CloudFormer を使えば YAML を書かなくてもテンプレート化できるので、深くドキュメントを読み込む必要がないところもよいと感じた。

テンプレートフォーマットの選択

テンプレートフォーマットに YAML がサポートされるまではテンプレートは JSON で記述するか、サードパーティ製モジュールの SparkleFormation, kumogata, CoffeeFormation 等を用いて独自の DSL で記述するといった選択肢があった。 これらはナンセンスだと思っていて、 JSON を使って記述するとフォーマットとしての可読性はそこそこ高いのだけどコメントが書けない致命的な問題、サードパーティ製モジュールの DSL を利用するとサードパーティモジュールへの依存や、公式ドキュメントのサンプルを参照しにくい問題につまずいた。 YAML がサポートされるようになってからはこれらの問題が解決されて、 YAML が選択肢として良いと思っている。

しかし、 YAML を採用するとなると新たに考慮すべき点がある。例えば、ポリシードキュメントの記法を考慮しないといけない。 IAM グループを作成するときのテンプレートを、すべて YAML で記述すると以下のようになる。

...
  Groupadmin:
    Type: "AWS::IAM::Group"
    Properties:
      GroupName: admin
      Policies:
        - PolicyName: AllAllow
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action: "*"
                Resource: "*"
...

しかし、ポリシードキュメント部分は以下のようにして JSON (文字列扱い) で書くことができる。

...
  Groupadmin:
    Type: "AWS::IAM::Group"
    Properties:
      GroupName: admin
      Policies:
        - PolicyName: AllAllow
          PolicyDocument: |
            {
              "Statement": [
                {
                  "Effect": "Allow",
                  "Action": "*",
                  "Resource": "*"
                }
              ]
            }
...

どちらが見やすいかという議論ではなくて、ポリシードキュメントは AWS のシステム上では JSON で記述される。 そのため、ポリシードキュメントのドキュメントを見ても、 JSON のサンプルはあるものの YAML のサンプルはない。 また、変更したい時にマネジメントコンソール上で validate して確認した後にテンプレートに貼り付けることで Stack 更新時のエラーにハマることなく更新できる。 なので、自分はポリシードキュメント部分に関しては JSON で記述するようにしている。

どこまでを CloudFormation で扱うか

今回、 CloudFormation のテンプレートを書いていて困ったのは一部の API が扱えないこと。 例えば、 EC2 の KeyPair の登録や CloudFormation 以外で作成したリソースのプロパティの取得ができない。

EC2 の KeyPair の登録は AWS アカウントごとあるいはリージョンごとに繰り返す作業なので、 CloudFormation にまとめて定型化したい。 特に CloudFormation が API をサポートしてくれない理由はないと思うだけど、 CloudFormation が対応しない API に対しては無理に CloudFormation で対応するのではなく別のツールを使うのが良いと思う。 外部のリソースに関してはパラメータに埋め込むのがよいと思う。 テンプレートを動的に生成するような対応を取ると、リソースの依存解決が CloudFormation 外に依存することや環境ごとにテンプレートが異なる可能性 (この環境ではリソースを作成するが別の環境では作成しない等の If 文のような場合分け) が発生する。 テンプレートの生成次第でなにもかもできるようになってしまうと、結局のところ冒頭で書いた Ansible の Python コードと何ら変わりはないので、CloudFormation は CloudFormation がサポートする範囲内でオペレーションを収めたい。 AWS に CloudFormation の API のサポートを要望を出していって、それまでのつなぎとして使うには動的テンプレート生成はありかなと思うけれども、避けられるようなら避けたい課題かなと思う。

まとめ

CloudFormation の個人的に思っているベストプラクティス知見について書いた。もっとこうしたほうがいいとか、知見がほしい。