diff --git a/.gitignore b/.gitignore index 2676c43..650f9ff 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,17 @@ k6/secrets/*.txt !k6/secrets/.gitkeep deploy/ec2/monitoring/.env deploy/ec2/monitoring/alertmanager/secrets/discord_webhook_url +.terraform/ +*.tfstate +*.tfstate.* +terraform.tfvars +*.auto.tfvars +override.tf +override.tf.json +*_override.tf +*_override.tf.json +crash.log +tfplan +tfplan-* +*.tfplan +deploy/ec2/monitoring-lite/alertmanager/secrets/discord_webhook_url diff --git a/deploy/ec2/caddy/Caddyfile.template b/deploy/ec2/caddy/Caddyfile.template new file mode 100644 index 0000000..73513b2 --- /dev/null +++ b/deploy/ec2/caddy/Caddyfile.template @@ -0,0 +1,4 @@ +__SERVER_NAME__ { + encode zstd gzip + reverse_proxy 127.0.0.1:8088 +} diff --git a/deploy/ec2/install_caddy.sh b/deploy/ec2/install_caddy.sh new file mode 100644 index 0000000..a16422e --- /dev/null +++ b/deploy/ec2/install_caddy.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVER_NAME="${SERVER_NAME:?SERVER_NAME is required}" +DEPLOY_USER="${DEPLOY_USER:-${SUDO_USER:-ubuntu}}" +NGINX_INTERNAL_ONLY="${NGINX_INTERNAL_ONLY:-true}" + +if ! command -v caddy >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null + sudo apt-get update + sudo apt-get install -y caddy +fi + +if [[ "${NGINX_INTERNAL_ONLY}" == "true" ]]; then + config_file="/opt/ssd/config/application-dev.yml" + if [[ ! -f "${config_file}" ]]; then + echo "[ERROR] Config file not found: ${config_file}" >&2 + echo "[ERROR] Please run the main deployment flow or provide APP_CONFIG_SOURCE manually." >&2 + exit 1 + fi + sudo APP_CONFIG_SOURCE="/opt/ssd/config/application-dev.yml" \ + NGINX_ENABLE_SSL=false \ + NGINX_INTERNAL_ONLY=true \ + NGINX_SERVER_NAME="_" \ + "${SCRIPT_DIR}/install_infra.sh" +fi + +rendered_caddyfile="$(mktemp)" +trap 'rm -f "${rendered_caddyfile}"' EXIT + +sed \ + -e "s#__SERVER_NAME__#${SERVER_NAME}#g" \ + "${SCRIPT_DIR}/caddy/Caddyfile.template" > "${rendered_caddyfile}" + +sudo install -d -m 755 /etc/caddy +sudo install -m 644 "${rendered_caddyfile}" /etc/caddy/Caddyfile +sudo systemctl enable --now caddy +sudo systemctl restart caddy +sudo systemctl status caddy --no-pager diff --git a/deploy/ec2/install_infra.sh b/deploy/ec2/install_infra.sh index b5ef3ec..e76bc77 100755 --- a/deploy/ec2/install_infra.sh +++ b/deploy/ec2/install_infra.sh @@ -11,6 +11,8 @@ APP_CONFIG_FILE="${APP_CONFIG_FILE:-${RUNTIME_CONFIG_DIR}/application-dev.yml}" APP_CONFIG_SOURCE="${APP_CONFIG_SOURCE:-}" NETWORK_NAME="${NETWORK_NAME:-ssd-net}" NGINX_SERVER_NAME="${NGINX_SERVER_NAME:-dev-api.simsaimdang.shop}" +NGINX_ENABLE_SSL="${NGINX_ENABLE_SSL:-true}" +NGINX_INTERNAL_ONLY="${NGINX_INTERNAL_ONLY:-false}" NGINX_TIMEOUT_FILE="${NGINX_TIMEOUT_FILE:-/etc/nginx/conf.d/zzz-ssd-timeout.conf}" NGINX_UPSTREAM_FILE="${NGINX_UPSTREAM_FILE:-/etc/nginx/conf.d/ssd-upstream.conf}" NGINX_SITE_AVAILABLE="${NGINX_SITE_AVAILABLE:-/etc/nginx/sites-available/${NGINX_SERVER_NAME}}" @@ -101,6 +103,7 @@ fi if command -v nginx >/dev/null 2>&1; then install -d -m 755 /etc/nginx/conf.d /etc/nginx/sites-available /etc/nginx/sites-enabled + rm -f /etc/nginx/sites-enabled/default if copy_if_changed "${SCRIPT_DIR}/nginx/zzz-ssd-timeout.conf" "${NGINX_TIMEOUT_FILE}" 644; then nginx_changed=1 @@ -108,7 +111,13 @@ if command -v nginx >/dev/null 2>&1; then rendered_site_file="$(mktemp)" trap 'rm -f "${rendered_site_file}"' EXIT - sed "s#__SERVER_NAME__#${NGINX_SERVER_NAME}#g" "${SCRIPT_DIR}/nginx/site.conf.template" > "${rendered_site_file}" + nginx_template="${SCRIPT_DIR}/nginx/site.https.conf.template" + if [[ "${NGINX_INTERNAL_ONLY}" == "true" ]]; then + nginx_template="${SCRIPT_DIR}/nginx/site.internal.conf.template" + elif [[ "${NGINX_ENABLE_SSL}" != "true" ]]; then + nginx_template="${SCRIPT_DIR}/nginx/site.http.conf.template" + fi + sed "s#__SERVER_NAME__#${NGINX_SERVER_NAME}#g" "${nginx_template}" > "${rendered_site_file}" if install_rendered_if_changed "${rendered_site_file}" "${NGINX_SITE_AVAILABLE}" 644; then nginx_changed=1 fi diff --git a/deploy/ec2/install_prod_monitoring_lite.sh b/deploy/ec2/install_prod_monitoring_lite.sh new file mode 100644 index 0000000..8f91a97 --- /dev/null +++ b/deploy/ec2/install_prod_monitoring_lite.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_NAME="${APP_NAME:-ssd}" +DEPLOY_USER="${DEPLOY_USER:-${SUDO_USER:-ubuntu}}" +MONITORING_SOURCE_DIR="${MONITORING_SOURCE_DIR:-${SCRIPT_DIR}/monitoring-lite}" +MONITORING_SHARED_SOURCE_DIR="${MONITORING_SHARED_SOURCE_DIR:-${SCRIPT_DIR}/monitoring}" +MONITORING_DIR="${MONITORING_DIR:-/opt/${APP_NAME}/monitoring-prod-lite}" +APP_CONFIG_FILE="${APP_CONFIG_FILE:-/opt/${APP_NAME}/config/application-dev.yml}" +DISCORD_WEBHOOK_FILE="${DISCORD_WEBHOOK_FILE:-${MONITORING_DIR}/alertmanager/secrets/discord_webhook_url}" +ENV_FILE="${ENV_FILE:-${MONITORING_DIR}/.env}" + +install -d -m 755 -o "${DEPLOY_USER}" -g "${DEPLOY_USER}" "${MONITORING_DIR}" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "${tmp_dir}"' EXIT +cp -a "${MONITORING_SOURCE_DIR}/." "${tmp_dir}/" +if [[ -d "${MONITORING_SHARED_SOURCE_DIR}" ]]; then + cp -a "${MONITORING_SHARED_SOURCE_DIR}" "${tmp_dir}/../monitoring" +fi +rm -f "${tmp_dir}/.env" "${tmp_dir}/alertmanager/secrets/discord_webhook_url" +cp -a "${tmp_dir}/." "${MONITORING_DIR}/" +if [[ -d "${tmp_dir}/../monitoring" ]]; then + shared_monitoring_dir="$(dirname "${MONITORING_DIR}")/monitoring" + install -d -m 755 -o "${DEPLOY_USER}" -g "${DEPLOY_USER}" "${shared_monitoring_dir}" + cp -a "${tmp_dir}/../monitoring/." "${shared_monitoring_dir}/" +fi +chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${MONITORING_DIR}" + +install -d -m 750 -o "${DEPLOY_USER}" -g 65534 "$(dirname "${DISCORD_WEBHOOK_FILE}")" +if [[ ! -s "${DISCORD_WEBHOOK_FILE}" ]]; then + webhook_url="$(awk ' + /^discord:/ {in_discord=1; next} + in_discord && /^[^[:space:]]/ {in_discord=0} + in_discord && /webhook-url:/ {sub(/^[[:space:]]*webhook-url:[[:space:]]*/, ""); print; exit} + ' "${APP_CONFIG_FILE}" | tr -d '"' | tr -d "'")" + if [[ -z "${webhook_url//[[:space:]]/}" ]]; then + echo "[ERROR] Discord webhook URL을 찾지 못했습니다: ${APP_CONFIG_FILE}" >&2 + exit 1 + fi + printf '%s' "${webhook_url}" > "${DISCORD_WEBHOOK_FILE}" + chmod 600 "${DISCORD_WEBHOOK_FILE}" +fi + +chown "${DEPLOY_USER}:65534" "$(dirname "${DISCORD_WEBHOOK_FILE}")" +chmod 750 "$(dirname "${DISCORD_WEBHOOK_FILE}")" +chown 65534:65534 "${DISCORD_WEBHOOK_FILE}" +chmod 600 "${DISCORD_WEBHOOK_FILE}" + +if [[ ! -f "${ENV_FILE}" ]]; then + grafana_password="$(openssl rand -base64 24 | tr -d '\n')" + { + echo 'GRAFANA_ADMIN_USER=admin' + echo "GRAFANA_ADMIN_PASSWORD=${grafana_password}" + } > "${ENV_FILE}" + chmod 600 "${ENV_FILE}" + chown "${DEPLOY_USER}:${DEPLOY_USER}" "${ENV_FILE}" +fi + +cd "${MONITORING_DIR}" +docker compose -f docker-compose.monitoring-lite.yml config >/dev/null +docker compose -f docker-compose.monitoring-lite.yml up -d + +echo "[INFO] SSD lightweight monitoring is running" diff --git a/deploy/ec2/monitoring-lite/alertmanager/alertmanager.yml b/deploy/ec2/monitoring-lite/alertmanager/alertmanager.yml new file mode 100644 index 0000000..faf23c9 --- /dev/null +++ b/deploy/ec2/monitoring-lite/alertmanager/alertmanager.yml @@ -0,0 +1,19 @@ +global: + resolve_timeout: 5m + +route: + receiver: discord + group_by: ["alertname", "service"] + group_wait: 30s + group_interval: 5m + repeat_interval: 3h + +receivers: + - name: discord + discord_configs: + - webhook_url_file: /etc/alertmanager/secrets/discord_webhook_url + title: '{{ template "ssd.discord.title" . }}' + message: '{{ template "ssd.discord.message" . }}' + +templates: + - /etc/alertmanager/templates/*.tmpl diff --git a/deploy/ec2/monitoring-lite/alertmanager/secrets/.gitkeep b/deploy/ec2/monitoring-lite/alertmanager/secrets/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/deploy/ec2/monitoring-lite/alertmanager/secrets/.gitkeep @@ -0,0 +1 @@ + diff --git a/deploy/ec2/monitoring-lite/alertmanager/templates/discord.tmpl b/deploy/ec2/monitoring-lite/alertmanager/templates/discord.tmpl new file mode 100644 index 0000000..6d0515e --- /dev/null +++ b/deploy/ec2/monitoring-lite/alertmanager/templates/discord.tmpl @@ -0,0 +1,11 @@ +{{ define "ssd.discord.title" -}} +[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }} +{{- end }} + +{{ define "ssd.discord.message" -}} +{{ range .Alerts -}} +- service={{ .Labels.service }} severity={{ .Labels.severity }} + summary={{ .Annotations.summary }} + description={{ .Annotations.description }} +{{ end -}} +{{- end }} diff --git a/deploy/ec2/monitoring-lite/docker-compose.monitoring-lite.yml b/deploy/ec2/monitoring-lite/docker-compose.monitoring-lite.yml new file mode 100644 index 0000000..e30a6d9 --- /dev/null +++ b/deploy/ec2/monitoring-lite/docker-compose.monitoring-lite.yml @@ -0,0 +1,85 @@ +name: ssd-prod-monitoring-lite + +services: + prometheus: + image: prom/prometheus:v2.54.1 + container_name: ssd-prod-prometheus + restart: unless-stopped + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.retention.time=7d + - --storage.tsdb.retention.size=1GB + - --web.enable-lifecycle + ports: + - "127.0.0.1:9091:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./prometheus/rules:/etc/prometheus/rules:ro + - prometheus_data:/prometheus + networks: + - monitoring + - ssd-net + + alertmanager: + image: prom/alertmanager:v0.28.1 + container_name: ssd-prod-alertmanager + restart: unless-stopped + command: + - --config.file=/etc/alertmanager/alertmanager.yml + - --storage.path=/alertmanager + ports: + - "127.0.0.1:9093:9093" + volumes: + - ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + - ./alertmanager/templates:/etc/alertmanager/templates:ro + - ./alertmanager/secrets:/etc/alertmanager/secrets:ro + - alertmanager_data:/alertmanager + networks: + - monitoring + + grafana: + image: grafana/grafana:11.1.4 + container_name: ssd-prod-grafana + restart: unless-stopped + depends_on: + - prometheus + env_file: + - .env + environment: + GF_USERS_ALLOW_SIGN_UP: "false" + GF_AUTH_ANONYMOUS_ENABLED: "false" + GF_SERVER_ROOT_URL: "https://dev-api.simsaimdang.shop/grafana/" + GF_SERVER_SERVE_FROM_SUB_PATH: "true" + ports: + - "127.0.0.1:3001:3000" + volumes: + - ../monitoring/grafana/provisioning/datasources/prometheus.yml:/etc/grafana/provisioning/datasources/prometheus.yml:ro + - ../monitoring/grafana/provisioning/dashboards/dashboard.yml:/etc/grafana/provisioning/dashboards/dashboard.yml:ro + - ../monitoring/grafana/dashboards/ssd-production-overview.json:/var/lib/grafana/dashboards/ssd-production-overview.json:ro + - grafana_data:/var/lib/grafana + networks: + - monitoring + + node-exporter: + image: prom/node-exporter:v1.8.2 + container_name: ssd-prod-node-exporter + restart: unless-stopped + command: + - --path.rootfs=/host + pid: host + volumes: + - /:/host:ro,rslave + networks: + - monitoring + +networks: + monitoring: + name: ssd-prod-monitoring + ssd-net: + external: true + name: ssd-net + +volumes: + prometheus_data: + alertmanager_data: + grafana_data: diff --git a/deploy/ec2/monitoring-lite/prometheus/prometheus.yml b/deploy/ec2/monitoring-lite/prometheus/prometheus.yml new file mode 100644 index 0000000..9900a03 --- /dev/null +++ b/deploy/ec2/monitoring-lite/prometheus/prometheus.yml @@ -0,0 +1,25 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +alerting: + alertmanagers: + - static_configs: + - targets: + - alertmanager:9093 + +rule_files: + - /etc/prometheus/rules/*.yml + +scrape_configs: + - job_name: ssd-api + metrics_path: /actuator/prometheus + scheme: https + static_configs: + - targets: + - dev-api.simsaimdang.shop + + - job_name: node-exporter + static_configs: + - targets: + - node-exporter:9100 diff --git a/deploy/ec2/monitoring-lite/prometheus/rules/ssd-alerts.yml b/deploy/ec2/monitoring-lite/prometheus/rules/ssd-alerts.yml new file mode 100644 index 0000000..340028b --- /dev/null +++ b/deploy/ec2/monitoring-lite/prometheus/rules/ssd-alerts.yml @@ -0,0 +1,51 @@ +groups: + - name: ssd-operational-alerts + rules: + - alert: SsdApiScrapeDown + expr: sum(up{job="ssd-api"}) < 1 + for: 1m + labels: + severity: critical + service: ssd-api + annotations: + summary: "SSD API 서버 메트릭 수집 실패" + description: "Prometheus가 1분 이상 SSD API /actuator/prometheus를 수집하지 못했습니다." + + - alert: HighHostCpuUsage + expr: 1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) > 0.8 + for: 5m + labels: + severity: warning + service: host + annotations: + summary: "EC2 CPU 사용률 높음" + description: "호스트 CPU 사용률이 5분 이상 80%를 초과했습니다." + + - alert: HighHostDiskUsage + expr: | + 1 - ( + node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} + / + node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} + ) > 0.85 + for: 5m + labels: + severity: warning + service: host + annotations: + summary: "EC2 Disk 사용률 높음" + description: "루트 디스크 사용률이 5분 이상 85%를 초과했습니다." + + - alert: HighJvmHeapUsage + expr: | + sum(jvm_memory_used_bytes{job="ssd-api", area="heap"}) + / + sum(jvm_memory_max_bytes{job="ssd-api", area="heap"}) + > 0.8 + for: 3m + labels: + severity: warning + service: ssd-api + annotations: + summary: "JVM Heap 사용률 높음" + description: "JVM Heap 사용률이 3분 이상 80%를 초과했습니다." diff --git a/deploy/ec2/monitoring/prometheus/prometheus.yml b/deploy/ec2/monitoring/prometheus/prometheus.yml index abb3ce1..f842e29 100644 --- a/deploy/ec2/monitoring/prometheus/prometheus.yml +++ b/deploy/ec2/monitoring/prometheus/prometheus.yml @@ -14,7 +14,7 @@ rule_files: scrape_configs: - job_name: ssd-api metrics_path: /actuator/prometheus - scheme: http + scheme: https static_configs: - targets: - dev-api.simsaimdang.shop @@ -35,7 +35,7 @@ scrape_configs: module: [http_2xx] static_configs: - targets: - - http://dev-api.simsaimdang.shop/actuator/health + - https://dev-api.simsaimdang.shop/actuator/health - http://166.104.223.33:8080/docs relabel_configs: - source_labels: [__address__] diff --git a/deploy/ec2/nginx/site.http.conf.template b/deploy/ec2/nginx/site.http.conf.template new file mode 100644 index 0000000..198805e --- /dev/null +++ b/deploy/ec2/nginx/site.http.conf.template @@ -0,0 +1,56 @@ +map $http_x_request_id $ssd_request_id { + "~^(?:[A-Fa-f0-9]{32}|[A-Fa-f0-9-]{36})$" $http_x_request_id; + default $request_id; +} + +log_format ssd_json escape=json + '{"timestamp":"$time_iso8601",' + '"requestId":"$ssd_request_id",' + '"remoteAddr":"$remote_addr",' + '"method":"$request_method",' + '"uri":"$request_uri",' + '"status":$status,' + '"bodyBytesSent":$body_bytes_sent,' + '"requestTime":$request_time,' + '"upstreamStatus":"$upstream_status",' + '"upstreamResponseTime":"$upstream_response_time",' + '"httpReferer":"$http_referer",' + '"userAgent":"$http_user_agent"}'; + +server { + listen 80 default_server; + server_name __SERVER_NAME__; + + access_log /var/log/nginx/ssd-access.log ssd_json; + error_log /var/log/nginx/ssd-error.log warn; + + location = /grafana { + return 301 /grafana/; + } + + location /grafana/ { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Request-Id $ssd_request_id; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Prefix /grafana; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location / { + proxy_pass http://ssd_backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Request-Id $ssd_request_id; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/deploy/ec2/nginx/site.https.conf.template b/deploy/ec2/nginx/site.https.conf.template new file mode 100644 index 0000000..f28e7a7 --- /dev/null +++ b/deploy/ec2/nginx/site.https.conf.template @@ -0,0 +1,67 @@ +map $http_x_request_id $ssd_request_id { + "~^(?:[A-Fa-f0-9]{32}|[A-Fa-f0-9-]{36})$" $http_x_request_id; + default $request_id; +} + +log_format ssd_json escape=json + '{"timestamp":"$time_iso8601",' + '"requestId":"$ssd_request_id",' + '"remoteAddr":"$remote_addr",' + '"method":"$request_method",' + '"uri":"$request_uri",' + '"status":$status,' + '"bodyBytesSent":$body_bytes_sent,' + '"requestTime":$request_time,' + '"upstreamStatus":"$upstream_status",' + '"upstreamResponseTime":"$upstream_response_time",' + '"httpReferer":"$http_referer",' + '"userAgent":"$http_user_agent"}'; + +server { + server_name __SERVER_NAME__; + + access_log /var/log/nginx/ssd-access.log ssd_json; + error_log /var/log/nginx/ssd-error.log warn; + + location = /grafana { + return 301 /grafana/; + } + + location /grafana/ { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Request-Id $ssd_request_id; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Prefix /grafana; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location / { + proxy_pass http://ssd_backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Request-Id $ssd_request_id; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/__SERVER_NAME__/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/__SERVER_NAME__/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} + +server { + listen 80; + server_name __SERVER_NAME__; + return 301 https://$host$request_uri; +} diff --git a/deploy/ec2/nginx/site.internal.conf.template b/deploy/ec2/nginx/site.internal.conf.template new file mode 100644 index 0000000..1156141 --- /dev/null +++ b/deploy/ec2/nginx/site.internal.conf.template @@ -0,0 +1,56 @@ +map $http_x_request_id $ssd_request_id { + "~^(?:[A-Fa-f0-9]{32}|[A-Fa-f0-9-]{36})$" $http_x_request_id; + default $request_id; +} + +log_format ssd_json escape=json + '{"timestamp":"$time_iso8601",' + '"requestId":"$ssd_request_id",' + '"remoteAddr":"$remote_addr",' + '"method":"$request_method",' + '"uri":"$request_uri",' + '"status":$status,' + '"bodyBytesSent":$body_bytes_sent,' + '"requestTime":$request_time,' + '"upstreamStatus":"$upstream_status",' + '"upstreamResponseTime":"$upstream_response_time",' + '"httpReferer":"$http_referer",' + '"userAgent":"$http_user_agent"}'; + +server { + listen 127.0.0.1:8088 default_server; + server_name _; + + access_log /var/log/nginx/ssd-access.log ssd_json; + error_log /var/log/nginx/ssd-error.log warn; + + location = /grafana { + return 301 /grafana/; + } + + location /grafana/ { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Request-Id $ssd_request_id; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Prefix /grafana; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location / { + proxy_pass http://ssd_backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Request-Id $ssd_request_id; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + } +} diff --git a/infra/terraform/.terraform.lock.hcl b/infra/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..914cae9 --- /dev/null +++ b/infra/terraform/.terraform.lock.hcl @@ -0,0 +1,46 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.55" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.6" + hashes = [ + "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} diff --git a/infra/terraform/README.md b/infra/terraform/README.md new file mode 100644 index 0000000..0413911 --- /dev/null +++ b/infra/terraform/README.md @@ -0,0 +1,24 @@ +# Terraform bootstrap for SSD dev server + +## What it creates +- VPC with two public subnets +- EC2 application host (`t3.micro`) with Elastic IP +- RDS PostgreSQL +- S3 bucket for assets +- ECR repository for the Spring Boot image + +## Usage +```bash +export AWS_PROFILE=capstone-ssd +cd infra/terraform +cp terraform.tfvars.example terraform.tfvars +terraform init +terraform plan +terraform apply +``` + +## Next manual steps +1. Build and push the Docker image to the output ECR repository URL. +2. SSH into the EC2 instance using the output Elastic IP. +3. Upload `deploy/ec2` assets and create `/opt/ssd/config/application-dev.yml`. +4. Run `install_infra.sh` and `blue_green_deploy.sh`. diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 0000000..5a4bdaa --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,366 @@ +locals { + name_prefix = "${var.project}-${var.environment}" + ssh_public_key = file(pathexpand(var.ssh_public_key_path)) +} + +resource "random_id" "suffix" { + byte_length = 3 +} + +resource "random_password" "db" { + length = 24 + special = true + override_special = "!@#$%^&*()-_=+[]{}" +} + +locals { + resolved_db_password = var.db_password != "" ? var.db_password : random_password.db.result + bucket_name = "${local.name_prefix}-assets-${lower(random_id.suffix.hex)}" +} + +data "aws_availability_zones" "available" { + state = "available" +} + +data "aws_ami" "ubuntu" { + most_recent = true + owners = ["099720109477"] + + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +resource "aws_vpc" "main" { + cidr_block = var.vpc_cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = { + Name = "${local.name_prefix}-vpc" + } +} + +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "${local.name_prefix}-igw" + } +} + +resource "aws_subnet" "public_a" { + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.vpc_cidr, 8, 1) + availability_zone = data.aws_availability_zones.available.names[0] + map_public_ip_on_launch = true + + tags = { + Name = "${local.name_prefix}-public-a" + } +} + +resource "aws_subnet" "public_c" { + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.vpc_cidr, 8, 2) + availability_zone = data.aws_availability_zones.available.names[1] + map_public_ip_on_launch = true + + tags = { + Name = "${local.name_prefix}-public-c" + } +} + +resource "aws_subnet" "private_a" { + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.vpc_cidr, 8, 11) + availability_zone = data.aws_availability_zones.available.names[0] + + tags = { + Name = "${local.name_prefix}-private-a" + } +} + +resource "aws_subnet" "private_c" { + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.vpc_cidr, 8, 12) + availability_zone = data.aws_availability_zones.available.names[1] + + tags = { + Name = "${local.name_prefix}-private-c" + } +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = { + Name = "${local.name_prefix}-public-rt" + } +} + +resource "aws_route_table_association" "public_a" { + subnet_id = aws_subnet.public_a.id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table_association" "public_c" { + subnet_id = aws_subnet.public_c.id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table" "private" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "${local.name_prefix}-private-rt" + } +} + +resource "aws_route_table_association" "private_a" { + subnet_id = aws_subnet.private_a.id + route_table_id = aws_route_table.private.id +} + +resource "aws_route_table_association" "private_c" { + subnet_id = aws_subnet.private_c.id + route_table_id = aws_route_table.private.id +} + +resource "aws_security_group" "app" { + name = "${local.name_prefix}-app-sg" + description = "Security group for SSD application EC2" + vpc_id = aws_vpc.main.id + + ingress { + description = "SSH" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = var.ssh_allowed_cidrs + } + + ingress { + description = "HTTP" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "HTTPS" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${local.name_prefix}-app-sg" + } +} + +resource "aws_security_group" "db" { + name = "${local.name_prefix}-db-sg" + description = "Security group for SSD PostgreSQL" + vpc_id = aws_vpc.main.id + + ingress { + description = "PostgreSQL from app" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.app.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${local.name_prefix}-db-sg" + } +} + +resource "aws_key_pair" "operator" { + key_name = "${local.name_prefix}-operator" + public_key = local.ssh_public_key +} + +resource "aws_iam_role" "ec2" { + name = "${local.name_prefix}-ec2-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ec2.amazonaws.com" + } + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "ecr_readonly" { + role = aws_iam_role.ec2.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" +} + +resource "aws_iam_role_policy" "s3_access" { + name = "${local.name_prefix}-s3-policy" + role = aws_iam_role.ec2.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "s3:ListBucket" + ] + Resource = [ + aws_s3_bucket.assets.arn + ] + }, + { + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ] + Resource = [ + "${aws_s3_bucket.assets.arn}/*" + ] + } + ] + }) +} + +resource "aws_iam_instance_profile" "ec2" { + name = "${local.name_prefix}-ec2-profile" + role = aws_iam_role.ec2.name +} + +resource "aws_ecr_repository" "app" { + name = var.ecr_repository_name + image_tag_mutability = "IMMUTABLE" + + image_scanning_configuration { + scan_on_push = true + } + + force_delete = true +} + +resource "aws_s3_bucket" "assets" { + bucket = local.bucket_name + force_destroy = true +} + +resource "aws_s3_bucket_public_access_block" "assets" { + bucket = aws_s3_bucket.assets.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_db_subnet_group" "main" { + name = "${local.name_prefix}-db-subnets" + subnet_ids = [aws_subnet.private_a.id, aws_subnet.private_c.id] + + tags = { + Name = "${local.name_prefix}-db-subnets" + } +} + +resource "aws_db_instance" "postgres" { + identifier = "${local.name_prefix}-postgres" + engine = "postgres" + instance_class = var.db_instance_class + allocated_storage = 20 + max_allocated_storage = 100 + storage_type = "gp3" + db_name = var.db_name + username = var.db_username + password = local.resolved_db_password + db_subnet_group_name = aws_db_subnet_group.main.name + vpc_security_group_ids = [aws_security_group.db.id] + multi_az = false + publicly_accessible = false + backup_retention_period = 0 + skip_final_snapshot = true + deletion_protection = false + storage_encrypted = true + + tags = { + Name = "${local.name_prefix}-postgres" + } +} + +resource "aws_instance" "app" { + ami = data.aws_ami.ubuntu.id + instance_type = var.instance_type + subnet_id = aws_subnet.public_a.id + vpc_security_group_ids = [aws_security_group.app.id] + key_name = aws_key_pair.operator.key_name + iam_instance_profile = aws_iam_instance_profile.ec2.name + associate_public_ip_address = true + + user_data = templatefile("${path.module}/templates/ec2_user_data.sh.tftpl", { + app_name = var.project + }) + + metadata_options { + http_tokens = "required" + } + + root_block_device { + volume_size = 20 + volume_type = "gp3" + encrypted = true + delete_on_termination = true + } + + tags = { + Name = "${local.name_prefix}-app" + } +} + +resource "aws_eip" "app" { + domain = "vpc" + instance = aws_instance.app.id + + depends_on = [aws_internet_gateway.main] + + tags = { + Name = "${local.name_prefix}-eip" + } +} diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 0000000..27abd2e --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,49 @@ +output "app_public_ip" { + description = "Elastic IP attached to the application instance" + value = aws_eip.app.public_ip +} + +output "app_public_dns" { + description = "Public DNS name of the application instance" + value = aws_instance.app.public_dns +} + +output "ssh_command" { + description = "SSH command for the application instance" + value = "ssh ubuntu@${aws_eip.app.public_ip}" +} + +output "ecr_repository_url" { + description = "ECR repository URL for the application image" + value = aws_ecr_repository.app.repository_url +} + +output "s3_bucket_name" { + description = "S3 bucket name for application assets" + value = aws_s3_bucket.assets.bucket +} + +output "db_endpoint" { + description = "RDS PostgreSQL endpoint" + value = aws_db_instance.postgres.address +} + +output "db_name" { + description = "RDS PostgreSQL database name" + value = aws_db_instance.postgres.db_name +} + +output "db_username" { + description = "RDS PostgreSQL username" + value = aws_db_instance.postgres.username +} + +output "aws_region" { + description = "AWS region used by Terraform" + value = var.aws_region +} + +output "external_ai_base_url" { + description = "External AI base URL" + value = var.external_ai_base_url +} diff --git a/infra/terraform/providers.tf b/infra/terraform/providers.tf new file mode 100644 index 0000000..87b91d7 --- /dev/null +++ b/infra/terraform/providers.tf @@ -0,0 +1,11 @@ +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = var.project + Environment = var.environment + ManagedBy = "terraform" + } + } +} diff --git a/infra/terraform/scripts/deploy_ec2_app.sh b/infra/terraform/scripts/deploy_ec2_app.sh new file mode 100755 index 0000000..7ed07cf --- /dev/null +++ b/infra/terraform/scripts/deploy_ec2_app.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TERRAFORM_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +REPO_ROOT="$(cd "${TERRAFORM_DIR}/../.." && pwd)" + +AWS_PROFILE="${AWS_PROFILE:-capstone-ssd}" +SSH_PRIVATE_KEY_PATH="${SSH_PRIVATE_KEY_PATH:-$HOME/.ssh/id_rsa}" +SSH_USER="${SSH_USER:-ubuntu}" +IMAGE_TAG="${IMAGE_TAG:-$(date +%Y%m%d%H%M%S)}" +SPRING_PROFILE="${SPRING_PROFILE:-dev}" +APP_NAME="${APP_NAME:-ssd}" +NGINX_SERVER_NAME="${NGINX_SERVER_NAME:-_}" +APP_CONFIG_SOURCE="${APP_CONFIG_SOURCE:-}" + +export AWS_PROFILE + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "[ERROR] missing command: $1" >&2 + exit 1 + } +} + +for cmd in aws docker terraform ssh scp; do + require_cmd "${cmd}" +done + +cd "${TERRAFORM_DIR}" + +APP_IP="$(terraform output -raw app_public_ip)" +ECR_URL="$(terraform output -raw ecr_repository_url)" + +ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)" +AWS_REGION="$(terraform output -json >/dev/null 2>&1 && terraform console <<< 'var.aws_region' 2>/dev/null | tr -d '\"' || true)" +AWS_REGION="${AWS_REGION:-ap-northeast-2}" + +if [[ -z "${APP_CONFIG_SOURCE}" ]]; then + echo "[ERROR] APP_CONFIG_SOURCE is required. Provide a local application-${SPRING_PROFILE}.yml path." >&2 + exit 1 +fi + +if [[ ! -f "${APP_CONFIG_SOURCE}" ]]; then + echo "[ERROR] APP_CONFIG_SOURCE not found: ${APP_CONFIG_SOURCE}" >&2 + exit 1 +fi + +echo "[INFO] Build amd64 image ${ECR_URL}:${IMAGE_TAG}" +aws ecr get-login-password --region "${AWS_REGION}" | docker login --username AWS --password-stdin "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" +docker buildx build \ + --platform linux/amd64 \ + --push \ + -t "${ECR_URL}:${IMAGE_TAG}" \ + "${REPO_ROOT}" + +SSH_OPTS=(-i "${SSH_PRIVATE_KEY_PATH}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null) + +echo "[INFO] Wait for EC2 SSH: ${APP_IP}" +for _ in $(seq 1 60); do + if ssh "${SSH_OPTS[@]}" "${SSH_USER}@${APP_IP}" "echo ok" >/dev/null 2>&1; then + break + fi + sleep 5 +done + +ssh "${SSH_OPTS[@]}" "${SSH_USER}@${APP_IP}" "echo connected" >/dev/null + +scp "${SSH_OPTS[@]}" -r "${REPO_ROOT}/deploy/ec2" "${SSH_USER}@${APP_IP}:/home/${SSH_USER}/" +scp "${SSH_OPTS[@]}" "${APP_CONFIG_SOURCE}" "${SSH_USER}@${APP_IP}:/home/${SSH_USER}/application-${SPRING_PROFILE}.yml" + +ssh "${SSH_OPTS[@]}" "${SSH_USER}@${APP_IP}" \ + env \ + AWS_REGION="${AWS_REGION}" \ + NGINX_SERVER_NAME="${NGINX_SERVER_NAME}" \ + ECR_URL="${ECR_URL}" \ + IMAGE_TAG="${IMAGE_TAG}" \ + SPRING_PROFILE="${SPRING_PROFILE}" \ + APP_IP="${APP_IP}" \ + SSH_USER="${SSH_USER}" \ + bash <<'EOF' +set -euo pipefail +sudo APP_CONFIG_SOURCE="/home/${SSH_USER}/application-${SPRING_PROFILE}.yml" \ + NGINX_ENABLE_SSL=false \ + NGINX_SERVER_NAME="${NGINX_SERVER_NAME}" \ + /home/${SSH_USER}/ec2/install_infra.sh + +aws ecr get-login-password --region "${AWS_REGION}" | sudo docker login --username AWS --password-stdin "${ECR_URL%/*}" + +sudo DOCKER_REPO="${ECR_URL}" \ + IMAGE_TAG="${IMAGE_TAG}" \ + SPRING_PROFILES_ACTIVE="${SPRING_PROFILE}" \ + APP_GRAFANA_BASE_URL="http://${APP_IP}/grafana" \ + HEALTH_PATH="/actuator/health" \ + /home/${SSH_USER}/ec2/blue_green_deploy.sh +EOF + +echo "[INFO] Deployment finished" +echo "[INFO] Swagger: http://${APP_IP}/swagger-ui/index.html" +echo "[INFO] Health: http://${APP_IP}/actuator/health" diff --git a/infra/terraform/templates/ec2_user_data.sh.tftpl b/infra/terraform/templates/ec2_user_data.sh.tftpl new file mode 100644 index 0000000..d3a4a7e --- /dev/null +++ b/infra/terraform/templates/ec2_user_data.sh.tftpl @@ -0,0 +1,33 @@ +#!/bin/bash +set -euxo pipefail + +export DEBIAN_FRONTEND=noninteractive + +apt-get update +apt-get install -y docker.io docker-compose-v2 nginx jq unzip curl ca-certificates + +systemctl enable --now docker +systemctl enable --now nginx + +usermod -aG docker ubuntu + +cd /tmp +curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" +unzip -q awscliv2.zip +./aws/install --update +rm -rf /tmp/aws /tmp/awscliv2.zip + +mkdir -p /opt/${app_name}/config +mkdir -p /opt/${app_name}/logs +mkdir -p /home/ubuntu/infra + +chown -R ubuntu:ubuntu /home/ubuntu/infra +chmod 755 /opt/${app_name} /opt/${app_name}/config /opt/${app_name}/logs + +if ! swapon --show | grep -q '/swapfile'; then + fallocate -l 10G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=10240 + chmod 600 /swapfile + mkswap /swapfile + swapon /swapfile + grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab +fi diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example new file mode 100644 index 0000000..d0e5afc --- /dev/null +++ b/infra/terraform/terraform.tfvars.example @@ -0,0 +1,15 @@ +aws_region = "ap-northeast-2" +project = "ssd" +environment = "dev" +ssh_allowed_cidrs = ["118.221.254.20/32"] +ssh_public_key_path = "~/.ssh/id_rsa.pub" +instance_type = "t3.micro" +db_instance_class = "db.t3.micro" +db_name = "ssd" +db_username = "ssd_admin" +db_password = "" +ecr_repository_name = "ssd-api" +external_ai_base_url = "http://166.104.223.33:8080" +jwt_secret = "change-me" +discord_webhook_url = "https://discord.com/api/webhooks/your/webhook" +sentry_dsn = "https://examplePublicKey@o0.ingest.sentry.io/0" diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 0000000..14e550d --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,114 @@ +variable "aws_region" { + description = "AWS region" + type = string + default = "ap-northeast-2" +} + +variable "project" { + description = "Project name prefix" + type = string + default = "ssd" +} + +variable "environment" { + description = "Deployment environment" + type = string + default = "dev" +} + +variable "vpc_cidr" { + description = "VPC CIDR block" + type = string + default = "10.20.0.0/16" +} + +variable "ssh_allowed_cidrs" { + description = "CIDRs allowed to SSH into the EC2 instance" + type = list(string) + default = ["118.221.254.20/32"] +} + +variable "ssh_public_key_path" { + description = "Path to the local SSH public key used for EC2 access" + type = string + default = "~/.ssh/id_rsa.pub" +} + +variable "instance_type" { + description = "EC2 instance type" + type = string + default = "t3.small" +} + +variable "db_instance_class" { + description = "RDS instance class" + type = string + default = "db.t3.micro" +} + +variable "db_name" { + description = "Initial PostgreSQL database name" + type = string + default = "ssd" +} + +variable "db_username" { + description = "PostgreSQL master username" + type = string + default = "ssd_admin" +} + +variable "db_password" { + description = "PostgreSQL master password. Leave blank to auto-generate." + type = string + sensitive = true + default = "" +} + +variable "ecr_repository_name" { + description = "ECR repository name" + type = string + default = "ssd-api" +} + +variable "external_ai_base_url" { + description = "External AI service base URL" + type = string + default = "http://166.104.223.33:8080" +} + +variable "kakao_client_id" { + description = "Kakao OAuth client id" + type = string + default = "b93385a5496e64dd00060400787950fb" +} + +variable "jwt_secret" { + description = "JWT signing secret" + type = string + sensitive = true +} + +variable "discord_webhook_url" { + description = "Discord webhook URL" + type = string + sensitive = true +} + +variable "sentry_dsn" { + description = "Sentry DSN" + type = string + sensitive = true +} + +variable "app_allowed_origins" { + description = "Allowed frontend origins" + type = list(string) + default = [ + "http://localhost:5173", + "http://localhost:8080", + "https://dev-api.simsaimdang.shop", + "https://dev.simsaimdang.shop", + "https://ssd-client-zfl2.vercel.app" + ] +} diff --git a/infra/terraform/versions.tf b/infra/terraform/versions.tf new file mode 100644 index 0000000..dd78531 --- /dev/null +++ b/infra/terraform/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.55" + } + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + } +}