aws-ecs-pattern の ApplicationBalancedFargetService でのコスト削減法

CDKで @aws-cdk/aws-esc-pattern を使ってサクッとFagateで安くコンテナ建てたかったのですが、

    import * as ecr from '@aws-cdk/aws-ecr';
    import * as ecsPatterns from '@aws-cdk/aws-ecs-patterns';
    import * as ecs from '@aws-cdk/aws-ecs';

    const service = new ecsPatterns.ApplicationLoadBalancedFargateService(this, 'Fargate', {
      taskImageOptions: {
        image: ecs.ContainerImage.fromEcrRepository(ecr.Repository.fromRepositoryName(this, 'Repository', 'my_repository'))
      },
      cpu: 512,
      memoryLimitMiB: 1024,
    });

とかやると、プライベートサブネットでコンテナが建ちます。 プライベートサブネットは自動的にNATゲートウェイを伴い、生きている間じゅう時間課金が発生して個人プロジェクトではつらいので、プライベートサブネットを作らない方法を検討していました。

assignPublicIPtrue にすると、 taskSubnets のデフォルトが変わると記載がある

Type: SubnetSelection (optional, default: Public subnets if assignPublicIp is set, otherwise the first available one of Private, Isolated, Public, in that order.)

ので、 true にしてデプロイしてみたところ、空のプライベートサブネットができてしまいました。

NATゲートウェイを作成しない正解は、VPCを直接生成することのようです。

    import * as ec2 from '@aws-cdk/aws-ec2';
    import * as ecr from '@aws-cdk/aws-ecr';
    import * as ecs from '@aws-cdk/aws-ecs';
    import * as ecsPatterns from '@aws-cdk/aws-ecs-patterns';

    const vpc = new ec2.Vpc(this, 'VPC', { 
      natGateways: 0,
      subnetConfiguration: [
        { cidrMask: 24, subnetType: ec2.SubnetType.PUBLIC, name: 'ingress' }
      ] });

    const service = new ecsPatterns.ApplicationLoadBalancedFargateService(this, 'Fargate', {
      taskImageOptions: {
        image: ecs.ContainerImage.fromEcrRepository(ecr.Repository.fromRepositoryName(this, 'Repository', 'asset_tracker'))
      },
      cpu: 512,
      memoryLimitMiB: 1024,
      assignPublicIp: true,
      vpc
    });

このように vpc を直接していすると、そのコンストラクタで natGateways の数を直接指定できます。 この場合、PUBLICなサブネットと、ISOLATEDなサブネット(外との通信不可; プライベートサブネットでは中から外への通信はOK) ができますが、 ISOLATEDなサブネットの方には現状何も置いていないので、 subnetConfiguration を指定して作成しないようにしました。

期間を指定するActiveRecordのクエリ

開始日と終了日がDateで与えられて「 開始日以降終了日まで」みたいなクエリをActiveRecordで書く時、

Event.where("date BETWEEN ? AND ?", start_date, end_date)

とかやるのだけれど、JOINした時などテーブル名が曖昧になって通らなくなったりするので、 ハッシュで書いてテーブル名の修飾をRailsに任せたい。

こういう場合はRangeが使えて、

Event.where(date: start_date...end_date)

とかできる。で、start_dateend_datenilの時には不等号で評価してほしいんだけど、 上2つの書き方だとどちらも動かない。

range_handler.rbを見る感じだと、Rangeの端の値がinfinity?に対してtrueを返せばよさそう。

ruby - Is there a way to express 'infinite time'? - Stack Overflowによると、DateTime::Infinity.newで無限大の未来を表せるらしい。このクラスのドキュメントさがせなかったけど。 問題は無限大の開始日が指定されなかった時に代わりに使う無限大の過去をどう表すかで、DateTime::Infinity.new(-1)はRangeに使うとエラーになってしまう。

[1] pry(main)> t = DateTime::Infinity.new(-1)
=> #<Date::Infinity:0x00007f852411a9d0 ...>
[2] pry(main)> t...Date.today
ArgumentError: bad value for range

