kurosame’s diary

フロントエンドが得意です

Digdag から Amazon SNS 経由でエラーを通知する方法を考えた

Digdag から Amazon SNS 経由で Slack にエラーを通知するやつを今までは AWS Lambda を使ってやっていたが、Lambda を使うとコード管理どうするとか色々考えると面倒なので、今年使えるようになった AWS Chatbot が使えたら、いいなーって思ったので調査した

ただし、こちらにあるようにSNS -> Chatbotという経路では、使えません https://docs.aws.amazon.com/chatbot/latest/adminguide/related-services.html

でも簡単に設定できるので、一応試してみた
結果、できなかったが、やったことをメモ

Amazon SNS

トピックの作成

トピックの作成をクリックし、以下を設定

詳細

設定項目 設定値
タイプ スタンダード
名前 任意の名前

AWS Chatbot

Slack のワークスペースの設定

クライアントの設定でSlackを選択

f:id:kurosame-th:20201222101648p:plain

この後、Slack のワークスペースに Chatbot からのアクセスを許可を求める画面が出るので、許可する

チャネルの設定

新しいチャネルを設定をクリックし、以下を設定

設定の詳細

設定項目 設定値
設定名 任意の名前
ログ記録 Amazon CloudWatch Logs にログを発行する(エラーのみ)

Slack チャネル

設定項目 設定値
チャネル ID Slack のチャネル ID

Slack のチャネル ID の Slack の対象のチャネルを右クリックして、Copy linkで確認できる

アクセス許可

CloudWatch に関わるポリシーを付与した、IAM ロールが作成される

設定項目 設定値
IAM ロール 任意のロール名(新規にロールが作られる)
ポリシーテンプレート 通知のアクセス許可

以下のポリシーでアタッチされたロールが作成された

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": ["cloudwatch:Describe*", "cloudwatch:Get*", "cloudwatch:List*"],
      "Effect": "Allow",
      "Resource": "*"
    }
  ]
}

通知 - オプション

設定項目 設定値
SNS トピック 上記で作成した SNS トピック

AWS CLI

必要なポリシー

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "VisualEditor0",
      "Effect": "Allow",
      "Action": "sns:Publish",
      "Resource": "[作成した SNS トピックのARN]"
    }
  ]
}

実行

aws sns publish \
--topic-arn ${SNS トピックのARN} \
--message "SNS test message" \
--subject "SNS test subject" \
--region ap-northeast-1

以下のエラーが CloudWatch に出力された

