14
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Dockerイメージを試行錯誤しながら作る(PlayFramework環境)

Posted at

この記事の目的

Dockerを布教していたところ

Dockerで仮想環境を簡単に起動できるのはわかった。
ただ、Docker Hubに公開されているものならいいが、自作の環境を試行錯誤しながら作りたいときはどうすればいいのか。

というフィードバックをもらいました。
そこで、Dockerで環境を自作する様子を実際に私がたどった手順として公開し、イメージを掴んでもらうこととしました。

今回作成する環境は、Play Frameworkが起動する環境です。
筆者はこのとき初めてPlay Frameworkをインストールした初心者ですが、問題なく構築できました。

「空」のベースコンテナを起動し、インタラクティブに手順を確立していく

「Dockerで自作のイメージを作る」=「Dockerfileを作成する」です。
ほかにも方法はありますが、環境をコードとして残すことのメリットは大きいため、この方法がおすすめです。

しかし、いきなり正解のDockerfileを書くことはできません。
Dockerfileの中身はシェルコマンドの集まりですが、大抵の場合、書き間違いにより失敗します。
Dockerfileを書いてイメージをビルド、失敗した部分についてDockerfileを修正して再ビルド、などとしていては時間がかかりすぎます。

効率的なのは、「空」のベースコンテナを起動してshでコンテナ内に入り込み、対話的に正解のコマンドを探していくことです。
動作確認できたコマンドをDockerfileに書いていくのです。

この手法は以下の記事で説明されています:

効率的に安全な Dockerfile を作るには - Qiita

では具体的に始めてみます。

ベースとなる「空」のコンテナを起動する

Dockerfile作成

作業フォルダーに最低限のDockerfileを作成します。
うまくいった手順をメモする先として使い、そのまま完成形としてやるためです:

Dockerfile
# ベースイメージを指定する
# 軽量なAlpineを使う
FROM alpine:3.5

# コンテナ起動時に実行するコマンド
# とりあえず即時終了しないコマンドを指定しておく
CMD ["top"]

コンテナは、CMDまたはENTRYPOINTで指定したコマンドが実行中のみ、起動状態となります。
最終形としては、サーバーの起動コマンドを指定しますが、試行錯誤中はとりあえず即時終了しないtopコマンドを指定しておきます。
即時終了しなければいいので、tail -FでもOKです。

docker-compose.yml作成

コンテナの起動時オプションも、コードとして残したいので、作業フォルダーにdocker-compose.ymlも作成します:

docker-compose.yml
# バージョン2記法で記述する
version: '2'

# 起動するコンテナを定義する
services:

    # Play Frameworkコンテナ
    play:
        # DockerfileによってPlay Frameworkイメージの自作を目指す
        # このファイルと同じフォルダーのDockerfileを使う
        build: ./

        # コンテナ名を指定する
        # Docker Composeが自動生成するコンテナ名は長く、タイピングが面倒なため
        container_name: play

Docker Composeは複数コンテナを管理するためのツールですが、起動時オプションをコード化するためにも使えます。
多くのオプションを連ねてコマンドを入力するのは大変ですし、以下のようなシェルスクリプトを用意するより、専用のツールを使うほうが良いでしょう:

#/!bin/bash
docker run --rm -ti -p 8888:8888 -v /Users/kazuma/qiita/2017/feb18:/root/feb18 alpine:3.5 sh

コンテナ起動

コンテナを起動するには、docker-compose.ymlと同じ階層でdocker-compose upコマンドを実行します。
念のため--force-recreateオプションをつけ、今後docker-compose.ymlを変更したとき、変更を取り入れ損ねないようにします:

$ docker-compose up --force-recreate
Recreating play
Mem: 624228K used, 1422636K free, 151476K shrd, 64500K buff, 406840K cached
play    | CPU:   0% usr   0% sys   0% nic 100% idle   0% io   0% irq   0% sirq
play    | Load average: 0.30 0.22 0.09 1/252 6
play    |   PID  PPID USER     STAT   VSZ %VSZ CPU %CPU COMMAND

コンテナに接続

-dオプションをつけない場合、コンテナと接続し、コンテナ起動コマンドのtopコマンドがフォアグラウンドで実行された状態となります。
この状態ではほかのコマンドを入力できません。

