FastAPI, SQLAlchemy, Alembic 를 기술스택으로 사용해 프로젝트를 진행중이다.
dev 배포 자동화는 완료했고, prod 배포 자동화를 할 차례이다.
DEV RDS 의 스키마 정보가 변경된 것을 자동으로 PROD RDS 에 업데이트하고 싶다.
PROD 배포를 EC2 에서 직접 git pull 받아서 띄우고 있는데, 자동화하고 싶다.
2번 요구 사항은 이전에 DEV 배포와 동일하게 진행할 것이므로 1번 요구 사항에 대해 살펴보자.
Github actions, ECR, ECS로 배포 자동화 하기
https://www.inforum24.com/memos/1344
https://www.inforum24.com/memos/1342
PROD RDS 와 DEV RDS 의 스키마를 동기화하는 작업을 자동화하는 사람이 많지는 않아 자료가 적다...
기존에 사용중이였던 Alembic 을 사용해서 RDS 를 조작해보자.
Alembic
Alembic 마이그레이션 라이브러리에서는 env.py 파일을 통해 DB와 접근하고, 어떤 데이터를 사용해서 마이그레이션할 지 설정할 수 있다.
SQLAlchemy 의 ORM 모델을 통해 DB 스키마를 변경사항을 감지해 SQL 을 생성하고, 업데이트할 수 있다.
따라서, Alembic 으로 DEV RDS의 DB 메타데이터를 가져오고, PROD RDS 와 비교하여 revision 파일을 생성한 뒤 업데이트하면 요구사항을 만족할 수 있다.
VPC 내 public subnet 에 있는 dev, prod rds 들은 특정 ip(개발자)와 EC2 인스턴스와만 네트워킹이 가능하다.
따라서 github actions 등 외부에서 alembic 을 실행시켜 DB에 접근하려면 추가적인 설정이 필요하다.
VPC 내에 Lambda 함수를 만들어 migration 용 Lambda function 을 사용하자.
Lambda function 요구 사항
DEV, PROD RDS 에 연결할 정보를 갖되, RDS password 노출의 위험이 있으므로 보안 처리를 해주는 것이 좋을 것 같다.
alembic revision 파일로 마이그레이션을 완료한 뒤에는 S3 에 revision 파일을 올려 버전 관리가 되어야 한다.
AWS Secrets Manager 는 github secrets 처럼 보안이 중요한 정보를 저장해두고 사용할 수 있도록 만들어진 서비스이다.
Lambda 함수 내에서 boto3 라이브러리를 통해 정보를 가져올 수 있다.
import boto3
from botocore.exceptions import ClientError
def get_secret():
secret_name = "MySecretName"
region_name = "us-west-2"
session = boto3.session.Session()
client = session.client(
service_name='secretsmanager',
region_name=region_name,
)
try:
get_secret_value_response = client.get_secret_value(
SecretId=secret_name
)
except ClientError as e:
if e.response['Error']['Code'] == 'ResourceNotFoundException':
print("The requested secret " + secret_name + " was not found")
elif e.response['Error']['Code'] == 'InvalidRequestException':
print("The request was invalid due to:", e)
elif e.response['Error']['Code'] == 'InvalidParameterException':
print("The request had invalid params:", e)
elif e.response['Error']['Code'] == 'DecryptionFailure':
print("The requested secret can't be decrypted using the provided KMS key:", e)
elif e.response['Error']['Code'] == 'InternalServiceError':
print("An error occurred on service side:", e)
else:
# Secrets Manager decrypts the secret value using the associated KMS CMK
# Depending on whether the secret was a string or binary, only one of these fields will be populated
if 'SecretString' in get_secret_value_response:
text_secret_data = get_secret_value_response['SecretString']
else:
binary_secret_data = get_secret_value_response['SecretBinary']
boto3 공식문서에 따르면 위 코드를 통해 secret 을 가져올 수 있다.
그러나, lambda 함수에서 실행시키면 timeout 에러가 발생한다.
lambda 함수는 기본 3초의 실행 시간을 갖기 때문에 timeout 에러가 발생한 것이라 생각하고 10초, 20초, 1분까지 시간을 늘려보았지만 client.get_secret_value 부분에서 넘어가질 않는다.
대부분 이런 에러는 네트워크쪽 문제였던 것 같다.
VPC 의 public subnet 에서 lambda 를 실행시키기 때문에 IGW 를 통해 충분히 AWS SecretManger 에 접근할 수 있을 것이다. 그런데 왜 에러가 발생할까?
요약: 람다는 public subnet 에 생성은 될 수 있어도 public IP 를 갖진 못한다.
현재 배포환경은 private subnet 없이 (NAT 없이) 구성되어 있었기 때문에, 잘 일어나지 않는 PROD 배포 중 migration 만을 위해 한달에 NAT 로 42$ 가량을 지불해야하는 것은 현재 상황에서는 무리가 있다고 판단하고 인터넷에 접근하지 않고 VPC 내부에서 처리할 수 있는 방법을 생각해보아야 한다.
RDS 에는 3가지 인증방식이 존재한다.
password 인증
iam 인증
Kerberos 인증
password 인증 방식은 코드 상에서 사용할 경우 보안적으로 좋지 않고, Kerberos 는 대규모 서비스에서 사용하기 적합하며 NAT 보다 비싸다. 따라서 iam 방식을 사용하자.
import os
import boto3
def get_db_token():
session = boto3.Session()
rds_client = session.client(
'rds',
region_name="ap-northeast-2",
aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"],
aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"],
aws_session_token=os.environ["AWS_SESSION_TOKEN"]
)
database_token = db_client.generate_db_auth_token(
DBHostname="test-rds.xxxxxxxx.ap-northeast-2.rds.amazonaws.com",
Port=5432,
DBUsername="lambda_user"
)
return database_token
코드에서 환경변수로 가져오는 access_key, session_token 등은 lambda function 에서 default 로 제공하는 환경변수다. IAM role 를 기반으로 Lambda function 실행 시 알아서 만들어준다.
위 코드에서 iam token 은 잘 가져오는 것을 확인했다. 그러나, sqlalchemy 와의 db connect 에서 iam 인증방식을 사용할 경우 ssl 인증을 추가로 해주어야하는데, lambda 환경에서 ssl ca 인증서를 설치하려면 또 인터넷을 타야했다...
lambda 는 code 를 통해 실행하는 방식과 docker image 를 통해 컨테이너로 실행하는 방식 두 가지가 존재한다.
사실 docker 로 실행 시 환경변수로 넘기는 방식은 코드에 적은거나 크게 다르진 않다. 그래도 ECR private repo 에 저장해서 코드로 적는거보단 나을 수도 있을 것 같다.
Docker는 보안처리를 위한것도 조금 있지만 다른 이유가 크다.
lambda 함수에서 여러 라이브러리를 사용하는데, lambda 함수 실행 환경에서 pip install 을 하거나 할 수는 없다. 따라서 lambda 에서 제공하는 layer 를 사용해야한다.
로컬 환경에서 pip install 을 하고 파이썬 라이브러리들을 담은 패키지를 layer 로 구성하면 해당 패키지들을 사용할 수 있다.
그런데 greenlet 라이브러리를 설치했음에도 greenlet 3.0.3 module not found 라는 에러가 발생하였다.
위 에러는 sqlalchemy 를 통해 DB 를 조작할 때 내부적으로 greenlet 라이브러리를 사용하는데, 여기서 문제가 생긴 것 같다.
greenlet 라이브러리는 sqlalchemy 의 비동기 처리, 비동기 커넥션 풀 등을 위해 사용되는데, 컨텍스트 스위칭 등 저수준의 기능들을 직접 다루고 있다.
따라서 운영체제가 다르면 잘 동작하지 않을 수 있다고 한다.
운영체제를 lambda 의 실행환경인 Amazon linux 2023 과 맞추기 위해 EC2 를 띄워서 해당 환경에서 패키지를 생성하는 방법과, Docker 를 통해 운영체제를 설정해서 사용하는 방법이 있을텐데 후자를 선택했다.
FROM amazon/aws-lambda-python:3.12
RUN /var/lang/bin/python3.12 -m pip install --upgrade pip
COPY requirements.txt requirements.txt
RUN pip install -r requirements.txt
COPY ./ /var/task/
CMD ["lambda_function.lambda_handler"]
lambda 는 기본적으로 /var/task 에서 실행된다. 해당 환경에서 실행할 수 있도록 필요한 패키지들을 설치하고 복사한다.
docker 를 통해 alembic 이 잘 작동하는 것을 확인했다. 남은 일은 alembic upgrade 에 사용된 revision 파일을 S3 에 올리는 것이다.
lambda 환경에서는 write 가 가능한 폴더를 /tmp 로 정해두고 있어 해당 폴더에만 쓰기 작업이 가능하다. revision 파일을 /tmp 에 작성하고 S3 에 올린다.
import os
import zipfile
import shutil
import boto3
s3_bucket_name = 'my_bucket'
s3_key = 'versions.zip'
local_zip_file = '/tmp/alembic/versions.zip'
local_extract_dir = '/tmp/alembic'
from alembic.config import Config
from alembic import command
def create_tmp_directories():
# Create /tmp and /tmp/alembic directories if they don't exist
os.makedirs('/tmp/alembic', exist_ok=True)
def zip_versions_directory():
# Create a zip file of /tmp/alembic/versions directory
with zipfile.ZipFile(local_zip_file, 'w', zipfile.ZIP_DEFLATED) as zip_ref:
for root, dirs, files in os.walk(local_extract_dir+'/versions'):
for file in files:
file_path = os.path.join(root, file)
zip_ref.write(file_path, os.path.relpath(file_path, local_extract_dir))
def upload_to_s3():
# Upload the zip file to S3
s3 = boto3.client('s3')
s3.upload_file(local_zip_file, s3_bucket_name, s3_key)
def download_and_extract_versions():
# Download zip file from S3
s3 = boto3.client('s3')
s3.download_file(s3_bucket_name, s3_key, local_zip_file)
# Extract contents to /tmp/alembic
with zipfile.ZipFile(local_zip_file, 'r') as zip_ref:
zip_ref.extractall(local_extract_dir)
shutil.copy('./alembic/env.py', '/tmp/alembic/env.py')
shutil.copy('./alembic/script.py.mako', '/tmp/alembic/script.py.mako')
def lambda_handler(event, context):
try:
create_tmp_directories()
download_and_extract_versions()
alembic_cfg = Config("alembic.ini")
command.revision(alembic_cfg, autogenerate=True, message="sync_prod_instance")
command.upgrade(alembic_cfg, "head")
zip_versions_directory()
upload_to_s3()
return {
'statusCode': 200,
'body': 'Migration file generated successfully.'
}
except Exception as e:
print("General error:", e)
return {
'statusCode': 500,
'body': str(e)
}
위 코드는 로컬에서는 잘 작동하지만 lambda 환경에서는 동작하지 않는다. 또 IGW 문제다.
그래도 S3 는 VPC 에서 직접 접근할 수 있는 S3 게이트웨이 엔드포인트를 쉽게 설정할 수 있도록 제공하고 있고, 내부 연결망을 사용하기 때문에 무료로 사용이 가능하다.
이제 Lambda 함수 설정은 끝났다. 메인 브랜치에서 push 이벤트를 받아 해당 lambda 함수를 호출해야하므로 마지막으로 github actions 를 설정하자.
name: Alembic Migration And Docker Build & Push to Amazon ECR
on:
push:
branches: [ "main" ]
env:
AWS_REGION: ap-northeast-2
ECR_REPOSITORY: ecs-back-prod
LAMBDA_FUNCTION: prod-dev-sync-docker
jobs:
migration:
name: Alembic Migration
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install AWS CLI
run: |
python -m pip install --upgrade pip
pip install awscli
- name: Invoke Lambda function
run: |
aws lambda invoke --function-name $LAMBDA_FUNCTION response.json
status_code=$(grep -o '"statusCode": *[0-9]*' response.json | grep -o '[0-9]*')
if [ "$status_code" -ge 500 ]; then
echo "lambda function fail"
cat response.json
exit 1
fi
deploy:
name: Deploy
needs: migration
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create .env
run: echo "${{ secrets.DEV_ENV }}" > .env
- name: Install Poetry Action
uses: snok/install-poetry@v1.3.4
- name: Make requirements.txt
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
MOD=PROD
migration job 을 수행하고 나서 deploy job 을 실행하도록 needs: migration 를 deploy job 에 설정해주었다.
migration job 에서는 awscli 를 통해 lambda 함수를 호출할 수 있도록 미리 설치해주고 해당 결과를 기반으로 500 대 에러가 발생하면 action 을 중단하도록 했다.