Swift.Decodable + Int64 / iOS 10 = 要注意

モバイル基盤グループのヴァンサン(@vincentisambart)です。

Swift 4 で JSON を読み込むための仕組みとして Swift.Decodable が追加されました。

iOS クックパッドアプリでは、 Swift での JSON の読込は以前 Himotoki が使われていましたが、新規コードでは Swift.Decodable が使われています。依存関係を減らすために、 Himotoki を使っているコードが少しずつ Swift.Decodable に移行されています。

ただし、この間、ユーザーの報告で分かったのですが、最近 Himotoki から Swift.Decodable に移行したコード辺りに一部のユーザーにエラーが出ています。 iOS 10 に限りますが。

調査

調べてみた結果、以下のコードでエラーを再現できました。

struct MyDecodable: Decodable {
    var id: Int64
}

let str = "{\"id\":1000000000000000070}"
let data = str.data(using: .utf8)!
do {
    let decodable = try JSONDecoder().decode(MyDecodable.self, from: data)
    print("id: \(decodable.id)")
} catch {
    print("error: \(error)")
}

iOS 10 で実行してみると Parsed JSON number <1000000000000000070> does not fit in Int64. というエラーが出ます。 1000000000000000080 でも起きますが、 1000000000000000071 では起きません。

このエラーって何だろう… Swift がオープンソースなので、コードに grep してみましょう。これっぽい。エラーが発生する条件をもう少し見てみましょう。

guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else {
    throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value)
}

let int64 = number.int64Value
guard NSNumber(value: int64) == number else {
    throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed JSON number <\(number)> does not fit in \(type)."))
}

numberNSNumber なのに NSNumber(value: number.int64Value) == number を満たさない!?

JSON の解読は実は JSONDecoder が Foundation の JSONSerialization を使っているので、 JSONSerialization を直接使ってみましょう。

let str = "{\"id\":1000000000000000070}"
let data = str.data(using: .utf8)!
let jsonObject = try! JSONSerialization.jsonObject(with: data) as! [NSString: Any]
let number = jsonObject["id"] as! NSNumber
print("number: \(number)")
print("type: \(type(of: number))")
print("comparison: \(NSNumber(value: number.int64Value) == number)")

iOS 10 で実行してみた結果は以下の通りです。

number: 1000000000000000070
type: NSDecimalNumber
comparison: false

iOS 11 では以下のように表示されます。

number: 1000000000000000070
type: __NSCFNumber
comparison: true

結果がかなり違いますね。 iOS 10 でもっと小さい数字を使ってみると、 iOS 11 と同じ結果になります。

number: 10000070
type: __NSCFNumber
comparison: true

__NSCFNumber というクラス名は不思議に見えるかもしれませんが、一番見かける NSNumber のサブクラスです。 type(of: NSNumber(value: 1))__NSCFNumber です。

iOS 11 で JSONSerialization が数字に使っているクラスの条件が変わったようですね。実際 iOS 11 でも、 64-bit に入りきらない大きい数字だと NSDecimalNumber になります。

解決方法

では、原因があの NSDecimalNumber にあるのは分かりましたが、問題はどう解決すればいいのでしょうか。

iOS 10 の JSONSerialization は流石に直せません。

NSDecimalNumber と遊んでみると挙動が分かりにくいところがありますが、上記の大きい数字でも NSDecimalNumber(value: int64) == number が満たされるので、 Swift 本体は条件を以下のにすれば直りそうです。

let int64 = number.int64Value
let recreatedNumber = number is NSDecimalNumber ? NSDecimalNumber(value: int64) : NSNumber(value: int64)
guard recreatedNumber == number else {
    throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed JSON number <\(number)> does not fit in \(type)."))
}

iOS クックパッドアプリはどうしたかと言いますと、Swift.Decodable を使っていて、サーバーからとても大きい ID が来そうな箇所だけを Himotoki に戻すことにしました。 iOS 10 対応をやめたら、再度 Swift.Decodable に戻す予定です。

まとめ

iOS 10 にまだ対応しているアプリは Swift.Decodable を準拠している classstruct 内に Int64 を使っている場合、要注意です。一部のとても大きい数字では読込中にエラーが起きる可能性があります。その場合、すぐできる対応は対象の classstructSwift.Decodable を使うのをやめる必要あるかもしれません。