とりあえずinfinity?に対応できればよさそうなので、特異メソッドで対応してみた。

MIN_DATE = Date.new(2000, 1, 1) #ダミーなので適当に
class << MIN_DATE
  def infinite?
    true
  end
end

start_date ||= MIN_DATE
end_date ||= DateTime::INFINITY.new

Event.where(date: start_date...end_date)

infinity? でなく infinite? なのは、begin_bindを作るときに query_attribute.rb でラップされるから(?)。

github.com このコミット見る感じ、PostgreSQL以外では通用しないのかな?

Ruby 2.5, Rails 5.2, PostgreSQL 10.5 で 開始日、終了日どちらか or どちらも指定した場合ともに動きました。

CRA + TypeScript での storybook の設定

create-react-app で TypeScriptをサポートしているのいいんだけど、その時のstorybookの設定がよく分からなかった。

バーション

  • react-scripts@2.1.0
  • typescript@3.1.6
  • @storybook/react@4.0.0

Storybookの公式に色々書いてあって、 awesome-typescript-loaderとかを書いてある通りに設定したら動きはしたんだけど、 結局create-react-app本体の設定コード読んだほうがビルドしたものと一緒の設定になっていいかなと思い次のようにしてみました。

const TSDocgenPlugin = require('react-docgen-typescript-webpack-plugin');

module.exports = (baseConfig, env, config) => {
  config.module.rules.push(
    {
      test: /\.mjs$/,
      include: /node_modules/,
      type: 'javascript/auto',
    },
    {
      test: /\.(ts|tsx)$/,
      use: [
        {
          loader: 'babel-loader',
          options: {
            presets: ['react-app'],
          },
        },
      ],
    },
  );
  config.plugins.push(new TSDocgenPlugin());
  config.resolve.extensions.push('.mjs', '.ts', '.tsx');

  return config;
};

結果として設定も簡単になったと思います。 .mjsの設定はCan't reexport the named export '....' from non EcmaScript module というエラーへの対応です。

こういう風にあからさまに babel-preset-react-app に依存した時、devDependenciesに足したくなるけどJavaScriptの場合どうするのがいいんだろう? react-scripts内部の依存を直接使うのは良くない気がするけど、足しちゃうとreact-scriptsだけバージョンあげてバージョンが合わなくなったりしそう。

apollo-boostではlinkを設定できない

graphql-ruby と react + apollo ではじめてのGraphQLはじめました。

で 、graphql-ruby (Rails) はAPIサーバとして使っていて、Authorizationヘッダで認証するようにしています。

もともとはこんな感じでヘッダを設定していたのだけれど、

import ApolloClient from 'apollo-boost';
import { ApolloProvider } from 'react-apollo';

const client = new ApolloClient({
  uri: 'xxxxxxx',
  request: operation => {
    operation.setContext({
      headers: {
        'Authorization': 'xxxxxxxx'
      },
    });
  },
});

ReactDOM.render(
  <Router>
    <ApolloProvider client={client}>
      <App />
    </ApolloProvider>
  </Router>,
  document.getElementById('root'),
);

画像ファイルを上げたいということになり、apollo-upload-clientApolloClient にlinkオプションを渡そうとしたけどうまくいかない。

from 'apollo-boost'ApolloClient は下記のオプションしか取らず、そこにlinkは含まれていないのが原因でした。

github.com

なので、ApolloBoost was initialized with unsupported options: link とか怒られるんだけど、 ずっとNetworkタブをみてなんでだろって思っていたので、Consoleタブを見ていなかった。 どうせならもっと盛大に止まってくれればよかったのに。

import ApolloClient from 'apollo-client'; として解決。 この場合、

import ApolloClient from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';

const client = new ApolloClient({
  link: xxxxxx,
  cache: new InMemoryCache(),
})

として cache を指定しないといけないし、apollo-boostをみて必要そうなlinkをコピってくる必要がある。

turbolinksとGoogle Ad Manager (旧 DoubleClick for Publishers)