しかも、Ctrl + Cでtopを止めると、コンテナを起動中に保つプロセスがなくなるため、コンテナごと終了してしまいます:

Mem: 624228K used, 1422636K free, 151476K shrd, 64500K buff, 406840K cached
play    | CPU:   0% usr   0% sys   0% nic 100% idle   0% io   0% irq   0% sirq
play    | Load average: 0.30 0.22 0.09 1/252 6
play    |   PID  PPID USER     STAT   VSZ %VSZ CPU %CPU COMMAND
^CGracefully stopping... (press Ctrl+C again to force)
Killing play ... done

$ 

そのため、コンテナ内で作業をするには、docker-compose upを入力したのとは別の画面を使うか、docker-compose up -dとしてバックグラウンドでコンテナを起動する必要があります。

別の画面かバックグラウンドでコンテナを起動したら、以下のように、docker execコマンドでコンテナに接続します。
正確には異なりますが、SSHログインをしたような状態となります。

ちなみに、今回ベースイメージに使ったAlpine Linuxにはbashがインストールされていないため、sh (Almquist shell) を使う必要があります:

$ docker exec -ti play sh
/ # pwd
/

/ # whoami
root

/ # ls
bin    dev    etc    home   lib    media  mnt    proc   root   run    sbin   srv    sys    tmp    usr    var

この状態になったら、インストール作業ができるようになります。

Play Frameworkをインストールする

Play Frameworkは、Alpine Linuxのパッケージマネージャーapkで探しても見つからないようなので、公式ページからダウンロードする方法でインストールします。
手順はPlay Frameworkの公式ページに従います。

JDKインストール

前提条件にはJDK1.8が必要とありますが、初期状態ではJavaがインストールされていません:

(コンテナ内)
/ # which java

/ # 

JDKはapkでインストールできるようなので、apk経由でインストールします:

