ひょんなことからコンテナを使ったシステムを構築していて、その部品として MySQL サーバーが必要になりました。
ど~やって用意するのが最も楽で速くて適切かな~って、黒魔術バランス派として考えた結果、こんな感じになりましたよ、という一例でございます。
目的
大雑把に目的をまとめるとプラス、構築としても運用としても、できるだけ無駄なく速くしたいという感じです。内容的には後半が本番って感じになりそうです。
設計
図を書く気分じゃないので、ザックリと処理順に説明するとたいしたことはせずに済みましたが、要件的に少しずつ工夫しつつシンプルに仕上げてみた次第です。全く同じことをしようとする人はいないでしょうが、部分的に参考になれば嬉しい系。
バックアップとアップロード
まずは既存のMySQLサーバーから mysqldump を採って S3 に保存します。このスクリプトを cron で必要な更新頻度で定期実行します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#!/bin/sh export AWS_ACCESS_KEY_ID="*******" export AWS_SECRET_ACCESS_KEY="*************" MYSQL_USER="root" MYSQL_PASS="password" BACKUP_FILE="mysql.sql.gz" BACKUP_PATH="/var/tmp/$BACKUP_FILE" SPARE_PATH="$BACKUP_PATH.spare" S3_BUCKET="gedowfather-example-backup" S3_KEY="mysql/$BACKUP_FILE" [ -f $BACKUP_PATH ] && mv $BACKUP_PATH $SPARE_PATH mysqldump -u $MYSQL_USER -p$MYSQL_PASS --all-databases | gzip > $BACKUP_PATH chmod 700 $BACKUP_PATH aws s3 cp $BACKUP_PATH s3://$S3_BUCKET/$S3_KEY |
今回は鍵を書いてますが、EC2 なら profile にすればいいし、
もし鍵を書く場合は root しか読めないようにはしておきましょう。
cron でも手動でも結果は同じなので、S3オブジェクト発火をしたい時は、お手々で実行します。
S3オブジェクトからLambda発火
mysqldump ファイルが更新されたら、CodeBuild 実行用 Lambda を実行するように Terraform で設定します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
resource "aws_s3_bucket_notification" "test-mysql" { count = local.on_test_mysql ? 1 : 0 bucket = aws_s3_bucket.backup[0].id lambda_function { id = aws_codebuild_project.test-mysql[0].name lambda_function_arn = aws_lambda_function.codebuild-fire[0].arn events = ["s3:ObjectCreated:*"] filter_prefix = local.test_mysql_s3_key } depends_on = [aws_lambda_permission.test-mysql] } |
直接CodeBuildを実行できたらいいんですけど、それ言ってたら色んな追加要望がきてゴッチャになるので、まぁやらないでしょうね。
LambdaでCodeBuildを実行
Pythonコード
ここでは codebuild-fire って名前の Lambda にしましたが、こいつは発火元から Build名 を受けて、その CodeBuild を実行するだけの処理です。手元のコードはフレームワークちっくになってるので、部分的に大事なとこだけ記載しておきます。まずCodeBuildのプロジェクト名をどう渡すかですが、S3からならば上記の aws_s3_bucket_notification -> lambda_function -> id で伝えることができます。もし CloudWatchEvent からなら、任意の──ここでは project というキーで渡せるようにしてあります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
def lambda_handler(event, context): project_name = None # from S3 records = event.get('Records') # from CloudWatchEvent original input event_input = event.get('project') if records: record = records[0] event_source = record['eventSource'] if event_source == "aws:s3": project_name = record['s3']['configurationId'] print('From S3, project_name is %s.' % project_name) else: print('Found non-supported event source (%s).' % event_source) return elif event_input: project_name = event_input print('From CloudWatchEvent, project_name is %s.' % project_name) else: print('Not found project name.') return |
んで、project_name を取得したら、ガツンと実行するだけ
1 2 3 4 |
build = self.getCodeBuild() try: res = build.start_build(projectName = project_name) |
start_build してしまえば、Build自体は非同期に動くので、Lambda もすぐ終了します。
Terraformコード
この記事の本質じゃないので、縦長になるのを避けるためにスルーしようとも思いましたが、根が親切なので記載しておきます。関数とログ、
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
resource "aws_lambda_function" "codebuild-fire" { count = local.on_system ? 1 : 0 function_name = "codebuild-fire" handler = "codebuild-fire.lambda_handler" s3_bucket = local.lambda_bucket s3_key = local.lambda_main_key s3_object_version = data.aws_s3_bucket_object.lambda_main.version_id layers = [aws_lambda_layer_version.system-lib[0].arn] memory_size = 128 timeout = 30 runtime = local.lambda_runtime role = data.terraform_remote_state.common.outputs.aws_iam_role_lambda_arn } resource "aws_cloudwatch_log_group" "codebuild-fire" { count = local.on_system ? 1 : 0 name = format("%s%s", local.lambda_log_group_prefix, "codebuild-fire") retention_in_days = 7 } |
S3 からの実行するために、
1 2 3 4 5 6 7 8 9 |
resource "aws_lambda_permission" "test-mysql" { count = local.on_test_mysql ? 1 : 0 statement_id = "AllowExecutionFromS3Bucket" action = "lambda:InvokeFunction" principal = "s3.amazonaws.com" function_name = aws_lambda_function.codebuild-fire[0].arn source_arn = aws_s3_bucket.backup[0].arn } |
Build用リソース群
これも本質じゃないですが(ryECR
イメージ保存用レポジトリを作ります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
resource "aws_ecr_repository" "test-mysql" { count = local.on_test_mysql ? 1 : 0 name = local.test_mysql_repository_name } resource "aws_ecr_lifecycle_policy" "test-mysql" { count = local.on_test_mysql ? 1 : 0 repository = aws_ecr_repository.test-mysql[0].name policy = <<JSON { "rules": [ ${jsonencode(local.ecr_lifecycle_policy_rule_untagged_1day)} ] } JSON } |
tag がなくなったイメージは不要なので、ルールはこんな感じ。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
locals { ecr_lifecycle_policy_rule_untagged_1day = { "rulePriority": 1, "description": "Expire untagged images older than 1 day", "selection": { "tagStatus": "untagged", "countType": "sinceImagePushed", "countUnit": "days", "countNumber": 1 }, "action": { "type": "expire" } } } |
CodeCommit
内容はあとでまとめます。
1 2 3 4 5 6 |
resource "aws_codecommit_repository" "test-mysql" { count = local.on_system ? 1 : 0 repository_name = local.test_mysql_repository_name description = "creating test-mysql image for ${local.service_name}" } |
CodeBuild
ほぼテンプレですが、環境変数 MYSQL_BACKUP_URL を設定して、buildspec で使うようにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
resource "aws_codebuild_project" "test-mysql" { count = local.on_test_mysql ? 1 : 0 name = aws_codecommit_repository.test-mysql[0].repository_name description = "Building test-mysql image for ${local.service_name}" service_role = data.terraform_remote_state.common.outputs.aws_iam_role_codebuild_arn build_timeout = "10" artifacts { type = "NO_ARTIFACTS" } cache { type = "LOCAL" modes = ["LOCAL_DOCKER_LAYER_CACHE", "LOCAL_SOURCE_CACHE"] } environment { compute_type = "BUILD_GENERAL1_SMALL" image = "aws/codebuild/docker:18.09.0" type = "LINUX_CONTAINER" privileged_mode = true dynamic "environment_variable" { for_each = { AWS_DEFAULT_REGION = local.region AWS_ACCOUNT_ID = local.service_account_id IMAGE_REPO_NAME = aws_ecr_repository.test-mysql[0].name IMAGE_TAG = "latest" MYSQL_BACKUP_URL = local.test_mysql_s3_url } content { name = environment_variable.key value = environment_variable.value } } } source { type = "CODECOMMIT" location = aws_codecommit_repository.test-mysql[0].clone_url_http git_clone_depth = 1 } tags = { service = local.service_name } } resource "aws_cloudwatch_log_group" "test-mysql" { count = local.on_test_mysql ? 1 : 0 name = "/aws/codebuild/${aws_codecommit_repository.test-mysql[0].repository_name}" retention_in_days = 7 } |
DockerHub mysql の仕組み
CodeCommitのソース内容に入る前に、公式mysqlコンテナ の説明を軽くしておきます。公式説明では色々機能を確認できますが、今回私がやりたいことは、起動時間短縮のために mysqldump を丸っとリストアしておき、コンテナとして起動した時にそれをデータとして普通に動かしたい。ということなので、
「Initializing a fresh instance」機能にある、/docker-entrypoint-initdb.d/ の下に .sql.gz とか置いといたら、自動的にリストアするよってのを使います。
なので、バックアップ採取でも、好きな bzip2 じゃなく gzip にしたわけです。
ただ、この機能を素直に使うと、どういう挙動になるかというと、起動時にバックアップファイルがあるようにすれば、起動前にリストア処理を行い、その後に mysqld を起動する、という流れになります。
データ容量が小さい場合はこれでよいのですが、今回は結構大きかったので、毎回コンテナ起動のたびにリストア処理が走るのは待機時間が無駄になるので、リストア処理だけ済ませたイメージを作れるように工夫しました。
Build用CodeCommitソース
この辺からがやっと本編です。buildspec.yml
テンプレと違うところは、S3からバックアップファイルを取得しているのと、BUILDKITを有効にしているところです。なので、CodeBuild の実行Roll に、S3からバックアップファイルを取得できる権限を与えておく必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
version: 0.2 phases: install: commands: pre_build: commands: - $(aws ecr get-login --no-include-email --region $AWS_DEFAULT_REGION) - aws s3 cp $MYSQL_BACKUP_URL ./ build: commands: - DOCKER_BUILDKIT=1 docker build --rm --no-cache -t $IMAGE_REPO_NAME:$IMAGE_TAG . - docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG post_build: commands: - docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG |
Dockerfile
ここはたいしたことない内容にみえて、結構な肝になっています。
1 2 3 4 5 6 7 8 9 10 11 |
# syntax = docker/dockerfile:experimental FROM mysql:5.7 COPY init.sh / COPY entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /init.sh /usr/local/bin/docker-entrypoint.sh RUN --mount=target=docker-entrypoint-initdb.d,rw /init.sh mysqld --datadir=/data/mysql CMD ["mysqld", "--datadir=/data/mysql"] |
リストア専用スクリプト
公式の ENTRYPOINT スクリプトは、リストアもデーモン起動も含んだ内容になっているのですが、リストアだけをしてお終いにする処理が必要になりました。そこで、公式 entrypoint スクリプト を手元に docker-entrypoint.sh として落としてきて、init.sh として編集しました。
1 2 |
wget -O init.sh https://raw.githubusercontent.com/docker-library/mysql/master/5.7/docker-entrypoint.sh sed -i -e 's!exec "$@"!#exec "$@"!g' init.sh |
処理最後での mysqld の実行をやめているだけです。
リストア処理
この init.sh を使って、リストアだけを実行するのがここです。init.sh を実行すれば、mysql.sql.gz が解凍&リストアされるのですが、2つほど工夫を入れました。
1 |
RUN --mount=target=docker-entrypoint-initdb.d,rw /init.sh mysqld --datadir=/data/mysql |
1つは、buildspecでS3から落としたバックアップファイルを含む、Current のソースコード一式を、ビルド中イメージの docker-entrypoint-initdb.d にマウントしたことです。これにより、サイズが大きいほど COPY での無駄な処理を省けます。この –mount は、BUILDKIT を有効にしないと使えないので、このために有効にしました。
もう1つは、–datadir を指定したことです。本来は /var/lib/mysql なのですが、元イメージで VOLUME として指定されており、VOLUME 内のデータはビルド後に残らないと決まっていて、しかもその設定は上書きも削除もできないので、別ディレクトリにデータを残すことにしました。
そのため、Dockerfileの最後で、CMD で引数を上書きして、データディレクトリを指定しています。
公式のENTRYPOINTを上書き
元の ENTRYPOINT 実体は /usr/local/bin/docker-entrypoint.sh なのですが、一部 Fargate 1.4.0 において不都合があったので修正して上書きしています。さきほどと同様、entyrpoint.sh としてコピーし編集しました。内容の差異は
1 2 3 4 5 6 7 8 9 10 11 12 13 |
$ diff -u docker-entrypoint.sh entrypoint.sh --- docker-entrypoint.sh 2020-04-30 13:49:30.065025737 +0900 +++ entrypoint.sh 2020-04-30 04:06:17.767741707 +0900 @@ -380,5 +380,9 @@ # If we are sourced from elsewhere, don't perform any further actions if ! _is_sourced; then + RUN_DIR=/var/run/mysqld + ls -ld $RUN_DIR + chmod 777 $RUN_DIR + chown mysql:mysql $RUN_DIR _main "$@" fi |
理由は繊細というかどうでもいいようなものですが、
ECS Task 1つに複数コンテナを起動して、仮に app , mysql の2つを作成した時、app から mysql への接続方法は特に問題なく
1 2 |
# この場合 localhost じゃダメ $ mysql -h 127.0.0.1 -u root -p |
でできるのですが、アプリコードに極力手を加えないというインフラエンジニア視点において、socket 接続ができるようにしたかったのです。
1 2 |
# これで接続できればOK $ mysql -S /var/run/mysqld/mysqld.sock -u root -p |
そうするには、/var/run/mysqld を app , mysql コンテナで共有する必要があり、Fargate 1.3.0 では特に考えずに設定したらできたものが、1.4.0 になると共有設定したディレクトリが、mysqld 起動時に root:root 0755 になって、mysql ユーザーが書き込めない、という Fargate Platform Version の違いがネックになってしまいました。バインドマウントのタイミングが異なる、ということなのでしょう。
そのため、起動直前にディレクトリの所有権を書き換える必要があり、それができるのはまさにこのタイミングしかない!ということになり、微々たる内容ながらも上書きする決断をしました。
ただ、Fargate 1.4.0 の仕様が最近また変わりつつあるので、あとで不要になる可能性もあります。
あとは、ECS Task に app, mysql と並列して起動し、/var/run/mysqld をバインドマウントしておけば接続できるようになります。が、その辺は省略。
んで接続の確認とか、リストアデータの確認を考えると、appコンテナにsshd・mysqlクライアントなどを入れておいたほうが早い。とかそーゆーのが逆にコンテナっぽいっていうか。
小技の応酬を楽しむシステムって感じは、嫌いじゃないんですけど、要件によってこれが解!ってのが変わるので、小技=部品 的な記事もチマチマ上げていこうって気分になった次第であります:-)