2021-02-05

2020-12-22

技術ノート

eyecatch
この技術ブログが稼働している EC2 を最適化するに当たって、arm64 アーキテクチャの AMI からインスタンスを新規に起動してブログコンテンツを移行しました。インスタンスタイプは t3.micro から t4g.micro に、ボリュームタイプは gp2 から gp3 に移行しました。本エントリはその手順の備忘録です。

はじめに

以下は2020年の AWS re:Invent の開催概要です。

毎年大量のアップデートがアナウンスされ、情報を追うのに必死ですが、今回のエントリ内容にかかわるところでは以下がアップデートされました。

新しい AmazonEBS 汎用ボリューム gp3 のご紹介

また少し遡りますが、9月には以下のアップデートがありました。

AWS Graviton2 プロセッサを搭載した新しい Amazon EC2 T4g インスタンスを発表

ポイントを引用します。

T4g インスタンスを使用すると、T3 インスタンスと比較して 20% 低いコストで最大 40% のパフォーマンス上のメリットが得られます。これにより、幅広いワークロードに対して最高のコストパフォーマンスを実現できます。
ストレージキャパシティーに関係なくパフォーマンスをプロビジョニングでき、既存の gp2 ボリュームタイプよりも 20% 低い価格で提供される

コストを抑えてパフォーマンスを最適化できるかもしれない! ということでやってみました。このブログの構成は過去のエントリで紹介していますが、構成図を再掲しておきます。

構成図

以下はコンテナ構成の再掲です。

Docker 構成図

各コンテナの名前は以下のようにしています。

役割 名前
Nginx コンテナ django.webserver
Django コンテナ django.application
PostgreSQL コンテナ django.database

今回変更する部分の新旧比較です。

# AMI InstanceType VolumeType
amzn2-ami-hvm-2.0.20190618-x86_64-gp2 t3.micro gp2
amzn2-ami-hvm-2.0.20201126.0-arm64-gp2 t4g.micro gp3

フォルダ構成です。


# フォルダ構成
minbase.io
├── build
│   ├── django
│   └── nginx
├── django
│   ├── blog
│   │   └── fixtures
│   ├── config
│   │   └── settings.py
│   ├── manage.py
│   ├── static
│   ├── templates
│   └── uwsgi.ini
├── docker-compose.yml
├── nginx
│   ├── conf.d
│   │   └── default.conf
│   ├── nginx.conf
│   ├── templates
│   │   └── default.conf.template
│   └── uwsgi_params
└── recovery
    ├── cron
    ├── data
    └── image

リポジトリはなるべく最新化するよう心がけています。過去記事と細部が相違している可能性がありますので、ご注意ください。

IAM ポリシー、IAM ロールを作成する

今回の移行では AWS サービスとしては以下を利用しました。

AWS サービス 用途
Amazon S3 資材置場として利用。EC2 からアップロード / ダウンロードを行う
AWS Systems Manager パラメータストア EC2 セットアップの際に参照するパラメータをセキュアに保管する

これらのサービスを利用するために、EC2 インスタンスに IAM ロールを付与する必要があります。まずは IAM ロールに登録する IAM ポリシーを作成します。パラメータストアに関しては、以下のような形にしました。実際はリソースの階層をもっと絞ったほうがよいとは思います。


# _ssm_parameter-store_write
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "ssm:PutParameter",
                "ssm:DeleteParameter",
                "ssm:GetParameterHistory",
                "ssm:GetParametersByPath",
                "ssm:GetParameters",
                "ssm:GetParameter",
                "ssm:DeleteParameters"
            ],
            "Resource": "arn:aws:ssm:ap-northeast-1:000000000000:parameter/*"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": "ssm:DescribeParameters",
            "Resource": "*"
        }
    ]
}

S3 の IAM ポリシーは以下です。バケット名はdocker-django-blogとしています。


# _s3_docker-django-blog_write
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket",
                "s3:GetBucketLocation"
            ],
            "Resource": "arn:aws:s3:::docker-django-blog"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            "Resource": "arn:aws:s3:::docker-django-blog/*"
        },
        {
            "Sid": "VisualEditor2",
            "Effect": "Allow",
            "Action": [
                "s3:ListAllMyBuckets",
                "s3:CreateBucket"
            ],
            "Resource": "*"
        }
    ]
}

