Amazon ECSにGoサーバとMySQLのコンテナをデプロイする

Elasctic Container Service

Dockerコンテナをいい感じにさばいてくれるコンテナオーケストレーションサービスです。

GoのEchoで作ったサーバとMySQLがやり取りする構造を作りたいとき、ECSでどのように実現すれば良いのか調べてみました。 変なことを書いていたらご指摘いただければ幸いです。:bow_tone1:

また、前提として、ここではロードバランサー等を設定して、コンテナが停止した時に自動復帰する部分の設定とかは行なっていません。 飽くまで、タスク実行を行なって、EC2コンテナインスタンスのパブリックDNSにアクセスすると、DBで取得したデータが返ってくるのを確認する、というところまでをかなりざっくりとここに書きます。

macでやっています。

まずDockerComposeでつくってみる

# docker-compose.yml
  version: "3.1"
  services:
    mysql:
      image: mysql:latest
      container_name: mysql
      environment:
        MYSQL_ROOT_PASSWORD: mysql
      volumes:
        # 初期データ設定
        - ./initdb.d:/docker-entrypoint-initdb.d
        # データの永続化設定
        - ./mysql/data:/var/lib/mysql
      ports:
        - 3306:3306
      networks:
        ecs-network:
          ipv4_address: 172.30.0.2
    server:
      build: ./golang-server
      container_name: golang-server
      volumes:
        - ./golang-server/src:/server/src
      ports:
        - 8000:8000
      networks:
        ecs-network:
          ipv4_address: 172.30.0.3

  networks:
    ecs-network:
      driver: bridge
      ipam:
        driver: default
        config:
          - subnet: 172.30.0.0/24

MySQLのデータベースの初期化を行なって、永続化のためのボリュームを設定しています。 ネットワークもそれぞれ割り振って、golang-serverからmysqlipv4_addressのport3306に対して接続するようにしています。

docker-compose.ymlがあるディレクトリで、

$ docker-compose build ./
$ docker-compose up -d

を実行してコンテナを立ち上げます。

localhost:8000/userとかでデータを取得します。 (初期値設定でusersテーブルを作成して、go側の実装でそのデータを取りに行っている場合の話)

ECSでデプロイしてみる

MySQLは直接imageから実行するのではなく、Dockerfileを作って以下のように初期化用のデータだけCOPYするようにしました(実行時にdocker-entrypoint-initdb.d以下のsqlファイルが実行されます)。

#Dockerfile

FROM mysql:latest

# ローカルで作った初期化用sqlファイルが入ったフォルダをあらかじめ仕込んでおく。
# EC2内でのhostとcontainerのvolume設定でもできると思いますが、こっちの方が楽そうと思ったので。
# あと、docker-compose build時点でvolumeが有効になるようにしたかったのもあります(ECRへのプッシュが必要のため)。
COPY ./initdb.d /docker-entrypoint-initdb.d

EXPOSE 3306

で、さっきのdocker-compose.ymlのservices.mysqlにあるimage項目をbuildにして、

build: ./mysql

てな感じにして、mysql配下に上の内容のDockerfileを配置します。 で、docker-compose.ymlと同じディレクトリでビルドします。

$ docker-compose build ./

ビルドしたコンテナはこのままではECSで動かすことができないので、ECSで動かすために、AWSのコンテナ置き場に置いてあげます。 そのコンテナ置き場に当たるのが、ECR(Elastic Container Registry)です。

ECRへイメージをプッシュする

ビルドしたコンテナのイメージをECRにプッシュするために、まずはAWSコンソール上でECRに入り、リポジトリを作成します。

Screenshot 0031-09-19 at 9.38.58 PM.png

上画像の左ペインの一番下の項目Repositoriesをクリックして、「リポジトリの作成」を押します。

Screenshot 0031-09-19 at 9.40.18 PM.png

次の画面でリポジトリ名を入力して「リポジトリの作成」をクリックすると、リポジトリが作成されます。 以下のようにリポジトリが追加されます。このリポジトリにビルドしたイメージをプッシュするために、このURIの部分を使います。

Screenshot 0031-09-19 at 9.42.52 PM.png

追加したリポジトリを選択して「プッシュコマンドの表示」クリックします(リポジトリの作成ボタンの横の方にあるボタンです)。 以下の画面が出てくるので、これに従ってコマンドを入力していきます。

Screenshot 0031-09-19 at 9.45.23 PM.png

aws-cliが入っているのは大前提です。

1のカッコの中のコマンドを入力すると、ログインのコマンドが表示されるので、それをまるまるコピーして実行します。

