Simple minds think alike

より多くの可能性を

Github ActionsでGolangプロジェクトをHerokuに自動デプロイしてみた

前々回、前回の記事でHerokuでGolangプロジェクトを動かすのと、Heroku Container RegistoryにDockerイメージをPushし、web dynoにリリースするというのをやりました。

今回は、Github Actionsを使ってHerokuに自動デプロイしてみます。

simple-minds-think-alike.hatenablog.com

simple-minds-think-alike.hatenablog.com

.githubディレクトリを追加

自動デプロイというと大げさに聞こえますが、前回まででHerokuのgitリポジトリにpushすれば自動的にweb dynoにデプロイするようになっているので、PRがmasterブランチにマージされた際に自動的にgit pushするようにするだけです。

プロジェクトにファイル.github/workflows/heroku-push.ymlを追加し、 akhileshns/heroku-deploy を使ってHerokuのgitリポジトリへの自動pushが実行されるようにします。

name: Heroku push
on:
  push:
    branches:
      - master
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: akhileshns/heroku-deploy@v3.7.8
        with:
          heroku_api_key: ${{secrets.HEROKU_API_KEY}}
          heroku_app_name: "golang-sample-202005"
          heroku_email: ${{secrets.HEROKU_EMAIL}}

Secretsの設定

GithubのSetting/Secretsに、環境変数 HEROKU_API_KEY, HEROKU_EMAIL を登録しておきます。

masterにPRをマージしてみる

PRをマージすると、Actionsでworkflowが動き始め、以下のようなログが表示されます。

Heroku上で

  • Dockerfileのビルド
  • Heroku Container Registoryへの登録
  • web dynoへの反映

が自動的に行われます。

参考情報

Docker イメージのレイヤーの概念とファイルの整理

Docker イメージのレイヤーの理解を深めるために、Dockerホストのコンソール上で牛にMooと言わせるだけのコンテナを動かし、そのコンテナを例にとってDockerイメージのレイヤーの状態を整理してみました。

いまいちDockerイメージのレイヤーの概念がよく分からないという方に参考になれば幸いです。 (※各ファイルのディレクトリの構成は、現在ストレージドライバの標準になっているoverlay2を使った場合の例です。)

サンプルのDockerコンテナで実現すること

牛に"Moo"と言わせるだけです。

Dockerfileを作る

Dockerホストの任意のディレクトリ(ここで は cowsay としました)に、以下のようなDockerfileを作ってみます。

FROM debian:buster

RUN apt-get update && apt-get install -y cowsay
  • debian:busterのイメージをDocker hubから取得
  • 牛を表示するアプリケーションcowsay をインストール

というシンプルなDockerfileです。

Dockerfileをビルドし、イメージを作る

Dockerfileがあるディレクトリ内に移動し、作ったDockerfileをビルドし、イメージを作ります。

$ cd cowsay
$ docker build -t test/cowsay .
Sending build context to Docker daemon  2.048kB
Step 1/2 : FROM debian:buster
 ---> 6d6b00c22231
Step 2/2 : RUN apt-get update && apt-get install -y cowsay
 ---> Using cache
 ---> 5b2abe87f6d2
Successfully built 5b2abe87f6d2
Successfully tagged test/cowsay:latest

まずは docker images を実行して、test/cowsayのDockerイメージが出来ていることを確認します。

$ docker images test/cowsay:latest

REPOSITORY    TAG       IMAGE ID       CREATED          SIZE
test/cowsay   latest    20961704943e   16 seconds ago   179MB

次に docker historyを実行してイメージの状態を確認してみます。2つのイメージレイヤーが出来ていることを確認できました。

$docker history test/cowsay:latest

IMAGE          CREATED          CREATED BY                                      SIZE      COMMENT
5b2abe87f6d2   27 seconds ago   /bin/sh -c apt-get update && apt-get install…   64.5MB    
6d6b00c22231   6 days ago       /bin/sh -c #(nop)  CMD ["bash"]                 0B        
<missing>      6 days ago       /bin/sh -c #(nop) ADD file:6014cd9d7466825f8…   114MB  

この2つのイメージレイヤーはそれぞれDockerホストの異なるディレクトリにファイルの差分が保存されます。

  • 上の方のイメージレイヤー(IMAGE: 5b2abe87f6d2)は、牛が表示されるアプリケーションcowsayをインストールした後のレイヤー
  • 下の方のイメージレイヤ(IMAGE: 6d6b00c22231)は、ベースイメージdebian:busterをPULLした後のレイヤー

です。

それぞれのレイヤーの差分がDockerホスト上のどのディレクトリは保存されるかは、コンテナが出来た後docker container inspect というコマンド( 後述)を実行して確認できます。

Dockerホスト上のディレクトリを参照すると、各レイヤーはそれぞれ差分の情報しかもっておらず

  • 上の方のイメージレイヤー(IMAGE: 5b2abe87f6d2)には、アプリケーションcowsayがあるが、PULLしたベースイメージ debian:busterのファイルはない
  • 下の方のイメージレイヤ(IMAGE: 6d6b00c22231)は、ベースイメージdebian:busterのファイルはあるがcowsay はない