バグを報告したので、修正が行われたら追記します。

Cookpad Tech Kitchen #14 〜海外で働く〜 開催報告

Cookpad UKに出向中の西山(@yuseinishiyama)です。

去る2月16日、弊社で定期開催しているCookpad Tech Kitchenの一環として、海外事業をテーマとしたイベント『海外で働く』を開催しました。私も一時帰国して登壇しましたので、その内容をここで紹介させてください。

f:id:yuseinishiyama:20180301010500j:plain

f:id:yuseinishiyama:20180301010811j:plain

Introduction

まず最初に、Engineering ManagerのLeonard Chin(@l15n)から海外事業全体の概要説明がありました。

  • そもそもなぜ海外でやるのか
  • なにを目標としているのか
  • 日本のCookpadとはどういう関係性なのか
  • どういう組織体制なのか

などをカバーする内容で、詳細を以降の登壇者が埋めていきます。

Working at Cookpad UK

次に、私のほうからCookpad UKで働くことをテーマとした発表をしました。

海外オフィスがどこにあるか、そこでどんなメンバーが働いているのか、どれくらいの英語力が求められるのか、という点について触れています。

Workflow and development in globally distributed mobile teams

Data AnalysisチームのPaweł Rusin(@RusinPaw)からは海外事業部のワークフローについての説明がありました。

クックパッドの海外事業には様々なメンバーが複数のタイムゾーンからコミットしています。チームの多様性は、サービスの国際化という観点では非常に大きなメリットがありますが、一方で多くのコミュニケーションの問題を引き起こします。この発表では、我々がどのようなマインドセット、ルール、ツールを用いてこうした問題に対処しているかが言及されています。

20言語以上に対応している検索システムが楽しくない訳がない

次に、同じく一時帰国した滝口(@rejasupotaro)から、検索システムの話がありました。

様々な具体例を用いて、複数の言語をサポートし、かつ、個々の地域に合わせた細かなチューニングを行うことの難しさ(と楽しさ)が説明されています。

Architecting for Experiments at Cookpad Global

次に、iOSエンジニアのChristopher Trott(@twocentstudio)からプロトタイピングについての話がありました。

クックパッドの海外事業はプロダクトとしてはまだまだ初期のフェーズですが、一方で既に多くのユーザーが世界中にいて、日本で安定した収益源があるという点では、単なるスタートアップとは異なります。こうしたユニークな状況下で、既存のユーザーに悪影響を与えずに、新しい機能を試すためには多くの課題があります。

おわりに

クックパッドが海外事業をやっていることをなんとなく耳に挟んだことがあっても、その詳細についてご存知の方はほとんどいらっしゃらないのではないでしょうか。このイベントを通じて、皆さんにクックパッドの海外事業に興味を持っていただき、そこでどんなことが行われているのかについて、より具体的なイメージを持っていただけたなら幸いです。

イベントに参加していただいて、もしくは、この記事をご覧になってUKオフィスで働くことに興味を持ってくださった方は、ぜひ以下のリンクからご応募ください。

info.cookpad.com

クックパッドでは今後も様々なテーマで継続的にイベントを開催していく予定です。開催予定のイベントの詳細は以下のリンクからチェックできます。ご興味をお持ちいただけましたら、お気軽にお越しください。

クックパッド株式会社 - connpass

最後に最近ドローンで撮影したUKオフィスでの昼食時の写真を掲載して、この記事を締めたいと思います。

f:id:yuseinishiyama:20180305182218p:plain

Nginxへの変更に伴うリバースプロキシのテストの改善

Nginxへの変更に伴うリバースプロキシのテストの改善

SREグループの菅原です。 クックパッドではブラウザ用Webサイトのリバースプロキシ用のWebサーバとして長らくApacheを使っていたのですが、最近、Nginxへと変更しました。

Nginxへの変更に当たって、構成管理の変更やテストの改善を行ったので、それらについて書きたいと思います。

リバースプロキシのリニューアルについて

まず、ブラウザ用Webサイトの基本的なサーバ構成は以下のようになります。

f:id:winebarrel:20180227123255p:plain

リバースプロキシはELB経由でリクエストを受けて、静的ファイルの配信やキャッシュサーバ・Appサーバへの振り分けを行います。

