mmag

ハマったことメモなど

PipenvとDocker Compose

最近Djangoのプロジェクトをつくっているのですが、docker-compose upで立ち上がるようにしておこうとググったところ、なんかイマイチじゃない?ってのが多く出てきたので、自分なりのやつを書いておきます。

Dockerfile

FROM python:3.7-slim

RUN apt-get update -qq

ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIPENV_VENV_IN_PROJECT 1

WORKDIR /app

RUN pip install pipenv

docker-compose.yml

version: '3.7'
services:
  db:
    image: postgres:11.1-alpine
    volumes:
      - postgresql:/var/lib/postgresql/data
  app:
    build: .
    command: pipenv run server
    volumes:
      - .:/app:cached
    ports:
      - 7000:7000
    depends_on:
      - db
volumes:
  postgresql:

Pipfile

[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true

[dev-packages]

[packages]

[requires]
python_version = "3.7"

[scripts]
server = "python manage.py runserver 0.0.0.0:7000"
test = "python manage.py test"

ポイントとしてはPIPENV_VENV_IN_PROJECT=1にして、.venvをDocker Composeのvolumesに入るようにするとこ。ググって出てきたイマイチっぽいやつは、Dockerfileの中でPipfileとPipfile.lockをCOPYしてpip installをやっちゃうやつ。これだと新しい依存を追加するたびにbuildしないといけないんじゃないのって感じでお見送り。

ハチャメチャに速いサイトをつくりたい

ということを思って、もうだいぶ前だけどhttps://surisuri.ninjaっていうのをつくった。半端になってるページもあるけど、やりたいことはざっとできたので趣味としては満足。Herokuで動いてます。ソースはこちら。いま仕事で開発してるサービスの公開APIで本家をマネする企画がことの始まり。

やったこと

CDNのエッジキャッシュを使う

まず前提として、HTMLは全部CDNにキャッシュする。後述するように動的に変わるコンテンツはキャッシュに載せなかったので、そういうコンテンツは表示されなかったとしても、ヘッダとか静的な部分まで表示されず画面真っ白というのはハチャメチャに速いとは言えない。なのでオリジンのサーバはなるたけコンテンツを載せたtext/htmlを返すようにする。ほぼ空っぽのHTMLに<script>がぽつんとあるSPAみたいにはしない。

APIとフロントを分割する

いわゆるモノリシックなWebアプリにはしなかった。JSON APIとフロントが分かれていたほうが個人的に好みだし、チームで開発する場合も適していると思うのでそうした。ただそうなるとtext/htmlをつくるのはフロント側になるので、SSRする仕組みが必要になる。SSRをサポートしてくれるフレームワークはNuxtとかNextとかこの頃興味を持っていたSvelteのSapperなどがあるけど、Nextはpathパラメータの扱いが苦手そう?に見えたのでやめて、Sapperは後述するaタグでの遷移ができなかったのでやめてNuxtを選んだ。仕事でもVueを使っていて手に馴染んでるのでちょうどよかった。

普通のaタグを使う

普通じゃないaタグって何だよって感じだけど、SPA的な画面遷移をしないということ。リンクをタップしてからAPI叩いて必要な情報が揃ってから画面書き換え、ということをするよりも、CDNのエッジキャッシュに載ってるHTMLを取ってくる方が速いんじゃねーのと考えた。もちろん代替案はいくつも考えられて、

  • SPA的な遷移でも、遷移してからAPI叩けばいいじゃん
  • 次に遷移しそうな先で必要になるAPI叩いておいて、ブラウザとかService Workerにキャッシュしとけばいいじゃん

など。前者はちょっと記憶が曖昧だけど、Vueのmountedを使えば遷移してからAPIを叩けるけど、そのページをSSRしたときにコンテンツが載ってこない。だからと言ってNuxtのasyncDatafetchを使うと、こいつらは遷移前に呼ばれるので、リンクをタップしてから遷移するまで時間がかかってしまう。そんな具合にサーバサイドとクライアントサイド両方でいい感じに叩く手段がなかったので、サーバサイドに振り切ったということ。後者は無駄な通信が発生する可能性がありギガに優しくないなと思ったのでやめた。

その他

CSSはheadタグの中にstyleタグで書いてCSSOMの準備が早くできるようにするとか、assetなどURLと内容が対応してて変化しないものはService WorkerでNetwork Firstなキャッシュをしてオフライン対応するとか、いくつか考えていたけど、この辺はNuxtがやってくれちゃったので頑張った感じはない。詳しくはhttps://github.com/nuxt-community/pwa-moduleなど参照のこと。

やらなかったこと

動的なコンテンツのキャッシュはしなかった。CDNによってはクッキーの値をキャッシュキーに含めることができるらしいけど、こちらが意図していない、また把握できないキャッシュをつくられる可能性があるのでやめた。なのでユーザごとのコンテンツやユーザによって作成・更新されるものはブラウザからAPIを叩いて取得する。dev.toなんかはそういうコンテンツもキャッシュに載せていて、ユーザによって更新されたタイミングでCDNのキャッシュを消している。なので絶対無理ってわけでは無いのだけど、これはこれで大変なのでやらなかった。

まとめ

そんなところで、趣味レベルだけど一度キャッシュに載ってくれればそこそこ速いものはできた。ただ例えばトップは時間によって内容が変わるので3時間でキャッシュ消すようにしてるので、キャッシュ切れててHerokuのdynoが寝てるときの描画はかなり遅いし、やっぱりAPI叩くところはどうしてもモッサリするので、もうひと押しって感じですね。

Ectoのカスタムロガー

github.com

3.0-rc.1がもっぱら話題のEctoですが、ロガーを追加する方法をたまたま見つけたので書いておきます。Ecto.Repouseするときにloggersっていうオプションを渡します。

defmodule MyApp.Repo do
  use Ecto.Repo,
    otp_app: :my_app,
    loggers: [
      {Ecto.LogEntry, :log, []},
      {MyApp.CustomLogger, :log, []}
    ]
end

loggersのデフォルト値は[{Ecto.LogEntry, :log, []}]なので、今の挙動を変えたくないときはこんな風に追加するとよさそ。試しにこんなlog/1で動かしてみると、

defmodule MyApp.CustomLogger do
  def log(entry) do
    IO.inspect entry, structs: false
  end
end
[debug] QUERY OK source="nippoes" db=8.3ms
SELECT n0."id", n0."date", n0."content", n0."user_id", n0."inserted_at", n0."updated_at" FROM "nippoes" AS n0 WHERE (n0."date" = $1) ORDER BY n0."inserted_at" DESC [{2018, 10, 17}]
%{
  __struct__: Ecto.LogEntry,
  ansi_color: :cyan,
  caller_pid: #PID<0.599.0>,
  connection_pid: nil,
  decode_time: 11000,
  params: [{2018, 10, 17}],
  query: "SELECT n0.\"id\", n0.\"date\", n0.\"content\", n0.\"user_id\", n0.\"inserted_at\", n0.\"updated_at\" FROM \"nippoes\" AS n0 WHERE (n0.\"date\" = $1) ORDER BY n0.\"inserted_at\" DESC",
  query_time: 8328995,
  queue_time: 68000,
  result: {:ok,
   %{
     __struct__: Postgrex.Result,
     columns: ["id", "date", "content", "user_id", "inserted_at", "updated_at"],
     command: :select,
     connection_id: 15165,
     num_rows: 0,
     rows: []
   }},
  source: "nippoes"
}

こんなログが出ました。引数はEcto.LogEntryの構造体ですね。query_timeナノ秒)を見て、時間かかってたらLogger.warnしたりとか良いんじゃないでしょうか。本番環境だとNewRelicだなんだありますけど、開発環境とかテストとかで。