(コンテナ内)
/ # apk update
fetch http://dl-cdn.alpinelinux.org/alpine/v3.5/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.5/community/x86_64/APKINDEX.tar.gz
v3.5.1-31-g83b3d6857a [http://dl-cdn.alpinelinux.org/alpine/v3.5/main]
v3.5.1-29-ga981b1f149 [http://dl-cdn.alpinelinux.org/alpine/v3.5/community]
OK: 7958 distinct packages available

/ # apk search jdk
openjdk8-jre-base-8.121.13-r0
openjdk7-jre-base-7.121.2.6.8-r0
openjdk8-jre-8.121.13-r0
openjdk7-jre-7.121.2.6.8-r0
openjdk7-jre-lib-7.121.2.6.8-r0
openjdk8-8.121.13-r0
openjdk8-doc-8.121.13-r0
openjdk7-7.121.2.6.8-r0
openjdk8-jre-lib-8.121.13-r0
openjdk7-doc-7.121.2.6.8-r0
openjdk8-dbg-8.121.13-r0
openjdk8-demos-8.121.13-r0

/ # apk info openjdk8
openjdk8-8.121.13-r0 description:
OpenJDK 8 provided by IcedTea

openjdk8-8.121.13-r0 webpage:
http://icedtea.classpath.org/

openjdk8-8.121.13-r0 installed size:
19746816

/ # 

apk searchapk infoでリポジトリー内を探したところ、openjdk8パッケージが使えそうです。
apk addでインストールします:

(コンテナ内)
/ # apk add openjdk8
(1/39) Installing libffi (3.2.1-r2)
(2/39) Installing libtasn1 (4.9-r0)(39/39) Installing openjdk8 (8.121.13-r0)
Executing busybox-1.25.1-r0.trigger
Executing ca-certificates-20161130-r0.trigger
Executing java-common-0.1-r0.trigger
OK: 99 MiB in 50 packages

/ # 

インストール成功です:

(コンテナ内)
/ # java -version
openjdk version "1.8.0_121"
OpenJDK Runtime Environment (IcedTea 3.3.0) (Alpine 8.121.13-r0)
OpenJDK 64-Bit Server VM (build 25.121-b13, mixed mode)

/ # 

JDKをインストールするまでの手順をDockerfileに記録しておく

いったんここまでの手順をDockerfileに記録しておきます。
RUNインストラクションに、先ほど試したapk add openjdk8を書くだけです:

Dockerfile
# ベースイメージを指定する
# 軽量なAlpineを使う
FROM alpine:3.5

# 各種ライブラリーのインストール
# まずはJavaをインストールする
RUN apk --no-cache add openjdk8

# コンテナ起動時に実行するコマンド
# とりあえず即時終了しないコマンドを指定しておく
CMD ["top"]

apk add openjdk8に対して少しトリックを加えています。
--no-cacheオプションを使い、apk updaterm -rf /var/cache/apkを実行したのと同じ効果を得ています。
つまり、インストール後に余計なキャッシュが残らず、Dockerイメージのサイズを小さく抑えられるのです。

docker-compose buildコマンドでイメージを再ビルドし、docker-compose up --force-recreate -dでコンテナを再生成します:

$ docker-compose build
Building play
Step 1/3 : FROM alpine:3.5
 ---> 88e169ea8f46
Step 2/3 : RUN apk --no-cache add openjdk8
 ---> Using cache
 ---> a9c2d3ba4db0
Step 3/3 : CMD top
 ---> Using cache
 ---> d2f55a5051eb
Successfully built d2f55a5051eb

$ docker-compose up --force-recreate -d
Recreating play

$ 

すでにビルドしたことがあるとUsing cacheと表示されます。
初回は、その位置にインストールの様子が表示されます。

コンテナに接続すると、javaコマンドが使えるようになっていることが確認できます:

$ docker exec -ti play sh
/ # java -version
openjdk version "1.8.0_121"
OpenJDK Runtime Environment (IcedTea 3.3.0) (Alpine 8.121.13-r0)
OpenJDK 64-Bit Server VM (build 25.121-b13, mixed mode)

/ # 

Activatorをインストールする

次に、Activator1なるものをダウンロードせよとあるので、そのとおりにします。
ダウンロードURLは、Lightbend公式のダウンロードページにある"DOWNLOAD"ボタンのリンクをコピーして使います:

スクリーンショット 2017-02-18 13.10.08.png

(コンテナ内)
/ # curl -L -O https://downloads.typesafe.com/typesafe-activator/1.3.12/typesafe-activator-1.3.12.zip
sh: curl: not found

/ # wget https://downloads.typesafe.com/typesafe-activator/1.3.12/typesafe-activator-1.3.12.zip
Connecting to downloads.typesafe.com (54.230.111.211:443)
wget: can't execute 'ssl_helper': No such file or directory
wget: error getting response: Connection reset by peer

/ # 

おや、curlコマンドはインストールされていないし、wgetは(多分)依存パッケージがなく使えないようです。
wgetを使えるようにする手もあるかもしれませんが、今回はcurlをインストールすることにします:

(コンテナ内)
/ # apk update
fetch http://dl-cdn.alpinelinux.org/alpine/v3.5/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.5/community/x86_64/APKINDEX.tar.gz
v3.5.1-31-g83b3d6857a [http://dl-cdn.alpinelinux.org/alpine/v3.5/main]
v3.5.1-29-ga981b1f149 [http://dl-cdn.alpinelinux.org/alpine/v3.5/community]
OK: 7958 distinct packages available

/ # apk info curl
curl-7.52.1-r1 description:
An URL retrival utility and library

curl-7.52.1-r1 webpage:
http://curl.haxx.se

curl-7.52.1-r1 installed size:
200704

/ # apk add curl
(1/3) Installing libssh2 (1.7.0-r2)
(2/3) Installing libcurl (7.52.1-r1)
(3/3) Installing curl (7.52.1-r1)
Executing busybox-1.25.1-r0.trigger
OK: 100 MiB in 53 packages

/ # 

curlがインストールできたようなので、/tmpにActivatorをダウンロードします:

(コンテナ内)
/ # cd /tmp/

/tmp # curl -L -O https://downloads.typesafe.com/typesafe-activator/1.3.12/typesafe-activator-1.3.12.zip
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  682M  100  682M    0     0  6096k      0  0:01:54  0:01:54 --:--:-- 5411k

/tmp # 

ZIPファイルを解凍し、activatorコマンドにパスを通してインストール完了です:

(コンテナ内)
/tmp # unzip typesafe-activator-1.3.12.zip
…
  inflating: activator-dist-1.3.12/bin/activator-dist
   creating: activator-dist-1.3.12/lib/
  inflating: activator-dist-1.3.12/lib/org.scala-lang.scala-library-2.11.8.jar
  inflating: activator-dist-1.3.12/lib/com.typesafe.activator.activator-dist-1.3.12.jar

/tmp # ls
activator-dist-1.3.12          hsperfdata_root                typesafe-activator-1.3.12.zip

/tmp # ls activator-dist-1.3.12/
LICENSE.html  README.html   bin           lib           libexec       repository    templates

/tmp # ls activator-dist-1.3.12/bin/
activator           activator-dist      activator-dist.bat  activator.bat

/tmp # export PATH="/tmp/activator-dist-1.3.12/bin:$PATH"

/tmp # echo $PATH
/tmp/activator-dist-1.3.12/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

/tmp # which activator
/tmp/activator-dist-1.3.12/bin/activator

/tmp # 

解凍先やPATHの設定方法は公式ページの手順を参考にしました。

Activatorを起動できるようにする

activatorコマンドにパスまで通しましたが、まだ使えません:

(コンテナ内)
/tmp # activator -h
env: can't execute 'bash': No such file or directory

/tmp # 

Activatorはbashに依存しているようです。
Alpineにはash (Almquist Shell) しかないため、エラーになるのです。

apk addでbashをインストールします:

(コンテナ内)
/tmp # apk info bash
bash-4.3.46-r5 description:
The GNU Bourne Again shell

bash-4.3.46-r5 webpage:
http://www.gnu.org/software/bash/bash.html

bash-4.3.46-r5 installed size:
700416

/tmp # apk add bash
(1/5) Installing ncurses-terminfo-base (6.0-r7)
(2/5) Installing ncurses-terminfo (6.0-r7)
(3/5) Installing ncurses-libs (6.0-r7)
(4/5) Installing readline (6.3.008-r4)
(5/5) Installing bash (4.3.46-r5)
Executing bash-4.3.46-r5.post-install
Executing busybox-1.25.1-r0.trigger
OK: 108 MiB in 58 packages

/tmp # bash
bash-4.3# exit
exit

/tmp # 

これでActivatorが起動できるようになりました。
バックグラウンドでactivator uiを起動します:

(コンテナ内)
/tmp # activator ui &
Checking for a newer version of Activator (current version 1.3.12)...
…
Play server process ID is 313
[info] play - Application started (Prod)
[info] play - Listening for HTTP on /127.0.0.1:8888
[info] a.e.s.Slf4jLogger - Slf4jLogger started
Unable to open a web browser!
Please point your browser at:
 http://127.0.0.1:8888/

/tmp # curl -L http://127.0.0.1:8888/
<!-- Copyright (C) 2016 Lightbend, Inc <http://www.lightbend.com> -->
<!DOCTYPE html>
…(長いスクリプト)
  </body>
</html>

/tmp # 

curlで繋ぐと、生のHTMLからは中身が把握できませんが、無事起動したことは確認できました。

Activatorをインストールするまでの手順をDockerfileに記録しておく

ここまででActivatorをインストールできました。
その手順をDockerfileに書き残しておきます:

Dockerfile
# ベースイメージを指定する
# 軽量なAlpineを使う
FROM alpine:3.5

# 各種ライブラリーのインストール
# curl: Activatorのパッケージダウンロード用
# openjdk8, bash: Activator起動用
RUN apk --no-cache add openjdk8 curl bash && \
    cd /tmp && \
    curl --location --remote-name https://downloads.typesafe.com/typesafe-activator/1.3.12/typesafe-activator-1.3.12.zip && \
    unzip -q typesafe-activator-1.3.12.zip -d /root && \
    rm -f typesafe-activator-1.3.12.zip

# Activatorにパスを通す
ENV PATH /root/activator-dist-1.3.12/bin:$PATH

# ホストに公開したいコンテナ内のポートを指定する
# Dockerfile内の指定だけでは公開されないので、コンテナ起動時に引数を指定する必要がある
EXPOSE 8888 9000

# CMDの実行フォルダーを変更する
WORKDIR /root/activator-dist-1.3.12

# コンテナ起動時に実行するコマンド
# activatorコマンドで8888ポートにHTTPサーバーを起動する
CMD ["activator", "ui"]

変更点は、まずRUNインストラクションです。
curlとbashをインストールしているほか、Activatorもダウンロード&展開しています。
可読性を上げるためcurlのオプションをフルネームで記載したり、unzipの出力が冗長なので抑制したり、展開先を/tmpから変えたり、手直しもしています。

ちなみに、RUNの中では&&でコマンドをつなぎ、さらに最後でZIPを削除していますが、これはDockerイメージのサイズを抑えるために重要です。
Dockerイメージのサイズは、インストラクションごとに増える一方のため、別のRUN内でファイルを削除しても意味がないのです。

また、パスはENVインストラクションで変更しています。

DockerfileでPATHを通す時はRUNではなくENVを使おう - Qiita

EXPOSEインストラクションにて、Activatorがデフォルトで使う8888ポートと9000ポートを指定しました。
ですが、これはコンテナが外部に公開するポートの意思表示をしているだけであるため、実際にホストから接続するには、起動時にオプションを渡す必要があります。

具体的には、docker-compose.ymlの最後に以下のように追記すればOKです:

docker-compose.yml

        # コンテナ内のポートをホストに公開する
        # 書式 "HOST:CONTAINER"
        ports:
            # Activatorのポートを同じ番号でホストに公開する
            - "8888:8888"
            - "9000:9000"

Play Frameworkの公式ページには、ZIP展開したらcd activator*せよとあったので、WORKDIRインストラクションでそのように指定しています。

CMDインタラクションは、topコマンドからactivator uiに変更しました。
フォアグラウンドで起動しないとコンテナが即時終了するので、&を使う必要はありません。

docker-compose buildコマンドでイメージを再ビルドし、docker-compose up --force-recreateでコンテナを再生成します:

$ docker-compose build
Building play
Step 1/6 : FROM alpine:3.5
 ---> 88e169ea8f46
Step 2/6 : RUN apk --no-cache add openjdk8 curl bash &&     cd /tmp &&     curl --location --remote-name https://downloads.typesafe.com/typesafe-activator/1.3.12/typesafe-activator-1.3.12.zip &&     unzip -q typesafe-activator-1.3.12.zip -d /root &&     rm -f typesafe-activator-1.3.12.zip
 ---> Using cache
 ---> c1432da40503
Step 3/6 : ENV PATH /root/activator-dist-1.3.12/bin:$PATH
 ---> Using cache
 ---> 2a2622235ec8
Step 4/6 : EXPOSE 8888 9000
 ---> Using cache
 ---> ea17f5c3b67e
Step 5/6 : WORKDIR /root/activator-dist-1.3.12
 ---> Using cache
 ---> 6fcbc688b566
Step 6/6 : CMD activator ui
 ---> Running in 862918668a84
 ---> 43fd43cb4d90
Removing intermediate container 862918668a84
Successfully built 43fd43cb4d90

$ 
$ docker-compose up --force-recreate
Recreating play
…
play    | Play server process ID is 55
play    | [info] play - Application started (Prod)
play    | [info] play - Listening for HTTP on /127.0.0.1:8888
play    | [info] a.e.s.Slf4jLogger - Slf4jLogger started
play    | Unable to open a web browser!
play    | Please point your browser at:
play    |  http://127.0.0.1:8888/

ホストからActivator UIに接続できるようにする

前節で起動したコンテナは、docker-compose psでチェックすると、期待どおり起動してポートも公開しているはずですが、しかしホストのブラウザから http://localhost:8888/ へ接続しても、画面は表示されません:

$ docker-compose ps
Name     Command      State                       Ports                      
----------------------------------------------------------------------------
play   activator ui   Up      0.0.0.0:8888->8888/tcp, 0.0.0.0:9000->9000/tcp 

$ 

スクリーンショット 2017-02-13 21.39.24.png

コンテナ内からcurlで接続すると確かにレスポンスがあるのですが。

(コンテナ内)
$ docker exec -ti play sh
~/activator-dist-1.3.12 # curl -L http://localhost:8888/
…
  </body>
</html>

~/activator-dist-1.3.12 # 

原因は、Activator側の設定でした。
127.0.0.1しかリッスンしておらず、ホストからの経路は弾かれていたようです。
-Dhttp.address=0.0.0.0オプションを渡すと、ホストからもアクセスできるようになります:

scala - How to force Typesafe Activator to listen 0.0.0.0:8888 - Stack Overflow

# コンテナ起動時に実行するコマンド
# activatorコマンドで8888ポートにHTTPサーバーを起動する
# ホストからアクセスするために http.address=0.0.0.0 が必要
CMD ["activator", "-Dhttp.address=0.0.0.0", "ui"]

docker-compose buildコマンドでイメージを再ビルドし、docker-compose up --force-recreateでコンテナを再生成すると、ホストのブラウザから以下の画面が見られるようになります。

$ docker-compose up --force-recreate
Recreating play
…
play    | Play server process ID is 54
play    | [info] play - Application started (Prod)
play    | [info] play - Listening for HTTP on /0:0:0:0:0:0:0:0:8888
play    | [info] a.e.s.Slf4jLogger - Slf4jLogger started
play    | Unable to open a web browser!
play    | Please point your browser at:
play    |  http://0.0.0.0:8888/

スクリーンショット 2017-02-13 21.47.54.png

まとめ

「空」のAlpineイメージから始めて、Activator UIを起動するイメージを作成できました。
Play FrameworkとActivatorの関係がよくわかっていませんが、ひとまず環境の準備はできたので、docker execで接続して色々と試すことができます。
いじった結果が気に食わなければ、コンテナを破棄して作り直せばよいだけです。

「Activatorをインストールして起動できるようにする」という環境構築の大変さは、ホストマシン上でもDockerコンテナ上でも変わりません。
しかし、Dockerを使って環境を隔離すれば、やり直しが簡単なため、確実に効率化できるはずです。
Dockerfileとdocker-compose.ymlで構築手順をコード化できるのも利点です。

ファイルの最終形

docker-compose.yml
# バージョン2記法で記述する
version: '2'

# 起動するコンテナを定義する
services:

    # Play Frameworkコンテナ
    play:
        # DockerfileによってPlay Frameworkイメージの自作を目指す
        # このファイルと同じフォルダーのDockerfileを使う
        build: ./

        # コンテナ名を指定する
        # Docker Composeが自動生成するコンテナ名は長く、タイピングが面倒なため
        container_name: play

        # コンテナ内のポートをホストに公開する
        # 書式 "HOST:CONTAINER"
        ports:
            # Activatorのポートを同じ番号でホストに公開する
            - "8888:8888"
            - "9000:9000"
Dockerfile
# ベースイメージを指定する
# 軽量なAlpineを使う
FROM alpine:3.5

# 各種ライブラリーのインストール
# curl: Activatorのパッケージダウンロード用
# openjdk8, bash: Activator起動用
RUN apk --no-cache add openjdk8 curl bash && \
    cd /tmp && \
    curl --location --remote-name https://downloads.typesafe.com/typesafe-activator/1.3.12/typesafe-activator-1.3.12.zip && \
    unzip -q typesafe-activator-1.3.12.zip -d /root && \
    rm -f typesafe-activator-1.3.12.zip

# Activatorにパスを通す
ENV PATH /root/activator-dist-1.3.12/bin:$PATH

# ホストに公開したいコンテナ内のポートを指定する
# Dockerfile内の指定だけでは公開されないので、コンテナ起動時に引数を指定する必要がある
EXPOSE 8888 9000

# CMDの実行フォルダーを変更する
WORKDIR /root/activator-dist-1.3.12

# コンテナ起動時に実行するコマンド
# activatorコマンドで8888ポートにHTTPサーバーを起動する
# ホストからアクセスするために http.address=0.0.0.0 が必要
CMD ["activator", "-Dhttp.address=0.0.0.0", "ui"]
  1. 旧 Typesafe Activator、現 Lightbend Activator のようです。

14
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?