リバースプロキシとして利用されているApacheは、長年の改修により設定が煩雑なものとなっており、設定の追加や変更にコストがかかる状態になっていました。

また、Apacheの設定ファイルはItamaeでは管理されておらず、ItamaeのレシピがあるGitリポジトリとは別に、Apacheの設定ファイルだけを格納したGitリポジトリで管理され、Capistranoで設定を配布する方式になっていました。これは当初、サーバ全体の構成管理(当時はPuppet)の適用タイミングと、プロキシサーバの設定の変更タイミングが異なると考えてのことだったのですが、現状では単に管理が複雑になるだけでメリットがない状態になっていました。

この状況を踏まえ、リバースプロキシのOSなどのリニューアルをするタイミングで、より平易に設定を書くことができるNginxへ変更し、また、Nginxの設定ファイルについてはItamaeの管理下に置くことにしました。

既存のリバースプロキシのテストについて

以前の記事でも取り上げられたのですが、リバースプロキシの設定はInfratasterでテストが行われています。

InfratasterのテストにはDocker Composeを使用しており、以下のようなコンテナの構成になっていました。

f:id:winebarrel:20180227123314p:plain

前述の通り、Apacheの設定はItamaeとは別のGitリポジトリで管理されており、cookpad.comを含む主要なサービスの設定が同じリポジトリに含まれています。

[リバースプロキシリポジトリ]
├── cookpad/
│   ├── conf/
│   │   └── httpd.conf
│   └── conf.d/
│       └── xxx.conf
├── other_service_a/
│   ├── conf/
│   │   └── httpd.conf
│   └── conf.d/
│       └── xxx.conf
├── other_service_b/
│   ├── conf/
│   │   └── httpd.conf
│   └── conf.d/
│       └── xxx.conf
└── spec/
    ├── cookpad_spec.rb
    ├── other_service_a_spec.rb
    ├── other_service_b_spec.rb
    ├── docker/
    │   ├── apache/
    │   │   └── Dockerfile
    │   ├── backend
    │   │   └── Dockerfile
    │   └── taster/
    │       └── Dockerfile
    └── docker-compose.yml

テスト用のdocker-compose.ymlでは、cookpad.comを含むサービス毎のコンテナを、設定ファイルのディレクトリをマウントする形で起動し、InfratasterからRSpecを実行するようになっています。

リバースプロキシからダミーバックエンドにアクセスする場合は、Docker Composeのlink_local_ips設定を使ってダミーバックエンドのコンテナにIPアドレスを割り当て、リバースプロキシの/etc/hostsを書き換えることで、リバースプロキシからバックエンドへの問い合わせを、ダミーバックエンドに差し替えるようにしていました。*1

# docker-compose.yml
backend:
  networks:
    bridge:
      link_local_ips:
        - 169.254.100.100
        - 169.254.100.101
        - 169.254.100.102
        ...

 

# /etc/hosts
169.254.100.100 app-server-001
169.254.100.101 cache-server-001
169.254.100.102 ad-server-001
...

コンテナへのItamaeの適用

Itamae管理下に置かれたNginxの設定ファイルをテストするには、既存の方式のようにディレクトリをマウントするのではなく、コンテナに対してItamaeを適用する必要があります。

ItamaeレシピのGitリポジトリは、だいたい以下のようなファイル構成になっています。

[Itamaeリポジトリ]
├── ...
└── itamae/
    ├── function.rb
    ├── cookbooks/
    │   └── nginx/
    │       ├── default.rb
    │       ├── files/
    │       └── templates/
    └── roles/
        └── apne1_vpc-xxx/
            └── rproxy/
                ├── default.rb
                ├── td-agent.rb
                ├── files/
                └── templates/

これをCapistranoを使って、適用対象のサーバ上でitamae local function.rb roles/apne1_vpc-xxx/default.rbというようなコマンドを実行して、レシピを適用します。

function.rbはItamaeのヘルパーが定義されており