ということがわかります。

この例はシンプルなDockerfileなのでレイヤーが少ないですが、Dockerfileが大きくなるほど、レイヤーは多くなり、複数のレイヤーの差分を重ねることで1つのDockerイメージができます。

コンテナを触ってみる

bashで入る① (cowsayがあるイメージからコンテナを作る)

イメージからコンテナを作り、 bash で入ってみます。この際、docker historyの上の方のイメージ(5b2abe87f6d2: cowsayインストール後にできたイメージ)を指定してみます。

$ docker run -it 5b2abe87f6d2 /bin/bash

root@b2b28d1d223f:/# ls /usr/games/cowsay
/usr/games/cowsay

docker historyの上の方のイメージ(5b2abe87f6d2)を指定すると、下のイメージの差分も入っていることを確認できます。( bash コマンドは、ベースイメージdebian:busterの中にあります。)

コンテナに入る際、以下のように名前でイメージを指定しても結果は同じです。

$ docker run -it test/cowsay /bin/bash

bashで入る② (cowsayがないイメージからコンテナを作る)

次に、docker historyの下の方のイメージ(6d6b00c22231: cowsayインストール前のイメージ)を指定してみます。

$ docker run -it 6d6b00c22231 /bin/bash

root@4f9452a93ebd:/# ls /usr/games/cowsay
ls: cannot access '/usr/games/cowsay': No such file or directory

これは cowsay インストール前なので、 cowsay の実行ファイルがないことが確認できます

Dockerホストから cowsay を実行してみます

$ docker run 6d6b00c22231 /usr/games/cowsay "Moo"
 _____
< Moo >
 -----
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

cowsay インストール前のイメージを指定するとエラーが返ります。

$ docker run 6d6b00c22231 /usr/games/cowsay "Moo"
docker: Error response from daemon: OCI runtime create failed: container_linux.go:370: starting container process caused: exec: "/usr/games/cowsay": stat /usr/games/cowsay: no such file or directory: unknown.
ERRO[0000] error waiting for container: context canceled

Dockerホスト上のディレクトリの場所の確認方法

docker container inspect にコンテナ名を指定すると関連するファイルの場所を確認できます。

$ docker container ls -l
CONTAINER ID   IMAGE         COMMAND                  CREATED       STATUS                   PORTS     NAMES
c23fee232a48   test/cowsay   "/usr/games/cowsay M…"   8 hours ago   Exited (0) 8 hours ago             awesome_banach

ドキュメントを参照すると、イメージレイヤのファイルはLowerDirに保存されるようです。

$ $ docker container inspect awesome_banach -f "{{json .GraphDriver.Data}}" | jq .

{
  "LowerDir": "/var/lib/docker/overlay2/4e1a74ac2ac471b3484c196250a7e1be8f36eb8dc64ade4fcc4bdec1e4a95010-init/diff:/var/lib/docker/overlay2/d867fc751ea99450c01330ebdac487c1f12db47f35eeab6c628683a3f762310c/diff:/var/lib/docker/overlay2/6431aa52a32ab412806f379a4830b6cc059c3fb5f9380c8d5b8df298ae005e5b/diff",
  "MergedDir": "/var/lib/docker/overlay2/4e1a74ac2ac471b3484c196250a7e1be8f36eb8dc64ade4fcc4bdec1e4a95010/merged",
  "UpperDir": "/var/lib/docker/overlay2/4e1a74ac2ac471b3484c196250a7e1be8f36eb8dc64ade4fcc4bdec1e4a95010/diff",
  "WorkDir": "/var/lib/docker/overlay2/4e1a74ac2ac471b3484c196250a7e1be8f36eb8dc64ade4fcc4bdec1e4a95010/work"
}

参考資料

【トラブルシューティング】Docker (daemon) の手動起動、デバッグ・ログ出力に関して調べてみた

Dockerのトラブルシューティングがうまくできるようになりたいと思い

に関して調べてみました。

今回使用したdockerのバージョンは20.10.0です。2020/12/15時点での情報のため、dockerのコマンドや設定ファイルのパス等、今後変更になる可能性があります。

Dockerエンジンの基本的な構成

まず、Dockerエンジンは、以下の図のように主に3つのコンポーネントから構成されています。

  • Docker CLI (dockerコマンド): コンテナやイメージ、ネットワーク等の操作をHTTP経由のAPIを介して、Docker daemonと通信するクライアント。
  • Dockerエンジン API: CLIやプログラムからのHTTP経由で要求を受け付ける。
  • Docker daemon: コンテナの生成、実行、モニタリング等を行い、結果を返す。

今回調べたトラブルシューティング時に追えるようになる情報は、Docker daemonの部分です。

デーモンのログ出力を詳細を見れることでデーモンが起動していない場合でも発生したエラーの内容を見れるので、特に普段dockerコマンド(Docker CLI)しか使わないという場合、トラブル対応時に役に立つかと思います。

なお、Docker CLIdocker logs はコンテナのログを出力するものなので、デーモンのログとは別のものです。docker logs にはコンテナ作成時のエラー等は出力されません。