これらの IAM ポリシーを IAM ロールに登録し、現行サーバーにアタッチします。マネジメントコンソールで作業する想定ですが、PowerShell でやるパターンも用意してみました。


Set-DefaultAWSRegion -Region "ap-northeast-1" -Scope Script
Import-Module -Name AWS.Tools.IdentityManagement
$iamPolicies = @()

$awsAccountId = "000000000000"
$bucketName = "docker-django-blog"

# Create S3 IAM Policy
$policyName = "_s3_${bucketName}_write"
$policyDocument = @"
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket",
                "s3:GetBucketLocation"
            ],
            "Resource": "arn:aws:s3:::${bucketName}"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            "Resource": "arn:aws:s3:::${bucketName}/*"
        },
        {
            "Sid": "VisualEditor2",
            "Effect": "Allow",
            "Action": "s3:ListAllMyBuckets",
            "Resource": "*"
        }
    ]
}
"@

$param = @{
    PolicyName = $policyName
    Description = $policyName
    PolicyDocument = $policyDocument
}

$s3Policy = New-IAMPolicy @param
$iamPolicies += $s3Policy

# Create SSM ParameterStore IAM Policy
$policyName = "_ssm_parameter-store_write"
$policyDocument = @"
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "ssm:PutParameter",
                "ssm:DeleteParameter",
                "ssm:GetParameterHistory",
                "ssm:GetParametersByPath",
                "ssm:GetParameters",
                "ssm:GetParameter",
                "ssm:DeleteParameters"
            ],
            "Resource": "arn:aws:ssm:ap-northeast-1:${awsAccountId}:parameter/*"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "s3:ListAllMyBuckets",
                "s3:CreateBucket"
            ],
            "Resource": "*"
        }
    ]
}
"@

$param = @{
    PolicyName = $policyName
    Description = $policyName
    PolicyDocument = $policyDocument
}

$ssmPolicy = New-IAMPolicy @param
$iamPolicies += $ssmPolicy