$ aws ecr get-login --no-include-email --region ap-northeast-1
docker login -u AWS -p ####################.....dkr.ecr.ap-northeast-1.amazonaws.com
$ docker login -u AWS -p ####################.....dkr.ecr.ap-northeast-1.amazonaws.com

あとはdocker-composeでビルドしたイメージをdocker tagの対象に指定し、最後の引数にさっきリポジトリ作成後に表示されたURIを入力します。 で、最後にタグを指定してECRにイメージをプッシュします。

プッシュが成功すれば、先ほどのECRリポジトリ一覧のリポジトリ名からそのイメージの詳細一覧画面へ遷移してイメージの状態を確認できます。

ECSでコンテナを動かす

ECRにコンテナをプッシュしたので、今度はそれを動かしていきましょう。

ECSではこれらのコンテナをタスクとして扱い、実行していくのですが、 まず、このタスクについての定義を作成する必要があります。 タスク定義はjsonで作成します。

以下のようにタスク定義を設定しました。

# task-def.json

{
    "family": "ecs-demo-app",
    "volumes": [
        {
            "name": "mysql-data",
            "host": {
                "sourcePath": "/mysql"
            }
        }
    ],
    "containerDefinitions": [
        {
            "environment": [],
            "name": "golang-server",
            "image": "ECRのリポジトリのURI",
            "cpu": 10,
            "memory": 500,
            "portMappings": [
                {
                    "containerPort": 8000,
                    "hostPort": 80
                }
            ],
            "entryPoint": [ "sh", "setUpServer.sh" ],
            "essential": true
        },
        {
            "name": "mysql",
            "image": "ECRのリポジトリのURI",
            "cpu": 10,
            "memory": 500,
            "portMappings": [
                {
                    "containerPort": 3306,
                    "hostPort": 3306
                }
            ],
            "environment": [
                {
                    "name": "MYSQL_ROOT_PASSWORD",
                    "value": "mysql"
                }
            ],
            "mountPoints": [
                {
                    "sourceVolume": "mysql-data",
                    "containerPath": "/var/lib/mysql"
                }
            ],
            "essential": false
        }
    ]
}

数点の項目に触れます。

family ・・・ タスク名

volumes ・・・ ボリューム名(name)とホスト側のマウント場所(host.sourcePath)を設定できる

containerDefinitions[i].name ・・・ コンテナ名

containerDefinitions[i].image ・・・ コンテナのイメージURI

containerDefinitions[i].portMappings ・・・ コンテナ側のポートとホスト側のポートの対応

containerDefinitions[i].environment ・・・ 環境変数の設定(ここではmysqlに入る時のrootのパスワードだけ)

containerDefinitions[i].entryPoint ・・・ DockerfileのENTRYPOINTと同じ意味。コンテナを立ち上げた時に実行される。CMDとの違いがまだはっきりとわかっていない。。。:sweat_smile:

containerDefinitions[i].mountPoints ・・・ sourceVolumeにvolumesのボリューム名を設定し、そのホスト側のパスにcontainerPathにあるファイル等をマウントすることを宣言する

containerDefinitions[i].essential ・・・ たしかデフォルトでtrueになっている。trueになっているコンテナがこけると、ここで設定した全てのコンテナが停止する。

*外から来たアクセスがgoのサーバーに通じるように、golang-serverのportMappingsのhostPortを80にしています。 *8000:8000でも良いですが、その場合はEC2のセキュリティグループで8000をインバウンドで許可し、最終的にクラスター上で動くEC2のパブリックDNSに:8000をつけてあげれば良いです。

そのほかの項目も含めてタスク定義の設定項目については以下を参考に。 https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task_definition_parameters.html

タスク定義を書いたら、それをECSのタスク定義に登録します。 以下を実行します(ファイル名をtask-def.jsonとしている場合)。

$ aws ecs register-task-definition --cli-input-json file://task-def.json

この時、aws configureには、ECRデプロイ用のIAMユーザを設定しておいてください。

*設定方法は以下でいい感じに説明されていたので参考にしてみてください。 https://qiita.com/reflet/items/e4225435fe692663b705

権限設定は以下の感じで良いと思います、ひとまず。

Screenshot 0031-09-19 at 10.14.22 PM.png

次にECSのクラスターを作成します。 Amazon ECSのクラスターから「クラスターの作成」を選択します。

Screenshot 0031-09-19 at 10.18.08 PM.png

「EC2 Linux + ネットワーキング」を選択します。

Screenshot 0031-09-19 at 10.21.26 PM.png

