Kubernetes上のアプリケーションログを自動収集する

image.png

TL;DR;

新サービスや既存サービスをKubernetesに移行するたびに、ログの収集設定のためインフラエンジニア待ちになってしまうのは面倒ですよね。
そこで、アプリのログをFluentdとDatadog LogsやStackdriver Loggingで自動的に収集する方法を紹介します。

主に以下のOSSを利用します。

今回はDatadog Logsを使いますが、Stackdriver Loggingを使う場合でもUIやAPIクレデンシャル等の設定以外は同じです。

お急ぎの方へ: アプリ側の設定手順

標準出力・標準エラーログを出力するだけでOKです。

参考: The Twelve-Factor App (日本語訳)

詳しくは、この記事の「サンプルアプリからログを出力する」以降を読んでください。

あとはクラスタ側に用意しておいたFluentdの仕事ですが、Kubernetesがノード上に特定のフォーマットで保存するため、アプリ毎の特別な設定は不要です。

まえおき1: なぜDatadogやStackdriver Loggingなのか

分散ロギングのインフラを準備・運用するのがつらい

分散ロギングと一口にいっても、実現したいことは様々です。例えば、多数のサービス、サーバ、プロセス、コンテナから出力されたログを

  1. 分析などの用途で使いやすいようにETLしてRedshiftのようなデータウェアハウスに投入しておきたい
  2. S3などのオブジェクトストレージに低コストでアーカイブしたい
  3. ほぼリアルタイムでストリーミングしたり、絞込検索したい
    • Web UI、CLIなど

1.はtd-agent + TreasureData or BigQuery、2.はfluentd (+ Kinesis Streams) + S3、3.はfilebeat or Logstash + Elasticsearch + Kibana、Graylog2、専用のSaaSなど、ざっとあげられるだけでも多数の選択肢があります。

方法はともかく、できるだけ運用保守の手間を省いて、コアな開発に集中したいですよね。

メトリクス、トレース、ログを一つのサービスで一元管理したい・運用工数を節約したい

「Kubernetesにデプロイしたアプリケーションのメトリクスを自動収集する – Qiita」でも書きましたが、例えばKubernetesの分散ロギング、分散トレーシング、モニタリングをOSSで実現すると以下のような構成が定番だと思います。

  • 基本的なグラフ作成とメトリクス収集、アラート設定はPrometheus
  • 分散ログはEKF(Elasticsearch + Kibana Fluentd)
  • 分散トレースはZipkinやJaeger

もちろん、ソフトウェアライセンス費用・サポート費用、将来の拡張性などの意味では良い判断だと思います。

一方で、

  • アラートを受けたときに、その原因調査のために3つもサービスを行ったり来たりするのは面倒
  • 人が少ない場合に、セルフホストしてるサービスの運用保守に手間をかけたくない
    • アカウント管理を個別にやるだけでも面倒・・最低限、SSO対応してる?

などの理由で

  • 個別のシステムではなく3つの役割を兼ねられる単一のシステム

がほしいと思うことがあると思います。

fluentd + Datadog Logs/Stackdriver Logging

StackdriverとDatadogはSaaSで、かつ(それぞれサブサービスで、サブサービス間連携の度合いはそれぞれではありますが、)3つの役割を兼ねられます。

SaaSへログを転送する目的でfluentdを利用しますが、Kubernetesのログを収集するエージェントとしてfluentdがよく使われている関係で、Kubernetes界隈でよく使われるfluentdプラグインに関しては、よくある「メンテされていない、forkしないと動かない」という問題に遭遇しづらいというのも利点です。

まえおき2: なぜDatadogなのか

もともとStackdriver Loggingを利用していたのですが、以下の理由で乗り換えたので、この記事ではDatadog Logsの例を紹介することにします。

  • メトリクスやAPMで既にDatadogを採用していた
  • UI面で使いやすさを感じた

UI面に関して今のところ感じている使いやすさは以下の2点です。

  • ログメッセージの検索ボックスでメタデータの補完が可能

    • hostで絞込をしようとすると、hostの値が補完される
    • あとで説明します
  • 柔軟なFaceting
    • Datadog LogsもStackdriver Loggingもログにメタデータを付与できるが、Datadog Logsは任意のメタデータキーで絞り込むためのショートカットを簡単に追加できる
    • あとで説明します

Stackdriver Loggingを利用する場合でも、この記事で紹介する手順はほぼ同じです。Kubernetesの分散ロギングをSaaSで実現したい場合は、せっかくなので両方試してみることをおすすめします。

Fluentdのセットアップ手順

DatadogのAPIキー取得

Datadog > Integratinos > APIsの「New API Key」から作成できます。

image.png

以下では、ここで取得したAPIキーをDD_API_KEYという環境変数に入れた前提で説明を続けます。

fluentdのインストール

今回はkube-fluentdを使います。

$ git clone git@github.com:mumoshu/kube-fluentd.git
$ cd kube-fluentd

# 取得したAPIキーをsecretに入れる
$ kubectl create secret generic datadog --from-literal=api-key=$DD_API_KEY

# FluentdにK8Sへのアクセス権を与えるためのRBAC関連のリソース(RoleやBinding)を作成
$ kubectl create -f fluentd.rbac.yaml

# 上記で作成したsecretとRBAC関連リソースを利用するfluentd daemonsetの作成
$ kubectl create -f fluentd.datadog.daemonset.yaml

設定内容の説明

今回デプロイするfluentdのmanifestを上から順に読んでみましょう。

kind: DaemonSet

Kubernetes上のアプリケーションログ(=Podの標準出力・標準エラー)は各ノードの/var/log/containers以下(より正確には、そこからsymlinkされているファイル)に出力されます。それをfluentdで集約しようとすると、必然的に各ノードにいるfluentdがそのディレクトリ以下のログファイルをtailする構成になります。fluentdに限らず、何らかのコンテナをデプロイしたいとき、KubernetesではPodをつくります。Podを各ノードに一つずつPodをスケジュールするためにはDaemonSetを使います。

  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1

DaemonSetをアップデートするとき(例えばDockerイメージを最新版にするためにタグの指定を変える)、Podを1つずつローリングアップデートします。アップデートによってfluentdが動かなくなった場合の影響を抑えることが目的ですが、気にしない場合はこの設定は記述は不要です。

serviceAccountName: fluentd-cloud-logging

kubectl -f fluent.rbac.yamlで作成されたサービスアカウントを利用する設定です。これがないとデフォルトのサービスアカウントが使われてしまいますが、ほとんどのツールでつくられたKubernetesクラスタではデフォルトのサービスアカウントに与えられる権限が絞られているので、デフォルトのサービスアカウントではkube-fluentdが動作しない可能性があります。

      tolerations:
      - operator: Exists
        effect: NoSchedule
      - operator: Exists
        effect: NoExecute
      - operator: Exists

KubernetesのMasterノードや、その他taintが付与された特定のワークロード専用のWorkerノード含めて、すべてのノードにfluentd podをスケジュールための記述です。何らかの理由でfluentdを動作させたくないノードがある場合は、tolerationをもう少し絞り込む必要があります。

env:
        - name: DD_API_KEY
          valueFrom:
            secretKeyRef:
              name: datadog
              key: api-key
        - name: DD_TAGS
          value: |
            ["env:test", "kube_cluster:k8s1"]

一つめは、secretに保存したDatadog APIキーを環境変数DD_API_KEYにセットする、二つめはfluentdが収集したすべてのログに二つのタグをつける、という設定です。タグはDatadogの他のサブサービスでもよく見られる形式で、”key:value”形式になっています。

Datadogタグのenvは、Datadogで環境名を表すために慣習的に利用されています。もしDatadogでメトリクスやトレースを既に収集していて、それにenvタグをつけているのであれば、それと同じような環境名をログにも付与するとよいでしょう。

kube_clusterは個人的におすすめしたいタグです。Kubernetesクラスタは複数同時に運用する可能性があります。このタグがあると、メトリクスやトレース、ログをクラスタ毎に絞り込むことができ、何か障害が発生したときにその原因が特定のクラスタだけで起きているのかどうか切り分ける、などの用途で役立ちます。

        ports:
        - containerPort: 24231
          name: prometheus-metrics

「Kubernetesにデプロイしたアプリケーションのメトリクスを自動収集する」で紹介した方法でdd-agentにfluentdのPrometheusメトリクスをスクレイプさせるために必要なポートです。

サンプルアプリからログを出力する

適当なPodを作成して、testmessage1というメッセージを出力します。

$ kubectl run -it --image ruby:2.4.2-slim-stretch distlogtest-$(date +%s) -- ruby -e 'puts %q| mtestmessage1|; sleep 60'

ログの確認

何度か同じコマンドを実行したうえで、DatadogのLog Explorerでtestmessage1を検索してみると、以下のようにログエントリがヒットします。

image.png

ログエントリを一つクリックして詳細を開いてみると、testmessage1というログメッセージの他に、それに付随する様々なメタデータが確認できます。