Google Ad Managerのレポートを見ていたら、Key-Valueと広告ユニットの組み合わせとしてありえないものが出力されていた。 turbolinksのせいで前の画面のKey-Valueが残っているのだろうと思い、調べてみると、

Turbolinks Compatibility with DoubleClick for Publishers

こういうのが出てきた。すごくよさそうだけど、使っているイベントはturbolinks 5に対応していないので、リンクされているGitHubのissueを参照してみる。

Google Adsense strategy not supported by Google Loader · Issue #36 · reed/turbolinks-compatibility · GitHub

CoffeeScriptなのを書き換えて、レスポンシブ対応とKey-Value対応を入れるとこんな感じ。

export default class Gpt {
  constructor() {
    this.slots = {};
    window.googletag = window.googletag || {};
    window.googletag.cmd = window.googletag.cmd || [];

    document.addEventListener('turbolinks:before-visit', this.clearAds.bind(this));
    document.addEventListener('turbolinks:load', this.evaluate.bind(this));
    this.evaluate();
  }

  evaluate() {
    let targeting = document.querySelector('meta[name=gpt-targeting]');
    if (targeting) {
      targeting = JSON.parse(targeting.content);
    }

    window.googletag.cmd.push(() => {
      for (let target in targeting) {
        window.googletag.pubads().setTargeting(target, targeting[target]);
      }
    });

    const ads = document.querySelectorAll('.gpt-ad');
    for (var slot of ads) {
      const cachedSlot = this.slots[slot.id];
      if (cachedSlot) {
        this.refreshSlot(cachedSlot);
      } else {
        this.defineSlot(slot);
      }
    }
  }

  defineSlot(slotEl) {
    const divId = slotEl.id;
    const path = slotEl.dataset.gptPath;
    const dimensions = JSON.parse(slotEl.dataset.gptDimensions);

    window.googletag.cmd.push(() => {
      // 指定できるブラウザサイズは実際の端末サイズよりも小さくなります。
      // https://support.google.com/admanager/answer/3423562?hl=ja
      // 実際には15程度小さくなるので、調整する
      const mapping = window.googletag.sizeMapping()
            .addSize([992 - 15, 690], [[728, 90], [1080, 128]])
            .addSize([0, 0], [[320, 50], [280, 200]])
            .build();
      const slot = window.googletag.defineSlot(path, dimensions, divId).defineSizeMapping(mapping).addService(window.googletag.pubads());
      window.googletag.enableServices();
      window.googletag.display(divId);
      this.slots[divId] = slot;
    });
  }

  refreshSlot(slot) {
    window.googletag.cmd.push(() => {
      window.googletag.pubads().refresh([slot]);
    });
  }

  clearAds() {
    window.googletag.cmd.push(() => {
      window.googletag.pubads().clearTargeting();
      window.googletag.pubads().clear();
    });
  }
}

ページのKey-ValueはmetaタグにJSONで書く。 defineSizeMappingはページごとにカスタマイズできずサイトで1つになってしまっているけど、とりあえずはこれで困っていないのでいいかな。

fresh_whenとrender

def index
  @foo = Foo.find(params[:id])
  render "bar"
end

こんな感じのやつに fresh_when を入れて

def index
  @foo = Foo.find(params[:id])
  fresh_when(@foo)
  render "bar"
end

とかやると、キャッシュが効いたときに AbstractController::DoubleRenderError になってしまう。

ソースみると fresh_whenの中 head :not_modified してヘッダを送ってしまっているのでこうなるらしい。

def index
  @foo = Foo.find(params[:id])
  fresh_when(@foo)
  render "bar" unless request.fresh?(response)
end

として直したけど、Rails Guide読むと

If you don't have any special response processing and are using the default rendering mechanism (i.e. you're not using respond_to or calling render yourself) then you've got an easy helper in fresh_when:

とか書いてあって、自分で renderするときは

def index
  @foo = Foo.find(params[:id])
  render "bar" if stale?(@foo)
end

とやるのがいいみたい。turbolinkを疑ってすごく時間を潰してしまった。