Automating Deployment with Github Actions

Automating Deployment with Github Actions

使用 GitHub Actions 实现自动化部署

Deploying to a Server You Can’t Reach: Building a CI/CD Pipeline with AWS SSM and OIDC 部署到无法直接访问的服务器:使用 AWS SSM 和 OIDC 构建 CI/CD 流水线

From pushing code manually to building, testing, containerizing, and deploying every change automatically. In the previous article, we learned how to spin up a functioning network and compute resources on AWS, and deploy our application on them. Let’s remember a key architectural decision: our EC2 instance is in a private subnet, shielded from the internet. Now we have a problem. It gets tedious to log in, pull code, and build our Docker images every time we make changes to our codebase. 从手动推送代码到自动完成构建、测试、容器化和部署。在上一篇文章中,我们学习了如何在 AWS 上搭建功能完备的网络和计算资源,并部署我们的应用程序。请记住一个关键的架构决策:我们的 EC2 实例位于私有子网中,与互联网隔离。现在我们遇到了一个问题:每次修改代码库后,都要手动登录、拉取代码并构建 Docker 镜像,这变得非常繁琐。

Enter CI/CD. CI/CD stands for Continuous Integration and Continuous Delivery/Deployment. It aims to streamline and accelerate the software development lifecycle. Continuous Integration (CI) refers to the practice of automatically integrating code changes into a shared source code repository. Continuous Delivery and/or Deployment (CD) is a two-part process that refers to the integration, testing, and delivery of code changes. Continuous delivery stops short of automatic production deployment, while continuous deployment automatically releases the updates into the production environment. This means our deployment should happen every time we push code to our GitHub repo. (If you need a refresher on our setup, check it out here: Terraform Deployment) 引入 CI/CD。CI/CD 代表持续集成(Continuous Integration)和持续交付/部署(Continuous Delivery/Deployment)。它的目标是简化并加速软件开发生命周期。持续集成(CI)是指将代码变更自动集成到共享源代码仓库的实践。持续交付和/或部署(CD)是一个包含集成、测试和交付代码变更的两个阶段的过程。持续交付止步于自动部署到生产环境之前,而持续部署则会自动将更新发布到生产环境。这意味着每当我们向 GitHub 仓库推送代码时,都应该触发部署。(如果你需要回顾我们的设置,请点击这里:Terraform 部署)

Here is the catch: our server has no public IP address. It sits in a private subnet behind a load balancer. GitHub Actions cannot SSH into it. There is no port 22 open to the internet. There is no bastion host. So how do you deploy to a server you can’t reach from the internet? 问题在于:我们的服务器没有公网 IP 地址。它位于负载均衡器后的私有子网中。GitHub Actions 无法通过 SSH 连接到它。互联网上没有开放 22 端口,也没有堡垒机。那么,如何部署到一个无法从互联网访问的服务器呢?

Why This is More Complex Than a Typical CI/CD Setup

为什么这比典型的 CI/CD 设置更复杂

Typically, we would set up a pipeline that builds a Docker image, pushes it to a registry, then SSHes into a server with a stored SSH key to pull the image and restart the container. None of that works for this setup. 通常,我们会设置一个流水线来构建 Docker 镜像,将其推送到镜像仓库,然后使用存储的 SSH 密钥通过 SSH 登录服务器,拉取镜像并重启容器。但这些方法在当前架构下都行不通。

The First Problem: The server is unreachable. The EC2 instance lives in a private subnet (10.0.10.0/24). Traffic from the internet goes through the Application Load Balancer, not directly to the server. There is no public IP. GitHub Actions cannot SSH in. 第一个问题:服务器无法访问。 EC2 实例位于私有子网 (10.0.10.0/24) 中。来自互联网的流量通过应用负载均衡器(ALB),而不是直接到达服务器。它没有公网 IP,GitHub Actions 无法通过 SSH 访问。