module RecipeHelper
  def include_role(name)
    include_role_or_cookbook(name, "role")
  end

  def include_cookbook(name)
    include_role_or_cookbook(name, "cookbook")
  end

  def include_role_or_cookbook(name, type)
    Pathname.new(File.dirname(@recipe.path)).ascend do |dir|
      names = name.split("::")
      names << "default" if names.length == 1

      if type == "cookbook"
        recipe_file = dir.join("cookbooks", *names)
      else
        recipe_file = dir.join(*names)
      end
      if recipe_file.exist?
        include_recipe(recipe_file.to_s)
        return
      end
    end

    raise "#{name} is not found."
  end
end

Itamae::Recipe::EvalContext.send(:include, RecipeHelper)

Itamaeのレシピ内で

include_cookbook 'nginx'
include_recipe 'rproxy::td-agent'

と書くことにより、roles/cookbooks/配下のレシピを読み込めるようにしていました。

これらのItamaeのレシピをコンテナに適用する場合、以下の問題点があります。

  1. Nodeオブジェクトに含まれるサーバのメタ情報(例: node[:ec2])が、コンテナ適用時には含まれない
  2. Nginx以外の不要なレシピ(例: zabbix-agentなど)が適用されてしまう

1の問題については、Nodeクラスを書き換えることによって回避しました。

以下のコードをItamae適用時に読み込ませることで、レシピからEC2のメタ情報などを参照する場合に、未定義のときはnilを返すのではなく、ダミー値(key)が返るようにして、レシピの適用が失敗しないようにしました。

module FakeNode
  module Value
    def [](key)
      key.to_s
    end
    alias :fetch :[]
  end

  def [](key)
    value = super

    if value.nil? && !self.mash.has_key?(key)
      case key
      when :http_proxy
        nil
      when :rspec
        true
      else
        key.to_s.tap do |v|
          v.extend(Value)
        end
      end
    else
      value
    end
  end
end

Itamae::Node.prepend FakeNode

また、2の問題については、SKIP_RECIPESという環境変数を定義して、そこに含まれるレシピはItamaeでは適用しないようにヘルパーを修正しました。

module RecipeHelper
  SKIP_RECIPES = ENV.fetch('SKIP_RECIPES', '').split(',')

  def include_role_or_cookbook(name, type)
    return if SKIP_RECIPES.include?(name)

上記の修正などにより、既存のItamaeレシピに大きな修正をすることなく、サーバ同様にコンテナにもItamaeを適用できるようになりました。

DockerfileでのItamaeを適用する箇所は以下のようなコードになります。

ENV SKIP_RECIPES haproxy,td-agent,zabbix-agent

RUN cd /infra2 && \
    itamae local \
      spec/itamae/fake_node.rb \
      itamae/functions.rb \
      itamae/roles/apne_vpc-xxx/rproxy/default.rb

テストの改善

既存のリバースプロキシのテストをItamaeリポジトリに移行するに当たって、以下の点を改善するようにしました。

  1. 複数のサービスを同じdocker-compose.ymlで定義するのをやめて、環境構築の時間を短縮し、テストの相互依存をなくす
  2. link_local_ips/etc/hostsを使った経路の差し替えをやめ、エンドポイントのホスト名そのままでダミーサーバにアクセスできるようにする
  3. 実際のサーバ構成を再現するようにコンテナを構成して、SSL TerminationやIPの偽装などの処理をリバースプロキシのコンテナに持ち込まない

最終的にテストまわりのコンテナ構成・ファイル構成は以下のようになりました。

f:id:winebarrel:20180227123328p:plain

[itamae/spec]
├── backend/
│   ├── Dockerfile
│   └── files/
│       ├── backend.rb
│       └── init.sh
├── internal_service_proxy/
│   ├── Dockerfile
│   └── files/etc/nginx/conf.d/default.conf.tmpl
├── itamae/
│   └── fake_node.rb
├── rproxy/
│   ├── Dockerfile
│   ├── docker-compose.yml
│   ├── files/
│   │   ├── etc/haproxy/haproxy.cfg
│   │   └── init.sh
│   └── spec/
│       ├── cookpad_spec.rb
│       └── spec_helper.rb
├── ssl/
│   ├── cookpad.com.crt
│   ├── cookpad.com.key
│   └── root-ca.crt
└── taster/
    └── Dockerfile

またdocker-compose.ymlは以下のようになりました。

