php-fpmで動くLaravelのdocker imageをgithub actions使ってECRへデプロイ

laravelのアプリをphp-fpmでsocket使ってgithub actionsでECSにデプロイしたのでメモ。
php&laravel知識は私にはほぼないです。DB使ってないアプリなのでDB設定なし。

雑において使えればよかったのでいろいろ適当です。
だったらphp-fpmにする必要もなかったんですができるのかなと思ってやったら出来たので記録。

# 前提

* Docker
  * docker-compose
* デプロイできるAWS ECSクラスタが存在する
* AWS ECR
* github actions
* Terraform

# dockerの作成

ローカルで確認できるように＆pushするときのタグを保存できるのでdocker-compose.ymlでbuildするようにする。

環境変数で基本必要なものを `.env.docker` に作っておいてsecretな情報は引き数で入れる方針で。


```docker-compose.yml
version: '3.7'

volumes:
  sock:
    name: sock

services:
  nginx:
    image: 012345678901.dkr.ecr.us-east-1.amazonaws.com/app-nginx
    container_name: app-nginx
    build:
      context: .
      dockerfile: ./docker/nginx/Dockerfile
    volumes:
      - sock:/sock
    depends_on:
      - php
    ports:
      - "8080:80"

  php:
    image: 012345678901.dkr.ecr.us-east-1.amazonaws.com/app-php
    container_name: app-php
    build:
      context: .
      args:
        SECRET_TOKEN: "$SECRET_TOKEN"
    volumes:
      - sock:/sock
```


## php-fpm

```Dockerfile
FROM php:7.4-fpm-alpine

ENV COMPOSER_ALLOW_SUPERUSER 1
ARG SECRET_TOKEN

COPY --from=composer /usr/bin/composer /usr/bin/composer
RUN apk add --no-cache zip unzip

WORKDIR /code

COPY --chown=www-data:www-data . .
RUN composer install
COPY .env.docker .env
COPY docker/php-fpm.d /usr/local/etc/php-fpm.d
RUN echo SECRET_TOKEN="$SECRET_TOKEN" >> .env

RUN php artisan key:generate
RUN chown www-data /usr/local/var/log/

WORKDIR /code/public
```

php-fpmのコンテナだとzz-docker.confにlistenが入っているので上書きしないとsocketが書き込まれないので上書き

```docker/php-fpm.d/zz-docker.conf
[global]
daemonize = no

[www]
listen = /sock/php-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0666
```

他php-fpmで変えたい項目があるなら、 `www.conf` も上書きすればよい

## nginx

`public/` にあるファイルは必要なので配置

```docker/nginx/Dockerfile
FROM nginx:stable-alpine

ADD docker/nginx/conf.d/ /etc/nginx/conf.d
ADD public/ /code/public

EXPOSE 80

STOPSIGNAL SIGTERM

CMD ["nginx", "-g", "daemon off;"]
```

`/sock/php-fpm.sock` でボリューム共有するのでそこをfastcgi_passに指定

```docker/nginx/conf.d/default.conf
server {
  listen 80 default_server;
  server_name _;
  charset utf-8;
  client_max_body_size 75M;

  gzip            on;
  gzip_types      text/plain application/xml text/css application/javascript;
  gzip_min_length 1000;


  index index.php index.html;

  root /code/public;

  location / {
    try_files $uri $uri/ /index.php?$args;
  }
  location ~ \.php$ {
    try_files $uri =404;
    fastcgi_split_path_info ^(.+\.php)(/.+)$;
    fastcgi_pass unix:/sock/php-fpm.sock;
    fastcgi_index index.php;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_param PATH_INFO $fastcgi_path_info;
  }
}
```

下記実行して `127.0.0.1:8080` がひらけたら成功

```
docker-compose build
docker-compose up
```

# TerraformでECR と ECS service用意

networkModeをawsvpcにしてdiscovery serviceで参照する設定いれてます。
security group、vpcとdiscovery serviceの設定は別のところでやっているとして省略。

github actions用のユーザterraformで作って `ACCESS_KEY` と `SECRET_ACCESS_KEY` はtfstateにいれたくないからWebコンソールからとってます。