The Second Problem: Four containers, not one. This is not a single Docker image deployment. It is a docker-compose.yml stack with four interdependent services: PostgreSQL → (health check passes) → Backend → (health check passes) → Frontend → Nginx. If the backend starts before PostgreSQL is healthy, the Alembic database migrations crash. If Nginx starts before the backend is healthy, it throws a 502 Bad Gateway. Docker Compose manages this dependency chain with health checks and depends_on conditions. So the deployment tool needs to orchestrate Docker Compose on the server, not just swap one container. 第二个问题:四个容器,而非一个。 这不是单个 Docker 镜像的部署,而是一个包含四个相互依赖服务的 docker-compose.yml 堆栈:PostgreSQL →(健康检查通过)→ 后端 →(健康检查通过)→ 前端 → Nginx。如果后端在 PostgreSQL 就绪前启动,Alembic 数据库迁移就会失败;如果 Nginx 在后端就绪前启动,则会抛出 502 Bad Gateway 错误。Docker Compose 通过健康检查和 depends_on 条件来管理这种依赖链。因此,部署工具需要在服务器上编排 Docker Compose,而不仅仅是替换一个容器。

Problem 3: No stored credentials. I did not want AWS access keys sitting in GitHub Secrets. Keys do not expire. If they leak, they are valid until someone notices and manually revokes them. That could be weeks. Or months. In a personal project, probably never. I needed a solution that was: Keyless (no stored AWS credentials), Reachable (can talk to a private subnet server), Composable (can orchestrate a multi-container stack). 问题 3:没有存储的凭证。 我不想将 AWS 访问密钥存放在 GitHub Secrets 中。密钥不会过期,如果泄露,在有人发现并手动撤销之前它们一直有效。这可能需要几周甚至几个月,而在个人项目中,可能永远不会被发现。我需要一个解决方案,它必须是:无密钥的(不存储 AWS 凭证)、可访问的(能与私有子网服务器通信)、可编排的(能编排多容器堆栈)。

The answer turned out to be three AWS services I had already partially set up: IAM OIDC, Systems Manager (SSM), and Terraform. 答案最终指向了我已经部分设置好的三个 AWS 服务:IAM OIDC、Systems Manager (SSM) 和 Terraform。

The Architecture

架构设计

Here is how the pipeline works end to end. 以下是流水线端到端的运作方式。

(Diagram omitted for brevity) (此处省略流程图)

No SSH. No stored keys. No open ports. The only thing stored in GitHub Secrets is the ARN of an IAM role — a reference, not a credential. 没有 SSH,没有存储的密钥,没有开放的端口。GitHub Secrets 中唯一存储的内容是一个 IAM 角色的 ARN——这只是一个引用,而非凭证。

Part 1: The CI Pipeline — Catching Problems Before They Reach the Server

第一部分:CI 流水线——在问题到达服务器前将其拦截

The CI workflow runs on every push to main and every pull request. Its job is simple: make sure the code is not broken before we even think about deploying. CI 流水线在每次推送到 main 分支和提交 Pull Request 时运行。它的任务很简单:在考虑部署之前,确保代码没有问题。

name: CI
on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]
jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      - name: Validate Docker Compose build
        run: docker compose build
      - name: Set up Node.js for Frontend
        uses: actions/setup-node@v4
        with:
          node-version: '20'
      - name: Check Frontend build
        working-directory: ./fe-apartment
        run: |
          npm install
          npm run build
      - name: Set up Python for Backend
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - name: Lint Backend (Flake8)
        working-directory: ./be-apartment
        run: |
          python -m pip install --upgrade pip
          pip install flake8
          flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
          flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics

Three things happen: 这里发生了三件事:

  1. Docker Compose build validation. This builds all four container images. If a Dockerfile is broken, a requirements.txt has a bad package, or Nginx config has a syntax error — it fails here, not on the server.

  2. Docker Compose 构建验证。 这会构建所有四个容器镜像。如果 Dockerfile 有误、requirements.txt 包含错误的包,或者 Nginx 配置有语法错误,它会在这里报错,而不是在服务器上。

  3. Frontend build. React with Vite. npm install then npm run build. If someone introduces a TypeScript error or a bad import, the build fails. The CI catches it.

  4. 前端构建。 使用 Vite 构建 React。执行 npm install 然后 npm run build。如果有人引入了 TypeScript 错误或错误的导入,构建就会失败,CI 会捕获到这些问题。

  5. Backend lint. Flake8 on the FastAPI codebase. The first pass (—select=E9,F63,F7,F82) catches hard errors — syntax errors, undefined names, things that will crash at runtime. The second pass reports style warnings without failing the build. I am strict on this.

  6. 后端代码检查(Lint)。 对 FastAPI 代码库运行 Flake8。第一遍检查(—select=E9,F63,F7,F82)会捕获严重错误,如语法错误、未定义的名称等会导致运行时崩溃的问题。第二遍检查则报告代码风格警告,但不会导致构建失败。我对这一点要求很严格。