Docker daemonの起動(自動起動)

Dockerをインストールするとシステムユーティリティー(多くのLinuxのディストリの場合Systemd)がOS起動時に自動的にデーモンを起動しています。

試しにdocker.serviceのステータスを確認すると

$ sudo systemctl status docker

● docker.service - Docker Application Container Engine
   Loaded: loaded (/lib/systemd/system/docker.service; enabled; vendor preset: enabled)
   Active: active (running) since Tue 2020-12-15 16:29:17 JST; 6h ago
     Docs: https://docs.docker.com
 Main PID: 1740 (dockerd)
    Tasks: 80
   CGroup: /system.slice/docker.service
           ├─1740 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
           ├─2987 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 15432 -container-ip 172.22.0.3 -container-port 5432
           └─3059 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 1080 -container-ip 172.22.0.4 -container-port 80

1215 16:29:12 takashi dockerd[1740]: time="2020-12-15T16:29:12.851824229+09:00" level=warning msg="Your kernel does not support swap memory limit"

Main PID: 1740 (dockerd) という出力があり、デーモンが動いているのが確認できるかと思います。

Docker daemonの手動起動

もし、systemd自動起動 or systemd start docker で デーモンが起動できない場合などに、手動でdockerd コマンドを実行して起動することもできます。

基本的には1つのdockerホスト内で複数のdocker daemonを起動できないため、systemdから起動されたデーモンが起動中の場合、新しいデーモンの起動には失敗します。

$ sudo dockerd
INFO[2020-12-15T23:03:29.443621640+09:00] Starting up                                  
failed to start daemon: pid file found, ensure docker is not running or delete /var/run/docker.pid

一旦 systemddocker.service を停止すると起動でき、起動時のログがコンソールに出力されます。

sudo systemctl stop docker
takashi@tamo:/var/lib/docker$ sudo dockerd
INFO[2020-12-15T23:05:18.685050913+09:00] Starting up
INFO[2020-12-15T23:05:18.685967243+09:00] detected 127.0.0.53 nameserver, assuming systemd-resolved, so using resolv.conf: /run/systemd/resolve/resolv.conf 
︙

Docker daemonの設定

自動起動の場合

Linuxの場合 /etc/docker/daemon.json をデーモンの設定ファイルとして自動的に読み込みます。(Mac, Windowの場合はGUI上のPreferences / Daemon / Advancedから設定)

設定できるオプションは様々ありますが、トラブルシューティング時に

  • デバッグモードを有効にし、デーモンからより詳細な出力を得られるようにする(デフォルトは無効)
  • ログレベルをdebugにする(デフォルトはinfo)

という場合は、以下のように記載した設定ファイルを配置します。

{
  "debug": true,
  "log-level": "debug"
}

※設定ファイルの他のオプションに関しては、こちらに記載されています。

Linuxの場合、以下のコマンドでデーモンに対して HUP シグナルを送信し、設定を再読み込みさせることができます。

$ sudo kill -SIGHUP $(pidof dockerd)

手動起動の場合

docker daemon は、設定ファイルを自動的に読み込むようになっていますが、同時に手動起動の場合はオプションも併せて指定することができます。

$ dockerd --debug --log-level debug

設定でオプションを変更したらデーモンが起動しなくなった場合などは、手動起動でどのオプションを変更したら動かなくなるかを確認すると良さそうです。

自動的に読み込まれる設定ファイルとオプションが重複する場合は、以下のようなエラーが発生し、デーモン起動は失敗します。