version: '3'
services:
  backend:
    build:
      context: ../backend
    networks:
      - spec-network
  internal-service:
    environment:
      SERVERS: cache-server-001:backend
    build:
      context: ../internal_service_proxy
    depends_on:
      - backend
    networks:
      spec-network:
        aliases:
          - cache-server-001
  rproxy:
    build:
      context: ../..
      dockerfile: spec/rproxy/Dockerfile
    depends_on:
      - backend
      - internal-service
    networks:
      - spec-network
  elb:
    environment:
      SERVERS: cookpad.com:rproxy
    build:
      context: ../ssl_termination_proxy
    volumes:
      - ../ssl:/ssl
    depends_on:
      - rproxy
    networks:
      spec-network:
        aliases:
          - cookpad.com
  taster:
    build:
      context: ../..
      dockerfile: spec/taster/Dockerfile
    volumes:
      - ./spec:/spec
    working_dir: /spec
    depends_on:
      - elb
    networks:
      - spec-network
networks:
  spec-network:

elbコンテナ(ssl_termination_proxy/

elbコンテナは、SSL Terminationを行うコンテナで、ELBの役割を担うコンテナです。 Docker Composeのaliasesを利用して、実際と同様のホスト名でコンテナにアクセスできるようにしています。

コンテナで動くNginxの設定には、以下のようにテンプレートを用意して*2

{{ range $server_name, $backend := var "SERVERS" | split "," | splitkv ":" }}
server {
  listen 80;
  listen 443 ssl;
  server_name {{ $server_name }};
  ssl_certificate /ssl/{{ $server_name }}.crt;
  ssl_certificate_key /ssl/{{ $server_name }}.key;

  underscores_in_headers on;

  location / {
    # set external network ip address
    set $custom_x_forwarded_for "93.184.216.34";

    if ($http_x_test_client_ip != "") {
      set $custom_x_forwarded_for $http_x_test_client_ip;
    }

    proxy_set_header X-Forwarded-Host $host:$server_port;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $custom_x_forwarded_for;
    proxy_set_header Host $host;
    proxy_pass http://{{ $backend }};
  }
}
{{ end }}

環境変数SERVERSの値(ホスト名:バックエンド,...)で各サービスのserverproxy_passを定義し、また、ホスト名毎に、あらかじめ用意しておいたSSL証明書を読み込むようにして、httpsでもアクセスできるようにしています。 (ルート証明書はtasterコンテナに含めています)

また、クライアントからのリクエストにX-Test-Client-IPというヘッダをつけることで、任意のIPからリバースプロキシにリクエストが投げられたように見せかけるようにもしています。

この機能のため、ngx_http_realip_moduleを読み込む箇所のItamaeレシピでは、テスト用コンテナへの適用時にset_real_ip_fromのレンジを広げるようにしています。

real_ip_header X-Forwarded-For;
set_real_ip_from xx.xx.xx.xx/xx;
<%- if node[:rspec] -%>
set_real_ip_from 0.0.0.0/0;
<%- end -%>
real_ip_recursive on;

rproxyコンテナ(rproxy/

rproxyコンテナは、テスト対象となるリバースプロキシのコンテナです。前述のItamaeレシピの適用を行ったNginxサーバが動作します。 cookpad.com用のdocker-compose.ymlはこのディレクトリに置いて、cookpad.com関連以外のコンテナの定義が含まれないようにしました。

rproxyコンテナのNginxの設定はItamaeレシピで適用されるため、実際のサーバと同じですが、HAProxyの設定だけはItamaeレシピのものを使わずに、テスト専用に用意しています。

listen www
  bind :8080
  mode http
  balance roundrobin
  http-request set-header X-Via-Haproxy 'localhost:www:8080'
  server app-server-001 backend:80 check inter 5s fall 3

この設定により、localhost:8080へのアクセスはbackendコンテナに投げられるようになります。

internal-serviceコンテナ(internal_service_proxy

internal-serviceコンテナは、HAProxyを経由しないキャッシュサーバへのリクエストを受け付けるコンテナです。基本的にはelbコンテナと同じような構成で、SSL Terminationの機能が除かれています。

Nginxの設定は以下のようなテンプレートで、elbコンテナと同様に環境変数 SERVERSserverproxy_passを定義しています。

{{ range $server_name, $backend := var "SERVERS" | split "," | splitkv ":" }}
server {
  listen 80;
  server_name {{ $server_name }};

  location / {
    set $custom_x_forwarded_for $proxy_add_x_forwarded_for;

    if ($http_x_test_client_ip) {
      set $custom_x_forwarded_for $http_x_test_client_ip;
    }

    proxy_set_header X-Dest {{ $server_name }};
    proxy_pass http://{{ $backend }};
  }
}
{{ end }}

また、aliases設定を使って、プロダクションサーバのホスト名そのままで、internal-serviceコンテナにアクセスできるようにしています

backendコンテナ(backend/

backendコンテナは、各種サーバを偽装するダミーサーバで、Webrickで書いています。

#!/usr/bin/env ruby
require 'webrick'
require 'json'
require 'mime/types'

URI_ATTRS = %w(
  scheme
  userinfo
  host
  port
  registry
  path
  opaque
  query
  fragment
)

server = WEBrick::HTTPServer.new(Port: 80)
trap(:INT){ server.shutdown }

server.mount_proc('/') do |req, res|
  uri = req.request_uri

  res.body = JSON.pretty_generate({
    request_method: req.request_method,
    uri: URI_ATTRS.map {|k| [k, uri.send(k)] }.to_h,
    header: req.header,
    body: req.body,
  })

  res.keep_alive = false

  mime_type = MIME::Types.type_for(uri.path).first
  res.content_type = mime_type.to_s if mime_type

  x_test_set_cookie = req['x-test-set-cookie']
  res['set-cookie'] = x_test_set_cookie if x_test_set_cookie

  x_test_status = req['x-test-status']
  res.status = x_test_status.to_i if x_test_status
end

server.start

基本的にリクエストの情報をJSONで返すだけのサーバですが、X-Test-...というリクエストヘッダが来た場合に、任意のCookieやステータスコードを返せるようにして、異常系などのテストパターンに対応しています。

tasterコンテナ(taster/

tasterコンテナは、Infratasterを実行するコンテナです。 Infratasterと、rproxy/配下のspecを含むようにしています。

RSpec

リバースプロキシをテストするためのspecファイルは以下のようになります。

# spec_helper.rb
require 'infrataster/rspec'

%w(
  cookpad.com
  xxx.cookpad.com
).each do |server|
  Infrataster::Server.define(server, server)

  RSpec.shared_examples "#{server} normal response" do
    it 'returns 200' do
      expect(response.status).to eq(200)
    end

    it "accesses #{server}" do
      expect(request_uri.fetch('host')).to eq(server)
    end
  end
end

 

# cookpad_spec.rb
describe server('cookpad.com') do
  let(:body_as_json) { JSON.parse(response.body) }
  let(:request_uri) { body_as_json.fetch('uri') }

  describe 'normal' do
    describe http('https://cookpad.com') do
      it_behaves_like 'cookpad.com normal response'
      it_behaves_like 'https'

      it "doesn't cache" do
        expect(response.headers).not_to have_key('cache-control')
      end
    end
  end

  describe 'error pages' do
    describe http('https://cookpad.com/error', headers: {'X-Test-Status' => 500}) do
      it "return front 500 page" do
        expect(response.status).to eq(500)
        expect(response.body.strip).to eq("fw_errors/500.html")
      end
    end
  end
end

aliases設定を使ったことで、コンテナのIPなどを意識することなくテストを記述することができます。

RSpecはdocker-composeをつかって以下のように実行します。

docker-compose run taster rspec -I. -r spec_helper .

まとめ

今回の作業により、煩雑だった設定ファイルの見通しがよくなり、設定の追加などが大分楽になりました。 また、テストまわりの改善をしたことで、テスト環境の構築に時間がかかったり、原因不明でテストがコケるようなことがなくなり、テストに付随するyak shavingも減らせました。 以前の「設定を変更する→テストがめんどくさい→テストをサボる/設定の追加を諦める」というような負のスパイラルをうまく断ち切れた気がします。

ちょっと実行したテストの結果がFFFFFFFFFFFFFFF...になっていると、精神に大変なダメージを受けるので、未来の自分のメンタルヘルスを保つために、今後も改善を続けていきたいところです。

*1:link_local_ipsはCompose file version 3でサポートされなくなりました

*2:テンプレートを使うためにEntrykitを利用しています