Event received is not supported (see https://docs.aws.amazon.com/chatbot/latest/adminguide/related-services.html )

SNS -> Chatbotという連携はできないっぽい
まあ当然の結果ではありますが、、

たとえば、CloudWatch Alarms -> SNS -> Chatbotという経由であれば、できるのだと思う
ただ、今回のケースでは Digdag でエラーが起きる度に通知を飛ばしたいので、CloudWatch Alarm で閾値や期間を設定するのは、難しいのかなーと

Digdag を使っているので、現状だと解決方法は以下なのかな

Nuxt.js で Vuex を使わない場合に考えること

Vuex を使うのに慣れすぎて、Vuex を使わないパターンを考えた時に色々考えたのでメモ

方針

  • 比較的規模が小さいシステムでは、Vuex の利用をまずは避けたい
  • Nuxt.js を使用
  • Composition API を使用

コンポーネント間のデータ受け渡しパターン

親から子

  • 普通に props 経由

子から親

  • $emitで親の関数を呼ぶ
  • .sync修飾子と$emit.updateで親の状態を更新する

子から子

  • 禁止
  • 必ず親を経由する

親から親

たとえば、pages コンポーネントから pages コンポーネントにデータを渡したい時など

代替手段

  • URL に含めて vue-router から取る
    • 渡したいデータが増えた時の拡張性が低い
  • ブラウザのセッションストレージとかに入れとく
    • なんか微妙(TypeScript 使っているし)
  • Provide/Inject パターンを使う
    • Vue の 2.2.0 からあるらしい
    • Vuex の代わりになりそう
    • これは小規模なアプリケーションには、よい気がする

Provide/Inject パターンの実装

Nuxt.js と Composition API での実装

Key/Value を定義する
Value にグローバルで管理したい状態とロジックを定義する

// composables/dashboard.ts
import { Dashboard } from '@nuxt/types'
import { ref, Ref, InjectionKey } from '@nuxtjs/composition-api'

// Storeの型定義
export type DashboardStore = {
  dashboard: Ref<Dashboard>
  setDashboard: (d: Dashboard) => void
}

// Key
export const DashboardKey: InjectionKey<DashboardStore> = Symbol(
  'DashboardStore'
)

// Value
export const useDashboard = (): DashboardStore => {
  // 状態
  const dashboard = ref<Dashboard>({ dashboard_id: 0, dashboard_name: '' })
  // ロジック
  const setDashboard = (d: Dashboard) => (dashboard.value = d)

  return { dashboard, setDashboard }
}

次に、上記の Key/Value で provide する
今回は pages コンポーネント間でデータ受け渡しを行いたいので、layout コンポーネントで実装した

// layouts/default.vue
import { defineComponent, provide } from '@nuxtjs/composition-api'
import { DashboardKey, useDashboard } from '@/composables/dashboard'

export default defineComponent({
  setup() {
    provide(DashboardKey, useDashboard())
  }
})

状態を変更する場合

// pages/set.vue
import { defineComponent, inject } from '@nuxtjs/composition-api'
import { DashboardKey, DashboardStore } from '@/composables/dashboard'

export default defineComponent({
  setup() {
    const { setDashboard } = inject<DashboardStore>(DashboardKey) || {
      setDashboard: () => console.error('Not found setDashboard')
    }
    setDashboard({...})
  }
})

状態を取得する場合

// pages/get.vue
import { defineComponent, inject } from '@nuxtjs/composition-api'
import { DashboardKey, DashboardStore } from '@/composables/dashboard'

export default defineComponent({
  setup() {
    const { dashboard } = inject<DashboardStore>(DashboardKey) || {}
  }
})

データの種類と取得場所

静的なデータ

サーバーなどから 1 回取得したら、変更がないデータなど

「Nuxt の plugins で取得し、Nuxt のコンテキストに inject しておく」

  • plugins 配下のスクリプトはルートの Vue コンポーネントインスタンス化される前に実行される
  • SSR していれば、クライアントとサーバーでそれぞれ実行される
  • nuxt.config.jsの plugins オプションで対象のモジュールを定義する際、modeというオプションで、そのプラグインの実行をクライアントのみ、もしくは、サーバーのみと指定可能

これはいい感じな気がする

動的なデータ

たとえば、アプリケーション内で変更可能なデータなど

「親コンポーネントで状態を管理および、変更処理(サーバーからデータ取得など)を行う」

コンポーネントが肥大化する場合

コンポーネントが pages の場合

レガシーな CoffeeScript で書かれた hubot に Datadog 監視を導入する

CoffeeScript で書かれた hubot が ECS + Fargate 上で稼働中なのですが、これに Datadog の監視を入れてみます

ChatWork 経由で hubot に命令しているのですが、ChatWork のメンテナンス等があるとメンテナンス後も hubot がエラーで落ちたままになったことがあったので、hubot を監視して異常があれば、Slack に通知する機能作ります

コンテナーのメトリクスを取得

Datadog Agent をインストール

まずは、Datadog 管理画面の Integrations から Amazon Fargate をインストール

次はホストに Datadog Agent をインストールします
ECS + Fargate の場合は、Datadog Agent のコンテナーを作成し、ECS のタスク定義内で設定します

今回は AWS コンソール上から作業します

タスク定義の新しいリビジョンを作成します

コンテナーの追加をクリックして、以下の内容を設定します

項目名 設定値
コンテナー名 datadog-agent
イメージ datadog/agent:latest
環境変数 Key=DD_API_KEY, Value=Datadog の API Key
環境変数 Key=ECS_FARGATE, Value=true

Datadog のドキュメントでは、メモリ制限 (MiB)や CPU ユニット数も細かく指定しています

ただし、AWS の開発ガイドを見てみると、コンテナーのメモリはオプションで、 「ほとんどのユースケースでは、タスクレベルでこれらのリソースを指定するだけで十分です。」 って書いてあるので、今回は設定しませんでした
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/AWS_Fargate.html

CPU ユニット数も 1vCPU なら 1024CPU ユニット以内の数値であれば、設定できますが、Fargate の場合は、こちらもオプションだったので、設定しませんでした

この辺は使いながら、調整しようと思います

タスク定義更新後は、ECS のサービスをタスク定義の新しいリビジョンを使うように更新します

IAM ポリシーの設定

Datadog の Amazon Web Services Integration に設定しているロールのポリシーに以下の権限を追加します

  • ecs:ListClusters
  • ecs:ListContainerInstances
  • ecs:DescribeContainerInstances

確認

Datadog の管理画面からInfrastructure -> Containersに移動し、アプリケーションの CPU やメモリ等のメトリクス情報が取得できていたら成功です

hubot のプロセス監視

先ほど作成したdatadog-agentコンテナーの環境変数に以下を追加すれば、プロセス情報の収集ができるようになります

Key Value
DD_PROCESS_AGENT_ENABLED true

Datadog の管理画面からInfrastructure -> Processesに移動し、プロセス情報が取れていたら成功です

ただ、今回は有用な情報は取れませんでした

hubot コンテナーのログ監視

色々調べたのですが、hubot が CoffeeScript というレガシーな言語を使っているという縛りもあり、アプリ側から何か仕込むことが難しかったので、結局コンテナー内のログを全部 Datadog へ飛ばすことにしました

ログドライバーを FireLens に変更

FireLens は ECS で使えるログルーターであり、ログの出力先をルーティングできます
ここでは FireLens から Fluent Bit にログを転送し、Fluent Bit が Datadog をサポートしているので、Fluent Bit から Datadog にログを転送します

hubot が動いている方のコンテナーのログドライバーをawsfirelensに設定し、以下の Key/Value を設定します

Key Value
Name datadog
Host http-intake.logs.datadoghq.com
TLS on
apikey Datadog の API Key
dd_service 対象のサービス名
dd_source 対象のミドルウェア
provider ecs

上記で保存すると、log_routerというコンテナー名でaws-for-fluent-bitイメージのコンテナーがタスク定義内に追加されます
これを独自の Fluentd や Fluent Bit イメージを使うように書き換えることもできます

この Fluent Bit を使って Datadog にログを転送します

他のやり方として、awslogsログドライバーでも Lambda を使って、Datadog にログを転送するやり方もありますが、Datadog では FireLens を使ったやり方を推奨しているみたいです

確認

Datadog の管理画面からLogsに移動し、ログが出力されていたら成功です

Slack 通知

Datadog の Integrations から Slack を追加し、Webhook URL と通知先の Channel 名を設定しておきます

Datadog 管理画面のMonitors -> New Monitor -> Logsに移動します

hubot から ChatWork に対してポーリングしているので、hubot と ChatWork 間でエラーが発生しているとエラーログを出力し続けます
ポーリングの回数は環境変数の HUBOT_CHATWORK_API_RATE の設定値によって変動しますが、とりあえず以下のように直近 15 分で 20 件より多くのログが出力されたら、Slack へ通知するようにしました

logs("service:hubot").index("*").rollup("count").last("15m") > 20

これで上手くいかなかったら、また考えます

追記 - 2020/11/13
ログの Facet を追加して、以下のように 500 系のエラーが出た場合に通知するように変更した

logs("service:stg-hubot @log:(*ERROR* *Chatwork* *50*)").index("*").rollup("count").last("15m") > 0

参考

https://docs.datadoghq.com/integrations/ecs_fargate/?tab=fluentbitandfirelens

moxios を廃止して Jest.Mock に移行する

JS/TS のユニットテストで axios をモックするのになんとなくmoxiosを使っていたが、やめようかなという話

理由は

  • moxios が 3 年以上更新されていない
    • Issues の返信や Dependabot のプルリクも放置されているっぽい
  • moxios.stubRequestした axios のリクエストがたまに返ってこない時がある
    • ある特定の URL でmoxios.stubRequest(URL, ...だと Jest から axios を実行した際、then に入ってこない時があるので、moxios.stubRequest(new RegExp(URL), ...という謎対応で回避している
    • この辺のコードが怪しいけど、調べてない
  • そもそも moxios のような高機能なライブラリは必要なさそうなテストケースだった
    • axios がPromise.resolvePromise.rejectを返すようにモック化すれば、テストケース的には事足りる

テストフレームワークに Jest を使っているので、Jest のモック機能だけでいけそう

また、1 つの関数内で複数回 axios を実行したりしていないので、テストコードが複雑になることもないと思う

Jest のモック機能へ移行

以下の 2 行でaxios.getPromise.resolveを返すようにモックできる

jest.mock('axios')
axios.get.mockResolvedValue({ data: { name: 'test' } })

JS ならこれでオッケー、ただし TS だとaxios.getに mockResolvedValue は存在しないので、エラーになる
以下のようにすればエラーも消えて、型推論も効くようになる

;(axios as jest.Mocked<typeof axios>).get.mockResolvedValue({
  data: { name: 'test' }
})

Jest の設定

毎回モック設定を書くのは冗長なので、以下の設定をしておく

// test/unit/setup.ts
import axios from 'axios'

jest.mock('axios')

export default axios as jest.Mocked<typeof axios>
// jest.config.js
setupFiles: ['<rootDir>/test/unit/setup.ts'],

setupFiles にテストを実行する前に実行してほしい処理を書いておく

// テストコード
import mockAxios from '@test/setup'

// Promise.resolve
mockAxios.get.mockResolvedValue({ data: { name: 'test' } })
// Promise.reject
mockAxios.get.mockRejectedValue({ response: { status: 400 } })

また、moxios.waitを使って非同期処理を調整していた場合、すべてasync/awaitなどに置き換える必要がある
moxios.waitは内部的に setTimeout 関数を呼んでいるだけなので、そもそもasync/awaitに置き換えた方がよい

Vue3の事前調査まとめ

概要

来月 Vue3 がリリース予定です!

先日ステータスが RC となりました
https://github.com/vuejs/rfcs/issues/189

ただし、Vue3 にバージョンアップするのは、Vuex や Vue Router などの主要なエコシステムが Vue3 対応を正式にリリースしてからになると思います

Vue3 のコードはこちらに公開されています
https://github.com/vuejs/vue-next

中身のコードを見てみると、全部 TypeScript で書かれているのが分かります
今回コンパイラーから DOM、レンダリングコードまですべてゼロから実装し直したらしいです

以下は VueCLI 用ですが、vue-next を試すプラグインがありました
これを使うことで Vue およびそのエコシステムや vue-loader などの開発環境周りもまとめて Vue3 仕様にバージョンアップしてくれるものと思われます
https://github.com/vuejs/vue-cli-plugin-vue-next

残念ながら、私が今携わっているシステムでは CLI を使ってないので、バージョンアップしてどのくらいの影響が出るのかの確認は来月以降に見送ります

Vue3 移行時に破壊的変更を伴うものは、Vue2 で事前に対応できるように配慮されています
(たとえば、Vue3 では旧slot構文が削除されるので、事前に Vue2.6 からv-slot構文が追加されています)

よって、Vue2 のマイナーバージョンアップに追従して、事前に破壊的変更を伴うものを対応していれば、Vue3 への移行はかなりスムーズになると思います

今回は以下を調査しました

  • 新機能
  • 既存機能の変更
  • Composition API を実際に使ってみる

※ネットから適当に拾って書いてます

ちなみに Vue3 ではnew Vueしてるところから書き方が変わってたので、バージョンアップしても秒で落ちます

// Vue2
new Vue({
  render: (h): VNode => h(App)
}).$mount('#app')

// Vue3
createApp(App).mount('#app')

新機能

Teleport

あるコンポーネント内の要素を別のコンポーネントの DOM にマウントできる

React の Portal 機能と同等です

<Teleport to="#modals">
  <div>A</div>
</Teleport>
<Teleport to="#modals">
  <div>B</div>
</Teleport>

<!-- 上記のTeleport内の要素がtoで指定したidの要素の中にマウントされる -->
<div id="modals">
  <div>A</div>
  <div>B</div>
</div>

親子関係のコンポーネントであれば、$emitを書かなくて済む
親子孫というネストしたコンポーネントで孫から親に$emitする際、孫 -> 子子 -> 親のそれぞれで$emitを書く必要がある
そして、子は孫から親に中継するだけの自分自身には関係ない$emitとなる
この問題を解決するのに Vuex を使う手もあるが、Vuex に connect するコンポーネントは限定すべきである

上記を解決するのに Teleport 機能は利用できるかなと思います

Suspense

以下のコードだと ComponentA がロードされるまでの間、<div>Loading...</div>を出力し、ロード後 ComponentA を表示してくれる

React にも同機能があります

<Suspense>
  <template #default>
    <ComponentA />
  </template>
  <template #fallback>
    <div>Loading...</div>
  </template>
</Suspense>

使い所は多そう

既存機能の変更

v-model が複数書ける

<example v-model:name="name" v-model:value="value" />

コンポーネントのルート要素を複数にできる

React でいう Fragment 構文に相当します

Vue3 では以下はエラーになりません

<template>
  <div>AAA</div>
  <div>BBB</div>
</template>

Vue2 だと上記はエラーになるので、以下のように書く必要がありました

<template>
  <div>
    <div>AAA</div>
    <div>BBB</div>
  </div>
</template>

その他

  • style scopedの改善
    • ::v-deep,::v-slotted,::v-globalの追加
    • >>>/deep/などの書き方は将来的に削除される
    • ただし、古い書き方をコンパイラーで警告するのみでしばらくは互換性を保つっぽい
    • 参考
  • slot構文は廃止、v-slot構文へ移行する必要あり
  • |演算子を使ったフィルター機能は Vue3 で削除
  • $on,$off,$onceは Vue3 で削除

Composition API

この機能が 1 番の目玉だと(個人的に)思ってます
Vue2 でも@vue/composition-apiをインストールすれば、Composition API が使えます

import VueCompositionAPI from '@vue/composition-api'

Vue.use(VueCompositionAPI)

以下のようなcomponents/ExampleTable.vueを Composition API で書き直してみます

import Vue from 'vue'
import ExampleChart from '@/components/ExampleChart.vue'

export default Vue.extend({
  name: 'ExampleTable',
  components: { ExampleChart },
  data(): {
    pagination: { sortBy: string; descending: boolean }
  } {
    return {
      pagination: { sortBy: 'id', descending: true }
    }
  },
  computed: {
    items(): {}[] {
      return this.$store.getters.items || []
    },
    headers(): { text: string; value: string }[] {
      return [
        { text: 'ID', value: 'id' },
        { text: '名前', value: 'name' }
      ]
    }
  },
  methods: {
    getTableData() {
      this.$store.dispatch('GET_TABLE_DATA')
    }
  }
})

↓↓↓↓↓

import {
  defineComponent,
  reactive,
  computed,
  SetupContext
} from '@vue/composition-api'
import ExampleChart from '@/components/ExampleChart.vue'

export default defineComponent({
  name: 'ExampleTable',
  components: { ExampleChart },
  setup(_, ctx: SetupContext) {
    const pagination = reactive({
      sortBy: 'id',
      descending: true
    })
    const headers = computed(() => [
      { text: 'ID', value: 'id' },
      { text: '名前', value: 'name' }
    ])
    const items = computed(() => ctx.root.$store.getters.items || [])

    function getTableData() {
      ctx.root.$store.dispatch('GET_TABLE_DATA')
    }

    return { pagination, headers, items, getTableData }
  }
})

上記は単純に Composition API 仕様に書き換えただけです

ポイント

  • とりあえず defineComponent に全部渡すように書くことで、defineComponent の型定義のおかげで型推論が効くようになっている
    • VS Code で不自由なく実装できた
  • setup 関数に色々記載する
    • HTML 側で使う場合は return する
  • computed<string[]>のようにジェネリクスで型書けるのよい
    • でも書かなくても型推論が効いてる
  • this(Vue コンテキスト)を書く必要がなくなった
    • 今までのthisに相当する部分は setup 関数の第 2 引数から取れる
  • vue-loader などビルド環境周りをバージョンアップしてないけど、動いた
  • vue-devtools でちゃんと中身見れた

設計上のポイント

Composition API の書き方にする際はコンポーネントの実装を再設計する必要があると考えています

テーブルコンポーネントを例にします

Vue2 の書き方だと以下のように最上位がdatacomputedmethodsとなっているので、その中にテーブルのヘッダー、中身、フッターの処理を書くことになります
規模の大きいテーブルチャートになるとヘッダーを変えたいだけなのに、テーブルの中身やフッターの処理まで目を通すことになり、メンテナンス性に優れているとは言えません

export default Vue.extend({
  name: 'ExampleTable',
  components: { ExampleChart },
  data(): {
    header: // テーブルヘッダーの処理
    items: // テーブル内のデータの処理
    footer: // テーブルフッターの処理
  } {
    return {
      header: // テーブルヘッダーの処理
      items: // テーブル内のデータの処理
      footer: // テーブルフッターの処理
    }
  },
  computed: {
    // テーブル内のデータの処理
    itemsData(): {}[] {
      return this.$store.getters.tableData || []
    },
    // テーブルヘッダーの処理
    headers(): { text: string; value: string }[] {
      return [
        { text: 'ID', value: 'id' },
        { text: '名前', value: 'name' }
      ]
    }
  },
  methods: {
    // テーブル内のデータの処理
    function getTableData() {
      ctx.root.$store.dispatch('GET_TABLE_DATA')
    }
  }
})

Composition API だと以下のようになります
最上位が「処理の内容」です

明らかに Composition API で作られたコンポーネントの方が良い設計だというのは伝わるかなと思います
そして、この書き方だとコードの分離や再利用も容易に行なえます

たとえば、テーブルヘッダーの処理はどのテーブルも同様の処理をしているはずなので、別モジュールとして実装し、それを各テーブルコンポーネントが import して使うなど可能になります

export default defineComponent({
  setup(_, ctx: SetupContext) {
    // テーブルヘッダーの処理
    const headers = computed(() => [...])

    // テーブル内のデータの処理
    const pagination = reactive(...)
    const itemsData = computed(...)

    function getTableData() {...}

    //  テーブルフッターの処理
    ...

    // HTMLで使うやつだけreturnすればよい
    return { pagination, headers }
  }
})

処理を別モジュールで実装してもよいという書き方をしましたが
それはつまり、単一のコンポーネントではなく、システム全体のコンポーネント設計力が求められるということかなと思います

その他

こうゆうバージョンアップではあるあるですが、高速化・ファイルサイズの軽量化・メンテナンス性の向上も期待されるとあります

  • 高速化

  • ファイルサイズの軽量化

    • Vue の内部的に不要なパッケージの削除
    • Tree Shaking
      • これは元々採用している機能
      • export されてるが import されていない関数をバンドルファイルから削除するやつ
  • メンテナンス性の向上

    • 全面的な TypeScript の採用

まとめ

Vue は React にかなり影響を受けているので、React の機能を Vue でも使えるようにするというパターンが今回もそして今後も多いと思います

Composition API も Vue の独自機能のように見えますが、React Hooks にかなり影響を受けています(Function-based Component API

最近以下のような関数型プログラミングをサポートする機能を JS に導入する提案がされているのを見ます

Composition API や React Hooks も関数型プログラミングをサポートする機能と言えます
これらを採用することは同時に関数型プログラミングパラダイムシフトすることを求められます

Composition API や React Hooks をプロジェクトで採用する際は、このことも踏まえてチームで話し合う必要があるでしょう

React Hooks が登場した時と同じですが、Composition API を採用すると、以下の機能は今後書く機会が減っていくのかなと思います

  • Class Component
  • Mixins
  • Higher-order Component

また、Vue2 の書き方が今後使えなくなるということは(今の所)ないです

  • 段階的に Composition API に移行する
  • 新しく追加したコンポーネントのみ Composition API で書く
  • 今のシステムでは採用しない

など柔軟に移行計画が立てられます

Vue2 の書き方から、単純に Composition API に移行するだけであれば、比較的楽に移行できそうです

ただし、前述したように設計方針が大きく変わるので、むやみに移行するのは避けたいです
加えて、Composition API はフロントエンドの実装方針を大きく変えるものなので、段階的に移行する場合でもチームでコンセンサスをとって慎重に進めたほうがよいかなと思います

簡単ではありますが、雑多に調査結果をまとめました
詳細は Vue3 のリリース後に使いながら、知識を深めていこうと思います

CircleCI のコンテナー間のファイル受け渡し

やることになった経緯

  • Python で重い関数 A と重い関数 B がある
  • A の結果が B の引数になっている
  • CircleCI 上で A と B を実行するのだが、諸事情で A と B のジョブを分けたい
  • ジョブを分けると CircleCI 上のジョブ A とジョブ B のコンテナー間でどうにか情報を連携する方法が必要
  • ジョブ A の結果をファイルに書き出せば、CircleCI 上の tmp ストレージに保存して A と B 間の情報連携ができそうだった

結果をローカルファイルに保存

import pandas as pd

pd.to_pickle(res, "./output/result.pkl")

to_pickleは配列などのオブジェクトをシリアライズしてファイルに保存してくれるやつ

CircleCI の tmp ストレージに保存

steps:
  - checkout
  - run: ...
  - persist_to_workspace:
      root: ./output
      paths:
        - result.pkl

persist_to_workspaceを使ってワークフローのワークスペースに保存できる

注意点として

  • tmp ストレージに保存したファイルの保存期間は 15 日間
  • tmp ストレージ内のファイルは同じワークフロー内でのみ共有できる

以下のようにjob-ajob-bを同一ワークフロー内で実行し、requiresを使って 2 つのジョブを同期的に実行できるようにする

workflows:
  version: 2
  test:
    jobs:
      - job-a
      - job-b:
          requires:
            - job-a

tmp ストレージからファイルを取得する

steps:
  - checkout
  - attach_workspace:
      at: ./output
  - run: ...

attach_workspaceを使ってこのジョブのコンテナーにマウントする

ファイルを確認

import pandas as pd

res = pd.read_pickle("./output/result.pkl")
print(res)

read_pickleを使ってファイルを取得&デシリアライズできる

Firebase の Functions から Firestore への連携が(たまに)失敗する件の対応

あるシステムで以下のような処理を行っている

  1. Firebase の Storage にファイルをアップロード
  2. そのファイルを Functions でダウンロードして色々処理
  3. 加工した情報を Firestore に保存

Firestore への保存処理は Functions でadmin.firestore().collection('...').doc(fileName).set(...)のようにset関数を使って行っている

この Firestore への保存処理が成功する時と失敗する時があった

Functions が原因の可能性が高いと思ったが、エラーも出ておらず、成功する時と失敗する時があるのでちゃんとした再現が難しく、調査に難航した

Firebase は Spark プラン(無料のやつ)
Firebase プロジェクトのリージョンはnam5 (us-central)
Firestore のリージョンはnam5 (us-central)

やったこと、やろうとしたこと

  • 何かプランの制限にかかっていないか確認
  • 試しに Functions のリージョンを変えてみる
  • Firebase リソースのリージョンをすべて東京にする
  • Firestore へ反映されていなかったらリトライする

何かプランの制限にかかっていないか確認

以下を確認
https://firebase.google.com/docs/functions/quotas

とくに大丈夫そう

試しに Functions のリージョンを変えてみる

Functions はデフォルトではus-central1で実行される
東京リージョンに変えて実行してみた

functions
  .region('asia-northeast1')
  .storage.object()
  .onFinalize(...)

Functions のダッシュボード上で以下に変わっていればオッケー f:id:kurosame-th:20200515192533p:plain

なぜか Functions のリージョンを東京に変えたら失敗しなくなったっぽい(何回か試しただけだけど)
でも Storage も Firestore もリージョンはnam5 (us-central)なので、Functions の実行環境として 1 番近いのはus-central1である

この検証結果は忘れることにした

Firebase リソースのリージョンをすべて東京にする

Firebase プロジェクトのリージョンは後から変更できない
変えるならば新規プロジェクトを作り、全データ移行および設定し直しをする必要がある

かなり面倒だからやめた。。

ちなみに Firebase プロジェクトをasia-northeast1リージョンに変更しても、Firebase の主要リソースはすべて利用できる
また、自分が調べた限りasia-northeast1リージョンで不利になるのは以下だけ

  • Firebase Hosting で Functions を使って動的コンテンツを配信するのはus-central1のみをサポート
  • Realtime Database は常にus-central1になる

参考
https://firebase.google.com/docs/functions/locations?hl=ja#background

Firestore へ反映されていなかったらリトライする

結局、失敗したらリトライ処理をするプログラムを書くことにした

const doSet = async (): Promise<void> => {
  admin
    .firestore()
    .collection('...')
    .doc(fileName)
    .set(...)
  await new Promise(resolve => setTimeout(resolve, 120000)) // Sleep for 2 minutes
  return admin
    .firestore()
    .collection('...')
    .doc(fileName)
    .get()
    .then(doc => {
      if (!doc.exists) return Promise.reject()
      const data = doc.data()
      return newData === data ? Promise.resolve() : Promise.reject()
    })
    .catch(() => Promise.reject())
}

けっこう省略してるが、doSet関数は以下の処理を行っている

  1. admin.firestore().collection('...').doc(fileName).set(...)で Firestore のドキュメントを登録/更新
  2. 2 分スリープ
  3. admin.firestore().collection('...').doc(fileName).get()で Firestore のドキュメントを取得
  4. 取得したドキュメントをチェック
    1. ドキュメントが存在しない、または更新されていない場合はPromise.reject()を返す
    2. ドキュメントが登録/更新されている場合はPromise.resolve()を返す

doSetは以下のように呼ぶ

doSet()
  .catch(doSet)
  .catch(doSet)
  .catch(() => console.error(`Document not updates`))

最大 3 回doSetをコールする
3 回目のコールでdoSetPromise.reject()を返せば、エラーログを出して処理終了
いずれかのdoSetPromise.resolve()を返せば、キャッチされないので処理終了

本当は Functions でエラー時にアラートを仕込めたらよいが Firebase コンソール上ではできなそう
GCP の Stackdriver Logging(有料)を使って色々設定すればできるかも

また、注意点として、Functions のデフォルトのタイムアウト時間は 60 秒なので以下のように変更しておく

functions
  .runWith({ timeoutSeconds: 540 })
  .storage.object()
  .onFinalize(...)

540 秒(9 分)が最大値
Functions のダッシュボード上で以下に変わっていればオッケー f:id:kurosame-th:20200515192607p:plain

また、runWithオプションで CPU のコア数やメモリも増やせる

参考
https://firebase.google.com/docs/functions/manage-functions?hl=ja#set_timeout_and_memory_allocation

さいごに

Firebase で最初にプロジェクトを作ったとき、ちゃんと東京リージョンにしておけば、今回の問題は起きなかったかもしれない

Functions のプログラム全体は以下
https://github.com/kurosame/glossary/blob/master/functions/src/index.ts