unable to configure the Docker daemon with file /etc/docker/daemon.json:
the following directives are specified both as a flag and in the configuration
file: hosts: (from flag: [unix:///var/run/docker.sock], from file: [tcp://127.0.0.1:2376])

デーモンのログのパス/確認方法

ログファイルのパス/確認方法はOS毎に異なります。

他のOSのログファイルに関しては、詳細はドキュメント を参照してください。

スタックトレースの確認

デーモンの反応がない場合は、Linuxだと以下のコマンドでスタックトレースを強制的に出力できます。スタックトレースがログに出力されますが、デーモンは停止しません。

$ sudo kill -SIGUSR1 $(pidof dockerd)

Docker deamonの起動確認

いくつかの方法で起動を確認できます。Docker CLIのコマンドを実行して、反応がなかったり、エラーが発生した場合に起動しているか状態を確認すると良さそうです。

  • OSに依らない確認方法
    • Docker CLIを使う
      • docker info
  • OSに依る確認方法
    • Systemdを使う
      • sudo systemctl is-active docker
      • sudo systemctl status docker
      • sudo service docker status
    • プロセスの状態を確認する
      • ps
      • top

参考資料

【node-sass】 nodeバージョンを上げた時に発生したError: Can't find Python executable "python", you can set the PYTHON env variable.の直し方

最近触っていなかったReactアプリを久しぶりにheroku (Container Registry) 環境にデプロイしようとしたら、Dockerfileのreact-scripts buildを実行している箇所で、以下のエラーが発生するようになっていました。

remote: npm ERR! gyp verb check python checking for Python executable "python" in the PATH
remote: npm ERR! gyp verb `which` failed Error: not found: python
remote: npm ERR! gyp verb `which` failed     at getNotFoundError (/node_modules/which/which.js:13:12)
remote: npm ERR! gyp verb `which` failed     at F (/node_modules/which/which.js:68:19)
remote: npm ERR! gyp verb `which` failed     at E (/node_modules/which/which.js:80:29)
remote: npm ERR! gyp verb `which` failed     at /node_modules/which/which.js:89:16
remote: npm ERR! gyp verb `which` failed     at /node_modules/isexe/index.js:42:5
remote: npm ERR! gyp verb `which` failed     at /node_modules/isexe/mode.js:8:5
remote: npm ERR! gyp verb `which` failed     at FSReqCallback.oncomplete (node:fs:192:21)
remote: npm ERR! gyp verb `which` failed  python Error: not found: python
remote: npm ERR! gyp verb `which` failed     at getNotFoundError (/node_modules/which/which.js:13:12)
remote: npm ERR! gyp verb `which` failed     at F (/node_modules/which/which.js:68:19)
remote: npm ERR! gyp verb `which` failed     at E (/node_modules/which/which.js:80:29)
remote: npm ERR! gyp verb `which` failed     at /node_modules/which/which.js:89:16
remote: npm ERR! gyp verb `which` failed     at /node_modules/isexe/index.js:42:5
remote: npm ERR! gyp verb `which` failed     at /node_modules/isexe/mode.js:8:5
remote: npm ERR! gyp verb `which` failed     at FSReqCallback.oncomplete (node:fs:192:21) {
remote: npm ERR! gyp verb `which` failed   code: 'ENOENT'
remote: npm ERR! gyp verb `which` failed }
remote: npm ERR! gyp ERR! configure error 
remote: npm ERR! gyp ERR! stack Error: Can't find Python executable "python", you can set the PYTHON env variable.
remote: npm ERR! gyp ERR! stack     at PythonFinder.failNoPython (/node_modules/node-gyp/lib/configure.js:484:19)
remote: npm ERR! gyp ERR! stack     at PythonFinder.<anonymous> (/node_modules/node-gyp/lib/configure.js:406:16)
remote: npm ERR! gyp ERR! stack     at F (/node_modules/which/which.js:68:16)
remote: npm ERR! gyp ERR! stack     at E (/node_modules/which/which.js:80:29)
remote: npm ERR! gyp ERR! stack     at /node_modules/which/which.js:89:16
remote: npm ERR! gyp ERR! stack     at /node_modules/isexe/index.js:42:5
remote: npm ERR! gyp ERR! stack     at /node_modules/isexe/mode.js:8:5
remote: npm ERR! gyp ERR! stack     at FSReqCallback.oncomplete (node:fs:192:21)
remote: npm ERR! gyp ERR! System Linux 4.14.177-139.254.amzn2.x86_64
remote: npm ERR! gyp ERR! command "/usr/local/bin/node" "/node_modules/node-gyp/bin/node-gyp.js" "rebuild" "--verbose" "--libsass_ext=" "--libsass_cflags=" "--libsass_ldflags=" "--libsass_library="
remote: npm ERR! gyp ERR! cwd /node_modules/node-sass
remote: npm ERR! gyp ERR! node -v v15.3.0
remote: npm ERR! gyp ERR! node-gyp -v v3.8.0
remote: npm ERR! gyp ERR! not ok 
remote: npm ERR! Build failed with error code: 1
remote: 
remote: npm ERR! A complete log of this run can be found in:
remote: npm ERR!     /root/.npm/_logs/2020-12-03T17_30_50_380Z-debug.log

原因を色々調べてみるとどうやら Dockerfile に指定していた node:alpine イメージのnodeバージョン v15.3.0 に変わったことで、node-sass のバージョンとの不整合が起こるようになったことが原因でした。

FROM node:alpine AS node_builder

node-sassのgithubリポジトリを見るとたしかに node-sass 4.14.1は、nodeバージョン v14までしか対応していないと書いてありました。

node-sassのgithubリポジトリのnodeバージョンの対応表記

node-sass 5.0.0 が出たばかりということもあって、 node-sass の方のバージョンを上げるのも怖いので、node のバージョンを14まで下げました。

FROM node:14.15.1-alpine AS node_builder

このようにDockerfileを変えたら、heroku環境デプロイ時の react-scripts build 実行時にエラーが発生せず、デプロイできるようになりました。

参考

AWSのEC2 macインスタンスにVNCクライアントから接続してみた

re:Invent 2020の前夜祭でAmazon EC2 Mac Instancesが公開されたということを聞き、iOS用アプリを開発する時とかに便利そうだな〜と思い、試しに触ってみました。最後に使用感に関しても書いてますので、よかったら参考にしてみて下さい!

試すにあたって、以下のAWSドキュメントを参照しました。

aws.amazon.com

2020/12/02現在、利用できるリージョンが限られており、アメリカ東部(バージニア北部)、アメリカ東部(オハイオ)、アメリカ西部(オレゴン)、ヨーロッパ(アイルランド)、アジアパシフィック(シンガポール)で利用可能です。

ゴール

VNCクライアントからEC2 macインスタンスに接続するまでに試した手順を書いてみます。

前提

  • AWS CLI (2.1.6)インストール済
  • aws configureでAccessキー、シークレット設定済
  • EC2ダッシュボードからキーペアを作成済

EC2 macインスタンス作成

専有ホストの割り当て

EC2 macインスタンスを作るには、専有ホストを割り当てる必要があるのですが、米国東部(バージニア北部) us-east-1 リージョンで作ろうとしたところ以下のエラーが発生しました。 Insufficient capacityというエラーなので mac1.metal の専有ホストを作れる最大の数でもあるんでしょうか。

$ aws ec2 allocate-hosts --instance-type mac1.metal \
>   --availability-zone us-east-1a --auto-placement on \
>   --quantity 1 --region us-east-1

An error occurred (InsufficientHostCapacity) when calling the 
AllocateHosts operation (reached max retries: 4): Insufficient capacity

なので、アメリカ東部(オハイオus-east-2 リージョンでインスタンスを作ってみました。しかし、最初オハイオにおいてもAvailability Zoneに us-east-2a を指定した時、以下のエラーが発生しました。

$ aws ec2 allocate-hosts --instance-type mac1.metal   --availability-zone us-east-2a --auto-placement on   --quantity 1 --region us-east-2

An error occurred (UnsupportedHostConfiguration) when calling 
the AllocateHosts operation: 
Your requested instance type (mac1.metal) is not supported in 
your requested Availability Zone (us-east-2a). 
Please retry your request by not specifying an Availability Zone 
or choosing us-east-2b, us-east-2c.

us-east-2a では、macインスタンス用の専有ホストを作れないようなので、以下のように us-east-2b で作りました。

$ aws ec2 allocate-hosts --instance-type mac1.metal   --availability-zone us-east-2b --auto-placement on   --quantity 1 --region us-east-2
{
    "HostIds": [
        "h-03089f5ba21964402"
    ]
}

macインスタンスの起動

無事専有ホストが割当られたので、AWS CLImacインスタンスを起動しようと試みました。が、再度エラー発生。

$ aws ec2 run-instances --region us-east-2 \
  --instance-type mac1.metal \
  --image-id  ami-00692c69a6f9c6ea1 \
  --key-name macos_catalina  --associate-public-ip-address

An error occurred (Unsupported) when calling the
 RunInstances operation: The requested configuration is 
currently not supported. Please check the documentation for supported configurations.

ドキュメント通りのコマンドでもエラーが発生したので、やむなくコンソール上からインスタンスを起動しました。

AMIはMojaveとCatalinaを選択できます。今回は、Catalinaで。

masOSのAMIを選択すると、インスタンスタイプの選択画面でファミリー: mac1、タイプ: mac1.metal が選択できます。以下のように他のAMIで選択できる t2.micro とかにはできませんでした。

割り当てた専有ホストIDを指定して、起動します。

作成済のキーペアを指定して、起動します。

十数分後に、ステータスチェックが合格し、インスタンスが実行中の状態になりました。

セキュリティグループの設定

無事、インスタンスが起動できたので、セキュリティグループを設定し、SSHVNC(5900)でアクセスできるようにします。

リモートからのアクセス(SSH)

では、キーペアを使ってアクセスしてみます。ec2-userでログインできます。

$ssh -i ~/.ssh/macos_catalina.pem ec2-user@ec2-xxx-xxx-xxx-xxx.us-east-2.compute.amazonaws.com


             .:'
         __ :'__       __|  __|_  )
      .'`  `-'  ``.    _|  (     /
     :          .-'   ___|\___|___|
     :         :
      :         `-;   Amazon EC2
       `.__.-.__.'    macOS Catalina 10.15.7

ec2-user@ip-xxx-xxx-xxx-xxx ~ %

接続できるようになりました!

VNC設定

ユーザ作成

VNCから操作するためのユーザを作ります。

以下のようにID1000 (idは任意です)のユーザがいないか確認します。

$ id 1000
id: 1000: no such user

IDが空いていることが確認できたらユーザを作ります。パスワードの設定と管理者権限の付与も行います。

# ユーザ作成
$ sudo dscl . -create /Users/takashi
$ sudo dscl . -create /Users/takashi UserShell /bin/bash
$ sudo dscl . -create /Users/takashi RealName "takashi morita"
$ sudo dscl . -create /Users/takashi UniqueID 1000
$ sudo dscl . -create /Users/takashi PrimaryGroupID 1000
$ sudo dscl . -create /Users/takashi NFSHomeDirectory /Users/takashi

# パスワードの設定と管理者権限付与
$ sudo dscl . -passwd /Users/takashi mynewpassword
$ sudo dscl . -append /Groups/admin GroupMembership takashi

macosの画面共有をONにします。vncpwオプションには上記のユーザ作成時に設定したパスワードを設定します。

$ sudo /System/Library/CoreServices/RemoteManagement/ARDAgent.app/Contents/Resources/kickstart \
-activate -configure -access -on \
-clientopts -setvnclegacy -vnclegacy yes \
-clientopts -setvncpw -vncpw mynewpassword \
-restart -agent -privs -all

Starting...
Warning: macos 10.14 and later only allows control if Screen Sharing is enabled through System Preferences.
Activated Remote Management.
Stopped ARD Agent.
ec2-user: Set user remote control privileges.
ec2-user: Set user remote access.
takashi: Set user remote control privileges.
takashi: Set user remote access.
Set the client options.
Done.

VPNクライアントから接続

まずは暗号化せずに接続できるか確認

VPNクライアントにパブリック IPv4 DNS、ポートを指定して、作成したユーザ名、パスワードを入力すれば接続できます。

接続後、作ったユーザ、パスワードでログインできます。

暗号化して接続できるか確認

上記の方法だと、暗号化されずに接続しに行くので、業務で使用する際はポートフォワードして接続すると良さそうです。

$ssh -L 5111:localhost:5900 ec2-user@ec2-xxx-xxx-xxx-xxx.us-east-2.compute.amazonaws.com macos_catalina.pem

使用感

ちょっと使い物にならないくらいの遅さだったので、残念な印象でした。

リージョンが米国しか試せなかったのと、家のネットワーク回線の速度があまりよくない、というのが影響していると思います。

今度は、アジアパシフィックでインスタンスを作ったり、他の環境でも確認してみたいと思います! できたら、東京リージョンに来てくれたら嬉しいな。

料金

リージョンによって若干異なるとは思いますが、今回オハイオus-east-2 リージョンで14時間使ったら、$15.16でした。

オンデマンドで数日間だけ利用するか、長期で利用する場合にはSaving Planが良いかもしれません。

参考

CDNを用いたWebアプリケーションの画像のキャッシュについて

最近、CDNを用いたWebアプリケーションにおけるデータの持ち方やCDN等の画像ファイルのキャッシュの設定に関して、どのようにすると最適になるかな、と悩む機会がありました。そして、データの設計やCDN、オリジンサーバー等のキャッシュの設定は多岐に渡り、どこから検討を始めていくとスムーズなのか、と思うに至りました。

その中で、実現したいアプリケーションの要件に対して、まず以下の2つの観点を考えると大まかに検討項目を分けられ、設計がうまくいくんじゃないかという気づきがあったので、共有してみたいと思います。

  • TTL(画像の保存期間) >0にするか、TTL=0にするか
  • データストアに保存される画像ファイルパスを変更可にするか、変更不可にするか

構成図

以下の図のように

  • 画像ファイルはオリジンサーバーにホスティングされる
  • オリジンサーバー上の画像ファイルのパスはRDS等のデータストアに保存される

という構成のWebアプリケーションを想定しています。

考えるべき観点

アプリケーションの要件として、主に画像ファイルの更新が必要なのか、不要なのかという点を元に以下の2つの観点を検討しました。

観点1: TTL(画像の保存期間) >0にするか、TTL=0にするか

  • TTL>0の場合
    • ローカルキャッシュの制御には、HTTP header(Cache-Control max-age or Expires)が設定される。
    • CDNキャッシュサーバ及びローカルに画像を保存するが、TTLが過ぎるまではキャッシュされたコンテンツを利用する。
  • TTL=0 の場合
    • ローカルキャッシュの制御には、HTTP header(Etag, Last-Modified)が設定される。
    • CDNキャッシュサーバ及びローカルに画像を保存するが、毎回画像の更新を確認し、もし変更があればコンテンツをダウンロードする。
      • もし、更新がなければ、304(not modified)が返り、Bodyは返らない。

CDNは主に以下の目的で導入されることが多いので、no-cache (キャッシュしない)という選択肢はあまり考慮しなくて良いと思っています。 no-cache は通信の最適化を目的にするには良い選択肢だと認識しています。

  • オリジンサーバへの負荷を下げること
  • クライアントへのレスポンスを早くすること

観点2: データストアに保存される画像ファイルパスを変更可にするか、変更不可にするか

  • 変更可能にする場合
    • 画像ファイルの変更時に、新しくオリジンサーバに画像を保存する
  • 変更不可にする場合
    • オリジンサーバに保存する画像自体を更新する。データストアに保存されているデータはそのままで、画像のファイル名は変更しない。
    • または、画像の更新が必要な場合には、データストアに新規にレコードを追加し、新しくオリジンサーバーに画像を保存する。

要件毎の観点の検討

①画像ファイルの更新が必要ない場合

観点1: TTL(画像の保存期間) >0にするか、TTL=0にするか

不要なコンテンツダウンロードを避けるために、基本的に一度ダウンロードしたらキャッシュを使うようにしたいので、TTL>0にする。

観点2: データストアに保存される画像ファイルパスを変更可にするか、変更不可にするか

更新がないので、変更不可にする。

懸念点・検討事項

以下の詳細な設定は、要件毎に異なるので検討する。

  • TTLはどの程度にするか。更新はないので、基本的に高いヒット率になるように設定して良い。
  • Cache-Control: max-ageではなく、s-maxageを使ったり、minimum TTL、maximum TTLを使う。
    • 例えばAkamaiだと使えないオプションになるので、利用するCDN毎にどの程度要件に合わせられるか変わってくる。
  • Cache-Controlヘッダー等の設定は、CDN側で設定するか、オリジンサーバー側で設定するか。

②画像ファイルの更新があり、かつオリジンサーバへのリクエストに制約がない場合

この選択肢は、一度保存した画像ファイルのパスは変更せずに、コンテンツのみ変える必要があるケースで有効だと思いました。

  • オリジンサーバへの負荷が高くなり、リクエスト数も増える
    • オリジンサーバーに、S3のようにGETが5,500回という制約があるようなサービスを利用する場合には、要件を元に採用可否を検討。(参考
  • クライアントへのレスポンスが遅くなる
    • 即時性が求められる要件には向かない。
  • 画像ファイルは、遅延なく常に最新のコンテンツを利用したい

という特徴があります。

観点1: TTL(画像の保存期間) >0にするか、TTL=0にするか

TTL=0 にし、都度画像ファイルの更新有無をオリジンサーバに確認する。

観点2: データストアに保存される画像ファイルパスを変更可にするか、変更不可にするか

データストアの画像ファイル名は変更不可にする。画像ファイルパスはそのままで、コンテンツのみを更新する。

懸念点・検討事項

以下の点は要件に応じて検討が必要。

  • 大体のCDNは従量制なので、TTL=0にして、コンテンツのダウンロードが多くなった場合、費用負担が大きくならないか。
  • 画像ファイルの更新頻度が高いケースにおいては、CDNのキャッシュの効果が薄くなる。
    • この場合は、CDNの利用をやめる、
    • もしくは、通信の最適化の目的のみでCDNを利用する。

③画像ファイルの更新があり、かつオリジンサーバへのリクエストに制約がある場合

この選択肢は、一度保存した画像ファイルのパスは変更して、既にホスティングされている画像ファイルコンテンツ自体の内容は変えないケースで有効だと思いました。

②の逆で

  • オリジンサーバへの負荷は低く、リクエストを少なくしたい
  • オリジンサーバーにリクエストの制約があるようなサービス
  • クライアントへのレスポンスに即時性を求める

というケースで有効です。

観点1: TTL(画像の保存期間) >0にするか、TTL=0にするか

一度保存された画像は同じパスのまま更新されることはないので、①と同じでTTL(画像の保存期間) >0で良い。懸念点・検討事項も同様。

観点2: データストアに保存される画像ファイルパスを変更可にするか、変更不可にするか

データストアに保存される画像ファイルパスを変更可にし、オリジンサーバーに保存されている画像自体を更新するというよりは、新しいパスで画像のファイルを保存する。

懸念点・検討事項

この選択肢の懸念点は

  • データストアの画像ファイル名を変更することになるので、データ自体をKVSに一時的に保存しているような場合には、RDSとの同期性の検討が必要。
    • 同期しない時間が長く、支障がある場合はデータストアにレコードを追加する。
  • 変更前のパスの画像ファイル、データストアのレコードをどのタイミングで削除するか。
  • 画像ファイルの更新頻度が高いケースにおいては、CDNのキャッシュの効果が薄くなる。
    • この場合は、CDNの利用をやめる、
    • もしくは、通信の最適化の目的のみでCDNを利用する。

参考資料

Reactコンポーネントをうまく分割するためのテクニック〜カリー化〜

大きくなってしまったReactコンポーネントを小さく分割するにはカリー化が効果的だと感じています。

  • 少しずつ機能を足していったら大きくなってしまって、次第に分割するのが怖くなってしまった
  • そもそも、どう分割したら良いのか分からない

というのが、コンポーネントが大きくなってしまう理由としてあるかと思います。

今回はカリー化とはなにか、そしてどうしてカリー化がReactコンポーネントの分割に有効なのかを共有してみます。保守しやすいReactコンポーネントの実装にお役に立てればと思います。

カリー化とは

まず、Javascriptでシンプルな掛け算をする関数を作ってみたいと思います。

const multiply = (x, y) => {
    return x * y;
}
multiply(4, 5);
// => 20

この関数をカリー化するとこんな書き方になります。

const multiply = x => y => {
    return x * y;
}
multiply(4)(5); 
// => 20

最終的な結果は同じ 20 になりますが、カリー化の方は

  • multiply(4)を呼び出した時点で、return 4 * yという関数が返り
  • multiply(4)(5)を呼び出した時点で、20が返ります。

つまり、カリー化すると、関数の引数を部分的に固定化できます。 以下のようにすると、1つ目の引数を 5 に固定した関数を作ることができます。

const multiply = x => y => {
  return x * y;
}
const multiply2 = multiply(5);
console.log(multiply2(6));
// => 30 ( = 5 * 6 )
console.log(multiply2(7));
// => 35 ( = 5 * 7)

Reactコンポーネントでカリー化を使う

以下のような画面をReactで作ってみます。アイテムをクリックすると、名前を更新する関数 updateItem が呼ばれます。

①分割しない状態

まずは、コンポーネントを1つで分割せず、関数のカリー化もしないで、Javascript / Typescriptコードを書いてみます。

import React, { useState } from 'react';
import axios from 'axios';

const initialCategories = [
  {
    id: 1,
    name: '学園モノ',
    items: [
      {
        id: 11,
        name: '彼女、お借りします'
      },
      {
        id: 12,
        name: '魔王学院の不適合者'
      }
    ]
  },
  {
    id: 2,
    name: '異世界転生',
    items: [
      {
        id: 21,
        name: 'Re:ゼロから始める異世界生活'
      }
    ]
  }
]

function App() {
  const [categories] = useState(initialCategories);

  const updateItem = async (categoryId: number,
                            itemId: number, 
                            name: string) => {
    await axios.put(
      `http://sample_domain.com/categories/${categoryId}/items/${itemId}`, 
      { name: name }
    ) 
  }

  return (
    <div className="App">
      {categories.map(category => (
        <div key={category.id}>
          <span>{category.name}</span>
          <ul>
           {category.items.map(item => (
             <li key={item.id} 
                 onClick={() => 
                   updateItem(category.id, item.id, '更新後の名前')}>
               {item.name}
             </li>
           ))}
          </ul>
        </div>
      ))}
    </div>
  );
}

export default App;

②カリー化を使わないでコンポーネントを分割

①のコンポーネントItem の箇所を別コンポーネントに分割してみたいと思います。

注目すべきは、親コンポーネントからID(categoryId)を子コンポーネントに渡している点です。子コンポーネントであるItemの更新APIの構造的に親コンポーネントのidが必要で、子コンポーネントから関数を呼び出す際、親のIDが必要になっています。

type ItemtProps = {
  categoryId: number;
  id: number;
  name: string;
  updateFunc: Function;
}

const Item: React.FC<ItemtProps> = props => {
  return (
    <li onClick={() => 
        props.updateFunc(props.categoryId, props.id, props.name)}>
      {props.name}
    </li>
  )
};

function App() {
  const [categories] = useState(initialCategories);

  const updateItem = async (categoryId: number, itemId: number, name: string) => {
    await axios.put(
      `http://sample_domain.com/categories/${categoryId}/items/${itemId}`, 
      { name: name }
    ) 
  }

  return (
    <div className="App">
      {categories.map(category => (
        <div key={category.id}>
          <span>{category.name}</span>
          <ul>
           {category.items.map(item => (
             <Item key={item.id}
                   categoryId={category.id} 
                   id={item.id} 
                   name={item.name}
                   updateFunc={updateItem} />
           ))  /* ↑カリー化せずに関数をそのまま渡している */ }  }
          </ul>
        </div>
      ))}
    </div>
  );
}

③カリー化を使ってコンポーネントを分割

カリー化を使うと、親コンポーネントからID(categoryId)を子コンポーネントに渡さなくて済むようになります。

type ItemtProps = {
  id: number;
  name: string;
  updateFunc: Function;
}

const Item: React.FC<ItemtProps> = props => {
  return (
    <li onClick={() => props.updateFunc(props.id, props.name)}>
      {props.name}
    </li>
  )
};

function App() {
  const [categories] = useState(initialCategories);

  const updateItem =  (categoryId: number) => async(itemId: number, name: string) => {
    await axios.put(
      `http://sample_domain.com/categories/${categoryId}/items/${itemId}`, 
      { name: name }
    )
  }

  return (
    <div className="App">
      {categories.map(category => (
        <div key={category.id}>
          <span>{category.name}</span>
          <ul>
           {category.items.map(item => (
             <Item key={item.id} 
                   id={item.id} 
                   name={item.name} 
                   updateFunc={updateItem(category.id)} />
           )) /* ↑カリー化して、カテゴリーIDを固定にした関数を渡している */ }
          </ul>
        </div>
      ))}
    </div>
  );
}

なぜカリー化が効果的なのか

一般的に、コンポートを分割する際には、結合度という観点に着目すると保守性があがります。

コンポーネントの分割方は2通りあり

  • 親子(parent/child)関係
<div className="App">
  <parent>
    <child />
  </parent>
</div>
  • 兄弟(sibling)関係
<div className="App">
  <sibling />
  <sibling />
</div>

のどちらかになります。(HoCのような共通で部品化する場合も親子関係とします。)

兄弟(sibling)関係の場合、情報として各々依存関係がないため、分割を考えた時に結合度という観点では考慮する必要がないです。なので、何の工夫もなくスッキリコンポーネントを分割できることが多いです。

一方、親子(parent/child)関係の場合、親に依存する情報は、親だけにもたせたほうが保守性が高くなります。カリー化の特性を利用し、関数の引数の親だけにある情報を固定化して、子コンポーネントに関数を渡して上げると依存度が低くなり、結合度が低く保つことができます。

参考

Split fat component into smaller flexible components in React - DEV Community 👩‍💻👨‍💻