以下のインスタンスの設定だけをいじります。 インスタンスタイプをt2.xlargeに設定し(t2.microでは容量的なエラーが出たので。。。)、 インスタンス数を2つにして(別に1でも問題ないですが。。。)、 キーペアを設定しています。 キーペアはEC2のコンソールから適当に作ってください:key2:

Screenshot 0031-09-19 at 10.23.24 PM.png

あと、クラスター名も設定して、最下部の作成をクリックします。 クラスターができたら以下の画面みたいになるので、「クラスターの表示」をクリックします。

Screenshot 0031-09-19 at 10.28.23 PM.png

するとクラスター詳細が出てくるので、この「タスク」タブをクリックします。

Screenshot 0031-09-19 at 10.29.14 PM.png

「タスク」タブにある「新しいタスクの実行」からさっき作成したタスク(コンテナ)を実行していきましょう。

Screenshot 0031-09-19 at 10.29.59 PM.png

起動タイプはEC2、タスク定義に先ほど登録したタスク定義のリビジョンを選択し、クラスターはそのまま、 タスクの数は一応2にしていますが、1でも良いと思います。 右下の「タスクの実行」をクリックします。

Screenshot 0031-09-19 at 10.34.00 PM.png

タスク実行が成功すると、以下のようにRUNNINGの状態になります。

Screenshot 0031-09-19 at 10.38.03 PM.png

実行中のタスクの数のEC2が起動しているタスクの数だけ表示されていれば良いです。 (さっきタスクを2つ指定したはずなのに1つになっているのはあとで説明します)

Screenshot 0031-09-19 at 10.59.40 PM.png

失敗したらStoppedから確認できます。 失敗する原因としては単なるコードのエラーや存在しないファイルを参照しているとか、ネットワーク的にうまくいっていないとかがあると思うので、その辺を調べれば良いかなと思っています:sweat_smile:

cloudwatch使えばもっと効率的にログを確認しながらトラブルシューティングできるかもですね、まだやり方わかってません。。。

クラスター上で動いているEC2インスタンスのネットワークの情報は、「ECSインスタンス」タブからインスタンスを選択してみることができます。

Screenshot 0031-09-19 at 10.43.55 PM.png

クラスター作成時にインスタンス数を2にしていたので、AZの1aと1cで作られているようです。 1aの方を選ぶと以下の画面が出てきます(テキトーに塗りつぶしていますが笑)。

Screenshot 0031-09-19 at 10.46.17 PM.png

パブリックDNSにアクセスすると、Goのサーバーのアクセスできます(ここではportMappingsが80:8000となっているので)。 /userとかでデータベースから情報が取れていることを確認してみてください(/userとかでデータベースからデータを取れるようにしている場合の話)。

。。。とれなかったら、docker logsとかでログ出したりしてエラー改善してみてください:head_bandage:

コンテナ間の通信について(Goサーバ <=> MySQL)

あと、ここではgormを使って以下のようにmysqlと接続していますが、tcp()の中のIPアドレスは上の画像のインスタンス詳細にある「プライベートIP」を指定してあげるとうまく接続できるようになりました。

// connectionDB.go

package database

import (
  "github.com/jinzhu/gorm"
  _ "github.com/jinzhu/gorm/dialects/mysql"
)

func ConnectionDBWithGorm() *gorm.DB {
  DBMS := "mysql"
  USER := "root"
  PASS := "mysql" // task-def.jsonのenvironmentで設定したMYSQL_ROOT_PATHの値
  PROTOCOL := "tcp(ここにプライベートIPを:mysql)"
  DBNAME := "your database name"

  CONNECT := USER + ":" + PASS + "@" + PROTOCOL + "/" + DBNAME
  db, err := gorm.Open(DBMS, CONNECT)
  if err != nil {
    panic(err.Error())
  }
  return db
}

さらっと書いていますが、mysqlと書いてあげると、mysql(多分task-def.jsonにあるcontainerDefinitionsのname項目を参照していると思われる)が動いているポートを表してくれるようです。

ただ、プライベートIPを上記で設定してあげればよかったのですが、プライベートIPはEC2インスタンス一つにつき一つ存在するものなので、このサーバ側のコードにプライベートIPをハードコーディングすると、そのIPに対応するEC2コンテナ上でしかタスクが動かないことになります。

これが、先ほどタスクが1つだけしか動いていなかったことの原因です。

おそらく、どのインスタンスでもそれぞれのインスタンスのプライベートIPを参照できる環境変数みたいなものがあるとは思いますが(上記で言うところのmysql変数のようなものがあるのだと思っています)、現状わかってないです:pensive: わかる人がいたら教えて欲しいです。。。:pray_tone2: