折腾 CI/CD,搞半天还用了自建 Git 服务
Jenkins
Ref: Jenkins
Installation
为什么自己搭 Git
起因是因为要用 WebHook,给 Jenkins,如果用 Github Action 先不说
Private repo 的限制,Webhook 请求到国内网络的问题,另一个则是相反的,拉
Github 上 repo 的网络问题,因此还是决定自己搭内网 Git,而且 Gitea
蛮简单的
安装 Jenkins
Jenkins 基于 Java 运行,所以需要安装 Java 环境,按照官方的就行
1 2 3 sudo apt updatesudo apt install fontconfig openjdk-21-jrejava -version
这个从官方源下可能会慢一点
1 2 3 4 5 6 7 sudo wget -O /etc/apt/keyrings/jenkins-keyring.asc \ https://pkg.jenkins.io/debian-stable/jenkins.io-2026.key echo "deb [signed-by=/etc/apt/keyrings/jenkins-keyring.asc]" \ https://pkg.jenkins.io/debian-stable binary/ | sudo tee \ /etc/apt/sources.list.d/jenkins.list > /dev/null sudo apt updatesudo apt install jenkins
我遇到的一个主要问题是 Jenkins 的默认 plugins
安装慢的没边,后来是先换了个 update-center.json
的源,也因为这个源后来装不上 node
参考 gh:
lework/jenkins-update-center
注意这些最好在你第一次访问 Web 界面并开始安装 plugins
之前就配置好,否则需要中断安装,不过影响不大,我是中断恢复也没有问题
安装证书
1 2 3 [ ! -d /var/lib/jenkins/update-center-rootCAs ] && mkdir /var/lib/jenkins/update-center-rootCAs wget https://cdn.jsdelivr.net/gh/lework/jenkins-update-center/rootCA/update-center.crt -O /var/lib/jenkins/update-center-rootCAs/update-center.crt chown jenkins.jenkins -R /var/lib/jenkins/update-center-rootCAs
换源
1 2 3 4 sed -i 's#https://updates.jenkins.io/update-center.json#https://cdn.jsdelivr.net/gh/lework/jenkins-update-center/updates/tsinghua/update-center.json#' /var/lib/jenkins/hudson.model.UpdateCenter.xml rm -f /var/lib/jenkins/updates/default.jsonsystemctl restart jenkins
我当时换的 ustc
安装必要插件
Manage Jenkins → Plugins → Available plugins → 搜索
NodeJS、Gitea、Generic Webhook Trigger、SSH Agent → Install
这里在 download 页面会提示你勾一个在闲时重启的框
配置 Node
Manage Jenkins → Tools → NodeJS installations
node installation with
mirror
这里我配了国内源,然后和 G
老师说的有点出入,实际上要配置版本号,而不是下拉菜单,然后我在测试构建时遇到了问题
problem
后来是我把 update-center.json 换回官方源,才解决了这个问题
安装 uv
uv 与 Jenkins 无关,但是为了 Jenkins 用户的访问,需要全局安装
1 2 3 4 5 6 7 8 9 10 curl -LsSf https://astral.sh/uv/install.sh \ -o /tmp/uv-installer.sh sudo env UV_UNMANAGED_INSTALL=/usr/local/bin \ sh /tmp/uv-installer.sh sudo -u jenkins -H sh -c ' cd "$HOME" /usr/local/bin/uv --no-config python install 3.13 '
测试 Pipeline
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 pipeline { agent any tools { nodejs 'node-24' } options { timestamps() disableConcurrentBuilds() timeout(time: 30 , unit: 'MINUTES' ) buildDiscarder(logRotator( numToKeepStr: '20' , artifactNumToKeepStr: '10' )) } environment { UV_PYTHON = '3.13' UV_CACHE_DIR = "${WORKSPACE}/.cache/uv" } stages { stage('Checkout' ) { steps { dir('example_frontend' ) { git branch: 'main' , CredentialsId: 'gitea-http' , url: 'https://git.example.com/example/example_frontend.git' } dir('example_backend' ) { git branch: 'main' , CredentialsId: 'gitea-http' , url: 'https://git.example.com/example/example_backend.git' } } } stage('Verify Source' ) { steps { sh ''' set -eux test -f example_frontend/package.json test -f example_frontend/pnpm-lock.yaml test -f example_backend/pyproject.toml test -f example_backend/uv.lock test -f example_backend/main.py ''' } } stage('Check Toolchains' ) { steps { sh ''' set -eux node --version npm --version pnpm --version uv --version uv python find 3.13 uv run \ --no-project \ --python 3.13 \ python --version ''' } } stage('Build Frontend' ) { steps { dir('example_frontend' ) { sh ''' set -eux pnpm install --frozen-lockfile pnpm build test -d dist test -f dist/index.html ''' } } } stage('Check Backend' ) { steps { dir('example_backend' ) { sh ''' set -eux uv sync --locked uv run --locked python --version uv run --locked python -m compileall -q main.py uv run --locked python -c "from main import app; print(app.title)" uv run --locked python -c "import fastapi; print(fastapi.__version__)" ''' } } } } post { success { archiveArtifacts( artifacts: 'example_frontend/dist/**' , fingerprint: true , allowEmptyArchive: false ) echo 'Frontend and backend checks completed successfully.' } failure { echo 'Pipeline failed. Check the first failed stage in Console Output.' } always { echo "Build result: ${currentBuild.currentResult}" } } }
这里需要配置 Gitea 的 credential(Access Token),只需要勾一个
repository 的 read
Gitea 右上角头像 → Settings / 设置 → Applications / 应用 → Manage
Access Tokens / 管理访问令牌
gitea token
然后配置 Credentials,选 Username with password
gitea token credential
配置 WebHooks
添加 webhook 到 Credentials
先生成一个随机字符串
存入 Credentials,选 Secret text
webhook token credential
这里其实可以先测一下 webhook,事实上需要在 app.ini 配置然后重启 gitea
服务才能生效
/etc/gitea/app.ini 1 2 [security] ALLOWED_HOST_LIST = external,loopback
配置 repo 的 Webhook
webhook configuration
建议先不配置 pipeline
然后可以往下滚一点,点击下面的测试推送事件按钮做测试发送
webhook test
这里虽然是失败,但这是因为没有 pipeline 配置,所以是 ok 的
配置生产服务器的 deploy
因为我想的是简单部署,通过 service 注册后端,然后restart,而不是
docker,因此我需要一个 deploy 用户,从而需要密钥对登陆
创建与配置 deploy 用户
在 deploy 服务器上
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 sudo adduser \ --disabled-password \ --gecos "" \ deploy sudo install -d \ -o deploy \ -g deploy \ -m 0755 \ /srv/jenkins-test sudo install -d \ -o deploy \ -g deploy \ -m 0755 \ /srv/jenkins-test/releases sudo install -d \ -o deploy \ -g deploy \ -m 0700 \ /home/deploy/.ssh sudo touch /home/deploy/.ssh/authorized_keyssudo chown deploy:deploy /home/deploy/.ssh/authorized_keyssudo chmod 0600 /home/deploy/.ssh/authorized_keys
生成密钥对,建议直接在 deploy 服务器上做
1 2 3 4 5 ssh-keygen \ -t ed25519 \ -a 100 \ -C "jenkins-production-deploy" \ -f jenkins-production-deploy
随后将公钥写入 deploy 服务器的
/home/deploy/.ssh/authorized_keys
在 Jenkins 中添加凭据,选
SSH Username with private key
Manage Jenkins → Credentials → System → Global credentials → Add
Credentials
ssh credential
在 jenkins 服务器做这些操作,其中 TARGET_SERVER_IP 是 deploy 服务器的
IP 地址,用于先获取 fingerprint
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 ssh-keyscan \ -p 22 \ -t ed25519 \ TARGET_SERVER_IP \ > /tmp/jenkins-test-hostkey sudo install \ -d \ -o jenkins \ -g jenkins \ -m 0700 \ /var/lib/jenkins/.ssh sudo sh -c \ 'cat /tmp/jenkins-test-hostkey >> /var/lib/jenkins/.ssh/known_hosts' sudo chown jenkins:jenkins \ /var/lib/jenkins/.ssh/known_hosts sudo chmod 0600 \ /var/lib/jenkins/.ssh/known_hosts
注册服务与配置后端环境
同样的,装 uv
1 2 3 4 5 6 7 curl -LsSf \ https://astral.sh/uv/install.sh \ -o /tmp/uv-installer.sh sudo env \ UV_UNMANAGED_INSTALL=/usr/local/bin \ sh /tmp/uv-installer.sh
创建后端 env 文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 sudo install \ -d \ -o root \ -g deploy \ -m 0750 \ /etc/jenkins-test sudo touch /etc/jenkins-test/backend.envsudo chown root:deploy \ /etc/jenkins-test/backend.env sudo chmod 0640 \ /etc/jenkins-test/backend.env
注册 systemd service
/etc/systemd/system/jenkins-test-backend.service 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 [Unit] Description =Jenkins Test FastAPI BackendWants =network-on line.targetAfter =network-on line.target[Service] Type =simpleUser =deployGroup =deployWorkingDirectory =/srv/jenkins-test/backend-currentEnvironment =PYTHONUNBUFFERED=1 EnvironmentFile =-/etc/jenkins-test/backend.envExecStart =/srv/jenkins-test/backend-current/.venv/bin/uvicorn \ main:app \ --host 127.0.0.1 \ --port 8000 \ --proxy-headers \ --forwarded-allow-ips =127.0 .0.1 Restart =on -failureRestartSec =3 TimeoutStopSec =20 KillSignal =SIGINTNoNewPrivileges =true PrivateTmp =true ProtectSystem =fullProtectHome =read-on ly[Install] WantedBy =multi-user.target
加载服务并设置开机自启动
1 2 sudo systemctl daemon-reloadsudo systemctl enable jenkins-test-backend
限制 deploy 用户权限,只允许 deploy 重启指定服务
1 sudo visudo -f /etc/sudoers.d/jenkins-test-deploy
1 deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart jenkins-test-backend
配置 nginx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 server { listen 80 ; listen [::]:80 ; server_name app.example.com; root /srv/jenkins-test/frontend-current; index index.html; client_max_body_size 10m ; location ^~ /api/ { proxy_pass http://127.0.0.1:8000; proxy_http_version 1 .1 ; proxy_set_header 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 ; proxy_connect_timeout 5s ; proxy_read_timeout 60s ; } location / { try_files $uri $uri / /index.html; } location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2)$ { try_files $uri =404 ; expires 7d ; add_header Cache-Control "public" ; } }
Pipeline 配置
frontend-pipeline 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 pipeline { agent any tools { nodejs 'node-24' } triggers { GenericTrigger( genericVariables: [ [key: 'GITEA_REF' , value: '$.ref' ], [key: 'GITEA_COMMIT' , value: '$.after' ], [key: 'GITEA_REPOSITORY' , value: '$.repository.full_name' ], [key: 'GITEA_PUSHER' , value: '$.pusher.username' ] ], causeString: 'Frontend push: $GITEA_COMMIT by $GITEA_PUSHER' , tokenCredentialId: 'gitea-webhook-token' , printContributedVariables: false , printPostContent: false , silentResponse: false , shouldNotFlatten: false , regexpFilterText: '$GITEA_REPOSITORY:$GITEA_REF' , regexpFilterExpression: '^iy88/jenkins_test_frontend:refs/heads/main$' ) } options { timestamps() disableConcurrentBuilds() timeout(time: 30 , unit: 'MINUTES' ) buildDiscarder(logRotator( numToKeepStr: '20' , artifactNumToKeepStr: '10' )) } environment { DEPLOY_HOST = '替换为目标服务器地址' DEPLOY_PORT = '22' DEPLOY_USER = 'deploy' DEPLOY_ROOT = '/srv/jenkins-test' } stages { stage('Checkout' ) { steps { deleteDir() git branch: 'main' , credentialsId: 'gitea-http' , url: 'https://git.example.com/example/jenkins_test_frontend.git' script { String requestedCommit = env.GITEA_COMMIT?.trim() if (requestedCommit ==~ /[0-9a-fA-F]{40}/ ) { sh "git checkout --detach ${requestedCommit}" } env.DEPLOY_REVISION = sh( script: 'git rev-parse HEAD' , returnStdout: true ).trim() env.DEPLOY_RELEASE = "${env.DEPLOY_REVISION}-${env.BUILD_NUMBER}" } } } stage('Build' ) { steps { sh ''' set -eux test -f package.json test -f pnpm-lock.yaml node --version pnpm --version pnpm install --frozen-lockfile pnpm build test -f dist/index.html ''' } } stage('Package' ) { steps { sh ''' set -eux ARCHIVE="frontend-${DEPLOY_RELEASE}.tar.gz" tar -C dist -czf "${ARCHIVE}" . test -s "${ARCHIVE}" ''' archiveArtifacts( artifacts: 'frontend-*.tar.gz' , fingerprint: true ) } } stage('Deploy' ) { steps { sshagent( credentials: ['production-deploy-ssh' ], ignoreMissing: false ) { sh ''' set -eux ARCHIVE="frontend-${DEPLOY_RELEASE}.tar.gz" scp \ -P "${DEPLOY_PORT}" \ -o BatchMode=yes \ -o StrictHostKeyChecking=yes \ "${ARCHIVE}" \ "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/${ARCHIVE}" ssh \ -p "${DEPLOY_PORT}" \ -o BatchMode=yes \ -o StrictHostKeyChecking=yes \ "${DEPLOY_USER}@${DEPLOY_HOST}" \ bash -s -- \ "${DEPLOY_ROOT}" \ "${DEPLOY_RELEASE}" \ "${ARCHIVE}" <<'REMOTE' set -eux DEPLOY_ROOT="$1" DEPLOY_RELEASE="$2" ARCHIVE="$3" RELEASE_DIR="${DEPLOY_ROOT}/releases/frontend-${DEPLOY_RELEASE}" NEXT_LINK="${DEPLOY_ROOT}/.frontend-current-${DEPLOY_RELEASE}" CURRENT_LINK="${DEPLOY_ROOT}/frontend-current" mkdir -p "${RELEASE_DIR}" tar -xzf "/tmp/${ARCHIVE}" -C "${RELEASE_DIR}" rm -f "/tmp/${ARCHIVE}" test -f "${RELEASE_DIR}/index.html" ln -s "${RELEASE_DIR}" "${NEXT_LINK}" mv -Tf "${NEXT_LINK}" "${CURRENT_LINK}" echo "Frontend deployed to ${RELEASE_DIR}" REMOTE ''' } } } } post { success { echo "Frontend deployed successfully: ${env.DEPLOY_REVISION}" } failure { echo 'Frontend build or deployment failed.' } always { echo "Build result: ${currentBuild.currentResult}" } } }
backend-pipeline 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 pipeline { agent any triggers { GenericTrigger( genericVariables: [ [key: 'GITEA_REF' , value: '$.ref' ], [key: 'GITEA_COMMIT' , value: '$.after' ], [key: 'GITEA_REPOSITORY' , value: '$.repository.full_name' ], [key: 'GITEA_PUSHER' , value: '$.pusher.username' ] ], causeString: 'Backend push: $GITEA_COMMIT by $GITEA_PUSHER' , tokenCredentialId: 'gitea-webhook-token' , printContributedVariables: false , printPostContent: false , silentResponse: false , shouldNotFlatten: false , regexpFilterText: '$GITEA_REPOSITORY:$GITEA_REF' , regexpFilterExpression: '^iy88/jenkins_test_backend:refs/heads/main$' ) } options { timestamps() disableConcurrentBuilds() timeout(time: 30 , unit: 'MINUTES' ) buildDiscarder(logRotator( numToKeepStr: '20' , artifactNumToKeepStr: '10' )) } environment { UV_PYTHON = '3.13' UV_CACHE_DIR = "${WORKSPACE}/.cache/uv" DEPLOY_HOST = '替换为目标服务器地址' DEPLOY_PORT = '22' DEPLOY_USER = 'deploy' DEPLOY_ROOT = '/srv/jenkins-test' BACKEND_SERVICE = 'jenkins-test-backend' BACKEND_HEALTH_URL = 'http://127.0.0.1:8000/api/hello' } stages { stage('Checkout' ) { steps { deleteDir() git branch: 'main' , credentialsId: 'gitea-http' , url: 'https://git.example.com/example/jenkins_test_backend.git' script { String requestedCommit = env.GITEA_COMMIT?.trim() if (requestedCommit ==~ /[0-9a-fA-F]{40}/ ) { sh "git checkout --detach ${requestedCommit}" } env.DEPLOY_REVISION = sh( script: 'git rev-parse HEAD' , returnStdout: true ).trim() env.DEPLOY_RELEASE = "${env.DEPLOY_REVISION}-${env.BUILD_NUMBER}" } } } stage('Check Backend' ) { steps { sh ''' set -eux test -f pyproject.toml test -f uv.lock test -f main.py uv --version uv python find 3.13 uv sync --locked uv run --locked python --version uv run --locked python -m compileall -q main.py uv run --locked python -c \ "from main import app; print(app.title)" ''' } } stage('Package' ) { steps { sh ''' set -eux ARCHIVE="backend-${DEPLOY_RELEASE}.tar.gz" git ls-files -z | tar --null -T - -czf "${ARCHIVE}" test -s "${ARCHIVE}" ''' archiveArtifacts( artifacts: 'backend-*.tar.gz' , fingerprint: true ) } } stage('Deploy' ) { steps { sshagent( credentials: ['production-deploy-ssh' ], ignoreMissing: false ) { sh ''' set -eux ARCHIVE="backend-${DEPLOY_RELEASE}.tar.gz" scp \ -P "${DEPLOY_PORT}" \ -o BatchMode=yes \ -o StrictHostKeyChecking=yes \ "${ARCHIVE}" \ "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/${ARCHIVE}" ssh \ -p "${DEPLOY_PORT}" \ -o BatchMode=yes \ -o StrictHostKeyChecking=yes \ "${DEPLOY_USER}@${DEPLOY_HOST}" \ bash -s -- \ "${DEPLOY_ROOT}" \ "${DEPLOY_RELEASE}" \ "${ARCHIVE}" \ "${BACKEND_SERVICE}" \ "${BACKEND_HEALTH_URL}" <<'REMOTE' set -u DEPLOY_ROOT="$1" DEPLOY_RELEASE="$2" ARCHIVE="$3" BACKEND_SERVICE="$4" HEALTH_URL="$5" RELEASE_DIR="${DEPLOY_ROOT}/releases/backend-${DEPLOY_RELEASE}" CURRENT_LINK="${DEPLOY_ROOT}/backend-current" NEXT_LINK="${DEPLOY_ROOT}/.backend-current-${DEPLOY_RELEASE}" PREVIOUS_RELEASE="" if [ -L "${CURRENT_LINK}" ]; then PREVIOUS_RELEASE="$(readlink -f "${CURRENT_LINK}")" fi set -e mkdir -p "${RELEASE_DIR}" tar -xzf "/tmp/${ARCHIVE}" -C "${RELEASE_DIR}" rm -f "/tmp/${ARCHIVE}" cd "${RELEASE_DIR}" /usr/local/bin/uv sync \ --locked \ --no-dev \ --python 3.13 test -x "${RELEASE_DIR}/.venv/bin/uvicorn" ln -s "${RELEASE_DIR}" "${NEXT_LINK}" mv -Tf "${NEXT_LINK}" "${CURRENT_LINK}" set +e sudo systemctl restart "${BACKEND_SERVICE}" RESTART_RESULT=$? HEALTHY=0 if [ "${RESTART_RESULT}" -eq 0 ]; then for ATTEMPT in $(seq 1 20); do if curl -fsS "${HEALTH_URL}" >/dev/null; then HEALTHY=1 break fi sleep 1 done fi if [ "${HEALTHY}" -ne 1 ]; then echo "Backend health check failed." if [ -n "${PREVIOUS_RELEASE}" ] && [ -d "${PREVIOUS_RELEASE}" ]; then ROLLBACK_LINK="${DEPLOY_ROOT}/.backend-rollback-${DEPLOY_RELEASE}" ln -s "${PREVIOUS_RELEASE}" "${ROLLBACK_LINK}" mv -Tf "${ROLLBACK_LINK}" "${CURRENT_LINK}" sudo systemctl restart "${BACKEND_SERVICE}" echo "Rolled back to ${PREVIOUS_RELEASE}" fi exit 1 fi echo "Backend deployed to ${RELEASE_DIR}" REMOTE ''' } } } } post { success { echo "Backend deployed successfully: ${env.DEPLOY_REVISION}" } failure { echo 'Backend build or deployment failed.' } always { echo "Build result: ${currentBuild.currentResult}" } } }
随后可以先 build backend 再 build
frontend,并观察配置结果,我这儿是没问题的。
jenkins-panel