diff --git a/.github/workflows/deploy-server.yml b/.github/workflows/deploy-server.yml new file mode 100644 index 0000000..00c1f23 --- /dev/null +++ b/.github/workflows/deploy-server.yml @@ -0,0 +1,115 @@ +name: Deploy Server + +# apps/server 변경이 main에 병합되면 자동 배포: +# arm64 이미지 빌드 → ECR push → EC2 재배포(SSM) → prisma migrate deploy +# web은 Vercel Git 연동으로 자동 배포되므로 별도 처리 없음. + +on: + push: + branches: + - main + paths: + - "apps/server/**" + - "packages/**" + - "pnpm-lock.yaml" + - ".github/workflows/deploy-server.yml" + workflow_dispatch: {} # 수동 실행 허용 + +# OIDC로 AWS 역할 assume (키 저장 없음) +permissions: + id-token: write + contents: read + +env: + AWS_REGION: ap-northeast-2 + AWS_ROLE_ARN: arn:aws:iam::671460502594:role/dookmark-gha-deploy + ECR_REPOSITORY: dookmark-server + INSTANCE_TAG: dookmark-server + CONTAINER_NAME: dookmark-server + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Set up QEMU (arm64 emulation) + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push (linux/arm64) + uses: docker/build-push-action@v6 + with: + context: . + file: apps/server/Dockerfile + platforms: linux/arm64 + push: true + tags: | + ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest + ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Deploy to EC2 via SSM + run: | + set -euo pipefail + REGISTRY="${{ steps.ecr.outputs.registry }}" + ECR_URL="$REGISTRY/${{ env.ECR_REPOSITORY }}" + + INSTANCE_ID=$(aws ec2 describe-instances \ + --filters "Name=tag:Name,Values=${{ env.INSTANCE_TAG }}" \ + "Name=instance-state-name,Values=running" \ + --query 'Reservations[0].Instances[0].InstanceId' --output text) + echo "Target instance: $INSTANCE_ID" + + # SSM 명령 JSON을 jq로 안전하게 조립(이스케이프 처리) + PARAMS=$(jq -n --arg reg "$REGISTRY" --arg url "$ECR_URL" --arg region "${{ env.AWS_REGION }}" --arg name "${{ env.CONTAINER_NAME }}" '{ + commands: [ + "set -e", + ("aws ecr get-login-password --region " + $region + " | docker login --username AWS --password-stdin " + $reg), + ("docker pull " + $url + ":latest"), + ("docker rm -f " + $name + " 2>/dev/null || true"), + ("docker run -d --name " + $name + " --restart always -p 3001:3001 --env-file /etc/dookmark/server.env " + $url + ":latest"), + ("docker exec -w /app/apps/server " + $name + " pnpm exec prisma migrate deploy"), + "sleep 8", + "curl -fsS http://localhost:3001/health" + ] + }') + + CMD_ID=$(aws ssm send-command \ + --instance-ids "$INSTANCE_ID" \ + --document-name AWS-RunShellScript \ + --comment "GHA deploy ${{ github.sha }}" \ + --parameters "$PARAMS" \ + --query 'Command.CommandId' --output text) + echo "SSM command: $CMD_ID" + + # 완료 대기 + 결과 판정 + for i in $(seq 1 50); do + ST=$(aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$INSTANCE_ID" --query Status --output text 2>/dev/null || echo Pending) + echo "status: $ST" + case "$ST" in + Success) break ;; + Failed|Cancelled|TimedOut) + echo "::error::deploy failed" + aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$INSTANCE_ID" --query StandardErrorContent --output text + exit 1 ;; + esac + sleep 6 + done + + echo "----- deploy output -----" + aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$INSTANCE_ID" --query StandardOutputContent --output text diff --git a/apps/server/src/auth/auth.controller.ts b/apps/server/src/auth/auth.controller.ts index 5088d98..95e7812 100644 --- a/apps/server/src/auth/auth.controller.ts +++ b/apps/server/src/auth/auth.controller.ts @@ -64,10 +64,12 @@ export class AuthController { private getCookieOptions(maxAge?: number) { const isProd = process.env.NODE_ENV === 'production'; + const domain = process.env.COOKIE_DOMAIN; return { httpOnly: true, secure: isProd, sameSite: (isProd ? 'none' : 'lax') as 'none' | 'lax', + ...(domain ? { domain } : {}), ...(maxAge !== undefined ? { maxAge } : {}), }; } diff --git a/apps/server/src/prisma/prisma.service.ts b/apps/server/src/prisma/prisma.service.ts index 7c1955b..5e4c0d0 100644 --- a/apps/server/src/prisma/prisma.service.ts +++ b/apps/server/src/prisma/prisma.service.ts @@ -7,7 +7,12 @@ import { Pool } from 'pg'; export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { constructor() { const connectionString = process.env.DATABASE_URL; - const pool = new Pool({ connectionString }); + + const isLocal = !connectionString || /@(localhost|127\.0\.0\.1)/.test(connectionString); + const pool = new Pool({ + connectionString, + ...(isLocal ? {} : { ssl: { rejectUnauthorized: false } }), + }); const adapter = new PrismaPg(pool); super({ adapter }); } diff --git a/infra/terraform/envs/prod/main.tf b/infra/terraform/envs/prod/main.tf index 3fe3083..f4c8f25 100644 --- a/infra/terraform/envs/prod/main.tf +++ b/infra/terraform/envs/prod/main.tf @@ -34,6 +34,7 @@ locals { plain_parameters = { GOOGLE_CALLBACK_URL = var.google_callback_url FRONTEND_URL = var.frontend_url + COOKIE_DOMAIN = ".dookmark.site" # web↔api 쿠키 공유 NODE_ENV = "production" PORT = "3001" REDIS_HOST = module.data.redis_address @@ -61,6 +62,15 @@ module "ecr" { name = "dookmark" } +# cicd 모듈 — GitHub Actions OIDC + 배포 전용 IAM 역할 (키 저장 없이 배포) +module "cicd" { + source = "../../modules/cicd" + + name = "dookmark" + github_repo = "Nohgh/dookmark" + ecr_repository_arn = module.ecr.repository_arn +} + # compute 모듈 — EC2(Docker), ALB, Target Group, ACM # network/ecr 출력을 참조로 연결. SSM은 secrets 모듈과 같은 경로 module "compute" { diff --git a/infra/terraform/envs/prod/outputs.tf b/infra/terraform/envs/prod/outputs.tf index 5fcb67b..b5d7f03 100644 --- a/infra/terraform/envs/prod/outputs.tf +++ b/infra/terraform/envs/prod/outputs.tf @@ -27,24 +27,23 @@ output "redis_address" { value = module.data.redis_address } -# 비밀번호는 민감값 → 화면에 안 찍힘. 값 확인은: -# terraform output -raw db_password + output "db_password" { value = module.data.db_password sensitive = true } -# ── secrets 모듈 (Phase 5) ── +# secrets 모듈 output "ssm_parameter_names" { value = module.secrets.parameter_names } -# ── ecr 모듈 (Phase 6) ── +# ecr 모듈 output "ecr_repository_url" { value = module.ecr.repository_url } -# ── compute 모듈 (Phase 7) ── +# compute 모듈 output "alb_dns_name" { value = module.compute.alb_dns_name } @@ -57,3 +56,8 @@ output "acm_validation_records" { description = "가비아에 등록할 ACM 검증 CNAME (Phase 8)" value = module.compute.acm_validation_records } + +# cicd 모듈 +output "github_deploy_role_arn" { + value = module.cicd.deploy_role_arn +} diff --git a/infra/terraform/modules/cicd/main.tf b/infra/terraform/modules/cicd/main.tf new file mode 100644 index 0000000..01aa7f9 --- /dev/null +++ b/infra/terraform/modules/cicd/main.tf @@ -0,0 +1,87 @@ +data "aws_caller_identity" "current" {} + +# GitHub Actions OIDC 신뢰 공급자 (계정당 1개) +resource "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" + client_id_list = ["sts.amazonaws.com"] + thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"] +} + +# 배포 역할 신뢰 정책 — 이 저장소의 main 브랜치 워크플로만 assume 가능 +data "aws_iam_policy_document" "assume" { + statement { + actions = ["sts:AssumeRoleWithWebIdentity"] + principals { + type = "Federated" + identifiers = [aws_iam_openid_connect_provider.github.arn] + } + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + condition { + test = "StringLike" + variable = "token.actions.githubusercontent.com:sub" + values = ["repo:${var.github_repo}:ref:refs/heads/main"] + } + } +} + +resource "aws_iam_role" "deploy" { + name = "${var.name}-gha-deploy" + assume_role_policy = data.aws_iam_policy_document.assume.json +} + +# 배포에 필요한 권한만: ECR push + SSM 배포 명령 +data "aws_iam_policy_document" "deploy" { + statement { + sid = "EcrAuth" + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } + + statement { + sid = "EcrPushPull" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:PutImage", + "ecr:InitiateLayerUpload", + "ecr:UploadLayerPart", + "ecr:CompleteLayerUpload", + ] + resources = [var.ecr_repository_arn] + } + + statement { + sid = "SsmSendCommandDocument" + actions = ["ssm:SendCommand"] + resources = ["arn:aws:ssm:${var.region}:${data.aws_caller_identity.current.account_id}:document/AWS-RunShellScript"] + } + + # 인스턴스 대상 SendCommand는 Name 태그로 제한(dookmark-server만) + statement { + sid = "SsmSendCommandInstance" + actions = ["ssm:SendCommand"] + resources = ["arn:aws:ec2:${var.region}:${data.aws_caller_identity.current.account_id}:instance/*"] + condition { + test = "StringEquals" + variable = "ssm:resourceTag/Name" + values = ["${var.name}-server"] + } + } + + statement { + sid = "SsmReadAndDescribe" + actions = ["ssm:GetCommandInvocation", "ssm:ListCommandInvocations", "ec2:DescribeInstances"] + resources = ["*"] + } +} + +resource "aws_iam_role_policy" "deploy" { + name = "${var.name}-gha-deploy-policy" + role = aws_iam_role.deploy.id + policy = data.aws_iam_policy_document.deploy.json +} diff --git a/infra/terraform/modules/cicd/outputs.tf b/infra/terraform/modules/cicd/outputs.tf new file mode 100644 index 0000000..966177f --- /dev/null +++ b/infra/terraform/modules/cicd/outputs.tf @@ -0,0 +1,4 @@ +output "deploy_role_arn" { + description = "GitHub Actions가 assume할 배포 역할 ARN (워크플로 role-to-assume)" + value = aws_iam_role.deploy.arn +} diff --git a/infra/terraform/modules/cicd/variables.tf b/infra/terraform/modules/cicd/variables.tf new file mode 100644 index 0000000..6e1a13f --- /dev/null +++ b/infra/terraform/modules/cicd/variables.tf @@ -0,0 +1,20 @@ +variable "name" { + description = "리소스 이름 접두사" + type = string + default = "dookmark" +} + +variable "region" { + type = string + default = "ap-northeast-2" +} + +variable "github_repo" { + description = "GitHub 저장소 (owner/repo). 이 저장소의 main 워크플로만 역할을 assume 가능" + type = string +} + +variable "ecr_repository_arn" { + description = "배포 대상 ECR 리포지토리 ARN (push 권한 범위)" + type = string +} diff --git a/infra/terraform/modules/compute/main.tf b/infra/terraform/modules/compute/main.tf index ac37ff4..18fea12 100644 --- a/infra/terraform/modules/compute/main.tf +++ b/infra/terraform/modules/compute/main.tf @@ -30,6 +30,13 @@ resource "aws_instance" "server" { encrypted = true } + # "최신 AL2023" AMI는 AWS가 새로 배포하면 값이 바뀌어, 매 apply마다 + # 인스턴스를 교체(재생성)해버린다. AMI 변경은 무시해 의도치 않은 교체를 막는다. + # (실제로 새 AMI로 갈아탈 땐 인스턴스를 명시적으로 taint/교체) + lifecycle { + ignore_changes = [ami] + } + tags = { Name = "${var.name}-server" } }