image.png

ログエントリに自動付与されたメタデータの確認

  • HOST: ログを出力したPodがスケジュールされているホスト名(=EC2インスタンスのインスタンスID)
  • SOURCE: コンテナ名
  • TAGS: Datadogタグ
    • pod_name: Pod名
    • kube_replicaset: ReplicaSet名
    • container_name: Dockerコンテナ名
    • kube_namespace: PodがスケジュールされているNamespace名
    • host: PodがスケジュールされているKubernetesノードのEC2インスタンスID
    • zone: Availability Zone
    • aws_account_id: AWSアカウントID
    • env: 環境名

Log Explorerを使うと、すべてのAWSアカウントのすべてのKubernetesクラスタ上のすべてのPodからのログが一つのタイムラインで見られます。それを上記のようなメタデータを使って絞り込むことができます。

ログエントリの絞り込み

ログエントリの詳細から特定のタグを選択すると、「Filter by」というメニュー項目が見つかります。

image.png

これを選択すると、検索ボックスに選択したタグがkey:value形式で入力された状態になり、そのタグが付与されたログエントリだけが絞り込まれます。

もちろん、検索ボックスに直接フリーワードを入力したり、key:value形式でタグを入力してもOKです。

Facetingを試す

定型的な絞り込み条件がある場合は、Facetを作成すると便利です。

ログエントリの詳細から特定のタグを選択すると、「Create new facet」というメニュー項目が見つかります。

image.png

これを選択すると、以下のようにどのような階層のどのような名前のFacetにするかを入力できます。

image.png

例えば、

  • Path: kube_namespace
  • Name: Namespace
  • Group: Kubernetes

のようなFacetを作成すると、ログエントリに付与されたkube_namespaceというタグキーとペアになったことがある値を集約して、検索条件のショートカットをつくってくれます。実際のNamespace Facetは以下のように見えます。

image.png

kube-system、mumoshu、istio-system、defaultなどが表示されていますが、それぞれkube_namespaceというタグキーとペアになったことがある値(=クラスタに実在するNamespace名)です。また、その右の数値はそのNamespaceから転送されたログエントリの件数です。この状態で例えばistio-systemを選択すると、kube_namespace:istio-systemというタグが付与されたログエントリだけを絞り込んでみることができます。

image.png

アーカイブ、ETLパイプラインへの転送など

kube-fluentdにはアーカイブやETLパイプラインのサポートは今のところないので、必要に応じてはfluentd.confテンプレート変更して、それを含むDockerイメージをビルドしなおす必要があります。

fluentd.confテンプレートは以下の場所にあります。

https://github.com/mumoshu/kube-fluentd/blob/master/rootfs/etc/confd/templates/fluent.conf.tmpl

fluentd.confテンプレートから参照できる環境変数を追加したい場合は、以下のconfd設定ファイルを変更します。

https://github.com/mumoshu/kube-fluentd/blob/master/rootfs/etc/confd/conf.d/fluent.conf.toml

// 今後、configmap内に保存したfluent.confの断片をfluentdの@includeを使ってマージしてくれるような機能を追加してもよいかもしれませんね。

まとめ

FluentdとDatadog Logsを使って、Kubernetes上のアプリケーションログを自動的に収集し、Datadog LogsのWeb UIからドリルダウンできるようにしました。

アプリ側はTwelve-Factor Appに則って標準出力・標準エラーにログを出力するだけでよい、という簡単さです。ドリルダウンしたり、そのためのFacetを作成するときも、グラフィカルな操作で完結できます。

また、ログの収集をするためだけにいちいちインフラエンジニアが呼び出されることもなくなって、楽になりますね!

Kubernetes上のアプリケーションの分散ロギングを自動化したい方は、ぜひ試してみてください。

(おまけ) 課題: ログメッセージに含まれるメタデータの抽出

Stackdriver Loggingではできて、Datadog Logsでは今のところできないことに、ログメッセージに含まれるメタデータの抽出があります。

例えば、Stackdriver Loggingの場合、

  • ログにメタデータを付与して検索対象としたい

    • 例えば「ログレベルDEBUGでHello World」のようなログを集約して、Web UIなどから「DEBUGレベルのログだけを絞り込みたい」

というような場合、アプリからは1行1 jsonオブジェクト形式で標準出力に流しておいて、fluent-plugin-google-cloud outputプラグイン(kube-fluentd内で利用しているプラグイン)でStackdriver Loggingに送ると、jsonオブジェクトをパースして、検索可能にしてくれます。

例えば、

