今まではEC2上でLaravelを動かしてきたが、CVEの対応など、定期的にミドルウェアをアップデートする仕組みとして、VMレベルでのプロビジョニングをするのが大変になってきたので、Dockerコンテナ上で動く仕組みを考える必要が出てきた。
Dockerコンテナ上で動かす仕組みとしてPreview環境ではk8sを採用しているものの、メンテナンス性において社内にECSを実際に運用したことのあるメンバーがいるという観点から、安全をとってECSの採用を検討した。
ECSをLaravelで採用する上で、特に運用面にていくつか考慮しなければならない点があったので、本記事でまとめる。
TL; DR
- ログ出力のため、標準出力・標準エラーへの反映を行う
- Terraform上で.envを作成、S3にあげてからGithubActionsで取り回す
- コンテナのデプロイ後を、CloudWatch Event + Lambdaで検知する
- Batchのコンテナサービス化か、Scheduled Task + On-demand migrationか
- キューワーカーはServiceのサイドカーで起動
以下上記を解説していく
ログ
Laravel側
標準の stack
ログチャンネルにおいては、エラーログをmonologを用いた stderr へのストリーミング送信、通常ログを laravel.log へのファイル出力としている。
一方ECSでは、docker log
で出力されるログをログとして収集しているため、stderrでの出力は検知できるものの、通常のログは検知する事ができない。
下記のようにファイル出力をstdoutにリダイレクトしようと思っても、PHPがファイルを見つけられずにエラーになってしまう
1
2
3
4
5
6
| FROM php:fpm-alpine
# ... 諸々のプロビジョニング
COPY . /var/www/html
RUN ln -sf /dev/stdout /var/www/html/storage/log/laravel.log
|
直接Laravelからstdoutに出力する方法は下記のようになる。
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
| return [
'default' => env('LOG_CHANNEL', 'stack'),
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['stderr', 'stdout'],
'ignore_exceptions' => false,
],
'stdout' => [
'driver' => 'monolog',
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'stream' => 'php://stdout',
],
],
'stderr' => [
'driver' => 'monolog',
'level' => 'error',
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'stream' => 'php://stderr',
],
],
// ...
];
|
こちらにすることによって、docker logに出力されるようになるのだが、php7.2以前を使っている場合、下記のように先頭にプロセスが表示されてしまう。
1
2
3
| php_1 | [14-Jun-2020 14:14:18] WARNING: [pool www] child 8 said into stdout: "[2020-06-14 14:14:18] local.INFO: this is info log "
php_1 | [14-Jun-2020 14:14:18] WARNING: [pool www] child 8 said into stderr: "[2020-06-14 14:14:18] local.ERROR: this is exception {"exception":"[object] (Exception(code: 0): this is exception at /var/www/html/routes/web.php:18)"
php_1 | [14-Jun-2020 14:14:18] WARNING: [pool www] child 8 said into stderr: "[stacktrace]" |
PHP7.3以降ではPHP-FPMの設定にdecorate_workers_output
というディレクティブが生えているので、こちらをnoにすることによって表示されないようになる。
1
| decorate_workers_output = no
|
ECS側
ECS側では標準のままだとログが一行ごとに出力されてしまい、スタックトレースなど複数行に渡るエラーが見えづらい。
そこで、TaskDefinitionのログ定義にて、datetime formatを指定することで、datetime formatベースでログをまとめてくれるようになる
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| [
{
// ... 諸々のコンテナの設定
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "${awslog_group}",
"awslogs-region": "${region}",
"awslogs-datetime-format": "%Y-%m-%d %H:%M:%S",
"awslogs-stream-prefix": "ecs"
}
}
},
]
|
デプロイ
基本
GitHub ActionsをCDで用いている場合、GitHub Actions側では下記のようにすることで、TaskDefinitionの変更とデプロイができる
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| jobs:
job:
runs-on: ubuntu-latest
steps:
# ...
- name: Change Task Definition
id: render-td
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ${{ steps.fetch-td.outputs.task-definition }}
container-name: my-container-name
image: ${{ steps.built-image.outputs.image }}
- name: Deploy to Amazon ECS service
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.render-td.outputs.task-definition }}
service: ecs-service
cluster: ecs-cluster
|
Task Definitionに環境変数をどのように注入するのか
ここで問題になるのはTaskDefinitionの環境変数をどうするかである。
ECSを使う場合、AWSの他のサービスを使うことになると思うが、
Task Definitionで記述される情報の中には当然これらの情報に対するアクセス情報を含める必要がある。
開発環境ではLaravelと同リポジトリに .env.local
のようなものを用意しておき、Dockerfile内にて
のようにすれば.envを適応できるが、本番環境の機微情報をバージョン管理システムにコミットするわけには行かないため、本番運用ではこの方法は使えない。
TaskDefinitionには environmentFiles
という設定項目があり、S3に存在する.envファイルを環境変数として使用することができる
1
2
3
4
5
6
| {
"environmentFiles": {
"value": "<S3のobjectのarn>",
"type": "s3" // s3のみサポート
}
}
|
Terraformで環境を構築しているのであれば、上記ファイルを動的に作成し、Taskロールに付与すればいい
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
| resource "aws_s3_bucket_object" "env_file" {
bucket = var.env_file_bucket
key = ".env"
content = <<ENV
DB_CONNECTION=mysql
DB_HOST=${var.db_host}
DB_PORT=3306
DB_DATABASE=${var.db_name}
DB_USERNAME=${var.db_username}
DB_PASSWORD=${var.db_password}
ENV
acl = "private"
}
resource "aws_iam_policy" "download_env_file" {
policy = <<POLICY
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": "arn:aws:s3:::${var.env_file_bucket}/.env"
}
]
}
POLICY
}
resource "aws_iam_user_policy_attachment" "env_file" {
policy_arn = aws_iam_policy.download_env_file.arn
user = var.iam_user_name
}
|
デプロイ後の通知
上記GitHub Actionsの例を用いると、
aws-actions/amazon-ecs-deploy-task-definition@v1
ではwait-for-service-stability
をtrueにすることによって、デプロイ完了が正常に終わったかどうかを確認することができる。
ただ、GitHubActionsは実行時間の従量課金型であるため、デプロイ方法によっては必要以上の課金が発生してしまう。
CloudWatch Event + Lambdaのログを用いることによってデプロイ状況を通知することが可能なので、そちらの紹介を行う。
Terraformにて下記のようなmoduleを作成し、
tfファイル(長いので省略)
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
| locals {
base_name = "${var.base_name}-ecs-notification"
}
resource "aws_cloudwatch_event_rule" "main" {
name = local.base_name
event_pattern = jsonencode({
"source" : [
"aws.ecs"
],
"detail-type" : [
"ECS Task State Change"
],
"detail" : {
"clusterArn" : [
var.cluster_arn
]
}
})
}
resource "aws_cloudwatch_log_group" "lambda_log" {
name = "/aws/lambda/${local.base_name}-lambda"
}
resource "aws_iam_role" "lambda" {
name = local.base_name
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement : [
{
Action = "sts:AssumeRole"
Principal = {
Service = "lambda.amazonaws.com"
}
Effect = "Allow"
}
]
})
}
resource "aws_iam_policy" "lambda" {
policy = jsonencode(
{
Version = "2012-10-17",
Statement = [
{
Effect = "Allow",
Action = [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
Resource = [
aws_cloudwatch_log_group.lambda_log.arn
]
}
]
}
)
}
resource "aws_iam_role_policy_attachment" "lambda" {
policy_arn = aws_iam_policy.lambda.arn
role = aws_iam_role.lambda.name
}
data "archive_file" "lambda" {
source_file = "${path.module}/lambda/index.js"
output_path = "${path.module}/lambda/index.zip"
type = "zip"
}
resource "aws_lambda_permission" "from_cw_event" {
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.main.function_name
source_arn = aws_cloudwatch_event_rule.main.arn
principal = "events.amazonaws.com"
}
resource "aws_lambda_function" "main" {
function_name = local.base_name
handler = "index.handler"
role = aws_iam_role.lambda.arn
runtime = "nodejs12.x"
filename = data.archive_file.lambda.output_path
source_code_hash = data.archive_file.lambda.output_base64sha256
environment {
variables = {
SLACK_CHANNEL = var.slack_channel
SLACK_ICON = var.slack_icon
SLACK_APP_NAME = local.base_name
SLACK_HOOK_URL = var.slack_hook_url
}
}
}
resource "aws_cloudwatch_event_target" "main" {
arn = aws_lambda_function.main.arn
rule = aws_cloudwatch_event_rule.main.name
}
|
以下のようなLambda関数を用意すればいい
Slack通知の関数
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
| 'use strict';
const https = require('https');
const url = require('url');
// envs
const SLACK_CHANNEL = process.env.SLACK_CHANNEL
const SLACK_ICON = process.env.SLACK_ICON
const SLACK_APP_NAME = process.env.SLACK_APP_NAME
const SLACK_HOOK_URL = process.env.SLACK_HOOK_URL
exports.handler = async (event, context, callback) => {
const eventDetail = event.detail
if (!eventDetail) {
console.log("The event is not expected", event)
}
const message = genNotifyMessage(eventDetail)
if (!message) {
return
}
const result = await sendSlack(message)
console.log(result)
}
function genNotifyMessage(eventDetail) {
const targetStatuses = [
{
from: "PENDING",
to: "RUNNING",
message: `${eventDetail.group} deploy started`
},
{
from: "RUNNING",
to: "RUNNING",
message: `${eventDetail.group} deploy completed`
}
]
const target = targetStatuses
.find(({from, to}) => from === eventDetail.lastStatus && to === eventDetail.desiredStatus)
return target && target.message
}
function sendSlack(message) {
return new Promise(((resolve, reject) => {
const formData = {
channel: SLACK_CHANNEL,
text: message,
icon_emoji: SLACK_ICON,
username: SLACK_APP_NAME,
};
const formDataEncripted = JSON.stringify(formData)
const options = {
...url.parse(SLACK_HOOK_URL),
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(formDataEncripted)
}
};
const req = https.request(options, function (res) {
let chunks = []
res.on('data', function (chunk) {
chunks.push(chunk)
});
res.on('end', function () {
console.log(chunks.join(''))
resolve({
body: chunks.join(''),
statusCode: res.statusCode,
statusMessage: res.statusMessage
})
})
});
req.write(formDataEncripted);
req.on('error', function (e) {
console.log("Sending request error: " + e.message);
reject(e)
});
req.end();
}))
}
|
非同期処理系
LaravelにはSchedule taskとJob workerがある。
通常のコンテナではこれらを適切に扱うことができないので、工夫が必要
ScheduleタスクとmigrationのためのBatchサービス
他のサービスを使わない形で手っ取り早く導入するのであればこの形が一番ラク。
要件としては下記
- 起動するコンテナ数は1
- 起動時のentrypointにてmigrationの実行と、crontabの登録を行う。
- 通常のserviceと同じタイミングでデプロイ
こうすることによって、バッチコンテナが同時に複数立ち上がることがないため、withoutOverlappingでの制御が可能となり、マイグレーションもデプロイタイミングで実行されるため、他のマネージドサービスを使う必要はない。
やり方も、LaravelのDockerfileで指定しているentrypointに下記を追加するだけ。
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
| # 環境変数で分岐させる場合
if [[ "$BATCH" -eq "1" ]]; then
apk --no-cache add sudo
COUNT=0
while [[ $COUNT -lt 1 ]]
do
# DB接続の確認。副作用を起こしたくないので、statusチェックのみ
php artisan migrate:status --database 'mysql-migrate'
READY=$?
if [[ $READY -eq 0 ]]; then
COUNT=$((COUNT + 1))
else
# migrateされていないパターンかも知れないので、installを実行
php artisan migrate:install --database 'mysql-migrate'
COUNT=0
fi
echo "waiting for finish init process... count: ${COUNT}, ready: ${READY}"
sleep 1
done
echo "migrate"
php artisan migrate --database 'mysql-migrate' --force
echo "* * * * * sudo -u www-data php /var/www/html/artisan schedule:run" >> /etc/crontab
fi
|
CloudWatchを使ったScheduleTaskとGithubActionsでのマイグレーション
よりマネージドで管理する場合は、Scheduleタスクを下記ドキュメントのやり方で毎分実行するようにするとよい。
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/userguide/scheduled_tasks.html
マイグレーションを自動で行う場合はTaskDefinitionに下記を追加する。
1
2
3
4
5
6
7
| jobs:
job:
runs-on: ubuntu-latest
steps:
# ...
- name: Run migration
run: aws ecs run-task --cluster ecs-cluster --task-definition migration-td #必要なオプションは適宜追加
|
マイグレーションにてAlter tableを用いたマイグレーションを行っている場合、テーブルロックにより実行が止まらない可能性があるので、自動化には注意したほうがいい。
Job Workerはメインのコンテナのサイドカーで実行
Queueを用いる場合、dequeueするコンテナが必要となる。
メインのコンテナとプロセスが別れていればいいので、キューの実行に厳密性が必要なかったり、FIFOで設定されていれば、サイドカーにしてしまって問題ない。
下記のようにentrypointをartisanでも実行可能な形にしておけば
1
2
3
4
5
6
7
| if [[ "$1" == *artisan* ]]; then
set -- php "$@"
else
set -- php-fpm "$@"
fi
exec "$@"
|
TaskDefinitionでcommandを上書きすることでqueue worker用のコンテナを実行できる
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| [
{
"name": "${container_name}-worker",
"command": [
"artisan",
"queue:work",
"sqs",
"--sleep=3",
"--tries=3"
],
// ... 諸々のコンテナの設定
}
},
]
|
終わりに
ECS上で本番運用する上でのトピックをいくつか紹介させてもらった。
紹介した中でもっといい方法などあればFBいただけると嬉しいです。
Author
kotamat
LastMod
2020-06-16
(430d37a)