```
resource "aws_cloudwatch_log_group" "app" {
  name              = "/app"
  retention_in_days = 60

  tags = {
    Environment = "app"
  }
}

resource "aws_ecs_task_definition" "app" {
  family = "app"
  volume {
    name = "sock"
    docker_volume_configuration {
      scope  = "task"
      driver = "local"
    }
  }
  container_definitions = file("task-definition/app.json")
  network_mode          = "awsvpc"
}

resource "aws_ecs_service" "app" {
  name                               = "app"
  cluster                            = "cluster"
  task_definition                    = aws_ecs_task_definition.app.arn
  desired_count                      = 1
  deployment_minimum_healthy_percent = 0
  deployment_maximum_percent         = 100

  network_configuration {
    subnets = aws_subnet.vpc.*.id
    security_groups = [
      aws_security_group.output.id
      aws_security_group.app.id
    ]
  }

  service_registries {
    registry_arn = aws_service_discovery_service.app.arn
  }

  lifecycle {
    ignore_changes = [desired_count]
  }
}

resource "aws_service_discovery_service" "app" {
  name = "app"

  dns_config {
    namespace_id = aws_service_discovery_public_dns_namespace.main.id

    dns_records {
      ttl  = 10
      type = "A"
    }

    routing_policy = "MULTIVALUE"
  }

  health_check_custom_config {
    failure_threshold = 1
  }
}

# ECR
resource "aws_ecr_repository" "app-nginx" {
  name = "app-nginx"
}

resource "aws_ecr_repository" "app-php" {
  name = "app-php"
}

resource "aws_ecr_lifecycle_policy" "app-nginx" {
  repository = aws_ecr_repository.app-nginx.name
  policy     = file("ecr/lifecycle-policy.json")
}

resource "aws_ecr_lifecycle_policy" "app-php" {
  repository = aws_ecr_repository.app-php.name
  policy     = file("ecr/lifecycle-policy.json")
}


resource "aws_iam_user" "github-actions-app" {
  name = "github-actions-app"
  path = "/"
}


resource "aws_iam_user_policy" "github-actions-app" {
  name = "github-actions-app"
  user = "${aws_iam_user.github-actions-app.name}"

  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "ecs:UpdateService",
                "ecr:GetDownloadUrlForLayer",
                "ecr:BatchGetImage",
                "ecr:CompleteLayerUpload",
                "ecr:DescribeRepositories",
                "ecr:UploadLayerPart",
                "ecr:InitiateLayerUpload",
                "ecr:BatchCheckLayerAvailability",
                "ecr:PutImage"
            ],
            "Resource": [
                "arn:aws:ecs:us-east-1:012345678901:service/cluster/app",
                "arn:aws:ecr:us-east-1:012345678901:repository/app-*"
            ]
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": "ecr:GetAuthorizationToken",
            "Resource": "*"
        }
    ]
}
EOF
}
```

volumeをmountしてsocketファイルをコンテナ間で共有

```task-definition/app.tf
[
    {
        "mountPoints": [
            {
              "sourceVolume": "sock",
              "containerPath": "/sock"
            }
        ],
        "logConfiguration": {
            "logDriver": "awslogs",
            "options": {
                "awslogs-group": "/app",
                "awslogs-region": "us-east-1",
                "awslogs-stream-prefix": "nginx"
            }
        },
        "cpu": 32,
        "memoryReservation": 32,
        "image": "012345678901.dkr.ecr.us-east-1.amazonaws.com/app-nginx:latest",
        "portMappings": [
            {
                "hostPort": 80,
                "containerPort": 80,
                "protocol": "tcp"
            }
        ],
        "name": "nginx"
    },
    {
        "mountPoints": [
            {
              "sourceVolume": "sock",
              "containerPath": "/sock"
            }
        ],
        "logConfiguration": {
            "logDriver": "awslogs",
            "options": {
                "awslogs-group": "/app",
                "awslogs-region": "us-east-1",
                "awslogs-stream-prefix": "php"
            }
        },
        "cpu": 64,
        "memoryReservation": 64,
        "image": "012345678901.dkr.ecr.us-east-1.amazonaws.com/app-php:latest",
        "name": "php"
    }
]
```

削除は単純なポリシーで

```ecr/lifecycle-policy.json
{
    "rules": [
        {
            "rulePriority": 1,
            "description": "Expire images older than 7 count",
            "selection": {
                "tagStatus": "untagged",
                "countType": "sinceImagePushed",
                "countUnit": "days",
                "countNumber": 7
            },
            "action": {
                "type": "expire"
            }
        }
    ]
}
```

imageがない状態で `terraform apply` するとECS Serviceが起動でエラーでますが気にしない。
するなら、ECRのところだけ `terraform apply` して `docker-compose push` してからやるとECS Serviceも起動する。

# github actions

githubのsecretsに `AWS_ACCESS_KEY_ID` `AWS_SECRET_ACCESS_KEY` `SECRET_TOKEN` を作ってます。

自動でデプロイされたくないので tagに `v*` とつけたらデプロイされるようにしてます。

```.github/workflows/deploy.yml
name: Deploy

on:
  push:
    tags:
      - v*

jobs:
  deploy_production:
    name: deploy production
    runs-on: ubuntu-latest
    steps:
    - name: AWS ECR login
      run: $(aws ecr get-login --no-include-email)
      env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: us-east-1
    - uses: actions/checkout@master
    - name: Get Composer Cache Directory
      id: composer-cache
      run: |
        echo "::set-output name=dir::$(composer config cache-files-dir)"
    - uses: actions/cache@v1
      with:
        path: ${{ steps.composer-cache.outputs.dir }}
        key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
        restore-keys: |
          ${{ runner.os }}-composer-
    - name: build base
      run: docker-compose build
      env:
        SECRET_TOKEN: ${{ secrets.SECRET_TOKEN }}
    - name: push image
      run: docker-compose push
    - name: force deployment ecr
      run: aws ecs update-service --cluster cluster --service app --force-new-deployment
      env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: us-east-1
```

これでgithubでreleaseからタグ打てばデプロイされて便利です。