{"message":"Hello World", "log_level":"info"}

のようなログをStackdriver Loggingにおくると、log_levelで検索可能になる、ということです。

このユースケースに対応する必要がある場合は、いまのところDatadog LogsではなくStackdriver Loggingを採用するとよいと思います。

今後の展望

同じくkube-fluentdでDatadog Logsへログを転送するために利用しているfluent-plugin-datadog-logに、fluent-plugin-google-cloudと同様にJSON形式のログをパースしてDatadogのタグに変換する機能を追加することはできるかもしれません。

また、Datadog Logsには、ログエントリのメッセージ部分に特定のミドルウェアの標準的な形式のログ(例えばnginxのアクセスログ)が含まれる場合に、それをよしなにパースしてくれる機能があります。その場合にログエントリに付与されるメタデータは、タグではなくアトリビュートというものになります。アトリビュートはタグ同様に検索条件に利用することができます。

ただ、いまのところfluent-plugin-datadog-logからの出力はすべてsyslog扱いになってしまっており、ログの内容によらず以下のようなアトリビュートが付与されてしまっています。

image.png

JSONをパースした結果がこのアトリビュートに反映されるような実装が可能であれば、それが最適なように思えます。

続きを読む

VistaNet-NEWS

2015/01/29 | Vistara SaaS基盤でAWSとの連携を強化. “Vistara SaaS基盤のソフトウェアを2月13日にV3.8.2にアップグレード。 本アップグレードにより、特にAWS(アマゾンウェブサービス)との連携強化が行われます。” クラウド環境においては複数のインスタンスが同じデバイス名やプライベートIPアドレス、 仮想MAC … 続きを読む

カテゴリー 未分類 | タグ

AWS LambdaをTest Runnerとして使ってみる

はじめに

Serverless Advent Calendar 2017 の6日目です。

サーバーレスの技術を使うとWeb APIの開発が非常にお手軽になりました。
ところで質問。

ちゃんとテスト書いてますか??

この記事では、サーバーレスで作ったWeb API (別にサーバーレスで作ってなくてもいいけど) のAPIテストをサーバーレスでやっちゃおう、という小ネタです。 実運用ができるかどうかはやってみないとわかりません。

Web APIのテストって?

デプロイ後のWeb API、皆さんはどのようにテストされていますか?

  • curlなどを使ってShellScrpitでテスト組む?
  • いわゆるxUnit系のテストフレームワークを使ってテストする?
  • 何かしらのSaaSを使ってテスト?
  • Rest Client (Postmanなど) を使って手動でテスト^^;

などでしょうか?

ここで提案です。

サーバーレスでやればいいんじゃね??

AWS Lambdaをテストランナーとして使う

概要

Nodeでやります。

JavaScriptのテストフレームワークは、MochaやJasmine、Jestなどが有名です。最近だとAVAが流行っていますかね。 これらは基本的にはLocal環境でテストしたり、CI/CD環境でテストをまわすのに利用されます。

が、今回は上記のようなテストフレームワークは一切使わず、Lambdaでテストを書いてみます。

テスト対象のAPI

すごーくシンプルな、下記のようなCRUDなAPIを想定します。

API description
POST /users ユーザリソース作成
GET /users/{userId} ユーザリソース取得
PUT /users/{userId} ユーザリソース更新
DELETE /users/{userId} ユーザリソース削除

テストを書く!

テストランナーはLambdaにするとしても、Web APIをたたいたり、Assertionなどは外部パッケージに頼ります。
とりあえず定番の supertestshould を使います。

コードの内容としては以下のようなものになっています。

  1. POST /users で34歳のTaroというユーザを作成し、
  2. GET /users/{userId} でユーザ情報を参照し、
  3. PUT /usert/{userId} でTaro -> Hanakoに改名し、
  4. DELETE /users/{userId} でユーザを削除する
const Supertest = require('supertest');
const should = require('should');

const agent = Supertest.agent('https://your-api-host/v1');

function testShouldPostUserSucceeds() {
  const user = {
    name: 'Taro',
    age: 34
  };

  return new Promise((resolve) => {
    agent.post('/users')
      .set('Accept', 'application/json')
      .send(user)
      .expect((res) => {
        res.status.should.equal(200);
        res.body.should.hasOwnProperty('userId');
        res.body.name.should.equal('Taro');
        res.body.age.should.equal(34);
      }).end((err, res) => {
        if (err) {
          console.log('[Failed] testShouldPostUserSucceeds');
          throw err;
        }
        console.log('[Passed] testShouldPostUserSucceeds');
        resolve(res.body.userId);
      });
  });
}