# Create IAM Role
$roleName = "_ec2_role"
$roleTag = New-Object -TypeName Amazon.IdentityManagement.Model.Tag -Property @{ Key="Name"; Value=$roleName }
$assumeRolePolicyDocument = @'
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "",
            "Effect": "Allow",
            "Principal": {
              "Service": "ec2.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
'@

$param = @{
    AssumeRolePolicyDocument = $assumeRolePolicyDocument
    RoleName = $roleName
    Description = $roleName
    Tag = $roleTag
}

New-IAMRole @param

# Register IAM Policy to IAM Role
$iamPolicies | ForEach-Object -Process { Register-IAMRolePolicy -PolicyArn $_.Arn -RoleName $roleName }

# Create IAM Instance Profile, Add IAM Role to IAM Instance Profile
New-IAMInstanceProfile -InstanceProfileName $roleName
Add-IAMRoleToInstanceProfile -InstanceProfileName $roleName -RoleName $roleName

Get-Variable | Remove-Variable -ErrorAction Ignore

どうしても冗長になってしまいます。CloudFormation を使ったほうがいいです。

Django の dumpdata を取得する

IAM の準備ができたら、外堀を埋めていきます。Django の DB に格納されているブログコンテンツをバックアップします。Django の場合はdumpdataを使えば簡単に取得できます。アプリに含まれるモデルをすべて取得したい場合はdumpdata <app_name>を指定します。


python manage.py dumpdata blog --indent 4 --output blog.json

モデルごとに取得したい場合はdumpdata <app_name>.<model_name>の形式で指定します。Site フレームワークを使っている場合、sites.siteも忘れずに取得しておきます。


python manage.py dumpdata blog.category --indent 4 --output blog.category.json
python manage.py dumpdata sites.site --indent 4 --output sites.site.json

以下のような感じで、DB データが json テキストに保存されます。


[
{
    "model": "blog.tag",
    "pk": "00000000000000000000000000000000",
    "fields": {
        "created_at": "2020-12-01T00:00:00.000Z",
        "updated_at": "2020-12-02T00:00:00.000Z",
        "name": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af",
        "slug": "network",
        "category": "11111111111111111111111111111111"
    }
},
{
    "model": "blog.tag",
    "pk": "22222222222222222222222222222222",
    "fields": {
        "created_at": "2020-12-15T00:00:00.000Z",
        "updated_at": "2020-12-16T00:00:00.000Z",
        "name": "Ubuntu",
        "slug": "ubuntu",
        "category": "33333333333333333333333333333333"

    }
},
# 略
]

私は以下のようなシェルスクリプトを用意して作業を簡略化しています。DB 構成に合わせてこういった簡易スクリプトを作っておくと便利です。


#!/bin/bash
models=(
    sites.site
    blog.category
    blog.tag
    blog.post
    blog.snippet
    blog.sitedetail
    blog.aboutsite
    blog.privacypolicy
    blog.image
    blog.link
    blog
)

target_dir=$(dirname "$(cd "$(dirname "${BASH_SOURCE:-$0}")" && pwd)")

cd /django

for model in ${models[@]} ; do
    python manage.py dumpdata ${model} --indent 4 --output ${target_dir}/${model}.json
done

なお、本来必要なのはblog.jsonsites.site.jsonだけですが、エラー発生時に個別に対応が必要になる可能性もあるのでモデル別のファイルも出しています。

docker のイメージをバックアップする

今回利用するわけではないのですが、移行時点での docker コンテナをイメージ化してバックアップしておきます。


# django コンテナをイメージ化
docker commit django.application django.application
docker save django.application > django.application.tar

# nginx コンテナをイメージ化
docker commit django.webserver django.webserver
docker save django.webserver > django.webserver.tar

# postgresql コンテナをイメージ化
docker commit django.database django.database
docker save django.database > django.database.tar

cron の設定をバックアップする

コンテナ内の定期タスクは以下のような形でホスト側で動かしています。


00 0 * * 0 docker exec -t django.webserver /usr/bin/certbot renew > /home/test-user/docker/minbase.io/nginx/log/certbot.log 2>&1
00 0 * * 1 docker exec -t django.application python /django/manage.py google_analytics_api_exec > /home/test-user/docker/minbase.io/django/log/google_analytics_api_exec.log 2>&1
30 0 * * 1 docker exec -t django.application python /django/manage.py site_report_api_exec > /home/test-user/docker/minbase.io/django/log/site_report_api_exec.log 2>&1

従って、cronの設定もバックアップしておく必要があります。


crontab -l > crontab.backup

ここまでの手順で作成したデータは、以下のようなフォルダ構成で配置しておく想定です。


minbase.io
├ 略
└── recovery
    ├── cron
    │   └── crontab.backup
    ├── data
    │   ├── blog.aboutsite.json
    │   ├── blog.category.json
    │   ├── blog.image.json
    │   ├── blog.json
    │   ├── blog.link.json
    │   ├── blog.post.json
    │   ├── blog.privacypolicy.json
    │   ├── blog.sitedetail.json
    │   ├── blog.snippet.json
    │   ├── blog.tag.json
    │   └── sites.site.json
    └── image
        ├── django.application.tar
        ├── django.database.tar
        └── django.webserver.tar

パラメータストアに git のユーザー情報を登録しておく

ここが今回の肝かと思うのですが、EC2 起動時に流し込む UserData 内でgitのユーザー情報が必要になるので、パラメータをセキュアな形でどこかにストアしておく必要があります。ということで、SSM パラメータストアの出番です。

aws cli で操作します。さきほど IAM ロールを付与した現行サーバーから以下のコマンドを実行します。管理しやすいように、名前を階層構造にしておきます。


aws ssm put-parameter --name "/account/git/username" --value "nekrassov01" --type String
aws ssm put-parameter --name "/account/git/password" --value "***********" --type SecureString

また今回はec2-userとは別に、パスワード認証も公開鍵認証も可能な管理用ユーザーを UserData の中で自動生成します。これに関してもパラメータを呼び出す必要があるので、同じ要領でパラメータをセットアップしておきます。


aws ssm put-parameter --name "/account/ec2/username" --value "test-user" --type String
aws ssm put-parameter --name "/account/ec2/password" --value "***********" --type SecureString
aws ssm put-parameter --name "/account/ec2/key/public" --value "***************( 略 )***************" --type SecureString

EC2 インスタンスを起動する

では新しい EC2 インスタンスを立ち上げましょう。今回は手になじんでいる PowerShell で起動します。以下のようなスクリプトを書きました。CloudFormation 勉強中です。。


Set-DefaultAWSRegion -Region "ap-northeast-1" -Scope Script
Import-Module -Name AWS.Tools.EC2 -Force

$instanceName = "web-01"

# Difine Name Tag
$tag = @{ Key="Name"; Value=$instanceName }
$nameTagObj = New-Object -TypeName Amazon.EC2.Model.TagSpecification
$nameTagObj.ResourceType = "instance"
$nameTagObj.Tags.Add($tag)

# Define EBS Setting
$bd = New-Object -TypeName Amazon.EC2.Model.EbsBlockDevice
$bd.VolumeSize = 50
$bd.VolumeType = "gp3"
$bd.Iops = 3000
$bd.DeleteOnTermination = $true
$bdm = New-Object -TypeName Amazon.EC2.Model.BlockDeviceMapping
$bdm.DeviceName = "/dev/xvda"
$bdm.Ebs = $bd

# Define UserData Shell Script
$userData = @"
#!/bin/bash
timedatectl set-timezone Asia/Tokyo
localectl set-locale LANG=ja_JP.UTF8
localectl set-keymap jp106
localectl set-keymap jp-OADG109A
hostnamectl set-hostname ${instanceName}

aws configure set region ap-northeast-1
aws configure set output json

yum update -y
yum install -y docker git jq

ec2_username=`$(aws ssm get-parameters-by-path --path "/account/ec2" --with-decryption --region ap-northeast-1 | jq -r '.Parameters[1].Value')
ec2_password=`$(aws ssm get-parameters-by-path --path "/account/ec2" --with-decryption --region ap-northeast-1 | jq -r '.Parameters[0].Value')
ec2_publickey=`$(aws ssm get-parameters-by-path --path "/account/ec2/key" --with-decryption --region ap-northeast-1 | jq -r '.Parameters[0].Value')
git_username=`$(aws ssm get-parameters-by-path --path "/account/git" --with-decryption --region ap-northeast-1 | jq -r '.Parameters[1].Value')
git_password=`$(aws ssm get-parameters-by-path --path "/account/git" --with-decryption --region ap-northeast-1 | jq -r '.Parameters[0].Value')

useradd `$ec2_username
usermod -G wheel `$ec2_username
echo `$ec2_password | sudo passwd --stdin `$ec2_username
touch /etc/sudoers.d/`${ec2_username}
echo "`${ec2_username} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/`${ec2_username}
sed -i -e 's/PasswordAuthentication no/PasswordAuthentication yes/g' /etc/ssh/sshd_config
systemctl restart sshd

ssh_dir=/home/`${ec2_username}/.ssh
key_file=`${ssh_dir}/authorized_keys

mkdir -p `$ssh_dir
touch `$key_file
echo `$ec2_publickey >> `$key_file

chown `$ec2_username:`$ec2_username `$key_file
chmod 600 `$key_file

chown `${ec2_username}:`${ec2_username} `$ssh_dir
chmod 700 `$ssh_dir

systemctl start docker
systemctl enable docker
usermod -a -G docker ec2-user
usermod -a -G docker `$ec2_username

compose_dir=/opt/docker-compose
compose_version=`$(curl https://api.github.com/repos/docker/compose/releases/latest | jq .name -r)
mkdir -p `$compose_dir
git clone -b `${compose_version} "https://`${git_username}:`${git_password}@github.com/docker/compose.git" `$compose_dir
cd `$compose_dir
./script/build/linux
cp dist/docker-compose-Linux-aarch64 /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose
"@ -replace "`r`n", "`n"

# Define EC2 Instance Parameters
$params = @{
    MinCount = 1
    MaxCount = 1
    InstanceType = [Amazon.EC2.InstanceType]::T4gMicro
    CreditSpecification_CpuCredit = "standard"
    PrivateIpAddress = "10.0.0.10"
    AssociatePublicIp = $true
    KeyName = "AWS-Key"
    ImageId = "ami-077527e5c50f1d6d1"
    SubnetId = "subnet-00000000000000000"
    SecurityGroupId = "sg-11111111111111111", "sg-2222222222222222"
    BlockDeviceMapping = $bdm
    EbsOptimized = $true
    DisableApiTermination = $false
    IamInstanceProfile_Name = "_ssm_role"
    TagSpecification = $nameTagObj
    EncodeUserData = $true
    UserData = $userData
}

# Run EC2 Instance
$reservation = New-EC2Instance @params
$instances = $reservation.Instances

# Wait for EC2 Instance Launch
while ((Get-EC2InstanceStatus -IncludeAllInstance $true -InstanceId $instances.InstanceId).InstanceState.Name.Value -ne "running")
{
    $logTime = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
    Write-Host "[${logTime}] Waiting for the instances to launch..."
    Start-Sleep -Seconds 15
}

Start-Sleep -Seconds 180

# Attach Elastic IP to EC2 Instance
$instances | ForEach-Object -Process {
    $elasticIp = (New-EC2Address -Domain vpc).AllocationId
    Register-EC2Address -InstanceId $_.InstanceId -AllocationId $elasticIp
}

# Cleanup Variables
Get-Variable | Remove-Variable -ErrorAction Ignore

見づらいので、UserData のみ切り出して以下に貼っておきます。


#!/bin/bash
timedatectl set-timezone Asia/Tokyo
localectl set-locale LANG=ja_JP.UTF8
localectl set-keymap jp106
localectl set-keymap jp-OADG109A
hostnamectl set-hostname web-01

aws configure set region ap-northeast-1
aws configure set output json

yum update -y
yum install -y docker git jq

ec2_username=$(aws ssm get-parameters-by-path --path "/account/ec2" --with-decryption --region ap-northeast-1 | jq -r '.Parameters[1].Value')
ec2_password=$(aws ssm get-parameters-by-path --path "/account/ec2" --with-decryption --region ap-northeast-1 | jq -r '.Parameters[0].Value')
ec2_publickey=$(aws ssm get-parameters-by-path --path "/account/ec2/key" --with-decryption --region ap-northeast-1 | jq -r '.Parameters[0].Value')
git_username=$(aws ssm get-parameters-by-path --path "/account/git" --with-decryption --region ap-northeast-1 | jq -r '.Parameters[1].Value')
git_password=$(aws ssm get-parameters-by-path --path "/account/git" --with-decryption --region ap-northeast-1 | jq -r '.Parameters[0].Value')

useradd $ec2_username
usermod -G wheel $ec2_username
echo $ec2_password | sudo passwd --stdin $ec2_username
touch /etc/sudoers.d/${ec2_username}
echo "${ec2_username} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/${ec2_username}
sed -i -e 's/PasswordAuthentication no/PasswordAuthentication yes/g' /etc/ssh/sshd_config
systemctl restart sshd

ssh_dir=/home/${ec2_username}/.ssh
key_file=${ssh_dir}/authorized_keys

mkdir -p $ssh_dir
touch $key_file
echo $ec2_publickey >> $key_file

chown ${ec2_username}:${ec2_username} $key_file
chmod 600 $key_file

chown ${ec2_username}:${ec2_username} $ssh_dir
chmod 700 $ssh_dir

systemctl start docker
systemctl enable docker
usermod -a -G docker ec2-user
usermod -a -G docker $ec2_username

compose_dir=/opt/docker-compose
compose_version=$(curl https://api.github.com/repos/docker/compose/releases/latest | jq .name -r)
mkdir -p $compose_dir
git clone -b ${compose_version} "https://${git_username}:${git_password}@github.com/docker/compose.git" $compose_dir
cd $compose_dir
./script/build/linux
cp dist/docker-compose-Linux-aarch64 /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose

かいつまんで説明します。まず EC2 につける Name タグはオブジェクトで定義する必要があります。


# Define Name Tag
$tag = @{ Key="Name"; Value=$instanceName }
$nameTagObj = New-Object -TypeName Amazon.EC2.Model.TagSpecification
$nameTagObj.ResourceType = "instance"
$nameTagObj.Tags.Add($tag)

EBS のオブジェクトを作ります。gp3を指定し、IOPS はベースラインパフォーマンスである3000を指定します。ちなみに今回は明示的に指定していますが、初期値が3000なので書かなくても問題ありません。


# Define EBS Setting
$bd = New-Object -TypeName Amazon.EC2.Model.EbsBlockDevice
$bd.VolumeSize = 50
$bd.VolumeType = "gp3"
$bd.Iops = 3000
$bd.DeleteOnTermination = $true
$bdm = New-Object -TypeName Amazon.EC2.Model.BlockDeviceMapping
$bdm.DeviceName = "/dev/xvda"
$bdm.Ebs = $bd

そして UserData です。冒頭ではタイムゾーンやロケールの設定をしています。


timedatectl set-timezone Asia/Tokyo
localectl set-locale LANG=ja_JP.UTF8
localectl set-keymap jp106
localectl set-keymap jp-OADG109A

ホスト名は、ひとつ上のレイヤーである PowerShell 側で Name タグとして定義した文字列を埋め込んでいます。


hostnamectl set-hostname ${instanceName}

aws configureのセットアップではリージョン、出力フォーマットを指定しています。あとは IAM ロールがよしなにやってくれます。


aws configure set region ap-northeast-1
aws configure set output json

yumを最新化して必要なツールをインストールします。jqは、UserData が動作する中で各パラメータを動的に取得するために必要です。


yum update -y
yum install -y docker git jq

今回の肝となる部分ですが、SSM パラメータストアから機密情報を取得します。新規に作成する管理用ユーザーの名前、パスワード、公開鍵情報、およびgitのログイン情報を取得しています。


ec2_username=`$(aws ssm get-parameters-by-path --path "/account/ec2" --with-decryption --region ap-northeast-1 | jq -r '.Parameters[1].Value')
ec2_password=`$(aws ssm get-parameters-by-path --path "/account/ec2" --with-decryption --region ap-northeast-1 | jq -r '.Parameters[0].Value')
ec2_publickey=`$(aws ssm get-parameters-by-path --path "/account/ec2/key" --with-decryption --region ap-northeast-1 | jq -r '.Parameters[0].Value')
git_username=`$(aws ssm get-parameters-by-path --path "/account/git" --with-decryption --region ap-northeast-1 | jq -r '.Parameters[1].Value')
git_password=`$(aws ssm get-parameters-by-path --path "/account/git" --with-decryption --region ap-northeast-1 | jq -r '.Parameters[0].Value')

ec2-userのほかに管理用ユーザーを作成し、パスワード認証が可能な状態にします。


useradd `$ec2_username
usermod -G wheel `$ec2_username
echo `$ec2_password | sudo passwd --stdin `$ec2_username
touch /etc/sudoers.d/`${ec2_username}
echo "`${ec2_username} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/`${ec2_username}
sed -i -e 's/PasswordAuthentication no/PasswordAuthentication yes/g' /etc/ssh/sshd_config
systemctl restart sshd

この管理用ユーザーについては、公開鍵認証もセットアップします。


ssh_dir=/home/`${ec2_username}/.ssh
key_file=`${ssh_dir}/authorized_keys

mkdir -p `$ssh_dir
touch `$key_file
echo `$ec2_publickey >> `$key_file

chown `${ec2_username}:`${ec2_username} `$key_file
chmod 600 `$key_file

chown `${ec2_username}:`${ec2_username} `$ssh_dir
chmod 700 `$ssh_dir

dockerを自動起動するようにし、sudoなしで使えるようにします。


systemctl start docker
systemctl enable docker
usermod -a -G docker ec2-user
usermod -a -G docker `$ec2_username

docker-composeは arm64 アーキテクチャ用のバイナリが用意されていないので、ソースからビルドする必要があります。UserData の中でどう実現するかというところで、以下の形に落ち着きました。パラメータストアからgitのログイン情報を取得してgit cloneし、ビルドスクリプトを走らせます。その際、API で取得した latest バージョン番号を指定するようにします。


# docker-compose のソース保存場所
compose_dir=/opt/docker-compose

# API で docker-compose の latest バージョン番号を取得する
compose_version=`$(curl https://api.github.com/repos/docker/compose/releases/latest | jq .name -r)

# arm64 版の docker-compose バイナリは用意されていないので、ソースからビルドする必要がある
# バージョンを指定して compose をクローンする
# 認証でコケないために、パラメータストアで取得した git のログイン情報を埋め込んでアクセスする
mkdir -p `$compose_dir
git clone -b `${compose_version} "https://`${git_username}:`${git_password}@github.com/docker/compose.git" /opt/docker-compose

# 用意されているビルドスクリプトを実行する
cd /opt/docker-compose/
./script/build/linux

# ビルドしたバイナリにアクセスできるようにリンクや権限を整備する
cp dist/docker-compose-Linux-aarch64 /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose

ちなみに、ビルドが完了するまではかなり時間がかかるので、アクセス可能となってからもまだdocker-composeできる状態になっていません。気になる場合は以下のコマンドで眺めましょう。


tail -f /var/log/cloud-init-output.log

大量のパラメータでNew-EC2Instanceします。インスタンスタイプはもちろんt4gにします。冒頭で作成した IAM ロールも忘れずにアタッチします。


# インスタンス起動
$params = @{
    MinCount = 1
    MaxCount = 1
    InstanceType = [Amazon.EC2.InstanceType]::T4gMicro
    CreditSpecification_CpuCredit = "standard"
    PrivateIpAddress = "10.0.0.10"
    AssociatePublicIp = $true
    KeyName = "AWS-Key"
    ImageId = "ami-077527e5c50f1d6d1"
    SubnetId = "subnet-00000000000000000"
    SecurityGroupId = "sg-11111111111111111", "sg-22222222222222222"
    BlockDeviceMapping = $bdm
    EbsOptimized = $true
    DisableApiTermination = $true
    IamInstanceProfile_Name = "_ec2_role"
    TagSpecification = $nameTagObj
    EncodeUserData = $true
    UserData = $userData
}

$reservation = New-EC2Instance @params
$instances = $reservation.Instances

起動するまで待機します。初期化中にアクセスしないよう、最後に180秒のウェイトを入れています。


while ((Get-EC2InstanceStatus -IncludeAllInstance $true -InstanceId $instances.InstanceId).InstanceState.Name.Value -ne "running")
{
    $logTime = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
    Write-Host "[${logTime}] Waiting for the instances to launch..."
    Start-Sleep -Seconds 15
}

Start-Sleep -Seconds 180

インスタンスが完全に起動したら ElasticIP を付与します。


$instances | ForEach-Object -Process {
    $elasticIp = (New-EC2Address -Domain vpc).AllocationId
    Register-EC2Address -InstanceId $_.InstanceId -AllocationId $elasticIp
}

移行用 S3 バケットを作成する

手っ取り早く済ませるために、ディレクトリ構造ごとそのまま S3 にアップロードしてしまいます。以下のコマンドでバケットを用意しましょう。


aws s3 mb s3://docker-django-blog

旧サーバーで移行用 S3 バケットにデータをアップロードする

続いて現行サーバーにある資材をすべて S3 に同期します。サブコマンドsyncがとても便利です。アプリのルートディレクトリ(冒頭のフォルダ構成に従えば minbase.io フォルダ)に移動してコマンドを実行します。


aws s3 sync . s3://docker-django-blog/minbase.io

新サーバーで移行用 S3 バケットからデータをダウンロードする

ここからは新サーバー側で作業します。S3 から資材を持ってくる必要があります。任意の場所で以下のコマンドを実行します。


mkdir minbase.io && cd minbase.io
aws s3 sync s3://docker-django-blog/minbase.io .

syncの source/destination を反転させただけです。ここまでの作業で、docker-composeによるデプロイが可能な状態になったはずです。カレントディレクトリにdocker-compose.ymlがあることを確認し、おもむろに以下のコマンドを叩きましょう。


docker-compose up -d --build

コーヒーでも飲んで完了を待ちます。docker-composeバイナリをビルドするときもそうですが、最小構成でやっている場合、スペックが足りずに落ちたりすることもあります。一時的にインスタンスタイプを調整しながらの作業になると思います。

Django の dumpdata をロードする

この時点ではまだ、ブラウザに Web サイトは表示されません。いくつか設定をいじる必要があります。証明書がなく HTTPS ではアクセスできなくなっているので、いったん HTTP の設定に戻します。

アプリケーション ファイル 確認事項
Django settings.py ALLOWED_HOSTSに自分自身の IP を入れるかワイルドカードを指定する
Django settings.py SECURE_SSL_REDIRECTTrueの場合はFalseにする
Nginx default.conf HTTP アクセス用の設定に差し替える

上記が終わったら、Django の初期設定を行います。まずdjango.applicationコンテナに入ります。


docker exec -it django.application /bin/bash

DB を migrate して、superuser を作って、static ファイルを配信します。


python manage.py migrate
python manage.py createsuperuser
python manage.py collectstatic

ここまで来たら、EC2 のセキュリティグループで80番が許可されていることを確認し、ブラウザで IP を直打ちしてみましょう。以下のように、まっさらな Web サイトが表示されれば成功です。

ブログ初期画面

そして、このまっさらなブログに対して DB データをロードします。コンテナ内で作業するので、事前にdumpdataを永続化されたボリューム内に移動しておきます。


cp recovery/data/blog.json django/blog/fixtures/
cp recovery/data/sites.site.json django/blog/fixtures/

再度コンテナに入り、


docker exec -it django.application /bin/bash

データをロードします。


python manage.py loaddata blog/fixtures/sites.site.json
python manage.py loaddata blog/fixtures/blog.json

エラーが出なければ、これでブログコンテンツはすべて新環境にリプレースされたことになります。メインの作業は完了です。

crontab を復元する

Django の定期タスクが組まれている crontab を復元します。コマンド一発です。


crontab recovery/cron/crontab.backup

グローバル IP を付け替える

コンテンツが移行できたので、ElasticIP を付け替えて、ドメイン名でアクセスしたときに新サーバーにルーティングされるようにします。この作業はマネジメントコンソールから手動で行います。手順は割愛。

SSL 証明書を失効、削除、再作成する

最後の工程になります。新サーバーではまだ証明書の設定を行っていないため、設定していきます。まずは旧サーバー側で証明書をキッチリ失効させておきましょう。ちなみに証明書は Let's Encrypt で運用しています。ここではdjango.webserverコンテナに入って作業します。


# 旧サーバー側作業
docker exec -it django.weberver /bin/bash

コンテナの中に入ったら、まず証明書の内容を確認します。


sudo certbot certificates

確認できたら、証明書を失効します。


sudo certbot revoke --cert-path /etc/letsencrypt/live/minbase.io/cert.pem

失効すると、certbotのバージョンによっては、ついでに関連ファイルを削除するか聞かれますのでyを返しましょう。明示的な削除コマンドは以下です。


sudo certbot delete --cert-name minbase.io

続いて新サーバーで証明書を発行します。django.webserverコンテナに入り、


# 新サーバー側作業
docker exec -it django.weberver /bin/bash

証明書を発行します。


certbot certonly \
      --webroot \
      -w /static \
      -d minbase.io \
      -d www.minbase.io \
      -m <email_address> \
      --agree-tos


以下は Let's Encrypt 公式の引用です。

Let's Encrypt から証明書を取得するときには、ACME 標準で定義されている「チャレンジ」を使用して、証明書が証明しようとしているドメイン名があなたの制御下にあることを検証します。(中略)Let’s Encrypt は ACME クライアントにトークンを発行し、ACME クライアントはウェブサーバー上の http://<domain>/.well-known/acme-challenge/ という場所に1つのファイルを設置します。このファイルにはトークンに加えて、あなたのアカウントのキーのフィンガープリントが含まれています。ACME クライアントが Let’s Encrypt にファイルが準備できたことを伝えると、Let’s Encrypt はそのファイルを取得しようとします ( 複数の有効な場所から複数回取得する可能性もあります )。私たちの検証チェックであなたのウェブサーバーから正当なレスポンスが得られた場合、検証は成功したとみなされ、証明書の発行に進むことができます。検証チェックに失敗した場合には、新しい証明書の発行を再試行する必要があります。

証明書を正常に発行するには、certbotの証明書発行プロセスがhttp://<domain>/.well-known/acme-challenge/にアクセスできるようにしてあげる必要があります。これに関しては、Django の場合は静的ファイル配置場所を指定することでパスできます。今回のケースはdjango.webserverコンテナ内の/staticを指定しています。あとは、nginx の conf に以下の記述があれば OK です。


server {
    ・・・
    location ^~ /.well-known/acme-challenge/ {
        root /static;
    }
    ・・・
}

これで、証明書の発行が通るはずです。あとは、一時的に HTTP アクセス用の設定にしてあったところを、本来の HTTPS アクセス用の設定に戻し、リクエストがhttps://にリダイレクトされるかを確認して正常であれば、すべての作業が完了となります。また後始末としては、S3 に配置した資材は削除しておくのが無難でしょう。

おわりに

EC2, Docker, Django の組み合わせでのサーバー移行は初めてだったので、試行錯誤しながら進めていった感じでしたが、SSM パラメータストアを利用した EC2 初期設定の自動化等、副産物が大きかったと思います。久々にブログを書きましたが他にも書きたいことが溜まってきているので、時間を見つけて執筆したいと思います。

追記 :

最小構成で運用する場合、ルートボリュームに swap 領域を確保したほうがよかったのですが、すっかり忘れていました。こちらの記事を参考に実装しています。