function testShouldGetUserSucceeds(userId) {
  return new Promise((resolve) => {
    agent.get(`/users/${userId}`)
      .set('Accept', 'application/json')
      .expect((res) => {
        res.status.should.equal(200);
        res.body.userId.should.equal(userId);
        res.body.name.should.equal('Taro');
        res.body.age.should.equal(34);
      }).end((err, res) => {
        if (err) {
          console.log('[Failed] testShouldGetUserSucceeds');
          throw err;
        }
        console.log('[Passed] testShouldGetUserSucceeds');
        resolve(res.body.userId);
      });
  });
}

function testShouldPutUserSucceeds(userId) {
  const user = {
    name: 'Hanako',
    age: 34
  }

  return new Promise((resolve) => {
    agent.put(`/users/${userId}`)
      .set('Accept', 'application/json')
      .send(user)
      .expect((res) => {
        res.status.should.equal(200);
        res.body.userId.should.equal(userId);
        res.body.name.should.equal('Hanako');
        res.body.age.should.equal(34);
      })
      .end((err, res) => {
        if (err) {
          console.log('[Failed] testShouldPutUserSucceeds');
          throw err;
        }
        console.log('[Passed] testShouldPutUserSucceeds');
        resolve(res.body.userId);
      });
  });
}

function testShouldDeleteUserSucceeds(userId) {
  return new Promise((resolve) => {
    agent.delete(`/users/${userId}`)
      .set('Accept', 'application/json')
      .expect((res) => {
        res.status.should.equal(200);
        res.body.userId.should.equal(userId);
      }).end((err, res) => {
        if (err) {
          console.log('[Failed] testShouldDeleteUserSucceeds');
          throw err;
        }
        console.log('[Passed] testShouldDeleteUserSucceeds');
        resolve();
      });
  });
}

exports.handler = (event, context) => testShouldPostUserSucceeds()
  .then(userId => testShouldGetUserSucceeds(userId))
  .then(userId => testShouldPutUserSucceeds(userId))
  .then(userId => testShouldDeleteUserSucceeds(userId))
  .then(() => {
    context.succeed('Test Passed');
  }).catch(() => {
    context.fail('Test Failed');
  });
  • Node 6.x でTranspile無しで動くように書いています。
  • ESLintにはめちゃくちゃ怒られる書き方になっているのでご注意を。
  • Supertestなどの説明は省いているので他の記事をご参照ください。

これをLambdaで実行するとどうなるか?

テスト成功時

上記のコードをLambdaで実行し、テストに全部成功すると、例えばAWSのConsoleで確認すると以下の図のようになります。

1-ok-min.png

テスト失敗時

一方で、 testShouldPutUserSucceeds をちょっといじって、Taro -> Hanakoの改名に失敗するような、Failするテストに変更してみると、

  res.status.should.equal(200);
  res.body.userId.should.equal(userId);
//  res.body.name.should.equal('Hanako');
  res.body.name.should.equal('Taro');
  res.body.age.should.equal(34);

下記の図のように、失敗したテストがLambdaの実行ログでわかるのです!!

2-ng-min.png

result.png

所感

これはいったい誰トクなのか?

たぶん下記のようなメリットがあるはず。

  • CloudWatch Eventsなどから定期実行できる
  • 定期的に動かせば、APIの死活監視にも使える
  • Lambdaの実行結果をとりあえずSNSにでも飛ばしておけば、メールだったりWebhookでどっかにポストしたり、通知の柔軟性が高い

ただ、冒頭に書いた通り、本当にこんなものが必要なのかはよくわからない。
もし気が向いたら (需要がありそうなら) フレームワーク化してOSS化を検討したいところ。

続きを読む

AWS Cloud9が発表されました!

screenshot-console.aws.amazon.com-2017-12-01-11-59-47.png

まだ東京リージョンには来てませんが、興奮気味に記事を書いてます。(※)

AWSがCloud9の買収を発表したが2016年7月。

Amazon、クラウドIDEを提供する「Cloud9」買収。AWSが統合開発環境をSaaSとして提供する布石か

その直後にQiitaにCloud9の記事を書いたものでした。

また今年のはじめにもCloud9についての記事を書いてました。

昨日までのCloud9の環境はリソースが貧弱な印象をお持ちの方も多いと思います。

でもこれからは違います。EC2インスタンスとして立ち上げることができるのです!

(※)昨日Cloud9に課金した矢先にこのビッグニュースでした。

関連ニュース

続きを読む